mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #1200 from kaitranntt/kai/feat/1199-shared-resource-controls
feat(auth): add shared resource controls
This commit is contained in:
46 files changed
+2721
-144
No files matched your search
@@ -26,6 +26,7 @@ Dashboard auth and account context metadata are separate:
|
||||
|
||||
- `dashboard_auth`: protects dashboard access with username/password
|
||||
- `accounts.<name>.context_mode/context_group`: controls isolated vs shared account context
|
||||
- `accounts.<name>.shared_resource_mode`: controls plugins/commands/skills/agents/settings.json sharing
|
||||
|
||||
Account context is isolation-first. The recommended two-account route is:
|
||||
|
||||
@@ -48,15 +49,22 @@ Shared continuity depth:
|
||||
- `standard` (default): shares project workspace context only
|
||||
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
||||
|
||||
`ccs auth show <profile>` reports credential isolation, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane.
|
||||
`ccs auth show <profile>` reports credential isolation, shared resource mode, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane.
|
||||
|
||||
Non-bare account profiles share the basic Claude `settings.json` with native Claude:
|
||||
Non-bare account profiles share Claude-local resources with native Claude:
|
||||
|
||||
```text
|
||||
~/.ccs/instances/<profile>/settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json
|
||||
```
|
||||
|
||||
This keeps ordinary Claude settings in sync without copying account tokens. Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`.
|
||||
This keeps ordinary Claude settings, plugins, commands, skills, and agents in sync without copying account tokens. Existing accounts can opt out or back in:
|
||||
|
||||
```bash
|
||||
ccs auth resources work --mode profile-local
|
||||
ccs auth resources work --mode shared
|
||||
```
|
||||
|
||||
Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`.
|
||||
|
||||
`context_group` normalization and validation:
|
||||
|
||||
@@ -83,6 +91,17 @@ Dashboard accounts context editing:
|
||||
- rejects CLIProxy OAuth account keys for this route
|
||||
- applies normalization/validation rules above
|
||||
|
||||
Shared resource editing:
|
||||
|
||||
- `PUT /api/accounts/:name/shared-resources` updates `shared_resource_mode` for existing auth accounts
|
||||
- accepts only `shared` or `profile-local`
|
||||
- rejects CLIProxy OAuth account keys for this route
|
||||
- reconciles the account instance after metadata is updated
|
||||
- Dashboard -> Accounts exposes this as a separate Resources action so it is not confused with History Sync.
|
||||
- Dashboard -> Shared Resources shows the shared hub inventory for commands, skills, agents, plugins, and `settings.json`.
|
||||
- The Plugins tab is registry-oriented: installed plugin entries come from `installed_plugins.json`, while internal cache/data/marketplace folders stay hidden unless a real plugin entry exists.
|
||||
- Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values.
|
||||
|
||||
## Commands
|
||||
|
||||
### `ccs config auth setup`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-05-07
|
||||
Last Updated: 2026-05-09
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-05-09**: **#1199** Existing Claude auth accounts now have dashboard-visible Shared Resources controls separate from History Sync. Accounts exposes a dedicated Resources action for `shared` vs `profile-local`, `/shared` now inventories commands, skills, agents, plugins, and `settings.json`, plugin directories without docs show factual directory contents, and shared settings content is inspectable read-only through the localhost-gated shared-content API.
|
||||
- **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing.
|
||||
- **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups.
|
||||
- **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=<text>` routes through headless delegation consistently with `--prompt <text>`.
|
||||
|
||||
@@ -26,7 +26,7 @@ ccs personal
|
||||
|
||||
This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials.
|
||||
|
||||
Basic Claude settings are shared for non-bare account profiles:
|
||||
Shared Resources are separate from History Sync. By default, non-bare account profiles inherit Claude-local resources from native Claude:
|
||||
|
||||
```text
|
||||
~/.ccs/instances/<account>/settings.json
|
||||
@@ -34,7 +34,17 @@ Basic Claude settings are shared for non-bare account profiles:
|
||||
-> ~/.claude/settings.json
|
||||
```
|
||||
|
||||
This is for ordinary Claude Code settings, hooks, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show <account>` reports the current `Settings`, `History`, and `Plain ccs` lanes so users can see whether settings and resume history are aligned.
|
||||
This covers ordinary Claude Code `settings.json`, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show <account>` reports `Resources`, `Settings`, `History`, and `Plain ccs` lanes so users can see whether shared resources and resume history are aligned.
|
||||
|
||||
For existing accounts, change Shared Resources from the CLI:
|
||||
|
||||
```bash
|
||||
ccs auth resources work --mode profile-local
|
||||
ccs auth resources work --mode shared
|
||||
```
|
||||
|
||||
- `shared`: link plugins, commands, skills, agents, and `settings.json` from the shared Claude resource layout.
|
||||
- `profile-local`: detach those shared resources for the account. This is the existing `--bare` behavior exposed as an existing-account setting.
|
||||
|
||||
Only opt in to shared history when both accounts should see the same local continuity:
|
||||
|
||||
@@ -42,7 +52,7 @@ Only opt in to shared history when both accounts should see the same local conti
|
||||
ccs auth create work2 --share-context --context-group daily --deeper-continuity
|
||||
```
|
||||
|
||||
For existing accounts, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context.
|
||||
For existing History Sync, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. History Sync does not control plugins or `settings.json`; use `ccs auth resources` for that.
|
||||
|
||||
## Why This Is Safe Enough
|
||||
|
||||
@@ -59,6 +69,7 @@ accounts:
|
||||
work:
|
||||
created: "2026-02-24T00:00:00.000Z"
|
||||
last_used: null
|
||||
shared_resource_mode: "shared"
|
||||
context_mode: "shared"
|
||||
context_group: "team-alpha"
|
||||
continuity_mode: "deeper"
|
||||
@@ -66,6 +77,7 @@ accounts:
|
||||
|
||||
Rules:
|
||||
|
||||
- `shared_resource_mode` controls commands, skills, agents, plugins, and `settings.json` (`shared` or `profile-local`)
|
||||
- `context_mode` must be `isolated` or `shared`
|
||||
- `context_group` is required when `context_mode=shared`
|
||||
- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`)
|
||||
@@ -147,11 +159,27 @@ ccs auth create backup2 --share-context --context-group sprint-a --deeper-contin
|
||||
|
||||
### Existing account
|
||||
|
||||
History Sync:
|
||||
|
||||
- Open `ccs config`
|
||||
- Go to `Accounts`
|
||||
- Click the pencil icon (`Edit History Sync`)
|
||||
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
|
||||
|
||||
Shared Resources:
|
||||
|
||||
```bash
|
||||
ccs auth resources work --mode profile-local
|
||||
ccs auth resources work --mode shared
|
||||
```
|
||||
|
||||
Dashboard:
|
||||
|
||||
- Open `ccs config`
|
||||
- Go to `Accounts`
|
||||
- Use `Resources` to switch an existing account between `shared` and `profile-local`
|
||||
- Go to `Shared Resources` to inspect the shared commands, skills, agents, plugins, and `settings.json` hub
|
||||
|
||||
No account recreation required for this workflow.
|
||||
|
||||
### Backup Before Changing Sync
|
||||
@@ -172,6 +200,7 @@ ccs auth backup default
|
||||
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
|
||||
- Session continuity still depends on what the upstream tool/provider stores and allows.
|
||||
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
|
||||
- Shared Resources inspection is read-only in the dashboard. Editing individual files still belongs to the owning command, skill, plugin, or settings surface.
|
||||
|
||||
## Alternative: CLIProxy Claude Pool
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
handleBackup,
|
||||
handleList,
|
||||
handleShow,
|
||||
handleResources,
|
||||
handleRemove,
|
||||
handleDefault,
|
||||
handleResetDefault,
|
||||
@@ -74,6 +75,9 @@ class AuthCommands {
|
||||
);
|
||||
console.log(` ${color('list', 'command')} List all saved profiles`);
|
||||
console.log(` ${color('show <profile>', 'command')} Show profile details`);
|
||||
console.log(
|
||||
` ${color('resources <profile>', 'command')} Show or change shared resource mode`
|
||||
);
|
||||
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
|
||||
console.log(` ${color('default <profile>', 'command')} Set default profile`);
|
||||
console.log(
|
||||
@@ -105,6 +109,10 @@ class AuthCommands {
|
||||
console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`);
|
||||
console.log(` ${color('ccs auth create sandbox --bare', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Change shared resources for an existing account')}`);
|
||||
console.log(` ${color('ccs auth resources work --mode profile-local', 'command')}`);
|
||||
console.log(` ${color('ccs auth resources work --mode shared', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Set work as default')}`);
|
||||
console.log(` ${color('ccs auth default work', 'command')}`);
|
||||
console.log('');
|
||||
@@ -136,6 +144,9 @@ class AuthCommands {
|
||||
console.log(
|
||||
` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--mode <mode>', 'command')} Shared resource mode for resources: shared|profile-local`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
||||
);
|
||||
@@ -160,6 +171,9 @@ class AuthCommands {
|
||||
console.log(
|
||||
` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show <profile>', 'command')} shows the link state.`
|
||||
);
|
||||
console.log(
|
||||
` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.`
|
||||
);
|
||||
console.log(
|
||||
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
|
||||
);
|
||||
@@ -170,7 +184,10 @@ class AuthCommands {
|
||||
` To make future plain ${color('ccs', 'command')} resume with an account, set ${color('ccs auth default <profile>', 'command')}; back up the current native lane first with ${color('ccs auth backup default', 'command')}.`
|
||||
);
|
||||
console.log(
|
||||
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
|
||||
` Existing history sync: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
|
||||
);
|
||||
console.log(
|
||||
` Existing shared resources: use ${color('ccs auth resources <profile> --mode shared|profile-local', 'command')}.`
|
||||
);
|
||||
console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
|
||||
console.log(
|
||||
@@ -204,6 +221,10 @@ class AuthCommands {
|
||||
return handleShow(this.getContext(), args);
|
||||
}
|
||||
|
||||
async handleResources(args: string[]): Promise<void> {
|
||||
return handleResources(this.getContext(), args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove profile - delegates to remove-command.ts
|
||||
*/
|
||||
@@ -221,8 +242,8 @@ class AuthCommands {
|
||||
/**
|
||||
* Reset default profile - delegates to default-command.ts
|
||||
*/
|
||||
async handleResetDefault(): Promise<void> {
|
||||
return handleResetDefault(this.getContext());
|
||||
async handleResetDefault(args: string[] = []): Promise<void> {
|
||||
return handleResetDefault(this.getContext(), args);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,6 +284,10 @@ class AuthCommands {
|
||||
await this.handleShow(commandArgs);
|
||||
break;
|
||||
|
||||
case 'resources':
|
||||
await this.handleResources(commandArgs);
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
await this.handleRemove(commandArgs);
|
||||
break;
|
||||
@@ -272,7 +297,7 @@ class AuthCommands {
|
||||
break;
|
||||
|
||||
case 'reset-default':
|
||||
await this.handleResetDefault();
|
||||
await this.handleResetDefault(commandArgs);
|
||||
break;
|
||||
|
||||
case 'current':
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
resolveRuntimePlainCcsResumeLane,
|
||||
} from '../resume-lane-diagnostics';
|
||||
import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context';
|
||||
import { CommandContext, parseArgs } from './types';
|
||||
import { isProfileLocalSharedResourceMode } from '../shared-resource-policy';
|
||||
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
|
||||
|
||||
interface BackupManifest {
|
||||
target: string;
|
||||
@@ -36,7 +37,11 @@ function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean
|
||||
|
||||
export async function handleBackup(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { profileName, json } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName, json } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth backup <profile|default> [--json]',
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
@@ -69,7 +74,7 @@ export async function handleBackup(ctx: CommandContext, args: string[]): Promise
|
||||
isAccountContextMetadata(profile) ? profile : undefined
|
||||
);
|
||||
sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
||||
bare: profile.bare === true,
|
||||
bare: isProfileLocalSharedResourceMode(profile),
|
||||
});
|
||||
artifactNames = getContinuityArtifactNames('account');
|
||||
}
|
||||
|
||||
@@ -21,9 +21,13 @@ import {
|
||||
isValidAccountProfileName,
|
||||
resolveAccountContextPolicy,
|
||||
} from '../account-context';
|
||||
import {
|
||||
isProfileLocalSharedResourceMode,
|
||||
sharedResourceModeToMetadata,
|
||||
} from '../shared-resource-policy';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, parseArgs } from './types';
|
||||
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
|
||||
import { stripAmbientProviderCredentials } from './create-command-env';
|
||||
import { isUnifiedMode } from '../../config/config-loader-facade';
|
||||
|
||||
@@ -36,20 +40,13 @@ function sanitizeProfileNameForInstance(name: string): string {
|
||||
*/
|
||||
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { profileName, force, shareContext, contextGroup, deeperContinuity, bare, unknownFlags } =
|
||||
parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName, force, shareContext, contextGroup, deeperContinuity, bare } = parsed;
|
||||
|
||||
if (unknownFlags && unknownFlags.length > 0) {
|
||||
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
|
||||
console.log(fail(`Unknown option(s): ${unknownList}`));
|
||||
console.log('');
|
||||
console.log(
|
||||
`Usage: ${color('ccs auth create <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
|
||||
);
|
||||
console.log(`Help: ${color('ccs auth --help', 'command')}`);
|
||||
console.log('');
|
||||
exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage:
|
||||
'ccs auth create <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--deeper-continuity]',
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
@@ -117,8 +114,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
? ctx.registry.getAllAccountsUnified()[profileName]
|
||||
: undefined;
|
||||
const previousBare =
|
||||
previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true;
|
||||
isProfileLocalSharedResourceMode(previousLegacyProfile) ||
|
||||
isProfileLocalSharedResourceMode(previousUnifiedProfile);
|
||||
const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare);
|
||||
const resourceMetadata = effectiveBare ? sharedResourceModeToMetadata('profile-local') : {};
|
||||
const previousContextPolicy =
|
||||
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
|
||||
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
|
||||
@@ -200,13 +199,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
ctx.registry.updateAccountUnified(profileName, {
|
||||
context_mode: contextMetadata.context_mode,
|
||||
context_group: contextMetadata.context_group,
|
||||
...(effectiveBare ? { bare: true } : {}),
|
||||
...resourceMetadata,
|
||||
});
|
||||
ctx.registry.touchAccountUnified(profileName);
|
||||
} else {
|
||||
ctx.registry.createAccountUnified(profileName, {
|
||||
...contextMetadata,
|
||||
...(effectiveBare ? { bare: true } : {}),
|
||||
...resourceMetadata,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -216,14 +215,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
type: 'account',
|
||||
context_mode: contextMetadata.context_mode,
|
||||
context_group: contextMetadata.context_group,
|
||||
...(effectiveBare ? { bare: true } : {}),
|
||||
...resourceMetadata,
|
||||
});
|
||||
} else {
|
||||
ctx.registry.createProfile(profileName, {
|
||||
type: 'account',
|
||||
context_mode: contextMetadata.context_mode,
|
||||
context_group: contextMetadata.context_group,
|
||||
...(effectiveBare ? { bare: true } : {}),
|
||||
...resourceMetadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -282,7 +281,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
`Type: account\n` +
|
||||
`Context: ${formatAccountContextPolicy(contextPolicy)}\n` +
|
||||
`Tokens: isolated per account\n` +
|
||||
`Settings: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude/settings.json'}` +
|
||||
`Resources: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude'}` +
|
||||
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
|
||||
'Profile Created'
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { initUI, color, dim, ok, fail } from '../../utils/ui';
|
||||
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, parseArgs } from './types';
|
||||
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
|
||||
import { isUnifiedMode } from '../../config/config-loader-facade';
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,11 @@ import { isUnifiedMode } from '../../config/config-loader-facade';
|
||||
*/
|
||||
export async function handleDefault(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { profileName } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth default <profile>',
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
@@ -48,8 +52,12 @@ export async function handleDefault(ctx: CommandContext, args: string[]): Promis
|
||||
/**
|
||||
* Handle the reset-default command (clear the custom default)
|
||||
*/
|
||||
export async function handleResetDefault(ctx: CommandContext): Promise<void> {
|
||||
export async function handleResetDefault(ctx: CommandContext, args: string[] = []): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseArgs(args);
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth reset-default',
|
||||
});
|
||||
|
||||
try {
|
||||
// Use unified or legacy based on config mode
|
||||
|
||||
@@ -19,5 +19,6 @@ export { handleCreate } from './create-command';
|
||||
export { handleBackup } from './backup-command';
|
||||
export { handleList } from './list-command';
|
||||
export { handleShow } from './show-command';
|
||||
export { handleResources } from './resources-command';
|
||||
export { handleRemove } from './remove-command';
|
||||
export { handleDefault, handleResetDefault } from './default-command';
|
||||
@@ -7,16 +7,27 @@
|
||||
import { ProfileMetadata } from '../../types';
|
||||
import { initUI, header, color, dim, warn, table } from '../../utils/ui';
|
||||
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
||||
import { resolveSharedResourcePolicy } from '../shared-resource-policy';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types';
|
||||
import {
|
||||
CommandContext,
|
||||
ListOutput,
|
||||
parseArgs,
|
||||
formatRelativeTime,
|
||||
rejectUnsupportedAuthOptions,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Handle the list command
|
||||
*/
|
||||
export async function handleList(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { verbose, json } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { verbose, json } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth list [--verbose] [--json]',
|
||||
});
|
||||
|
||||
try {
|
||||
// Get profiles from both legacy (profiles.json) and unified config (config.yaml)
|
||||
@@ -33,6 +44,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
continuity_mode: account.continuity_mode,
|
||||
shared_resource_mode: account.shared_resource_mode,
|
||||
bare: account.bare,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +59,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
profiles: profileNames.map((name) => {
|
||||
const profile = profiles[name];
|
||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||
const resourcePolicy = resolveSharedResourcePolicy(profile);
|
||||
const isDefault = name === defaultProfile;
|
||||
const instancePath = ctx.instanceMgr.getInstancePath(name);
|
||||
|
||||
@@ -58,6 +72,9 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group || null,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||
shared_resource_mode: resourcePolicy.mode,
|
||||
shared_resource_inferred: resourcePolicy.inferred,
|
||||
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||
instance_path: instancePath,
|
||||
};
|
||||
}),
|
||||
@@ -107,6 +124,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
const profile = profiles[name];
|
||||
const isDefault = name === defaultProfile;
|
||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||
const resourcePolicy = resolveSharedResourcePolicy(profile);
|
||||
|
||||
// Status column
|
||||
const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success');
|
||||
@@ -122,6 +140,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
if (verbose) {
|
||||
row.push(lastUsed);
|
||||
row.push(formatAccountContextPolicy(contextPolicy));
|
||||
row.push(resourcePolicy.mode);
|
||||
}
|
||||
|
||||
return row;
|
||||
@@ -129,14 +148,14 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
|
||||
// Headers
|
||||
const headers = verbose
|
||||
? ['Profile', 'Type', 'Status', 'Last Used', 'Context']
|
||||
? ['Profile', 'Type', 'Status', 'Last Used', 'Context', 'Resources']
|
||||
: ['Profile', 'Type', 'Status'];
|
||||
|
||||
// Print table
|
||||
console.log(
|
||||
table(rows, {
|
||||
head: headers,
|
||||
colWidths: verbose ? [15, 12, 15, 12, 34] : [15, 12, 15],
|
||||
colWidths: verbose ? [15, 12, 15, 12, 34, 16] : [15, 12, 15],
|
||||
})
|
||||
);
|
||||
console.log('');
|
||||
|
||||
@@ -11,7 +11,7 @@ import { InteractivePrompt } from '../../utils/prompt';
|
||||
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, parseArgs } from './types';
|
||||
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
|
||||
import { isUnifiedMode } from '../../config/config-loader-facade';
|
||||
|
||||
/**
|
||||
@@ -19,7 +19,11 @@ import { isUnifiedMode } from '../../config/config-loader-facade';
|
||||
*/
|
||||
export async function handleRemove(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { profileName, yes } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName, yes } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth remove <profile> [--yes]',
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { initUI, header, color, fail, ok, table } from '../../utils/ui';
|
||||
import { resolveAccountContextPolicy } from '../account-context';
|
||||
import {
|
||||
isSharedResourceMode,
|
||||
resolveSharedResourcePolicy,
|
||||
sharedResourceModeToMetadata,
|
||||
type SharedResourceMode,
|
||||
} from '../shared-resource-policy';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
|
||||
|
||||
function formatMode(mode: SharedResourceMode): string {
|
||||
return mode === 'profile-local' ? 'profile-local' : 'shared';
|
||||
}
|
||||
|
||||
function modeDescription(mode: SharedResourceMode): string {
|
||||
return mode === 'profile-local'
|
||||
? 'profile-local resources; plugins/settings/commands/skills/agents are not linked from ~/.claude'
|
||||
: 'shared resources from ~/.claude; plugins/settings/commands/skills/agents are linked into the account';
|
||||
}
|
||||
|
||||
export async function handleResources(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseArgs(args, { allowMode: true });
|
||||
const { profileName, mode, json } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth resources <profile> [--mode shared|profile-local] [--json]',
|
||||
allowMode: true,
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(
|
||||
`Usage: ${color('ccs auth resources <profile> [--mode shared|profile-local] [--json]', 'command')}`
|
||||
);
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
const profiles = ctx.registry.getAllProfilesMerged();
|
||||
const currentProfile = profiles[profileName];
|
||||
if (!currentProfile || currentProfile.type !== 'account') {
|
||||
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
const currentPolicy = resolveSharedResourcePolicy(currentProfile);
|
||||
|
||||
if (mode === '') {
|
||||
exitWithError(
|
||||
'Missing value for --mode: expected shared|profile-local',
|
||||
ExitCode.PROFILE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (mode !== undefined && !isSharedResourceMode(mode)) {
|
||||
exitWithError(
|
||||
'Invalid shared resource mode: expected shared|profile-local',
|
||||
ExitCode.PROFILE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === undefined) {
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: profileName,
|
||||
shared_resource_mode: currentPolicy.mode,
|
||||
shared_resource_inferred: currentPolicy.inferred,
|
||||
bare: currentPolicy.profileLocal ? true : undefined,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(header(`Shared Resources: ${profileName}`));
|
||||
console.log('');
|
||||
console.log(
|
||||
table(
|
||||
[
|
||||
['Mode', formatMode(currentPolicy.mode)],
|
||||
['Effective', modeDescription(currentPolicy.mode)],
|
||||
],
|
||||
{ colWidths: [14, 70] }
|
||||
)
|
||||
);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
const existsUnified = ctx.registry.hasAccountUnified(profileName);
|
||||
const existsLegacy = ctx.registry.hasProfile(profileName);
|
||||
const previousUnified = existsUnified
|
||||
? ctx.registry.getAllAccountsUnified()[profileName]
|
||||
: undefined;
|
||||
const previousLegacy = existsLegacy ? ctx.registry.getProfile(profileName) : undefined;
|
||||
const contextPolicy = resolveAccountContextPolicy(currentProfile);
|
||||
const selectedMode: SharedResourceMode = mode;
|
||||
const metadata = sharedResourceModeToMetadata(selectedMode);
|
||||
|
||||
try {
|
||||
if (existsUnified) {
|
||||
ctx.registry.updateAccountUnified(profileName, metadata);
|
||||
}
|
||||
if (existsLegacy) {
|
||||
ctx.registry.updateProfile(profileName, metadata);
|
||||
}
|
||||
|
||||
await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
||||
bare: selectedMode === 'profile-local',
|
||||
});
|
||||
} catch (error) {
|
||||
if (existsUnified && previousUnified) {
|
||||
ctx.registry.updateAccountUnified(profileName, {
|
||||
...previousUnified,
|
||||
shared_resource_mode: previousUnified.shared_resource_mode,
|
||||
bare: previousUnified.bare,
|
||||
});
|
||||
}
|
||||
if (existsLegacy && previousLegacy) {
|
||||
ctx.registry.updateProfile(profileName, {
|
||||
...previousLegacy,
|
||||
shared_resource_mode: previousLegacy.shared_resource_mode,
|
||||
bare: previousLegacy.bare,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: profileName,
|
||||
shared_resource_mode: selectedMode,
|
||||
shared_resource_inferred: false,
|
||||
bare: selectedMode === 'profile-local' ? true : undefined,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(ok(`Shared resources for "${profileName}" set to ${formatMode(selectedMode)}`));
|
||||
console.log('');
|
||||
console.log(modeDescription(selectedMode));
|
||||
console.log('');
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import { initUI, header, color, fail, table } from '../../utils/ui';
|
||||
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
||||
import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics';
|
||||
import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics';
|
||||
import { resolveSharedResourcePolicy } from '../shared-resource-policy';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, ProfileOutput, parseArgs } from './types';
|
||||
import { CommandContext, ProfileOutput, parseArgs, rejectUnsupportedAuthOptions } from './types';
|
||||
|
||||
function formatHistorySummary(history: ReturnType<typeof summarizeAccountHistory>): string {
|
||||
const scope = history.projects_shared ? 'shared projects' : 'profile-local projects';
|
||||
@@ -25,7 +26,11 @@ function formatHistorySummary(history: ReturnType<typeof summarizeAccountHistory
|
||||
*/
|
||||
export async function handleShow(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { profileName, json } = parseArgs(args);
|
||||
const parsed = parseArgs(args);
|
||||
const { profileName, json } = parsed;
|
||||
rejectUnsupportedAuthOptions(parsed, {
|
||||
usage: 'ccs auth show <profile> [--json]',
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
@@ -45,7 +50,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
const isDefault = profileName === defaultProfile;
|
||||
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
|
||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||
const settingsSync = describeSettingsSync(instancePath, { bare: profile.bare === true });
|
||||
const resourcePolicy = resolveSharedResourcePolicy(profile);
|
||||
const settingsSync = describeSettingsSync(instancePath, { bare: resourcePolicy.profileLocal });
|
||||
const historySummary = summarizeAccountHistory(instancePath, contextPolicy);
|
||||
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane().catch(() => null);
|
||||
const plainCcsUsesThisAccount =
|
||||
@@ -74,6 +80,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group || null,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||
shared_resource_mode: resourcePolicy.mode,
|
||||
shared_resource_inferred: resourcePolicy.inferred,
|
||||
instance_path: instancePath,
|
||||
session_count: sessionCount,
|
||||
settings_sync: {
|
||||
@@ -94,7 +102,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(profile.bare ? { bare: true } : {}),
|
||||
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
@@ -112,6 +120,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
['Created', new Date(profile.created).toLocaleString()],
|
||||
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
|
||||
['Context', formatAccountContextPolicy(contextPolicy)],
|
||||
['Resources', resourcePolicy.mode],
|
||||
['Credentials', 'isolated per account'],
|
||||
['Settings', settingsSync.description],
|
||||
['History', formatHistorySummary(historySummary)],
|
||||
@@ -125,7 +134,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
],
|
||||
]
|
||||
: []),
|
||||
...(profile.bare ? [['Bare', 'yes (no shared symlinks)']] : []),
|
||||
...(resourcePolicy.profileLocal ? [['Bare', 'yes (no shared symlinks)']] : []),
|
||||
['Sessions', `${sessionCount}`],
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
import ProfileRegistry from '../profile-registry';
|
||||
import { InstanceManager } from '../../management/instance-manager';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { color, fail } from '../../utils/ui';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { formatRelativeTime } from '../../utils/time';
|
||||
@@ -23,6 +26,7 @@ export interface AuthCommandArgs {
|
||||
contextGroup?: string;
|
||||
deeperContinuity?: boolean;
|
||||
bare?: boolean;
|
||||
mode?: string;
|
||||
unknownFlags?: string[];
|
||||
}
|
||||
|
||||
@@ -38,6 +42,8 @@ export interface ProfileOutput {
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string | null;
|
||||
continuity_mode?: 'standard' | 'deeper' | null;
|
||||
shared_resource_mode?: 'shared' | 'profile-local';
|
||||
shared_resource_inferred?: boolean;
|
||||
instance_path?: string;
|
||||
session_count?: number;
|
||||
settings_sync?: {
|
||||
@@ -80,12 +86,39 @@ export interface CommandContext {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function rejectUnsupportedAuthOptions(
|
||||
parsed: Pick<AuthCommandArgs, 'mode' | 'unknownFlags'>,
|
||||
options: { usage: string; allowMode?: boolean }
|
||||
): void {
|
||||
const unsupportedOptions = [
|
||||
...(parsed.unknownFlags ?? []),
|
||||
...(!options.allowMode && parsed.mode !== undefined ? ['--mode'] : []),
|
||||
];
|
||||
|
||||
if (unsupportedOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', ');
|
||||
console.log(fail(`Unknown option(s): ${unknownList}`));
|
||||
console.log('');
|
||||
console.log(`Usage: ${color(options.usage, 'command')}`);
|
||||
console.log(`Help: ${color('ccs auth --help', 'command')}`);
|
||||
console.log('');
|
||||
exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
interface ParseArgsOptions {
|
||||
allowMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse command arguments from raw args array
|
||||
*/
|
||||
export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
export function parseArgs(args: string[], options: ParseArgsOptions = {}): AuthCommandArgs {
|
||||
let profileName: string | undefined;
|
||||
let contextGroup: string | undefined;
|
||||
let mode: string | undefined;
|
||||
const unknownFlags = new Set<string>();
|
||||
const knownBooleanFlags = new Set([
|
||||
'--force',
|
||||
@@ -98,6 +131,9 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
'--bare',
|
||||
]);
|
||||
const knownValueFlags = new Set(['--context-group']);
|
||||
if (options.allowMode) {
|
||||
knownValueFlags.add('--mode');
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
@@ -114,11 +150,28 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.allowMode && arg === '--mode') {
|
||||
const next = args[i + 1];
|
||||
if (!next || next.startsWith('-')) {
|
||||
mode = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
mode = next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--context-group=')) {
|
||||
contextGroup = arg.slice('--context-group='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.allowMode && arg.startsWith('--mode=')) {
|
||||
mode = arg.slice('--mode='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
|
||||
const isKnownFlag =
|
||||
@@ -149,6 +202,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
shareContext: args.includes('--share-context'),
|
||||
deeperContinuity: args.includes('--deeper-continuity'),
|
||||
bare: args.includes('--bare'),
|
||||
mode,
|
||||
contextGroup,
|
||||
unknownFlags: [...unknownFlags],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { warn } from '../utils/ui';
|
||||
import InstanceManager from '../management/instance-manager';
|
||||
import ProfileRegistry from './profile-registry';
|
||||
import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context';
|
||||
import { isProfileLocalSharedResourceMode } from './shared-resource-policy';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat';
|
||||
import {
|
||||
@@ -155,7 +156,7 @@ export async function resolveProfileContinuityInheritance(
|
||||
);
|
||||
const instanceMgr = new InstanceManager();
|
||||
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, {
|
||||
bare: mappedProfile.bare === true,
|
||||
bare: isProfileLocalSharedResourceMode(mappedProfile),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -213,6 +213,7 @@ class ProfileDetector {
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
continuity_mode: account.continuity_mode,
|
||||
shared_resource_mode: account.shared_resource_mode,
|
||||
bare: account.bare,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
mutateConfig,
|
||||
} from '../config/config-loader-facade';
|
||||
import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy';
|
||||
|
||||
const logger = createLogger('auth:profile-registry');
|
||||
|
||||
@@ -50,6 +51,7 @@ interface CreateMetadata {
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
shared_resource_mode?: SharedResourceMode;
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export class ProfileRegistry {
|
||||
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
return normalizeSharedResourceMetadata(normalized);
|
||||
}
|
||||
|
||||
private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig {
|
||||
@@ -110,7 +112,7 @@ export class ProfileRegistry {
|
||||
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
return normalizeSharedResourceMetadata(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,6 +181,7 @@ export class ProfileRegistry {
|
||||
context_mode: metadata.context_mode,
|
||||
context_group: metadata.context_group,
|
||||
continuity_mode: metadata.continuity_mode,
|
||||
shared_resource_mode: metadata.shared_resource_mode,
|
||||
bare: metadata.bare,
|
||||
});
|
||||
|
||||
@@ -335,6 +338,7 @@ export class ProfileRegistry {
|
||||
context_mode: metadata.context_mode,
|
||||
context_group: metadata.context_group,
|
||||
continuity_mode: metadata.continuity_mode,
|
||||
shared_resource_mode: metadata.shared_resource_mode,
|
||||
bare: metadata.bare,
|
||||
});
|
||||
});
|
||||
@@ -462,6 +466,7 @@ export class ProfileRegistry {
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
continuity_mode: account.continuity_mode,
|
||||
shared_resource_mode: account.shared_resource_mode,
|
||||
bare: account.bare,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
export type SharedResourceMode = 'shared' | 'profile-local';
|
||||
|
||||
export interface SharedResourceMetadata {
|
||||
shared_resource_mode?: unknown;
|
||||
bare?: unknown;
|
||||
}
|
||||
|
||||
export interface SharedResourcePolicy {
|
||||
mode: SharedResourceMode;
|
||||
inferred: boolean;
|
||||
profileLocal: boolean;
|
||||
}
|
||||
|
||||
export interface SharedResourceMetadataUpdate {
|
||||
shared_resource_mode: SharedResourceMode;
|
||||
bare?: boolean | undefined;
|
||||
}
|
||||
|
||||
export function isSharedResourceMode(value: unknown): value is SharedResourceMode {
|
||||
return value === 'shared' || value === 'profile-local';
|
||||
}
|
||||
|
||||
export function resolveSharedResourcePolicy(
|
||||
metadata?: SharedResourceMetadata | null
|
||||
): SharedResourcePolicy {
|
||||
const explicitMode = metadata?.shared_resource_mode;
|
||||
if (isSharedResourceMode(explicitMode)) {
|
||||
return {
|
||||
mode: explicitMode,
|
||||
inferred: false,
|
||||
profileLocal: explicitMode === 'profile-local',
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata?.bare === true) {
|
||||
return {
|
||||
mode: 'profile-local',
|
||||
inferred: true,
|
||||
profileLocal: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'shared',
|
||||
inferred: true,
|
||||
profileLocal: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function isProfileLocalSharedResourceMode(
|
||||
metadata?: SharedResourceMetadata | null
|
||||
): boolean {
|
||||
return resolveSharedResourcePolicy(metadata).profileLocal;
|
||||
}
|
||||
|
||||
export function sharedResourceModeToMetadata(
|
||||
mode: SharedResourceMode
|
||||
): SharedResourceMetadataUpdate {
|
||||
if (mode === 'profile-local') {
|
||||
return {
|
||||
shared_resource_mode: 'profile-local',
|
||||
bare: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shared_resource_mode: 'shared',
|
||||
bare: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSharedResourceMetadata<T extends SharedResourceMetadata>(metadata: T): T {
|
||||
const normalized = { ...metadata } as T & {
|
||||
shared_resource_mode?: SharedResourceMode;
|
||||
bare?: boolean;
|
||||
};
|
||||
|
||||
if (normalized.shared_resource_mode === 'profile-local') {
|
||||
normalized.bare = true;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized.shared_resource_mode === 'shared') {
|
||||
delete normalized.bare;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
delete normalized.shared_resource_mode;
|
||||
if (normalized.bare === true) {
|
||||
normalized.shared_resource_mode = 'profile-local';
|
||||
normalized.bare = true;
|
||||
} else {
|
||||
delete normalized.bare;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -248,6 +248,7 @@ export const AUTH_SUBCOMMANDS = [
|
||||
'backup',
|
||||
'list',
|
||||
'show',
|
||||
'resources',
|
||||
'remove',
|
||||
'default',
|
||||
'reset-default',
|
||||
|
||||
@@ -108,6 +108,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
||||
return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']);
|
||||
if (subcommand === 'show')
|
||||
return completeSubcommands(getProfileNames('accounts'), ['--json']);
|
||||
if (subcommand === 'resources')
|
||||
return completeSubcommands(getProfileNames('accounts'), [
|
||||
'--mode',
|
||||
'--mode=shared',
|
||||
'--mode=profile-local',
|
||||
'--json',
|
||||
]);
|
||||
if (subcommand === 'remove')
|
||||
return completeSubcommands(getProfileNames('accounts'), ['--yes', '-y']);
|
||||
if (subcommand === 'default') return completeSubcommands(getProfileNames('accounts'));
|
||||
|
||||
@@ -54,6 +54,7 @@ async function showProfilesHelp(writeLine: HelpWriter): Promise<void> {
|
||||
'Profile Types',
|
||||
[
|
||||
{ name: 'ccs auth create <name>', summary: 'Concurrent Claude account profile' },
|
||||
{ name: 'ccs auth resources <name>', summary: 'Shared resources for an account profile' },
|
||||
{ name: 'ccs api create', summary: 'API-backed settings profile' },
|
||||
{ name: 'ccs cliproxy create <name>', summary: 'Named CLIProxy variant profile' },
|
||||
{ name: 'ccs env <profile>', summary: 'Export an existing profile for other tools' },
|
||||
|
||||
@@ -45,13 +45,14 @@ export async function handleSyncCommand(): Promise<void> {
|
||||
const { InstanceManager } = await import('../management/instance-manager');
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ProfileRegistry = (await import('../auth/profile-registry')).default;
|
||||
const { isProfileLocalSharedResourceMode } = await import('../auth/shared-resource-policy');
|
||||
const registry = new ProfileRegistry();
|
||||
const allProfiles = registry.getAllProfilesMerged();
|
||||
let mcpSynced = 0;
|
||||
|
||||
for (const [name, profile] of Object.entries(allProfiles)) {
|
||||
if (profile.bare) {
|
||||
continue; // Skip bare profiles
|
||||
if (isProfileLocalSharedResourceMode(profile)) {
|
||||
continue; // Skip profile-local shared-resource profiles
|
||||
}
|
||||
|
||||
if (!instanceMgr.hasInstance(name)) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { createEmptyUnifiedConfig } from './unified-config-types';
|
||||
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
||||
import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader';
|
||||
import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context';
|
||||
import { isSharedResourceMode, resolveSharedResourcePolicy } from '../auth/shared-resource-policy';
|
||||
import { infoBox, warn } from '../utils/ui';
|
||||
|
||||
const BACKUP_DIR_PREFIX = 'backup-v1-';
|
||||
@@ -222,6 +223,16 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
|
||||
context_group: contextMode === 'shared' ? contextGroup : undefined,
|
||||
continuity_mode: contextMode === 'shared' ? continuityMode : undefined,
|
||||
};
|
||||
const resourcePolicy = resolveSharedResourcePolicy({
|
||||
shared_resource_mode: metadata.shared_resource_mode,
|
||||
bare: metadata.bare,
|
||||
});
|
||||
if (resourcePolicy.mode === 'profile-local') {
|
||||
account.shared_resource_mode = 'profile-local';
|
||||
account.bare = true;
|
||||
} else if (isSharedResourceMode(metadata.shared_resource_mode)) {
|
||||
account.shared_resource_mode = 'shared';
|
||||
}
|
||||
unifiedConfig.accounts[name] = account;
|
||||
}
|
||||
migratedFiles.push('profiles.json → config.yaml.accounts');
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface AccountConfig {
|
||||
context_group?: string;
|
||||
/** Shared continuity depth when context_mode='shared' */
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
/** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */
|
||||
shared_resource_mode?: 'shared' | 'profile-local';
|
||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { execClaude } from '../../utils/shell-executor';
|
||||
import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning';
|
||||
import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy';
|
||||
import { resolveNativeClaudeLaunchArgs } from '../environment-builder';
|
||||
import type { ProfileDispatchContext } from '../dispatcher-context';
|
||||
|
||||
@@ -26,10 +27,11 @@ export async function runAccountFlow(ctx: ProfileDispatchContext): Promise<void>
|
||||
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
|
||||
? profileInfo.profile
|
||||
: undefined;
|
||||
const isBareProfile =
|
||||
typeof profileInfo.profile === 'object' &&
|
||||
profileInfo.profile !== null &&
|
||||
(profileInfo.profile as { bare?: unknown }).bare === true;
|
||||
const isBareProfile = isProfileLocalSharedResourceMode(
|
||||
typeof profileInfo.profile === 'object' && profileInfo.profile !== null
|
||||
? (profileInfo.profile as { shared_resource_mode?: unknown; bare?: unknown })
|
||||
: undefined
|
||||
);
|
||||
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
|
||||
|
||||
// Ensure instance exists (lazy init if needed)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { loadSettingsFromFile, type ProfileType } from '../auth/profile-detector
|
||||
import ProfileDetector from '../auth/profile-detector';
|
||||
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
||||
import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context';
|
||||
import { isProfileLocalSharedResourceMode } from '../auth/shared-resource-policy';
|
||||
import type { ProfileDetectionResult } from '../auth/profile-detector';
|
||||
import {
|
||||
getEffectiveEnvVars,
|
||||
@@ -158,8 +159,12 @@ async function resolveExtensionEnv(
|
||||
const policy = resolveAccountContextPolicy(
|
||||
isAccountContextMetadata(result.profile) ? result.profile : undefined
|
||||
);
|
||||
const sharedResourceMetadata =
|
||||
typeof result.profile === 'object' && result.profile !== null
|
||||
? (result.profile as { shared_resource_mode?: unknown; bare?: unknown })
|
||||
: undefined;
|
||||
const instancePath = await instanceManager.ensureInstance(result.name, policy, {
|
||||
bare: result.profile?.bare === true,
|
||||
bare: isProfileLocalSharedResourceMode(sharedResourceMetadata),
|
||||
});
|
||||
notes.push('Account profiles authenticate through the isolated Claude config directory.');
|
||||
return {
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface ProfileMetadata {
|
||||
context_group?: string;
|
||||
/** Shared continuity depth when context_mode='shared' */
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
/** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */
|
||||
shared_resource_mode?: 'shared' | 'profile-local';
|
||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface MergedAccountEntry {
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
shared_resource_mode?: 'shared' | 'profile-local';
|
||||
shared_resource_inferred?: boolean;
|
||||
bare?: boolean;
|
||||
provider?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
normalizeContextGroupName,
|
||||
resolveAccountContextPolicy,
|
||||
} from '../../auth/account-context';
|
||||
import {
|
||||
isSharedResourceMode,
|
||||
resolveSharedResourcePolicy,
|
||||
sharedResourceModeToMetadata,
|
||||
} from '../../auth/shared-resource-policy';
|
||||
import {
|
||||
buildCliproxyAccountKey,
|
||||
parseCliproxyKey,
|
||||
@@ -79,6 +84,7 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
// Add legacy profiles first
|
||||
for (const [name, meta] of Object.entries(legacyProfiles)) {
|
||||
const contextPolicy = resolveAccountContextPolicy(meta);
|
||||
const resourcePolicy = resolveSharedResourcePolicy(meta);
|
||||
const hasExplicitContextMode =
|
||||
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
|
||||
const hasExplicitContinuityMode =
|
||||
@@ -93,6 +99,9 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
context_inferred: !hasExplicitContextMode,
|
||||
continuity_inferred:
|
||||
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
||||
shared_resource_mode: resourcePolicy.mode,
|
||||
shared_resource_inferred: resourcePolicy.inferred,
|
||||
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +109,7 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
||||
const rawAccount = rawUnifiedAccounts[name];
|
||||
const contextPolicy = resolveAccountContextPolicy(account);
|
||||
const resourcePolicy = resolveSharedResourcePolicy(account);
|
||||
const hasExplicitContextMode =
|
||||
rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared';
|
||||
const hasExplicitContinuityMode =
|
||||
@@ -114,6 +124,9 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
context_inferred: !hasExplicitContextMode,
|
||||
continuity_inferred:
|
||||
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
||||
shared_resource_mode: resourcePolicy.mode,
|
||||
shared_resource_inferred: resourcePolicy.inferred,
|
||||
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,7 +318,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
|
||||
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
|
||||
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
||||
const isBare = previousUnified?.bare === true || previousLegacy?.bare === true;
|
||||
const resourcePolicy = resolveSharedResourcePolicy(previousUnified ?? previousLegacy);
|
||||
|
||||
try {
|
||||
if (existsUnified) {
|
||||
@@ -315,13 +328,21 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
registry.updateProfile(name, metadata);
|
||||
}
|
||||
|
||||
await instanceMgr.ensureInstance(name, policy, { bare: isBare });
|
||||
await instanceMgr.ensureInstance(name, policy, { bare: resourcePolicy.profileLocal });
|
||||
} catch (error) {
|
||||
if (existsUnified && previousUnified) {
|
||||
registry.updateAccountUnified(name, previousUnified);
|
||||
registry.updateAccountUnified(name, {
|
||||
...previousUnified,
|
||||
shared_resource_mode: previousUnified.shared_resource_mode,
|
||||
bare: previousUnified.bare,
|
||||
});
|
||||
}
|
||||
if (existsLegacy && previousLegacy) {
|
||||
registry.updateProfile(name, previousLegacy);
|
||||
registry.updateProfile(name, {
|
||||
...previousLegacy,
|
||||
shared_resource_mode: previousLegacy.shared_resource_mode,
|
||||
bare: previousLegacy.bare,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -342,6 +363,89 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/accounts/:name/shared-resources - Update account shared resource mode
|
||||
*/
|
||||
router.put('/:name/shared-resources', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const registry = createProfileRegistry();
|
||||
const instanceMgr = createInstanceManager();
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing account name' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null;
|
||||
if (cliproxyKey) {
|
||||
res.status(400).json({
|
||||
error: `Shared resource mode is not supported for CLIProxy account: ${name}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name);
|
||||
const existsLegacy = registry.hasProfile(name);
|
||||
if (!existsUnified && !existsLegacy) {
|
||||
res.status(404).json({ error: `Account not found: ${name}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = req.body?.shared_resource_mode;
|
||||
if (!isSharedResourceMode(mode)) {
|
||||
res.status(400).json({
|
||||
error: 'Missing or invalid shared_resource_mode: expected shared|profile-local',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
|
||||
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
||||
const previousMetadata = previousUnified ?? previousLegacy;
|
||||
const contextPolicy = resolveAccountContextPolicy(previousMetadata);
|
||||
const metadata = sharedResourceModeToMetadata(mode);
|
||||
|
||||
try {
|
||||
if (existsUnified) {
|
||||
registry.updateAccountUnified(name, metadata);
|
||||
}
|
||||
if (existsLegacy) {
|
||||
registry.updateProfile(name, metadata);
|
||||
}
|
||||
|
||||
await instanceMgr.ensureInstance(name, contextPolicy, {
|
||||
bare: mode === 'profile-local',
|
||||
});
|
||||
} catch (error) {
|
||||
if (existsUnified && previousUnified) {
|
||||
registry.updateAccountUnified(name, {
|
||||
...previousUnified,
|
||||
shared_resource_mode: previousUnified.shared_resource_mode,
|
||||
bare: previousUnified.bare,
|
||||
});
|
||||
}
|
||||
if (existsLegacy && previousLegacy) {
|
||||
registry.updateProfile(name, {
|
||||
...previousLegacy,
|
||||
shared_resource_mode: previousLegacy.shared_resource_mode,
|
||||
bare: previousLegacy.bare,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
res.json({
|
||||
name,
|
||||
shared_resource_mode: mode,
|
||||
shared_resource_inferred: false,
|
||||
bare: mode === 'profile-local' ? true : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/accounts/reset-default - Reset to CCS default
|
||||
*/
|
||||
|
||||
+403
-13
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Shared Data Routes (Phase 07)
|
||||
*
|
||||
* API routes for commands, skills, agents from ~/.ccs/shared/
|
||||
* API routes for commands, skills, agents, and plugins from ~/.ccs/shared/
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
@@ -32,14 +32,17 @@ const MAX_DESCRIPTION_LENGTH = 140;
|
||||
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
|
||||
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
|
||||
const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:';
|
||||
const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']);
|
||||
|
||||
type SharedCollectionType = 'commands' | 'skills' | 'agents';
|
||||
type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins';
|
||||
type SharedContentType = SharedCollectionType | 'settings';
|
||||
|
||||
interface SharedItem {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
type: 'command' | 'skill' | 'agent';
|
||||
type: 'command' | 'skill' | 'agent' | 'plugin';
|
||||
}
|
||||
|
||||
interface SharedItemsCacheEntry {
|
||||
@@ -48,6 +51,11 @@ interface SharedItemsCacheEntry {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface InstalledPluginRegistry {
|
||||
version?: number;
|
||||
plugins: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
|
||||
|
||||
/**
|
||||
@@ -75,13 +83,21 @@ sharedRoutes.get('/agents', (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/shared/content?type=commands|skills|agents&path=<item-path>
|
||||
* GET /api/shared/plugins
|
||||
*/
|
||||
sharedRoutes.get('/plugins', (_req: Request, res: Response) => {
|
||||
const items = getSharedItems('plugins');
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/shared/content?type=commands|skills|agents|plugins|settings&path=<item-path>
|
||||
*/
|
||||
sharedRoutes.get('/content', (req: Request, res: Response) => {
|
||||
const typeParam = req.query.type;
|
||||
const itemPathParam = req.query.path;
|
||||
|
||||
if (!isSharedCollectionType(typeParam)) {
|
||||
if (!isSharedContentType(typeParam)) {
|
||||
res.status(400).json({ error: 'Invalid or missing type parameter' });
|
||||
return;
|
||||
}
|
||||
@@ -91,15 +107,19 @@ sharedRoutes.get('/content', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared', typeParam);
|
||||
const sharedDir =
|
||||
typeParam === 'settings' ? path.join(ccsDir, 'shared') : path.join(ccsDir, 'shared', typeParam);
|
||||
if (!fs.existsSync(sharedDir)) {
|
||||
res.status(404).json({ error: 'Shared directory not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
||||
const allowedRoots = resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
||||
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots);
|
||||
const allowedRoots =
|
||||
typeParam === 'settings'
|
||||
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
|
||||
: resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
||||
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot);
|
||||
|
||||
if (!contentResult) {
|
||||
res.status(404).json({ error: 'Shared content not found' });
|
||||
@@ -116,18 +136,37 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => {
|
||||
const commands = getSharedItems('commands').length;
|
||||
const skills = getSharedItems('skills').length;
|
||||
const agents = getSharedItems('agents').length;
|
||||
const plugins = getSharedItems('plugins').length;
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||
const sharedDirRoot = safeRealPath(path.join(ccsDir, 'shared'));
|
||||
const resolvedSettingsPath = safeRealPath(settingsPath);
|
||||
const settingsAllowedRoots = sharedDirRoot
|
||||
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
|
||||
: new Set<string>();
|
||||
const hasSettings =
|
||||
Boolean(sharedDirRoot) &&
|
||||
Boolean(resolvedSettingsPath) &&
|
||||
isPathWithinAny(resolvedSettingsPath as string, settingsAllowedRoots);
|
||||
|
||||
res.json({
|
||||
commands,
|
||||
skills,
|
||||
agents,
|
||||
total: commands + skills + agents,
|
||||
plugins,
|
||||
settings: hasSettings,
|
||||
total: commands + skills + agents + plugins + (hasSettings ? 1 : 0),
|
||||
symlinkStatus: checkSymlinkStatus(),
|
||||
});
|
||||
});
|
||||
|
||||
function isSharedCollectionType(value: unknown): value is SharedCollectionType {
|
||||
return value === 'commands' || value === 'skills' || value === 'agents';
|
||||
return value === 'commands' || value === 'skills' || value === 'agents' || value === 'plugins';
|
||||
}
|
||||
|
||||
function isSharedContentType(value: unknown): value is SharedContentType {
|
||||
return isSharedCollectionType(value) || value === 'settings';
|
||||
}
|
||||
|
||||
function resolveAllowedRoots(
|
||||
@@ -135,7 +174,7 @@ function resolveAllowedRoots(
|
||||
ccsDir: string,
|
||||
sharedDirRoot: string
|
||||
): Set<string> {
|
||||
if (type === 'commands') {
|
||||
if (type === 'commands' || type === 'plugins') {
|
||||
return new Set<string>([sharedDirRoot]);
|
||||
}
|
||||
|
||||
@@ -151,6 +190,17 @@ function resolveAllowedRoots(
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set<string> {
|
||||
const claudeConfigDir = getClaudeConfigDir();
|
||||
return new Set<string>(
|
||||
[
|
||||
sharedDirRoot,
|
||||
safeRealPath(path.join(claudeConfigDir, 'settings.json')),
|
||||
safeRealPath(path.join(ccsDir, '.claude', 'settings.json')),
|
||||
].filter((dirPath): dirPath is string => typeof dirPath === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function getSharedItems(type: SharedCollectionType): SharedItem[] {
|
||||
const ccsDir = getCcsDir();
|
||||
const sharedDir = path.join(ccsDir, 'shared', type);
|
||||
@@ -180,6 +230,16 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] {
|
||||
return commandItems;
|
||||
}
|
||||
|
||||
if (type === 'plugins') {
|
||||
const pluginItems = getPluginItems(sharedDir, allowedRoots);
|
||||
sharedItemsCache.set(type, {
|
||||
items: pluginItems,
|
||||
sharedDir,
|
||||
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
|
||||
});
|
||||
return pluginItems;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||
|
||||
@@ -222,6 +282,76 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] {
|
||||
return sortedItems;
|
||||
}
|
||||
|
||||
function getPluginItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const registryItems = getPluginRegistryItems(sharedDir, allowedRoots);
|
||||
const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots);
|
||||
const itemsByPath = new Map<string, SharedItem>();
|
||||
|
||||
for (const item of [...registryItems, ...legacyDirectoryItems]) {
|
||||
itemsByPath.set(item.path, item);
|
||||
}
|
||||
|
||||
return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getPluginRegistryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
|
||||
if (!registry) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({
|
||||
name: pluginName,
|
||||
description: describeInstalledPlugin(pluginName, metadata),
|
||||
path: encodePluginRegistryPath(pluginName),
|
||||
type: 'plugin' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function getLegacyPluginDirectoryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: SharedItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = path.join(sharedDir, entry.name);
|
||||
const resolvedEntryPath = safeRealPath(entryPath);
|
||||
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const description = readFirstMarkdownDescription(
|
||||
[
|
||||
path.join(resolvedEntryPath, 'README.md'),
|
||||
path.join(resolvedEntryPath, 'readme.md'),
|
||||
path.join(resolvedEntryPath, 'PLUGIN.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
name: entry.name,
|
||||
description,
|
||||
path: entryPath,
|
||||
type: 'plugin',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
|
||||
const items: SharedItem[] = [];
|
||||
@@ -504,6 +634,89 @@ function readMarkdownContent(markdownPath: string, allowedRoots: Set<string>): s
|
||||
}
|
||||
}
|
||||
|
||||
function encodePluginRegistryPath(pluginName: string): string {
|
||||
return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`;
|
||||
}
|
||||
|
||||
function decodePluginRegistryPath(itemPath: string): string | null {
|
||||
if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length);
|
||||
const pluginName = decodeURIComponent(encodedName);
|
||||
return pluginName.length > 0 ? pluginName : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonObject(
|
||||
filePath: string,
|
||||
allowedRoots: Set<string>
|
||||
): Record<string, unknown> | null {
|
||||
const resolvedPath = safeRealPath(filePath);
|
||||
if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPluginInstalledRegistry(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): InstalledPluginRegistry | null {
|
||||
const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots);
|
||||
if (
|
||||
!parsed ||
|
||||
!parsed.plugins ||
|
||||
typeof parsed.plugins !== 'object' ||
|
||||
Array.isArray(parsed.plugins)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: typeof parsed.version === 'number' ? parsed.version : undefined,
|
||||
plugins: parsed.plugins as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function describeInstalledPlugin(pluginName: string, metadata: unknown): string {
|
||||
const recordCount = Array.isArray(metadata) ? metadata.length : 1;
|
||||
const marketplace = extractMarketplaceFromPluginName(pluginName);
|
||||
const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`;
|
||||
|
||||
return marketplace
|
||||
? `Installed from ${marketplace}; ${recordLabel} in shared registry`
|
||||
: `Installed plugin; ${recordLabel} in shared registry`;
|
||||
}
|
||||
|
||||
function extractMarketplaceFromPluginName(pluginName: string): string | null {
|
||||
const atIndex = pluginName.lastIndexOf('@');
|
||||
if (atIndex <= 0 || atIndex === pluginName.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pluginName.slice(atIndex + 1);
|
||||
}
|
||||
|
||||
function resolveReadableMarkdownPath(
|
||||
markdownPaths: string[],
|
||||
allowedRoots: Set<string>
|
||||
@@ -529,10 +742,22 @@ function resolveReadableMarkdownPath(
|
||||
}
|
||||
|
||||
function getSharedItemContent(
|
||||
type: SharedCollectionType,
|
||||
type: SharedContentType,
|
||||
itemPath: string,
|
||||
allowedRoots: Set<string>
|
||||
allowedRoots: Set<string>,
|
||||
sharedDir: string
|
||||
): { content: string; contentPath: string } | null {
|
||||
if (type === 'settings') {
|
||||
return getSharedSettingsContent(itemPath, allowedRoots);
|
||||
}
|
||||
|
||||
if (type === 'plugins') {
|
||||
const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots);
|
||||
if (pluginRegistryContent) {
|
||||
return pluginRegistryContent;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedItemPath = safeRealPath(itemPath);
|
||||
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
|
||||
return null;
|
||||
@@ -559,6 +784,23 @@ function getSharedItemContent(
|
||||
[path.join(resolvedItemPath, 'SKILL.md')],
|
||||
allowedRoots
|
||||
);
|
||||
} else if (type === 'plugins') {
|
||||
if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
markdownPath = resolveReadableMarkdownPath(
|
||||
[
|
||||
path.join(resolvedItemPath, 'README.md'),
|
||||
path.join(resolvedItemPath, 'readme.md'),
|
||||
path.join(resolvedItemPath, 'PLUGIN.md'),
|
||||
],
|
||||
allowedRoots
|
||||
);
|
||||
|
||||
if (!markdownPath) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (itemStats.isDirectory()) {
|
||||
markdownPath = resolveReadableMarkdownPath(
|
||||
@@ -589,6 +831,106 @@ function getSharedItemContent(
|
||||
};
|
||||
}
|
||||
|
||||
function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean {
|
||||
for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) {
|
||||
const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName));
|
||||
if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getPluginRegistryContent(
|
||||
itemPath: string,
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>
|
||||
): { content: string; contentPath: string } | null {
|
||||
const pluginName = decodePluginRegistryPath(itemPath);
|
||||
if (!pluginName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registryPath = path.join(sharedDir, 'installed_plugins.json');
|
||||
const resolvedRegistryPath = safeRealPath(registryPath);
|
||||
if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
|
||||
if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = registry.plugins[pluginName];
|
||||
const marketplace = extractMarketplaceFromPluginName(pluginName);
|
||||
const marketplaceEntry = marketplace
|
||||
? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace]
|
||||
: null;
|
||||
const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName);
|
||||
const lines = [
|
||||
`# ${pluginName}`,
|
||||
'',
|
||||
`Registry: \`${resolvedRegistryPath}\``,
|
||||
'',
|
||||
'## Installed Plugin',
|
||||
'',
|
||||
`- Source: shared \`installed_plugins.json\` registry`,
|
||||
`- Registry version: ${registry.version ?? 'unknown'}`,
|
||||
`- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`,
|
||||
`- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`,
|
||||
];
|
||||
|
||||
if (marketplaceEntry) {
|
||||
lines.push(
|
||||
'',
|
||||
'## Marketplace Registry Entry',
|
||||
'',
|
||||
'```json',
|
||||
stringifyJson(marketplaceEntry),
|
||||
'```'
|
||||
);
|
||||
}
|
||||
|
||||
if (blocklistEntry) {
|
||||
lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```');
|
||||
}
|
||||
|
||||
lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```');
|
||||
|
||||
return {
|
||||
content: lines.join('\n'),
|
||||
contentPath: resolvedRegistryPath,
|
||||
};
|
||||
}
|
||||
|
||||
function findPluginBlocklistEntry(
|
||||
sharedDir: string,
|
||||
allowedRoots: Set<string>,
|
||||
pluginName: string
|
||||
): unknown | null {
|
||||
const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots);
|
||||
const plugins = blocklist?.plugins;
|
||||
if (!Array.isArray(plugins)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
plugins.find(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
'plugin' in entry &&
|
||||
(entry as { plugin?: unknown }).plugin === pluginName
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2) ?? 'null';
|
||||
}
|
||||
|
||||
function safeRealPath(targetPath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(targetPath);
|
||||
@@ -597,6 +939,54 @@ function safeRealPath(targetPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedSettingsContent(
|
||||
itemPath: string,
|
||||
allowedRoots: Set<string>
|
||||
): { content: string; contentPath: string } | null {
|
||||
if (path.basename(itemPath) !== 'settings.json') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sharedRoot = Array.from(allowedRoots)[0];
|
||||
if (!sharedRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(sharedRoot, 'settings.json');
|
||||
const resolvedSettingsPath = safeRealPath(settingsPath);
|
||||
if (!resolvedSettingsPath || !isPathWithinAny(resolvedSettingsPath, allowedRoots)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(resolvedSettingsPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawContent = fs.readFileSync(resolvedSettingsPath, 'utf8');
|
||||
const parsed = JSON.parse(rawContent) as unknown;
|
||||
return {
|
||||
content: JSON.stringify(parsed, null, 2),
|
||||
contentPath: resolvedSettingsPath,
|
||||
};
|
||||
} catch {
|
||||
const content = readMarkdownContent(resolvedSettingsPath, allowedRoots);
|
||||
return content
|
||||
? {
|
||||
content,
|
||||
contentPath: resolvedSettingsPath,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithin(candidatePath: string, basePath: string): boolean {
|
||||
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
||||
const normalizedBase = normalizeForPathComparison(basePath);
|
||||
|
||||
@@ -63,6 +63,35 @@ describe('auth command args parsing', () => {
|
||||
expect(parsed.contextGroup).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('parses shared resource mode value for resources command', () => {
|
||||
const parsed = parseArgs(['work', '--mode', 'profile-local'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('parses inline shared resource mode value', () => {
|
||||
const parsed = parseArgs(['work', '--mode=shared'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('shared');
|
||||
});
|
||||
|
||||
it('flags missing shared resource mode value as empty string', () => {
|
||||
const parsed = parseArgs(['work', '--mode'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('');
|
||||
});
|
||||
|
||||
it('treats shared resource mode as unknown unless the command opts in', () => {
|
||||
const parsed = parseArgs(['work', '--mode', 'shared']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBeUndefined();
|
||||
expect(parsed.unknownFlags).toEqual(['--mode']);
|
||||
});
|
||||
|
||||
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import ProfileRegistry from '../../src/auth/profile-registry';
|
||||
import InstanceManager from '../../src/management/instance-manager';
|
||||
import { handleResources } from '../../src/auth/commands/resources-command';
|
||||
import { handleCreate } from '../../src/auth/commands/create-command';
|
||||
import { handleList } from '../../src/auth/commands/list-command';
|
||||
import { handleShow } from '../../src/auth/commands/show-command';
|
||||
import { handleBackup } from '../../src/auth/commands/backup-command';
|
||||
import { handleRemove } from '../../src/auth/commands/remove-command';
|
||||
import { handleDefault, handleResetDefault } from '../../src/auth/commands/default-command';
|
||||
|
||||
async function expectProfileExit(action: () => Promise<void>): Promise<string> {
|
||||
const originalExit = process.exit;
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const lines: string[] = [];
|
||||
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 0})`);
|
||||
}) as typeof process.exit;
|
||||
console.log = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(action()).rejects.toThrow('process.exit(7)');
|
||||
} finally {
|
||||
process.exit = originalExit;
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
describe('auth resources command', () => {
|
||||
let tempRoot = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalUnifiedMode: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-resources-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('updates an existing account shared resource mode without changing context metadata', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: sprint-a',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
path.join(ccsDir, 'instances', 'work')
|
||||
);
|
||||
const lines: string[] = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local', '--json']
|
||||
);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const output = JSON.parse(lines.join('\n')) as {
|
||||
shared_resource_mode: string;
|
||||
bare?: boolean;
|
||||
};
|
||||
expect(output.shared_resource_mode).toBe('profile-local');
|
||||
expect(output.bare).toBe(true);
|
||||
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBe('profile-local');
|
||||
expect(account.bare).toBe(true);
|
||||
expect(account.context_mode).toBe('shared');
|
||||
expect(account.context_group).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('rolls back inferred shared metadata when instance reconciliation fails', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue(
|
||||
new Error('reconcile failed')
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local']
|
||||
)
|
||||
).rejects.toThrow('reconcile failed');
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBeUndefined();
|
||||
expect(account.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects resources --mode without a value instead of showing current mode', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
const output = await expectProfileExit(() =>
|
||||
handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode']
|
||||
)
|
||||
);
|
||||
|
||||
expect(output).toContain('Missing value for --mode: expected shared|profile-local');
|
||||
});
|
||||
|
||||
it('rejects --mode on auth create instead of silently ignoring it', async () => {
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
const output = await expectProfileExit(() =>
|
||||
handleCreate(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local']
|
||||
)
|
||||
);
|
||||
|
||||
expect(output).toContain('Unknown option(s): "--mode"');
|
||||
});
|
||||
|
||||
it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => {
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ctx = { registry, instanceMgr, version: 'test' };
|
||||
const cases: Array<[string, () => Promise<void>]> = [
|
||||
['list', () => handleList(ctx, ['--mode', 'shared'])],
|
||||
['show', () => handleShow(ctx, ['work', '--mode=shared'])],
|
||||
['backup', () => handleBackup(ctx, ['work', '--mode', 'profile-local'])],
|
||||
['remove', () => handleRemove(ctx, ['work', '--mode', 'shared'])],
|
||||
['default', () => handleDefault(ctx, ['work', '--mode', 'shared'])],
|
||||
['reset-default', () => handleResetDefault(ctx, ['--mode', 'shared'])],
|
||||
];
|
||||
|
||||
for (const [commandName, action] of cases) {
|
||||
const output = await expectProfileExit(action);
|
||||
expect(`${commandName}\n${output}`).toContain('Unknown option(s): "--mode"');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,7 @@ describe('profile-registry context normalization', () => {
|
||||
|
||||
const profile = registry.getProfile('work');
|
||||
expect(profile.bare).toBe(true);
|
||||
expect(profile.shared_resource_mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('persists bare flag for unified accounts and merged projection', () => {
|
||||
@@ -126,8 +127,52 @@ describe('profile-registry context normalization', () => {
|
||||
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
expect(accounts.work.bare).toBe(true);
|
||||
expect(accounts.work.shared_resource_mode).toBe('profile-local');
|
||||
|
||||
const merged = registry.getAllProfilesMerged();
|
||||
expect(merged.work.bare).toBe(true);
|
||||
expect(merged.work.shared_resource_mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('lets explicit shared resource mode override stale unified bare metadata', () => {
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-03-05T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' shared_resource_mode: shared',
|
||||
' bare: true',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
|
||||
expect(accounts.work.shared_resource_mode).toBe('shared');
|
||||
expect(accounts.work.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists explicit profile-local mode for legacy profiles', () => {
|
||||
const registry = new ProfileRegistry();
|
||||
registry.createProfile('work', {
|
||||
type: 'account',
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
|
||||
const profile = registry.getProfile('work');
|
||||
expect(profile.shared_resource_mode).toBe('profile-local');
|
||||
expect(profile.bare).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
normalizeSharedResourceMetadata,
|
||||
resolveSharedResourcePolicy,
|
||||
sharedResourceModeToMetadata,
|
||||
} from '../../../src/auth/shared-resource-policy';
|
||||
|
||||
describe('shared resource policy', () => {
|
||||
it('defaults missing metadata to shared resources', () => {
|
||||
const policy = resolveSharedResourcePolicy(undefined);
|
||||
|
||||
expect(policy.mode).toBe('shared');
|
||||
expect(policy.inferred).toBe(true);
|
||||
expect(policy.profileLocal).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves legacy bare metadata as profile-local', () => {
|
||||
const policy = resolveSharedResourcePolicy({ bare: true });
|
||||
|
||||
expect(policy.mode).toBe('profile-local');
|
||||
expect(policy.inferred).toBe(true);
|
||||
expect(policy.profileLocal).toBe(true);
|
||||
});
|
||||
|
||||
it('lets explicit shared mode override stale bare metadata', () => {
|
||||
const normalized = normalizeSharedResourceMetadata({
|
||||
shared_resource_mode: 'shared',
|
||||
bare: true,
|
||||
});
|
||||
|
||||
expect(normalized.shared_resource_mode).toBe('shared');
|
||||
expect(normalized.bare).toBeUndefined();
|
||||
expect(resolveSharedResourcePolicy(normalized).profileLocal).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes explicit profile-local mode to legacy-compatible bare metadata', () => {
|
||||
const normalized = normalizeSharedResourceMetadata({
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
|
||||
expect(normalized.shared_resource_mode).toBe('profile-local');
|
||||
expect(normalized.bare).toBe(true);
|
||||
});
|
||||
|
||||
it('builds write metadata for both supported modes', () => {
|
||||
expect(sharedResourceModeToMetadata('profile-local')).toEqual({
|
||||
shared_resource_mode: 'profile-local',
|
||||
bare: true,
|
||||
});
|
||||
expect(sharedResourceModeToMetadata('shared')).toEqual({
|
||||
shared_resource_mode: 'shared',
|
||||
bare: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('sync command MCP sync behavior', () => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('syncs MCP servers only to non-bare profiles', async () => {
|
||||
it('syncs MCP servers only to shared-resource profiles', async () => {
|
||||
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
||||
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
||||
success: true,
|
||||
@@ -42,6 +42,8 @@ describe('sync command MCP sync behavior', () => {
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
work: profile(),
|
||||
sandbox: profile({ bare: true }),
|
||||
local: profile({ shared_resource_mode: 'profile-local' }),
|
||||
restored: profile({ bare: true, shared_resource_mode: 'shared' }),
|
||||
personal: profile(),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
@@ -55,8 +57,16 @@ describe('sync command MCP sync behavior', () => {
|
||||
|
||||
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
||||
|
||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']);
|
||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']);
|
||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
'work',
|
||||
'restored',
|
||||
'personal',
|
||||
]);
|
||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/tmp/work',
|
||||
'/tmp/restored',
|
||||
'/tmp/personal',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips MCP sync when all profiles are bare', async () => {
|
||||
@@ -69,7 +79,7 @@ describe('sync command MCP sync behavior', () => {
|
||||
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
sandbox: profile({ bare: true }),
|
||||
experiment: profile({ bare: true }),
|
||||
experiment: profile({ shared_resource_mode: 'profile-local' }),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
|
||||
|
||||
@@ -177,6 +177,43 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates legacy bare accounts to profile-local shared resource mode', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
default: 'sandbox',
|
||||
profiles: {
|
||||
sandbox: {
|
||||
type: 'account',
|
||||
created: '2026-02-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
bare: true,
|
||||
},
|
||||
restored: {
|
||||
type: 'account',
|
||||
created: '2026-02-02T00:00:00.000Z',
|
||||
last_used: null,
|
||||
shared_resource_mode: 'shared',
|
||||
bare: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = await migrate(false);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const unified = loadUnifiedConfig();
|
||||
expect(unified?.accounts.sandbox.shared_resource_mode).toBe('profile-local');
|
||||
expect(unified?.accounts.sandbox.bare).toBe(true);
|
||||
expect(unified?.accounts.restored.shared_resource_mode).toBe('shared');
|
||||
expect(unified?.accounts.restored.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies safe browser defaults when migrating legacy config files', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -262,6 +262,51 @@ describe('web-server account-routes context normalization', () => {
|
||||
expect(fs.existsSync(instancePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports effective shared resource mode without initializing an account instance', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' shared_resource_mode: profile-local',
|
||||
' restored:',
|
||||
' created: "2026-02-02T00:00:00.000Z"',
|
||||
' shared_resource_mode: shared',
|
||||
' bare: true',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const payload = await getJson<{
|
||||
accounts: Array<{
|
||||
name: string;
|
||||
shared_resource_mode?: string;
|
||||
shared_resource_inferred?: boolean;
|
||||
bare?: boolean;
|
||||
}>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
const work = payload.accounts.find((account) => account.name === 'work');
|
||||
const restored = payload.accounts.find((account) => account.name === 'restored');
|
||||
expect(work?.shared_resource_mode).toBe('profile-local');
|
||||
expect(work?.shared_resource_inferred).toBe(false);
|
||||
expect(work?.bare).toBe(true);
|
||||
expect(restored?.shared_resource_mode).toBe('shared');
|
||||
expect(restored?.shared_resource_inferred).toBe(false);
|
||||
expect(restored?.bare).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(ccsDir, 'instances', 'work'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not delete metadata when instance deletion fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
@@ -354,6 +399,148 @@ describe('web-server account-routes context normalization', () => {
|
||||
expect(work?.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('updates existing account shared resource mode without changing history sync metadata', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: sprint-a',
|
||||
' continuity_mode: deeper',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
path.join(ccsDir, 'instances', 'work')
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await response.json()) as {
|
||||
shared_resource_mode: string;
|
||||
shared_resource_inferred?: boolean;
|
||||
};
|
||||
expect(payload.shared_resource_mode).toBe('profile-local');
|
||||
expect(payload.shared_resource_inferred).toBe(false);
|
||||
expect(ensureSpy).toHaveBeenCalledWith(
|
||||
'work',
|
||||
{
|
||||
mode: 'shared',
|
||||
group: 'sprint-a',
|
||||
continuityMode: 'deeper',
|
||||
},
|
||||
{ bare: true }
|
||||
);
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBe('profile-local');
|
||||
expect(account.bare).toBe(true);
|
||||
expect(account.context_mode).toBe('shared');
|
||||
expect(account.context_group).toBe('sprint-a');
|
||||
expect(account.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('rolls back inferred shared resource metadata when instance reconciliation fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue(
|
||||
new Error('reconcile failed')
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('reconcile failed');
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBeUndefined();
|
||||
expect(account.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects invalid shared resource mode updates', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||
shared_resource_mode: 'plugins-only',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('shared_resource_mode');
|
||||
});
|
||||
|
||||
it('rejects shared resource updates for CLIProxy account identifiers', async () => {
|
||||
const response = await putJson(baseUrl, '/api/accounts/gemini:test/shared-resources', {
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('CLIProxy');
|
||||
});
|
||||
|
||||
it('rejects shared mode updates without context_group', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
@@ -285,6 +285,218 @@ describe('web-server shared-routes', () => {
|
||||
expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md')));
|
||||
});
|
||||
|
||||
it('hides plugin infrastructure directories when no plugins are installed', async () => {
|
||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||
const cacheDir = path.join(pluginsDir, 'cache');
|
||||
fs.mkdirSync(path.join(cacheDir, 'payloads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(cacheDir, 'plugin-index.json'), '{}');
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'installed_plugins.json'),
|
||||
JSON.stringify({ version: 2, plugins: {} }, null, 2)
|
||||
);
|
||||
|
||||
const listPayload = await getJson<{
|
||||
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||
}>(baseUrl, '/api/shared/plugins');
|
||||
|
||||
expect(listPayload.items).toEqual([]);
|
||||
|
||||
const contentResponse = await fetch(
|
||||
`${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}`
|
||||
);
|
||||
|
||||
expect(contentResponse.status).toBe(404);
|
||||
const contentPayload = (await contentResponse.json()) as { error: string };
|
||||
expect(contentPayload.error).toBe('Shared content not found');
|
||||
});
|
||||
|
||||
it('lists installed plugins from the shared plugin registry', async () => {
|
||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'installed_plugins.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
plugins: {
|
||||
'discord@claude-plugins-official': [
|
||||
{
|
||||
installPath: path.join(pluginsDir, 'cache', 'discord'),
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'known_marketplaces.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
'claude-plugins-official': {
|
||||
source: { type: 'github', repo: 'anthropics/claude-plugins' },
|
||||
installLocation: path.join(pluginsDir, 'marketplaces', 'claude-plugins-official'),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const listPayload = await getJson<{
|
||||
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||
}>(baseUrl, '/api/shared/plugins');
|
||||
|
||||
expect(listPayload.items).toEqual([
|
||||
{
|
||||
name: 'discord@claude-plugins-official',
|
||||
type: 'plugin',
|
||||
description: 'Installed from claude-plugins-official; 1 record in shared registry',
|
||||
path: 'plugin-registry:discord%40claude-plugins-official',
|
||||
},
|
||||
]);
|
||||
|
||||
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
||||
baseUrl,
|
||||
`/api/shared/content?type=plugins&path=${encodeURIComponent(listPayload.items[0].path)}`
|
||||
);
|
||||
|
||||
expect(contentPayload.content).toContain('# discord@claude-plugins-official');
|
||||
expect(contentPayload.content).toContain('## Marketplace Registry Entry');
|
||||
expect(contentPayload.content).toContain('## Plugin Registry Entry');
|
||||
expect(contentPayload.contentPath).toBe(
|
||||
fs.realpathSync(path.join(pluginsDir, 'installed_plugins.json'))
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects plugin registry prototype keys as plugin names', async () => {
|
||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'installed_plugins.json'),
|
||||
JSON.stringify({ version: 2, plugins: {} }, null, 2)
|
||||
);
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent('plugin-registry:toString')}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toBe('Shared content not found');
|
||||
});
|
||||
|
||||
it('returns real shared settings content when settings.json is present', async () => {
|
||||
const settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: { ANTHROPIC_MODEL: 'claude-sonnet-4-5' },
|
||||
permissions: { allow: ['Bash(git status)'] },
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const summaryPayload = await getJson<{ settings: boolean; total: number }>(
|
||||
baseUrl,
|
||||
'/api/shared/summary'
|
||||
);
|
||||
expect(summaryPayload.settings).toBe(true);
|
||||
expect(summaryPayload.total).toBe(1);
|
||||
|
||||
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
||||
baseUrl,
|
||||
`/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}`
|
||||
);
|
||||
|
||||
expect(contentPayload.content).toContain('"ANTHROPIC_MODEL": "claude-sonnet-4-5"');
|
||||
expect(contentPayload.content).toContain('"allow"');
|
||||
expect(contentPayload.contentPath).toBe(fs.realpathSync(settingsPath));
|
||||
});
|
||||
|
||||
it('returns shared settings content when settings.json links to the Claude config file', async () => {
|
||||
const claudeDir = path.join(tempHome, '.claude');
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
const claudeSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(
|
||||
claudeSettingsPath,
|
||||
JSON.stringify({ hooks: { PreToolUse: [] }, env: { ANTHROPIC_MODEL: 'claude-opus-4-1' } })
|
||||
);
|
||||
|
||||
const sharedSettingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||
createFileSymlink(claudeSettingsPath, sharedSettingsPath);
|
||||
|
||||
const summaryPayload = await getJson<{ settings: boolean; total: number }>(
|
||||
baseUrl,
|
||||
'/api/shared/summary'
|
||||
);
|
||||
expect(summaryPayload.settings).toBe(true);
|
||||
expect(summaryPayload.total).toBe(1);
|
||||
|
||||
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
||||
baseUrl,
|
||||
`/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}`
|
||||
);
|
||||
|
||||
expect(contentPayload.content).toContain('"ANTHROPIC_MODEL": "claude-opus-4-1"');
|
||||
expect(contentPayload.contentPath).toBe(fs.realpathSync(claudeSettingsPath));
|
||||
});
|
||||
|
||||
it('rejects shared settings content when settings.json resolves outside the shared directory', async () => {
|
||||
const settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||
const outsideSettingsPath = path.join(tempHome, 'outside-settings.json');
|
||||
fs.writeFileSync(outsideSettingsPath, '{"env":{"SECRET":"do-not-read"}}');
|
||||
createFileSymlink(outsideSettingsPath, settingsPath);
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toBe('Shared content not found');
|
||||
});
|
||||
|
||||
it('rejects shared settings symlinks to non-settings files inside the Claude config directory', async () => {
|
||||
const claudeDir = path.join(tempHome, '.claude');
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
const otherClaudeFile = path.join(claudeDir, 'credentials.json');
|
||||
fs.writeFileSync(otherClaudeFile, '{"SECRET":"do-not-read"}');
|
||||
createFileSymlink(otherClaudeFile, path.join(ccsDir, 'shared', 'settings.json'));
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toBe('Shared content not found');
|
||||
});
|
||||
|
||||
it('returns README content for a shared plugin directory when available', async () => {
|
||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||
const pluginDir = path.join(pluginsDir, 'legacy-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, 'README.md'), '# Plugin Marketplace\n\nShared docs');
|
||||
|
||||
const payload = await getJson<{ content: string; contentPath: string }>(
|
||||
baseUrl,
|
||||
`/api/shared/content?type=plugins&path=${encodeURIComponent(pluginDir)}`
|
||||
);
|
||||
|
||||
expect(payload.content).toContain('Shared docs');
|
||||
expect(payload.contentPath).toBe(fs.realpathSync(path.join(pluginDir, 'README.md')));
|
||||
});
|
||||
|
||||
it('does not use markdown frontmatter fences as descriptions when frontmatter is malformed', async () => {
|
||||
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { Box, Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
|
||||
import { EditAccountSharedResourcesDialog } from '@/components/account/edit-account-shared-resources-dialog';
|
||||
import {
|
||||
useSetDefaultAccount,
|
||||
useDeleteAccount,
|
||||
@@ -57,6 +59,7 @@ export function AccountsTable({
|
||||
const updateContextMutation = useUpdateAccountContext();
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [contextTarget, setContextTarget] = useState<AuthAccountRow | null>(null);
|
||||
const [resourcesTarget, setResourcesTarget] = useState<AuthAccountRow | null>(null);
|
||||
|
||||
const columns: ColumnDef<AuthAccountRow>[] = [
|
||||
{
|
||||
@@ -164,6 +167,42 @@ export function AccountsTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
header: t('accountsTable.sharedResources'),
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.type === 'cliproxy') {
|
||||
return <span className="text-muted-foreground/50">-</span>;
|
||||
}
|
||||
|
||||
const mode = row.original.shared_resource_mode || 'shared';
|
||||
const isInferred = row.original.shared_resource_inferred;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Badge
|
||||
variant={mode === 'shared' ? 'outline' : 'secondary'}
|
||||
className={cn(
|
||||
'font-mono text-[10px] uppercase px-1.5 py-0',
|
||||
mode === 'shared'
|
||||
? 'text-emerald-700 border-emerald-300/60 bg-emerald-50/50 dark:text-emerald-300 dark:border-emerald-900/40 dark:bg-emerald-900/20'
|
||||
: 'text-muted-foreground bg-muted/60 border-transparent shadow-none'
|
||||
)}
|
||||
>
|
||||
{mode === 'shared'
|
||||
? t('accountsTable.resourcesShared')
|
||||
: t('accountsTable.resourcesProfileLocal')}
|
||||
</Badge>
|
||||
{isInferred && (
|
||||
<p className="text-[10px] text-amber-700/80 dark:text-amber-400/80 whitespace-nowrap">
|
||||
{t('accountsTable.legacyReview')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('accountsTable.actions'),
|
||||
@@ -193,6 +232,19 @@ export function AccountsTable({
|
||||
{t('accountsTable.sync')}
|
||||
</Button>
|
||||
)}
|
||||
{!isCliproxy && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={isPending}
|
||||
onClick={() => setResourcesTarget(row.original)}
|
||||
title={t('accountsTable.resourcesTitle')}
|
||||
>
|
||||
<Box className="w-3.5 h-3.5 mr-1" />
|
||||
{t('accountsTable.sharedResources')}
|
||||
</Button>
|
||||
)}
|
||||
{!isCliproxy && hasLegacyInference && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -275,12 +327,13 @@ export function AccountsTable({
|
||||
{headerGroup.headers.map((header) => {
|
||||
const widthClass =
|
||||
{
|
||||
name: 'w-[200px]',
|
||||
type: 'w-[100px]',
|
||||
created: 'w-[150px]',
|
||||
last_used: 'w-[150px]',
|
||||
context: 'w-[170px]',
|
||||
actions: 'w-[290px]',
|
||||
name: 'w-[180px]',
|
||||
type: 'w-[80px]',
|
||||
created: 'w-[120px]',
|
||||
last_used: 'w-[120px]',
|
||||
context: 'w-[150px]',
|
||||
resources: 'w-[120px]',
|
||||
actions: 'w-[320px]',
|
||||
}[header.id] || 'w-auto';
|
||||
|
||||
return (
|
||||
@@ -333,6 +386,13 @@ export function AccountsTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
{resourcesTarget && (
|
||||
<EditAccountSharedResourcesDialog
|
||||
account={resourcesTarget}
|
||||
onClose={() => setResourcesTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useUpdateAccountSharedResources } from '@/hooks/use-accounts';
|
||||
import type { AuthAccountRow } from '@/lib/account-continuity';
|
||||
import { AlertCircle, Box, ShieldCheck } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
interface EditAccountSharedResourcesDialogProps {
|
||||
account: AuthAccountRow;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EditAccountSharedResourcesDialog({
|
||||
account,
|
||||
onClose,
|
||||
}: EditAccountSharedResourcesDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateMutation = useUpdateAccountSharedResources();
|
||||
|
||||
const [resourceMode, setResourceMode] = useState<'shared' | 'profile-local'>(
|
||||
account.shared_resource_mode || 'shared'
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate(
|
||||
{
|
||||
name: account.name,
|
||||
shared_resource_mode: resourceMode,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('editAccountSharedResources.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('editAccountSharedResources.description', { name: account.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="resource-mode">{t('editAccountSharedResources.resourceMode')}</Label>
|
||||
<Select
|
||||
value={resourceMode}
|
||||
onValueChange={(val) => setResourceMode(val as 'shared' | 'profile-local')}
|
||||
>
|
||||
<SelectTrigger id="resource-mode" className="w-full">
|
||||
<SelectValue placeholder={t('editAccountSharedResources.selectResourceMode')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shared">
|
||||
<div className="flex flex-col items-start py-1">
|
||||
<span className="font-medium text-sm">
|
||||
{t('editAccountSharedResources.sharedOption')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('editAccountSharedResources.sharedHint')}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="profile-local">
|
||||
<div className="flex flex-col items-start py-1">
|
||||
<span className="font-medium text-sm">
|
||||
{t('editAccountSharedResources.profileLocalOption')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('editAccountSharedResources.profileLocalHint')}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('editAccountSharedResources.implicationTitle')}
|
||||
</p>
|
||||
|
||||
{resourceMode === 'shared' ? (
|
||||
<Alert className="border-emerald-500/20 bg-emerald-500/5">
|
||||
<Box className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<AlertTitle className="text-xs font-semibold text-emerald-800 dark:text-emerald-300">
|
||||
{t('editAccountSharedResources.sharedOption')}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs text-emerald-700/80 dark:text-emerald-400/80">
|
||||
{t('editAccountSharedResources.sharedImplication')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert className="border-amber-500/20 bg-amber-500/5">
|
||||
<ShieldCheck className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<AlertTitle className="text-xs font-semibold text-amber-800 dark:text-amber-300">
|
||||
{t('editAccountSharedResources.profileLocalOption')}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs text-amber-700/80 dark:text-amber-400/80">
|
||||
{t('editAccountSharedResources.profileLocalImplication')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{account.shared_resource_inferred && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<p className="text-[11px] text-muted-foreground">{t('accountsTable.legacyReview')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('editAccountSharedResources.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending
|
||||
? t('editAccountSharedResources.saving')
|
||||
: t('editAccountSharedResources.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -208,3 +208,28 @@ export function useConfirmLegacyAccountPolicies() {
|
||||
},
|
||||
});
|
||||
}
|
||||
export function useUpdateAccountSharedResources() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
name,
|
||||
shared_resource_mode,
|
||||
}: {
|
||||
name: string;
|
||||
shared_resource_mode: 'shared' | 'profile-local';
|
||||
}) => api.accounts.updateSharedResources(name, { shared_resource_mode }),
|
||||
onSuccess: (_data, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
const resourceSummary =
|
||||
vars.shared_resource_mode === 'shared'
|
||||
? t('accountsPage.resourcesShared')
|
||||
: t('accountsPage.resourcesProfileLocal');
|
||||
toast.success(t('toasts.resourcesUpdated', { name: vars.name, summary: resourceSummary }));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,13 +4,17 @@ export interface SharedItem {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
type: 'command' | 'skill' | 'agent';
|
||||
type: 'command' | 'skill' | 'agent' | 'plugin';
|
||||
}
|
||||
|
||||
interface SharedSummary {
|
||||
export type SharedResourceTab = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings';
|
||||
|
||||
export interface SharedSummary {
|
||||
commands: number;
|
||||
skills: number;
|
||||
agents: number;
|
||||
plugins: number;
|
||||
settings: boolean;
|
||||
total: number;
|
||||
symlinkStatus: { valid: boolean; message: string };
|
||||
}
|
||||
@@ -99,20 +103,22 @@ export function useSharedSummary() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSharedItems(type: 'commands' | 'skills' | 'agents') {
|
||||
export function useSharedItems(type: SharedResourceTab) {
|
||||
return useQuery<{ items: SharedItem[] }>({
|
||||
queryKey: ['shared', type],
|
||||
enabled: type !== 'settings',
|
||||
queryFn: async () => {
|
||||
if (type === 'settings') {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/shared/${type}`);
|
||||
return readJsonOrThrow<{ items: SharedItem[] }>(res, `Failed to fetch shared ${type}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSharedItemContent(
|
||||
type: 'commands' | 'skills' | 'agents',
|
||||
itemPath: string | null
|
||||
) {
|
||||
export function useSharedItemContent(type: SharedResourceTab, itemPath: string | null) {
|
||||
return useQuery<SharedItemContent>({
|
||||
queryKey: ['shared', type, 'content', itemPath],
|
||||
enabled: typeof itemPath === 'string' && itemPath.length > 0,
|
||||
|
||||
@@ -934,10 +934,16 @@ export interface Account {
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
shared_resource_mode?: 'shared' | 'profile-local';
|
||||
shared_resource_inferred?: boolean;
|
||||
provider?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAccountSharedResources {
|
||||
shared_resource_mode: 'shared' | 'profile-local';
|
||||
}
|
||||
|
||||
export interface PlainCcsLane {
|
||||
kind: 'native' | 'account-default' | 'account-inherited' | 'profile-default' | 'ambient';
|
||||
label: string;
|
||||
@@ -1459,6 +1465,11 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateSharedResources: (name: string, data: UpdateAccountSharedResources) =>
|
||||
request(`/accounts/${encodeURIComponent(name)}/shared-resources`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
// Unified config API
|
||||
config: {
|
||||
|
||||
+112
-10
@@ -28,7 +28,7 @@ const resources = {
|
||||
claudeExtension: 'Claude Extension',
|
||||
accounts: 'Accounts',
|
||||
allAccounts: 'All Accounts',
|
||||
sharedData: 'Shared Data',
|
||||
sharedData: 'Shared Resources',
|
||||
compatibleClis: 'Compatible',
|
||||
deprecated: 'Deprecated',
|
||||
factoryDroid: 'Factory Droid',
|
||||
@@ -96,6 +96,7 @@ const resources = {
|
||||
settingsSaved: 'Settings saved',
|
||||
defaultTargetUpdated: 'Default target updated',
|
||||
failedUpdateDefaultTarget: 'Failed to update default target to {{target}}{{suffix}}',
|
||||
resourcesUpdated: 'Shared resources updated',
|
||||
},
|
||||
profileDialog: {
|
||||
editTitle: 'Edit Profile',
|
||||
@@ -675,8 +676,10 @@ const resources = {
|
||||
created: 'Created',
|
||||
lastUsed: 'Last Used',
|
||||
historySync: 'History Sync',
|
||||
sharedResources: 'Resources',
|
||||
actions: 'Actions',
|
||||
syncTitle: 'Edit sync mode, group, and continuity depth',
|
||||
resourcesTitle: 'Edit shared resource inheritance',
|
||||
confirmLegacyTitle: "Confirm this legacy account's current mode as explicit",
|
||||
sync: 'Sync',
|
||||
confirm: 'Confirm',
|
||||
@@ -696,6 +699,8 @@ const resources = {
|
||||
sharedGroupLegacy: 'shared ({{group}}, projects only, legacy)',
|
||||
isolatedLegacy: 'isolated (legacy default)',
|
||||
isolated: 'isolated',
|
||||
resourcesShared: 'shared',
|
||||
resourcesProfileLocal: 'profile-local',
|
||||
noSameGroupPeer: 'No same-group peer yet',
|
||||
sameGroupPeerCount_one: '{{count}} same-group peer',
|
||||
sameGroupPeerCount_other: '{{count}} same-group peers',
|
||||
@@ -706,6 +711,7 @@ const resources = {
|
||||
deeper: 'Deeper',
|
||||
legacy: 'Legacy',
|
||||
isolated: 'Isolated',
|
||||
profileLocal: 'Local',
|
||||
},
|
||||
},
|
||||
addAccountDialog: {
|
||||
@@ -1196,6 +1202,9 @@ const resources = {
|
||||
quickCommandsDesc: 'Copy and run in terminal.',
|
||||
workspaceBadge: 'ccs auth Workspace',
|
||||
historySyncBadge: 'History & Resume Controls',
|
||||
resourcesBadge: 'Shared Resources Controls',
|
||||
resourcesShared: 'Shared',
|
||||
resourcesProfileLocal: 'Profile Local',
|
||||
authAccounts: 'Auth Accounts',
|
||||
tableScopePrefix: 'This table is intentionally scoped to',
|
||||
tableScopeMiddle: 'accounts. Use',
|
||||
@@ -1494,11 +1503,15 @@ const resources = {
|
||||
commands: 'Commands',
|
||||
skills: 'Skills',
|
||||
agents: 'Agents',
|
||||
title: 'Shared Data',
|
||||
subtitle: 'Commands, skills, and agents shared across Claude instances',
|
||||
totalShared: 'Total Shared',
|
||||
plugins: 'Plugins',
|
||||
settings: 'Settings',
|
||||
title: 'Shared Resources',
|
||||
subtitle: 'Commands, skills, agents, plugins, and configuration shared across accounts',
|
||||
totalShared: 'Total Resources',
|
||||
visible: 'Visible',
|
||||
markdownDetail: 'Markdown detail view',
|
||||
registryDetail: 'Registry detail view',
|
||||
maskedSettingsDetail: 'Masked settings view',
|
||||
filterPrefix: 'Filter:',
|
||||
configurationRequired: 'Configuration Required',
|
||||
countsUnavailable: 'Counts unavailable',
|
||||
@@ -1512,14 +1525,48 @@ const resources = {
|
||||
retry: 'Retry',
|
||||
noSharedFound:
|
||||
'No shared {{tab}} found. Run `ccs sync` or add items in your shared directory.',
|
||||
noPluginsFound:
|
||||
'No shared plugins are installed. Plugin registry files and cache folders are hidden until an actual plugin is present.',
|
||||
noMatch: 'No {{tab}} match "{{query}}".',
|
||||
selectOne: 'Select a {{tab}} to view full content.',
|
||||
pathLabel: 'Path',
|
||||
resolvedSource: 'Resolved Source',
|
||||
loadingMarkdown: 'Loading markdown content...',
|
||||
loadingSettings: 'Loading settings content...',
|
||||
failedLoadContent: 'Failed to load content',
|
||||
retryContent: 'Retry content',
|
||||
noMarkdown: 'No markdown content available.',
|
||||
settingsTitle: 'settings.json',
|
||||
settingsDescription:
|
||||
'This file contains shared settings that are linked across all shared-mode accounts.',
|
||||
settingsPath: 'Shared Settings Path',
|
||||
settingsActive: 'Active',
|
||||
settingsInactive: 'Not Found',
|
||||
resourcePolicies: 'Resource policies',
|
||||
resourcePoliciesDescription:
|
||||
'Accounts marked shared inherit this hub. Profile-local accounts keep separate commands, skills, agents, plugins, and settings.',
|
||||
sharedAccounts: '{{count}} shared',
|
||||
profileLocalAccounts: '{{count}} profile-local',
|
||||
moreProfileLocalAccounts: '+{{count}} more',
|
||||
},
|
||||
editAccountSharedResources: {
|
||||
title: 'Edit Shared Resources',
|
||||
description:
|
||||
'Configure how "{{name}}" inherits commands, skills, and settings from the CCS shared directory.',
|
||||
resourceMode: 'Resource Mode',
|
||||
selectResourceMode: 'Select resource mode',
|
||||
sharedOption: 'Shared',
|
||||
sharedHint: 'Inherits commands, skills, and agents from the shared directory.',
|
||||
profileLocalOption: 'Profile Local',
|
||||
profileLocalHint: "Uses isolated resources in the account's private directory.",
|
||||
implicationTitle: 'What this means',
|
||||
sharedImplication:
|
||||
'This account will see all tools and configuration in the shared resource hub.',
|
||||
profileLocalImplication:
|
||||
'This account will not see shared tools. It stays fully isolated for security or testing.',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -4033,11 +4080,15 @@ const resources = {
|
||||
commands: '命令',
|
||||
skills: '技能',
|
||||
agents: '代理',
|
||||
title: '共享数据',
|
||||
subtitle: '在 Claude 实例间共享的命令、技能和代理',
|
||||
plugins: '插件',
|
||||
settings: '设置',
|
||||
title: '共享资源',
|
||||
subtitle: '在账号之间共享的命令、技能、代理、插件和配置',
|
||||
totalShared: '共享总数',
|
||||
visible: '可见',
|
||||
markdownDetail: 'Markdown 详情视图',
|
||||
registryDetail: '注册表详情视图',
|
||||
maskedSettingsDetail: '脱敏设置视图',
|
||||
filterPrefix: '筛选:',
|
||||
configurationRequired: '需要配置',
|
||||
countsUnavailable: '统计不可用',
|
||||
@@ -4050,14 +4101,27 @@ const resources = {
|
||||
failedLoadShared: '加载共享{{tab}}失败',
|
||||
retry: '重试',
|
||||
noSharedFound: '未找到共享{{tab}}。请运行 `ccs sync` 或在共享目录添加内容。',
|
||||
noPluginsFound: '未安装共享插件。真实插件存在前,插件注册表文件和缓存文件夹会保持隐藏。',
|
||||
noMatch: '没有匹配“{{query}}”的{{tab}}。',
|
||||
selectOne: '选择一个{{tab}}查看完整内容。',
|
||||
pathLabel: '路径',
|
||||
resolvedSource: '解析后来源',
|
||||
loadingMarkdown: '正在加载 Markdown 内容...',
|
||||
loadingSettings: '正在加载设置内容...',
|
||||
failedLoadContent: '加载内容失败',
|
||||
retryContent: '重试内容加载',
|
||||
noMarkdown: '暂无 Markdown 内容。',
|
||||
settingsTitle: 'settings.json',
|
||||
settingsDescription: '此文件包含所有 shared 模式账号链接使用的共享设置。',
|
||||
settingsPath: '共享设置路径',
|
||||
settingsActive: '已启用',
|
||||
settingsInactive: '未找到',
|
||||
resourcePolicies: '资源策略',
|
||||
resourcePoliciesDescription:
|
||||
'标记为 shared 的账号继承此中心。profile-local 账号保留单独的命令、技能、代理、插件和设置。',
|
||||
sharedAccounts: '{{count}} 个 shared',
|
||||
profileLocalAccounts: '{{count}} 个 profile-local',
|
||||
moreProfileLocalAccounts: '+{{count}} 个更多',
|
||||
},
|
||||
heroSection: {
|
||||
title: 'CCS Config',
|
||||
@@ -6623,11 +6687,15 @@ const resources = {
|
||||
commands: 'Lệnh',
|
||||
skills: 'Kỹ năng',
|
||||
agents: 'Agent',
|
||||
title: 'Dữ liệu dùng chung',
|
||||
subtitle: 'Các lệnh, kỹ năng và agent được chia sẻ giữa các phiên Claude',
|
||||
plugins: 'Plugin',
|
||||
settings: 'Cài đặt',
|
||||
title: 'Tài nguyên dùng chung',
|
||||
subtitle: 'Lệnh, kỹ năng, agent, plugin và cấu hình được chia sẻ giữa các tài khoản',
|
||||
totalShared: 'Tổng số mục dùng chung',
|
||||
visible: 'Hiển thị',
|
||||
markdownDetail: 'Chế độ xem chi tiết Markdown',
|
||||
registryDetail: 'Chế độ xem chi tiết registry',
|
||||
maskedSettingsDetail: 'Chế độ xem cài đặt đã ẩn nhạy cảm',
|
||||
filterPrefix: 'Lọc:',
|
||||
configurationRequired: 'Cần cấu hình',
|
||||
countsUnavailable: 'Không có số liệu',
|
||||
@@ -6641,14 +6709,29 @@ const resources = {
|
||||
retry: 'Thử lại',
|
||||
noSharedFound:
|
||||
'Không tìm thấy {{tab}} dùng chung. Chạy `ccs sync` hoặc thêm mục trong thư mục dùng chung của bạn.',
|
||||
noPluginsFound:
|
||||
'Chưa cài plugin dùng chung. Tệp registry plugin và thư mục cache sẽ bị ẩn cho đến khi có plugin thật.',
|
||||
noMatch: 'Không có {{tab}} khớp với "{{query}}".',
|
||||
selectOne: 'Chọn {{tab}} để xem toàn bộ nội dung.',
|
||||
pathLabel: 'Đường dẫn',
|
||||
resolvedSource: 'Nguồn đã giải quyết',
|
||||
loadingMarkdown: 'Đang tải nội dung Markdown...',
|
||||
loadingSettings: 'Đang tải nội dung cài đặt...',
|
||||
failedLoadContent: 'Không tải được nội dung',
|
||||
retryContent: 'Thử lại nội dung',
|
||||
noMarkdown: 'Không có nội dung Markdown khả dụng.',
|
||||
settingsTitle: 'settings.json',
|
||||
settingsDescription:
|
||||
'Tệp này chứa cài đặt dùng chung được liên kết qua mọi tài khoản ở chế độ shared.',
|
||||
settingsPath: 'Đường dẫn cài đặt dùng chung',
|
||||
settingsActive: 'Đang hoạt động',
|
||||
settingsInactive: 'Không tìm thấy',
|
||||
resourcePolicies: 'Chính sách tài nguyên',
|
||||
resourcePoliciesDescription:
|
||||
'Tài khoản shared kế thừa hub này. Tài khoản profile-local giữ lệnh, kỹ năng, agent, plugin và cài đặt riêng.',
|
||||
sharedAccounts: '{{count}} shared',
|
||||
profileLocalAccounts: '{{count}} profile-local',
|
||||
moreProfileLocalAccounts: '+{{count}} khác',
|
||||
},
|
||||
heroSection: {
|
||||
title: 'CCS Config',
|
||||
@@ -9238,11 +9321,15 @@ const resources = {
|
||||
commands: 'コマンド',
|
||||
skills: 'スキル',
|
||||
agents: 'エージェント',
|
||||
title: '共有データ',
|
||||
subtitle: 'Claudeインスタンス間で共有されるコマンド、スキル、エージェント',
|
||||
plugins: 'プラグイン',
|
||||
settings: '設定',
|
||||
title: '共有リソース',
|
||||
subtitle: 'アカウント間で共有されるコマンド、スキル、エージェント、プラグイン、設定',
|
||||
totalShared: '共有数',
|
||||
visible: '表示中',
|
||||
markdownDetail: 'Markdown詳細ビュー',
|
||||
registryDetail: 'レジストリ詳細ビュー',
|
||||
maskedSettingsDetail: 'マスク済み設定ビュー',
|
||||
filterPrefix: 'フィルター:',
|
||||
configurationRequired: '設定が必要です',
|
||||
countsUnavailable: '件数を取得できません',
|
||||
@@ -9256,14 +9343,29 @@ const resources = {
|
||||
retry: '再試行',
|
||||
noSharedFound:
|
||||
'共有{{tab}}が見つかりません。`ccs sync` を実行するか、共有ディレクトリに項目を追加してください。',
|
||||
noPluginsFound:
|
||||
'共有プラグインはインストールされていません。実際のプラグインが存在するまで、プラグインレジストリとキャッシュフォルダーは非表示になります。',
|
||||
noMatch: '{{tab}}で「{{query}}」に一致するものはありません。',
|
||||
selectOne: '内容を表示する{{tab}}を選択してください。',
|
||||
pathLabel: 'パス',
|
||||
resolvedSource: '解決後ソース',
|
||||
loadingMarkdown: 'Markdownコンテンツを読み込み中...',
|
||||
loadingSettings: '設定内容を読み込み中...',
|
||||
failedLoadContent: 'コンテンツの読み込みに失敗しました',
|
||||
retryContent: '再読み込み',
|
||||
noMarkdown: 'Markdownコンテンツはありません。',
|
||||
settingsTitle: 'settings.json',
|
||||
settingsDescription:
|
||||
'このファイルには shared モードの全アカウントにリンクされる共有設定が含まれます。',
|
||||
settingsPath: '共有設定パス',
|
||||
settingsActive: '有効',
|
||||
settingsInactive: '未検出',
|
||||
resourcePolicies: 'リソースポリシー',
|
||||
resourcePoliciesDescription:
|
||||
'shared のアカウントはこのハブを継承します。profile-local のアカウントはコマンド、スキル、エージェント、プラグイン、設定を分離します。',
|
||||
sharedAccounts: '{{count}} shared',
|
||||
profileLocalAccounts: '{{count}} profile-local',
|
||||
moreProfileLocalAccounts: '+{{count}} 件',
|
||||
},
|
||||
|
||||
accountCardStats: {
|
||||
|
||||
@@ -176,6 +176,7 @@ export function AccountsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{t('accountsPage.workspaceBadge')}</Badge>
|
||||
<Badge variant="secondary">{t('accountsPage.historySyncBadge')}</Badge>
|
||||
<Badge variant="secondary">{t('accountsPage.resourcesBadge')}</Badge>
|
||||
</div>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">
|
||||
{t('accountsPage.authAccounts')}
|
||||
|
||||
+278
-39
@@ -6,7 +6,14 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
|
||||
import { CodeEditor } from '@/components/shared/code-editor';
|
||||
import { useAccounts } from '@/hooks/use-accounts';
|
||||
import {
|
||||
type SharedSummary,
|
||||
useSharedItemContent,
|
||||
useSharedItems,
|
||||
useSharedSummary,
|
||||
} from '@/hooks/use-shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import '@/lib/i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -14,6 +21,8 @@ import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Box,
|
||||
FileJson,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
RefreshCw,
|
||||
@@ -21,15 +30,18 @@ import {
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
type TabType = 'commands' | 'skills' | 'agents';
|
||||
type TabType = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings';
|
||||
|
||||
export function SharedPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<TabType>('commands');
|
||||
const [hasUserSelectedTab, setHasUserSelectedTab] = useState(false);
|
||||
const tabLabels: Record<TabType, string> = {
|
||||
commands: t('sharedPage.commands'),
|
||||
skills: t('sharedPage.skills'),
|
||||
agents: t('sharedPage.agents'),
|
||||
plugins: t('sharedPage.plugins'),
|
||||
settings: t('sharedPage.settings'),
|
||||
};
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -41,7 +53,10 @@ export function SharedPage() {
|
||||
error: summaryError,
|
||||
refetch: refetchSummary,
|
||||
} = useSharedSummary();
|
||||
const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab);
|
||||
|
||||
const { data: accountsView } = useAccounts();
|
||||
const activeTab = summary && !hasUserSelectedTab ? getPreferredInitialTab(summary) : tab;
|
||||
const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(activeTab);
|
||||
const allItems = items?.items ?? [];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const activeQuery = query.trim();
|
||||
@@ -71,23 +86,53 @@ export function SharedPage() {
|
||||
return filteredItems.find((item) => item.path === selectedItemPath) ?? filteredItems[0];
|
||||
}, [filteredItems, selectedItemPath]);
|
||||
|
||||
const contentPath =
|
||||
activeTab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null);
|
||||
const {
|
||||
data: selectedItemContent,
|
||||
isLoading: isContentLoading,
|
||||
isError: isContentError,
|
||||
error: contentError,
|
||||
refetch: refetchContent,
|
||||
} = useSharedItemContent(tab, selectedItem?.path ?? null);
|
||||
} = useSharedItemContent(activeTab, contentPath);
|
||||
|
||||
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [
|
||||
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number | string }[] = [
|
||||
{ id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 },
|
||||
{ id: 'skills', label: tabLabels.skills, icon: Sparkles, count: summary?.skills ?? 0 },
|
||||
{ id: 'agents', label: tabLabels.agents, icon: Bot, count: summary?.agents ?? 0 },
|
||||
{ id: 'plugins', label: tabLabels.plugins, icon: Box, count: summary?.plugins ?? 0 },
|
||||
{
|
||||
id: 'settings',
|
||||
label: tabLabels.settings,
|
||||
icon: FileJson,
|
||||
count: summary?.settings ? 1 : 0,
|
||||
},
|
||||
];
|
||||
const totalSharedItems = tabs.reduce((sum, tabOption) => sum + tabOption.count, 0);
|
||||
const totalSharedItems = summary?.total ?? 0;
|
||||
const settingsCount = summary?.settings ? 1 : 0;
|
||||
const currentItemCount = activeTab === 'settings' ? settingsCount : allItems.length;
|
||||
const currentVisibleCount = activeTab === 'settings' ? settingsCount : filteredItems.length;
|
||||
const showListStatus = activeTab === 'settings' || (!isLoading && !isError);
|
||||
const detailModeLabel =
|
||||
activeTab === 'plugins'
|
||||
? t('sharedPage.registryDetail')
|
||||
: activeTab === 'settings'
|
||||
? t('sharedPage.maskedSettingsDetail')
|
||||
: t('sharedPage.markdownDetail');
|
||||
const emptyStateMessage =
|
||||
activeTab === 'plugins'
|
||||
? t('sharedPage.noPluginsFound')
|
||||
: t('sharedPage.noSharedFound', { tab: activeTab });
|
||||
|
||||
const hasNoItems = !isLoading && !isError && allItems.length === 0;
|
||||
const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0;
|
||||
const sharedResourceAccounts =
|
||||
accountsView?.accounts.filter(
|
||||
(account) => (account.shared_resource_mode || 'shared') === 'shared'
|
||||
) ?? [];
|
||||
const profileLocalResourceAccounts =
|
||||
accountsView?.accounts.filter((account) => account.shared_resource_mode === 'profile-local') ??
|
||||
[];
|
||||
|
||||
// TODO i18n: missing key for "Shared item totals could not be loaded. Listing still works."
|
||||
const summaryErrorMessage = getSharedErrorMessage(
|
||||
@@ -96,7 +141,7 @@ export function SharedPage() {
|
||||
);
|
||||
const itemsErrorMessage = getSharedErrorMessage(
|
||||
error,
|
||||
`Unable to fetch shared ${tab}. Please try again.`
|
||||
`Unable to fetch shared ${activeTab}. Please try again.`
|
||||
);
|
||||
const contentErrorMessage = getSharedErrorMessage(
|
||||
contentError,
|
||||
@@ -104,7 +149,7 @@ export function SharedPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden flex flex-col">
|
||||
<div className="h-full overflow-auto lg:overflow-hidden flex flex-col">
|
||||
<div className="p-6 pb-4 space-y-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
@@ -114,8 +159,9 @@ export function SharedPage() {
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
value={activeTab}
|
||||
onValueChange={(nextTab) => {
|
||||
setHasUserSelectedTab(true);
|
||||
setTab(nextTab as TabType);
|
||||
setQuery('');
|
||||
setSelectedItemPath(null);
|
||||
@@ -136,12 +182,12 @@ export function SharedPage() {
|
||||
<div className="flex flex-col gap-2 lg:items-end">
|
||||
<div className="grid w-full gap-2 sm:w-auto sm:min-w-[340px] sm:grid-cols-3">
|
||||
<HeaderMetricCard label={t('sharedPage.totalShared')} value={totalSharedItems} />
|
||||
<HeaderMetricCard label={tabLabels[tab]} value={allItems.length} />
|
||||
<HeaderMetricCard label={t('sharedPage.visible')} value={filteredItems.length} />
|
||||
<HeaderMetricCard label={tabLabels[activeTab]} value={currentItemCount} />
|
||||
<HeaderMetricCard label={t('sharedPage.visible')} value={currentVisibleCount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="secondary">{t('sharedPage.markdownDetail')}</Badge>
|
||||
<Badge variant="secondary">{detailModeLabel}</Badge>
|
||||
{activeQuery ? (
|
||||
<Badge variant="outline">
|
||||
{t('sharedPage.filterPrefix')} {activeQuery}
|
||||
@@ -183,21 +229,29 @@ export function SharedPage() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{accountsView ? (
|
||||
<ResourcePoliciesPanel
|
||||
sharedCount={sharedResourceAccounts.length}
|
||||
profileLocalCount={profileLocalResourceAccounts.length}
|
||||
profileLocalNames={profileLocalResourceAccounts.map((account) => account.name)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 px-6 pb-6">
|
||||
<div className="h-full rounded-lg border overflow-hidden bg-background">
|
||||
<div className="grid h-full min-h-0 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="min-h-0 border-b lg:border-b-0 lg:border-r flex flex-col bg-muted/30">
|
||||
<div className="px-6 pb-6 lg:flex-1 lg:min-h-0">
|
||||
<div className="rounded-lg border overflow-hidden bg-background lg:h-full">
|
||||
<div className="grid lg:h-full lg:min-h-0 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="border-b lg:border-b-0 lg:border-r flex flex-col bg-muted/30 lg:min-h-0">
|
||||
<div className="p-4 border-b bg-background space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
|
||||
<h2 className="font-semibold truncate">{tabLabels[tab]}</h2>
|
||||
<h2 className="font-semibold truncate">{tabLabels[activeTab]}</h2>
|
||||
</div>
|
||||
{!isLoading && !isError && (
|
||||
{showListStatus && (
|
||||
<Badge variant="outline" className="text-[10px] h-5">
|
||||
{filteredItems.length}/{allItems.length}
|
||||
{currentVisibleCount}/{currentItemCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -207,18 +261,18 @@ export function SharedPage() {
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('sharedPage.filterPlaceholder', { tab })}
|
||||
aria-label={t('sharedPage.filterPlaceholder', { tab })}
|
||||
placeholder={t('sharedPage.filterPlaceholder', { tab: activeTab })}
|
||||
aria-label={t('sharedPage.filterPlaceholder', { tab: activeTab })}
|
||||
className="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLoading && !isError && (
|
||||
{showListStatus && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('sharedPage.showing', {
|
||||
visible: filteredItems.length,
|
||||
total: allItems.length,
|
||||
tab,
|
||||
visible: currentVisibleCount,
|
||||
total: currentItemCount,
|
||||
tab: activeTab,
|
||||
})}
|
||||
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
|
||||
{isFetching ? t('sharedPage.refreshing') : ''}
|
||||
@@ -226,10 +280,37 @@ export function SharedPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<ScrollArea className="max-h-[360px] lg:max-h-none lg:flex-1 lg:min-h-0">
|
||||
{activeTab === 'settings' ? (
|
||||
<div className="p-2 space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedItemPath('settings.json')}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-md border transition-colors',
|
||||
summary?.settings
|
||||
? 'bg-primary/10 border-primary/30'
|
||||
: 'bg-background hover:bg-muted border-transparent opacity-50'
|
||||
)}
|
||||
disabled={!summary?.settings}
|
||||
>
|
||||
<p className="text-sm font-medium truncate">settings.json</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{t('sharedPage.settingsDescription')}
|
||||
</p>
|
||||
<Badge
|
||||
variant={summary?.settings ? 'default' : 'secondary'}
|
||||
className="mt-2 text-[10px]"
|
||||
>
|
||||
{summary?.settings
|
||||
? t('sharedPage.settingsActive')
|
||||
: t('sharedPage.settingsInactive')}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
{t('sharedPage.loadingShared', { tab })}
|
||||
{t('sharedPage.loadingShared', { tab: activeTab })}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="p-4 text-center">
|
||||
@@ -237,7 +318,7 @@ export function SharedPage() {
|
||||
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t('sharedPage.failedLoadShared', { tab })}
|
||||
{t('sharedPage.failedLoadShared', { tab: activeTab })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
|
||||
</div>
|
||||
@@ -254,12 +335,12 @@ export function SharedPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : hasNoItems ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
{t('sharedPage.noSharedFound', { tab })}
|
||||
<div className="p-4 text-sm text-muted-foreground leading-relaxed">
|
||||
{emptyStateMessage}
|
||||
</div>
|
||||
) : hasNoMatches ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
{t('sharedPage.noMatch', { tab, query: activeQuery })}
|
||||
{t('sharedPage.noMatch', { tab: activeTab, query: activeQuery })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
@@ -280,7 +361,7 @@ export function SharedPage() {
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/90 mt-2 font-mono truncate">
|
||||
{item.path}
|
||||
{formatSharedItemPath(item.path)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
@@ -289,12 +370,94 @@ export function SharedPage() {
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 min-h-0 flex flex-col bg-muted/20">
|
||||
{!selectedItem ? (
|
||||
<div className="min-w-0 min-h-[360px] flex flex-col bg-muted/20 lg:min-h-0">
|
||||
{!selectedItem && activeTab !== 'settings' ? (
|
||||
<div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground">
|
||||
{t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
|
||||
{t('sharedPage.selectOne', { tab: activeTab.slice(0, -1) })}
|
||||
</div>
|
||||
) : (
|
||||
) : activeTab === 'settings' ? (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b bg-background">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold truncate">settings.json</h2>
|
||||
<Badge variant="outline" className="uppercase text-[10px]">
|
||||
{t('sharedPage.settings')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4 min-h-0 flex-1 flex flex-col">
|
||||
<div className="rounded-md border bg-muted/35 p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<MetadataField
|
||||
label={t('sharedPage.settingsTitle')}
|
||||
value={t('sharedPage.settingsDescription')}
|
||||
/>
|
||||
{selectedItemContent?.contentPath ? (
|
||||
<MetadataField
|
||||
label={t('sharedPage.resolvedSource')}
|
||||
value={selectedItemContent.contentPath}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="min-h-0 flex-1">
|
||||
<CardContent className="p-0 h-full">
|
||||
<ScrollArea className="h-full px-5 py-4">
|
||||
{!summary?.settings ? (
|
||||
<div className="min-h-[260px] flex flex-col items-center justify-center text-center">
|
||||
<FileJson className="w-12 h-12 text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t('sharedPage.settingsTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mt-2">
|
||||
{t('sharedPage.settingsDescription')}
|
||||
</p>
|
||||
<div className="mt-6 p-3 rounded-md bg-muted/50 border font-mono text-xs">
|
||||
{t('sharedPage.settingsInactive')}
|
||||
</div>
|
||||
</div>
|
||||
) : isContentLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('sharedPage.loadingSettings')}
|
||||
</p>
|
||||
) : isContentError ? (
|
||||
<Alert variant="destructive" className="max-w-2xl">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{t('sharedPage.failedLoadContent')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{contentErrorMessage}</p>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void refetchContent();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t('sharedPage.retryContent')}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<CodeEditor
|
||||
value={selectedItemContent?.content ?? ''}
|
||||
onChange={() => {}}
|
||||
language="json"
|
||||
readonly
|
||||
minHeight="320px"
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : selectedItem ? (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b bg-background">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -310,7 +473,7 @@ export function SharedPage() {
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<MetadataField
|
||||
label={t('sharedPage.pathLabel')}
|
||||
value={selectedItem.path}
|
||||
value={formatSharedItemPath(selectedItem.path)}
|
||||
mono
|
||||
/>
|
||||
{selectedItemContent?.contentPath &&
|
||||
@@ -359,7 +522,7 @@ export function SharedPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,6 +531,78 @@ export function SharedPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function getPreferredInitialTab(summary: SharedSummary): TabType {
|
||||
if (summary.commands > 0) {
|
||||
return 'commands';
|
||||
}
|
||||
if (summary.skills > 0) {
|
||||
return 'skills';
|
||||
}
|
||||
if (summary.agents > 0) {
|
||||
return 'agents';
|
||||
}
|
||||
if (summary.plugins > 0) {
|
||||
return 'plugins';
|
||||
}
|
||||
if (summary.settings) {
|
||||
return 'settings';
|
||||
}
|
||||
|
||||
return 'commands';
|
||||
}
|
||||
|
||||
function ResourcePoliciesPanel({
|
||||
sharedCount,
|
||||
profileLocalCount,
|
||||
profileLocalNames,
|
||||
}: {
|
||||
sharedCount: number;
|
||||
profileLocalCount: number;
|
||||
profileLocalNames: string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const displayedProfileLocalNames = profileLocalNames.slice(0, 4);
|
||||
const hiddenProfileLocalCount = Math.max(
|
||||
profileLocalNames.length - displayedProfileLocalNames.length,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/25 px-4 py-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{t('sharedPage.resourcePolicies')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('sharedPage.resourcePoliciesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300/60 bg-emerald-50/50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-300"
|
||||
>
|
||||
{t('sharedPage.sharedAccounts', { count: sharedCount })}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{t('sharedPage.profileLocalAccounts', { count: profileLocalCount })}
|
||||
</Badge>
|
||||
{displayedProfileLocalNames.map((name) => (
|
||||
<Badge key={name} variant="outline" className="font-mono text-[10px]">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
{hiddenProfileLocalCount > 0 ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t('sharedPage.moreProfileLocalAccounts', { count: hiddenProfileLocalCount })}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderMetricCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||
@@ -408,6 +643,10 @@ function getSharedErrorMessage(error: unknown, fallbackMessage: string): string
|
||||
return error.message || fallbackMessage;
|
||||
}
|
||||
|
||||
function formatSharedItemPath(itemPath: string): string {
|
||||
return itemPath.startsWith('plugin-registry:') ? 'installed_plugins.json' : itemPath;
|
||||
}
|
||||
|
||||
interface MarkdownBlockHeading {
|
||||
type: 'heading';
|
||||
level: number;
|
||||
|
||||
@@ -21,6 +21,29 @@ function requestUrl(input: RequestInfo | URL): string {
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function accountsResponse() {
|
||||
return jsonResponse({
|
||||
default: 'work',
|
||||
plain_ccs_lane: null,
|
||||
accounts: [
|
||||
{
|
||||
name: 'work',
|
||||
type: 'oauth',
|
||||
created: '2026-05-01T00:00:00.000Z',
|
||||
context_mode: 'isolated',
|
||||
shared_resource_mode: 'shared',
|
||||
},
|
||||
{
|
||||
name: 'sandbox',
|
||||
type: 'oauth',
|
||||
created: '2026-05-02T00:00:00.000Z',
|
||||
context_mode: 'isolated',
|
||||
shared_resource_mode: 'profile-local',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('SharedPage', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
@@ -41,10 +64,15 @@ describe('SharedPage', () => {
|
||||
commands: 0,
|
||||
skills: 0,
|
||||
agents: 0,
|
||||
plugins: 0,
|
||||
settings: false,
|
||||
total: 0,
|
||||
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||
});
|
||||
}
|
||||
if (url.endsWith('/api/accounts')) {
|
||||
return accountsResponse();
|
||||
}
|
||||
if (url.endsWith('/api/shared/commands')) {
|
||||
return jsonResponse({ error: 'Backend unavailable' }, 500);
|
||||
}
|
||||
@@ -67,10 +95,15 @@ describe('SharedPage', () => {
|
||||
commands: 1,
|
||||
skills: 0,
|
||||
agents: 0,
|
||||
plugins: 0,
|
||||
settings: false,
|
||||
total: 1,
|
||||
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||
});
|
||||
}
|
||||
if (url.endsWith('/api/accounts')) {
|
||||
return accountsResponse();
|
||||
}
|
||||
if (url.endsWith('/api/shared/commands')) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
@@ -109,6 +142,174 @@ describe('SharedPage', () => {
|
||||
expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the first populated resource tab instead of an empty commands tab', async () => {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.endsWith('/api/shared/summary')) {
|
||||
return jsonResponse({
|
||||
commands: 0,
|
||||
skills: 1,
|
||||
agents: 0,
|
||||
plugins: 0,
|
||||
settings: true,
|
||||
total: 2,
|
||||
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||
});
|
||||
}
|
||||
if (url.endsWith('/api/accounts')) {
|
||||
return accountsResponse();
|
||||
}
|
||||
if (url.endsWith('/api/shared/commands')) {
|
||||
return jsonResponse({ items: [] });
|
||||
}
|
||||
if (url.endsWith('/api/shared/skills')) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{
|
||||
name: 'review-helper',
|
||||
description: 'Review the latest PR changes.',
|
||||
path: '/tmp/skills/review-helper/SKILL.md',
|
||||
type: 'skill',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/shared/content?') && url.includes('type=skills')) {
|
||||
return jsonResponse({
|
||||
content: '# Skill Body\n\nReal shared skill instructions',
|
||||
contentPath: '/tmp/skills/review-helper/SKILL.md',
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse({ items: [] });
|
||||
});
|
||||
|
||||
render(<SharedPage />);
|
||||
|
||||
expect(await screen.findByText('Showing 1 of 1 skills')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review the latest PR changes.')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Skill Body')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Select a command to view full content.')).not.toBeInTheDocument();
|
||||
|
||||
const requestedUrls = fetchMock.mock.calls.map(([input]) => requestUrl(input));
|
||||
expect(requestedUrls.some((url) => url.endsWith('/api/shared/skills'))).toBe(true);
|
||||
});
|
||||
|
||||
it('loads plugin registry content and renders real settings content', async () => {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.endsWith('/api/shared/summary')) {
|
||||
return jsonResponse({
|
||||
commands: 0,
|
||||
skills: 0,
|
||||
agents: 0,
|
||||
plugins: 1,
|
||||
settings: true,
|
||||
total: 2,
|
||||
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||
});
|
||||
}
|
||||
if (url.endsWith('/api/accounts')) {
|
||||
return accountsResponse();
|
||||
}
|
||||
if (url.endsWith('/api/shared/commands')) {
|
||||
return jsonResponse({ items: [] });
|
||||
}
|
||||
if (url.endsWith('/api/shared/plugins')) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{
|
||||
name: 'discord@claude-plugins-official',
|
||||
description: 'Installed from claude-plugins-official; 1 record in shared registry',
|
||||
path: 'plugin-registry:discord%40claude-plugins-official',
|
||||
type: 'plugin',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/shared/content?') && url.includes('type=plugins')) {
|
||||
return jsonResponse({
|
||||
content:
|
||||
'# discord@claude-plugins-official\n\n## Plugin Registry Entry\n\n```json\n{"enabled":true}\n```',
|
||||
contentPath: '/tmp/plugins/installed_plugins.json',
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/shared/content?') && url.includes('type=settings')) {
|
||||
return jsonResponse({
|
||||
content:
|
||||
'{\n "env": {\n "ANTHROPIC_MODEL": "claude-sonnet-4-5"\n },\n "permissions": {\n "allow": [\n "Bash(git status)"\n ]\n }\n}',
|
||||
contentPath: '/tmp/.ccs/shared/settings.json',
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse({ error: `Unexpected request: ${url}` }, 404);
|
||||
});
|
||||
|
||||
render(<SharedPage />);
|
||||
|
||||
expect(await screen.findByText('Showing 1 of 1 plugins')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('Installed from claude-plugins-official; 1 record in shared registry')
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText('Plugin Registry Entry')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /Settings/ }));
|
||||
|
||||
expect(await screen.findByText('Showing 1 of 1 settings')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText((content) =>
|
||||
content.includes('"ANTHROPIC_MODEL": "claude-sonnet-4-5"')
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('/tmp/.ccs/shared/settings.json')).toBeInTheDocument();
|
||||
|
||||
const requestedUrls = fetchMock.mock.calls.map(([input]) => requestUrl(input));
|
||||
expect(
|
||||
requestedUrls.some(
|
||||
(url) => url.includes('/api/shared/content?') && url.includes('type=settings')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows account resource policy context on the shared hub', async () => {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.endsWith('/api/shared/summary')) {
|
||||
return jsonResponse({
|
||||
commands: 0,
|
||||
skills: 0,
|
||||
agents: 0,
|
||||
plugins: 0,
|
||||
settings: false,
|
||||
total: 0,
|
||||
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||
});
|
||||
}
|
||||
if (url.endsWith('/api/accounts')) {
|
||||
return accountsResponse();
|
||||
}
|
||||
if (url.endsWith('/api/shared/commands')) {
|
||||
return jsonResponse({ items: [] });
|
||||
}
|
||||
|
||||
return jsonResponse({ items: [] });
|
||||
});
|
||||
|
||||
render(<SharedPage />);
|
||||
|
||||
expect(await screen.findByText('Resource policies')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 shared')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 profile-local')).toBeInTheDocument();
|
||||
expect(screen.getByText('sandbox')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /Plugins/ }));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'No shared plugins are installed. Plugin registry files and cache folders are hidden until an actual plugin is present.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows offline guidance when network request fails', async () => {
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
Reference in new issue
Block a user