mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +00:00
feat(dashboard): add shared resource controls
This commit is contained in:
@@ -97,6 +97,10 @@ Shared resource editing:
|
|||||||
- accepts only `shared` or `profile-local`
|
- accepts only `shared` or `profile-local`
|
||||||
- rejects CLIProxy OAuth account keys for this route
|
- rejects CLIProxy OAuth account keys for this route
|
||||||
- reconciles the account instance after metadata is updated
|
- 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`.
|
||||||
|
- Plugin directories without README/PLUGIN docs render their actual directory inventory.
|
||||||
|
- Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CCS Project Roadmap
|
# 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.
|
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
|
### 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**: **#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**: **#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>`.
|
- **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>`.
|
||||||
|
|||||||
@@ -171,6 +171,13 @@ ccs auth resources work --mode profile-local
|
|||||||
ccs auth resources work --mode shared
|
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.
|
No account recreation required for this workflow.
|
||||||
|
|
||||||
### Backup Before Changing Sync
|
### Backup Before Changing Sync
|
||||||
@@ -191,7 +198,7 @@ ccs auth backup default
|
|||||||
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
|
- 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.
|
- 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.
|
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
|
||||||
- Dashboard controls for existing-account Shared Resources are tracked separately; CLI/API support is the source of truth until that UI lands.
|
- 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
|
## Alternative: CLIProxy Claude Pool
|
||||||
|
|
||||||
|
|||||||
+183
-12
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Shared Data Routes (Phase 07)
|
* 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';
|
import { Router, Request, Response } from 'express';
|
||||||
@@ -33,13 +33,14 @@ const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
|
|||||||
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||||
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
|
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
|
||||||
|
|
||||||
type SharedCollectionType = 'commands' | 'skills' | 'agents';
|
type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins';
|
||||||
|
type SharedContentType = SharedCollectionType | 'settings';
|
||||||
|
|
||||||
interface SharedItem {
|
interface SharedItem {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: 'command' | 'skill' | 'agent';
|
type: 'command' | 'skill' | 'agent' | 'plugin';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SharedItemsCacheEntry {
|
interface SharedItemsCacheEntry {
|
||||||
@@ -75,13 +76,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) => {
|
sharedRoutes.get('/content', (req: Request, res: Response) => {
|
||||||
const typeParam = req.query.type;
|
const typeParam = req.query.type;
|
||||||
const itemPathParam = req.query.path;
|
const itemPathParam = req.query.path;
|
||||||
|
|
||||||
if (!isSharedCollectionType(typeParam)) {
|
if (!isSharedContentType(typeParam)) {
|
||||||
res.status(400).json({ error: 'Invalid or missing type parameter' });
|
res.status(400).json({ error: 'Invalid or missing type parameter' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -91,14 +100,18 @@ sharedRoutes.get('/content', (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ccsDir = getCcsDir();
|
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)) {
|
if (!fs.existsSync(sharedDir)) {
|
||||||
res.status(404).json({ error: 'Shared directory not found' });
|
res.status(404).json({ error: 'Shared directory not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
||||||
const allowedRoots = resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
const allowedRoots =
|
||||||
|
typeParam === 'settings'
|
||||||
|
? new Set<string>([sharedDirRoot])
|
||||||
|
: resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
||||||
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots);
|
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots);
|
||||||
|
|
||||||
if (!contentResult) {
|
if (!contentResult) {
|
||||||
@@ -116,18 +129,34 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => {
|
|||||||
const commands = getSharedItems('commands').length;
|
const commands = getSharedItems('commands').length;
|
||||||
const skills = getSharedItems('skills').length;
|
const skills = getSharedItems('skills').length;
|
||||||
const agents = getSharedItems('agents').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 hasSettings =
|
||||||
|
Boolean(sharedDirRoot) &&
|
||||||
|
Boolean(resolvedSettingsPath) &&
|
||||||
|
isPathWithin(resolvedSettingsPath as string, sharedDirRoot as string);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
commands,
|
commands,
|
||||||
skills,
|
skills,
|
||||||
agents,
|
agents,
|
||||||
total: commands + skills + agents,
|
plugins,
|
||||||
|
settings: hasSettings,
|
||||||
|
total: commands + skills + agents + plugins + (hasSettings ? 1 : 0),
|
||||||
symlinkStatus: checkSymlinkStatus(),
|
symlinkStatus: checkSymlinkStatus(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function isSharedCollectionType(value: unknown): value is SharedCollectionType {
|
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(
|
function resolveAllowedRoots(
|
||||||
@@ -135,7 +164,7 @@ function resolveAllowedRoots(
|
|||||||
ccsDir: string,
|
ccsDir: string,
|
||||||
sharedDirRoot: string
|
sharedDirRoot: string
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
if (type === 'commands') {
|
if (type === 'commands' || type === 'plugins') {
|
||||||
return new Set<string>([sharedDirRoot]);
|
return new Set<string>([sharedDirRoot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,13 +279,35 @@ function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedIt
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSkillOrAgentItem(
|
function getSkillOrAgentItem(
|
||||||
type: 'skills' | 'agents',
|
type: 'skills' | 'agents' | 'plugins',
|
||||||
entry: fs.Dirent,
|
entry: fs.Dirent,
|
||||||
entryPath: string,
|
entryPath: string,
|
||||||
resolvedEntryPath: string,
|
resolvedEntryPath: string,
|
||||||
allowedRoots: Set<string>,
|
allowedRoots: Set<string>,
|
||||||
stats: fs.Stats
|
stats: fs.Stats
|
||||||
): SharedItem | null {
|
): SharedItem | null {
|
||||||
|
if (type === 'plugins') {
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = readFirstMarkdownDescription(
|
||||||
|
[
|
||||||
|
path.join(resolvedEntryPath, 'README.md'),
|
||||||
|
path.join(resolvedEntryPath, 'readme.md'),
|
||||||
|
path.join(resolvedEntryPath, 'PLUGIN.md'),
|
||||||
|
],
|
||||||
|
allowedRoots
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
description: description ?? getDirectoryInventoryDescription(resolvedEntryPath, allowedRoots),
|
||||||
|
path: entryPath,
|
||||||
|
type: 'plugin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'skills') {
|
if (type === 'skills') {
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -529,10 +580,14 @@ function resolveReadableMarkdownPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSharedItemContent(
|
function getSharedItemContent(
|
||||||
type: SharedCollectionType,
|
type: SharedContentType,
|
||||||
itemPath: string,
|
itemPath: string,
|
||||||
allowedRoots: Set<string>
|
allowedRoots: Set<string>
|
||||||
): { content: string; contentPath: string } | null {
|
): { content: string; contentPath: string } | null {
|
||||||
|
if (type === 'settings') {
|
||||||
|
return getSharedSettingsContent(itemPath, allowedRoots);
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedItemPath = safeRealPath(itemPath);
|
const resolvedItemPath = safeRealPath(itemPath);
|
||||||
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
|
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -559,6 +614,26 @@ function getSharedItemContent(
|
|||||||
[path.join(resolvedItemPath, 'SKILL.md')],
|
[path.join(resolvedItemPath, 'SKILL.md')],
|
||||||
allowedRoots
|
allowedRoots
|
||||||
);
|
);
|
||||||
|
} else if (type === 'plugins') {
|
||||||
|
if (!itemStats.isDirectory()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownPath = resolveReadableMarkdownPath(
|
||||||
|
[
|
||||||
|
path.join(resolvedItemPath, 'README.md'),
|
||||||
|
path.join(resolvedItemPath, 'readme.md'),
|
||||||
|
path.join(resolvedItemPath, 'PLUGIN.md'),
|
||||||
|
],
|
||||||
|
allowedRoots
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!markdownPath) {
|
||||||
|
return {
|
||||||
|
content: renderPluginDirectoryContent(itemPath, resolvedItemPath, allowedRoots),
|
||||||
|
contentPath: resolvedItemPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (itemStats.isDirectory()) {
|
if (itemStats.isDirectory()) {
|
||||||
markdownPath = resolveReadableMarkdownPath(
|
markdownPath = resolveReadableMarkdownPath(
|
||||||
@@ -597,6 +672,102 @@ 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 getDirectoryInventoryDescription(
|
||||||
|
directoryPath: string,
|
||||||
|
allowedRoots: Set<string>
|
||||||
|
): string {
|
||||||
|
const entries = listDirectoryEntryNames(directoryPath, allowedRoots);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return 'Empty directory';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimDescription(
|
||||||
|
`Directory with ${entries.length} ${entries.length === 1 ? 'item' : 'items'}: ${entries.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPluginDirectoryContent(
|
||||||
|
displayPath: string,
|
||||||
|
resolvedPath: string,
|
||||||
|
allowedRoots: Set<string>
|
||||||
|
): string {
|
||||||
|
const name = path.basename(displayPath);
|
||||||
|
const lines = [`# Plugin directory: ${name}`, '', `Path: \`${displayPath}\``];
|
||||||
|
|
||||||
|
const entries = listDirectoryEntryNames(resolvedPath, allowedRoots);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
lines.push('', '## Directory contents', '', ...entries.map((entry) => `- ${entry}`));
|
||||||
|
} else {
|
||||||
|
lines.push('', 'No files or folders found in this plugin directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function listDirectoryEntryNames(directoryPath: string, allowedRoots: Set<string>): string[] {
|
||||||
|
if (!isPathWithinAny(directoryPath, allowedRoots)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readdirSync(directoryPath, { withFileTypes: true })
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isPathWithin(candidatePath: string, basePath: string): boolean {
|
function isPathWithin(candidatePath: string, basePath: string): boolean {
|
||||||
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
||||||
const normalizedBase = normalizeForPathComparison(basePath);
|
const normalizedBase = normalizeForPathComparison(basePath);
|
||||||
|
|||||||
@@ -285,6 +285,98 @@ describe('web-server shared-routes', () => {
|
|||||||
expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md')));
|
expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns factual directory content for a shared plugin directory without markdown', 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'), '{}');
|
||||||
|
|
||||||
|
const listPayload = await getJson<{
|
||||||
|
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||||
|
}>(baseUrl, '/api/shared/plugins');
|
||||||
|
|
||||||
|
expect(listPayload.items).toEqual([
|
||||||
|
{
|
||||||
|
name: 'cache',
|
||||||
|
type: 'plugin',
|
||||||
|
description: 'Directory with 2 items: payloads/, plugin-index.json',
|
||||||
|
path: cacheDir,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
||||||
|
baseUrl,
|
||||||
|
`/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(contentPayload.content).toContain('# Plugin directory: cache');
|
||||||
|
expect(contentPayload.content).toContain('plugin-index.json');
|
||||||
|
expect(contentPayload.content).toContain('payloads/');
|
||||||
|
expect(contentPayload.content).not.toContain('Shared plugin payload cache');
|
||||||
|
expect(contentPayload.contentPath).toBe(fs.realpathSync(cacheDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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('returns README content for a shared plugin directory when available', async () => {
|
||||||
|
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||||
|
const pluginDir = path.join(pluginsDir, 'marketplaces');
|
||||||
|
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 () => {
|
it('does not use markdown frontmatter fences as descriptions when frontmatter is malformed', async () => {
|
||||||
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||||
fs.mkdirSync(commandsDir, { recursive: true });
|
fs.mkdirSync(commandsDir, { recursive: true });
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} 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 { useTranslation } from 'react-i18next';
|
||||||
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
|
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
|
||||||
|
import { EditAccountSharedResourcesDialog } from '@/components/account/edit-account-shared-resources-dialog';
|
||||||
import {
|
import {
|
||||||
useSetDefaultAccount,
|
useSetDefaultAccount,
|
||||||
useDeleteAccount,
|
useDeleteAccount,
|
||||||
@@ -57,6 +59,7 @@ export function AccountsTable({
|
|||||||
const updateContextMutation = useUpdateAccountContext();
|
const updateContextMutation = useUpdateAccountContext();
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
const [contextTarget, setContextTarget] = useState<AuthAccountRow | null>(null);
|
const [contextTarget, setContextTarget] = useState<AuthAccountRow | null>(null);
|
||||||
|
const [resourcesTarget, setResourcesTarget] = useState<AuthAccountRow | null>(null);
|
||||||
|
|
||||||
const columns: ColumnDef<AuthAccountRow>[] = [
|
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',
|
id: 'actions',
|
||||||
header: t('accountsTable.actions'),
|
header: t('accountsTable.actions'),
|
||||||
@@ -193,6 +232,19 @@ export function AccountsTable({
|
|||||||
{t('accountsTable.sync')}
|
{t('accountsTable.sync')}
|
||||||
</Button>
|
</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 && (
|
{!isCliproxy && hasLegacyInference && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -275,12 +327,13 @@ export function AccountsTable({
|
|||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
const widthClass =
|
const widthClass =
|
||||||
{
|
{
|
||||||
name: 'w-[200px]',
|
name: 'w-[180px]',
|
||||||
type: 'w-[100px]',
|
type: 'w-[80px]',
|
||||||
created: 'w-[150px]',
|
created: 'w-[120px]',
|
||||||
last_used: 'w-[150px]',
|
last_used: 'w-[120px]',
|
||||||
context: 'w-[170px]',
|
context: 'w-[150px]',
|
||||||
actions: 'w-[290px]',
|
resources: 'w-[120px]',
|
||||||
|
actions: 'w-[320px]',
|
||||||
}[header.id] || 'w-auto';
|
}[header.id] || 'w-auto';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -333,6 +386,13 @@ export function AccountsTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{resourcesTarget && (
|
||||||
|
<EditAccountSharedResourcesDialog
|
||||||
|
account={resourcesTarget}
|
||||||
|
onClose={() => setResourcesTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<AlertDialogContent>
|
<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;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
path: 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;
|
commands: number;
|
||||||
skills: number;
|
skills: number;
|
||||||
agents: number;
|
agents: number;
|
||||||
|
plugins: number;
|
||||||
|
settings: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
symlinkStatus: { valid: boolean; message: string };
|
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[] }>({
|
return useQuery<{ items: SharedItem[] }>({
|
||||||
queryKey: ['shared', type],
|
queryKey: ['shared', type],
|
||||||
|
enabled: type !== 'settings',
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (type === 'settings') {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/shared/${type}`);
|
const res = await fetch(`/api/shared/${type}`);
|
||||||
return readJsonOrThrow<{ items: SharedItem[] }>(res, `Failed to fetch shared ${type}`);
|
return readJsonOrThrow<{ items: SharedItem[] }>(res, `Failed to fetch shared ${type}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSharedItemContent(
|
export function useSharedItemContent(type: SharedResourceTab, itemPath: string | null) {
|
||||||
type: 'commands' | 'skills' | 'agents',
|
|
||||||
itemPath: string | null
|
|
||||||
) {
|
|
||||||
return useQuery<SharedItemContent>({
|
return useQuery<SharedItemContent>({
|
||||||
queryKey: ['shared', type, 'content', itemPath],
|
queryKey: ['shared', type, 'content', itemPath],
|
||||||
enabled: typeof itemPath === 'string' && itemPath.length > 0,
|
enabled: typeof itemPath === 'string' && itemPath.length > 0,
|
||||||
|
|||||||
@@ -934,10 +934,16 @@ export interface Account {
|
|||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
context_inferred?: boolean;
|
context_inferred?: boolean;
|
||||||
continuity_inferred?: boolean;
|
continuity_inferred?: boolean;
|
||||||
|
shared_resource_mode?: 'shared' | 'profile-local';
|
||||||
|
shared_resource_inferred?: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateAccountSharedResources {
|
||||||
|
shared_resource_mode: 'shared' | 'profile-local';
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlainCcsLane {
|
export interface PlainCcsLane {
|
||||||
kind: 'native' | 'account-default' | 'account-inherited' | 'profile-default' | 'ambient';
|
kind: 'native' | 'account-default' | 'account-inherited' | 'profile-default' | 'ambient';
|
||||||
label: string;
|
label: string;
|
||||||
@@ -1459,6 +1465,11 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
updateSharedResources: (name: string, data: UpdateAccountSharedResources) =>
|
||||||
|
request(`/accounts/${encodeURIComponent(name)}/shared-resources`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
// Unified config API
|
// Unified config API
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
+97
-10
@@ -28,7 +28,7 @@ const resources = {
|
|||||||
claudeExtension: 'Claude Extension',
|
claudeExtension: 'Claude Extension',
|
||||||
accounts: 'Accounts',
|
accounts: 'Accounts',
|
||||||
allAccounts: 'All Accounts',
|
allAccounts: 'All Accounts',
|
||||||
sharedData: 'Shared Data',
|
sharedData: 'Shared Resources',
|
||||||
compatibleClis: 'Compatible',
|
compatibleClis: 'Compatible',
|
||||||
deprecated: 'Deprecated',
|
deprecated: 'Deprecated',
|
||||||
factoryDroid: 'Factory Droid',
|
factoryDroid: 'Factory Droid',
|
||||||
@@ -96,6 +96,7 @@ const resources = {
|
|||||||
settingsSaved: 'Settings saved',
|
settingsSaved: 'Settings saved',
|
||||||
defaultTargetUpdated: 'Default target updated',
|
defaultTargetUpdated: 'Default target updated',
|
||||||
failedUpdateDefaultTarget: 'Failed to update default target to {{target}}{{suffix}}',
|
failedUpdateDefaultTarget: 'Failed to update default target to {{target}}{{suffix}}',
|
||||||
|
resourcesUpdated: 'Shared resources updated',
|
||||||
},
|
},
|
||||||
profileDialog: {
|
profileDialog: {
|
||||||
editTitle: 'Edit Profile',
|
editTitle: 'Edit Profile',
|
||||||
@@ -675,8 +676,10 @@ const resources = {
|
|||||||
created: 'Created',
|
created: 'Created',
|
||||||
lastUsed: 'Last Used',
|
lastUsed: 'Last Used',
|
||||||
historySync: 'History Sync',
|
historySync: 'History Sync',
|
||||||
|
sharedResources: 'Resources',
|
||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
syncTitle: 'Edit sync mode, group, and continuity depth',
|
syncTitle: 'Edit sync mode, group, and continuity depth',
|
||||||
|
resourcesTitle: 'Edit shared resource inheritance',
|
||||||
confirmLegacyTitle: "Confirm this legacy account's current mode as explicit",
|
confirmLegacyTitle: "Confirm this legacy account's current mode as explicit",
|
||||||
sync: 'Sync',
|
sync: 'Sync',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
@@ -696,6 +699,8 @@ const resources = {
|
|||||||
sharedGroupLegacy: 'shared ({{group}}, projects only, legacy)',
|
sharedGroupLegacy: 'shared ({{group}}, projects only, legacy)',
|
||||||
isolatedLegacy: 'isolated (legacy default)',
|
isolatedLegacy: 'isolated (legacy default)',
|
||||||
isolated: 'isolated',
|
isolated: 'isolated',
|
||||||
|
resourcesShared: 'shared',
|
||||||
|
resourcesProfileLocal: 'profile-local',
|
||||||
noSameGroupPeer: 'No same-group peer yet',
|
noSameGroupPeer: 'No same-group peer yet',
|
||||||
sameGroupPeerCount_one: '{{count}} same-group peer',
|
sameGroupPeerCount_one: '{{count}} same-group peer',
|
||||||
sameGroupPeerCount_other: '{{count}} same-group peers',
|
sameGroupPeerCount_other: '{{count}} same-group peers',
|
||||||
@@ -706,6 +711,7 @@ const resources = {
|
|||||||
deeper: 'Deeper',
|
deeper: 'Deeper',
|
||||||
legacy: 'Legacy',
|
legacy: 'Legacy',
|
||||||
isolated: 'Isolated',
|
isolated: 'Isolated',
|
||||||
|
profileLocal: 'Local',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
addAccountDialog: {
|
addAccountDialog: {
|
||||||
@@ -1196,6 +1202,9 @@ const resources = {
|
|||||||
quickCommandsDesc: 'Copy and run in terminal.',
|
quickCommandsDesc: 'Copy and run in terminal.',
|
||||||
workspaceBadge: 'ccs auth Workspace',
|
workspaceBadge: 'ccs auth Workspace',
|
||||||
historySyncBadge: 'History & Resume Controls',
|
historySyncBadge: 'History & Resume Controls',
|
||||||
|
resourcesBadge: 'Shared Resources Controls',
|
||||||
|
resourcesShared: 'Shared',
|
||||||
|
resourcesProfileLocal: 'Profile Local',
|
||||||
authAccounts: 'Auth Accounts',
|
authAccounts: 'Auth Accounts',
|
||||||
tableScopePrefix: 'This table is intentionally scoped to',
|
tableScopePrefix: 'This table is intentionally scoped to',
|
||||||
tableScopeMiddle: 'accounts. Use',
|
tableScopeMiddle: 'accounts. Use',
|
||||||
@@ -1494,9 +1503,11 @@ const resources = {
|
|||||||
commands: 'Commands',
|
commands: 'Commands',
|
||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
agents: 'Agents',
|
agents: 'Agents',
|
||||||
title: 'Shared Data',
|
plugins: 'Plugins',
|
||||||
subtitle: 'Commands, skills, and agents shared across Claude instances',
|
settings: 'Settings',
|
||||||
totalShared: 'Total Shared',
|
title: 'Shared Resources',
|
||||||
|
subtitle: 'Commands, skills, agents, and configuration shared across accounts',
|
||||||
|
totalShared: 'Total Resources',
|
||||||
visible: 'Visible',
|
visible: 'Visible',
|
||||||
markdownDetail: 'Markdown detail view',
|
markdownDetail: 'Markdown detail view',
|
||||||
filterPrefix: 'Filter:',
|
filterPrefix: 'Filter:',
|
||||||
@@ -1517,9 +1528,41 @@ const resources = {
|
|||||||
pathLabel: 'Path',
|
pathLabel: 'Path',
|
||||||
resolvedSource: 'Resolved Source',
|
resolvedSource: 'Resolved Source',
|
||||||
loadingMarkdown: 'Loading markdown content...',
|
loadingMarkdown: 'Loading markdown content...',
|
||||||
|
loadingSettings: 'Loading settings content...',
|
||||||
failedLoadContent: 'Failed to load content',
|
failedLoadContent: 'Failed to load content',
|
||||||
retryContent: 'Retry content',
|
retryContent: 'Retry content',
|
||||||
noMarkdown: 'No markdown content available.',
|
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,8 +4076,10 @@ const resources = {
|
|||||||
commands: '命令',
|
commands: '命令',
|
||||||
skills: '技能',
|
skills: '技能',
|
||||||
agents: '代理',
|
agents: '代理',
|
||||||
title: '共享数据',
|
plugins: '插件',
|
||||||
subtitle: '在 Claude 实例间共享的命令、技能和代理',
|
settings: '设置',
|
||||||
|
title: '共享资源',
|
||||||
|
subtitle: '在账号之间共享的命令、技能、代理、插件和配置',
|
||||||
totalShared: '共享总数',
|
totalShared: '共享总数',
|
||||||
visible: '可见',
|
visible: '可见',
|
||||||
markdownDetail: 'Markdown 详情视图',
|
markdownDetail: 'Markdown 详情视图',
|
||||||
@@ -4055,9 +4100,21 @@ const resources = {
|
|||||||
pathLabel: '路径',
|
pathLabel: '路径',
|
||||||
resolvedSource: '解析后来源',
|
resolvedSource: '解析后来源',
|
||||||
loadingMarkdown: '正在加载 Markdown 内容...',
|
loadingMarkdown: '正在加载 Markdown 内容...',
|
||||||
|
loadingSettings: '正在加载设置内容...',
|
||||||
failedLoadContent: '加载内容失败',
|
failedLoadContent: '加载内容失败',
|
||||||
retryContent: '重试内容加载',
|
retryContent: '重试内容加载',
|
||||||
noMarkdown: '暂无 Markdown 内容。',
|
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: {
|
heroSection: {
|
||||||
title: 'CCS Config',
|
title: 'CCS Config',
|
||||||
@@ -6623,8 +6680,10 @@ const resources = {
|
|||||||
commands: 'Lệnh',
|
commands: 'Lệnh',
|
||||||
skills: 'Kỹ năng',
|
skills: 'Kỹ năng',
|
||||||
agents: 'Agent',
|
agents: 'Agent',
|
||||||
title: 'Dữ liệu dùng chung',
|
plugins: 'Plugin',
|
||||||
subtitle: 'Các lệnh, kỹ năng và agent được chia sẻ giữa các phiên Claude',
|
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',
|
totalShared: 'Tổng số mục dùng chung',
|
||||||
visible: 'Hiển thị',
|
visible: 'Hiển thị',
|
||||||
markdownDetail: 'Chế độ xem chi tiết Markdown',
|
markdownDetail: 'Chế độ xem chi tiết Markdown',
|
||||||
@@ -6646,9 +6705,22 @@ const resources = {
|
|||||||
pathLabel: 'Đường dẫn',
|
pathLabel: 'Đường dẫn',
|
||||||
resolvedSource: 'Nguồn đã giải quyết',
|
resolvedSource: 'Nguồn đã giải quyết',
|
||||||
loadingMarkdown: 'Đang tải nội dung Markdown...',
|
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',
|
failedLoadContent: 'Không tải được nội dung',
|
||||||
retryContent: 'Thử lại nội dung',
|
retryContent: 'Thử lại nội dung',
|
||||||
noMarkdown: 'Không có nội dung Markdown khả dụng.',
|
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: {
|
heroSection: {
|
||||||
title: 'CCS Config',
|
title: 'CCS Config',
|
||||||
@@ -9238,8 +9310,10 @@ const resources = {
|
|||||||
commands: 'コマンド',
|
commands: 'コマンド',
|
||||||
skills: 'スキル',
|
skills: 'スキル',
|
||||||
agents: 'エージェント',
|
agents: 'エージェント',
|
||||||
title: '共有データ',
|
plugins: 'プラグイン',
|
||||||
subtitle: 'Claudeインスタンス間で共有されるコマンド、スキル、エージェント',
|
settings: '設定',
|
||||||
|
title: '共有リソース',
|
||||||
|
subtitle: 'アカウント間で共有されるコマンド、スキル、エージェント、プラグイン、設定',
|
||||||
totalShared: '共有数',
|
totalShared: '共有数',
|
||||||
visible: '表示中',
|
visible: '表示中',
|
||||||
markdownDetail: 'Markdown詳細ビュー',
|
markdownDetail: 'Markdown詳細ビュー',
|
||||||
@@ -9261,9 +9335,22 @@ const resources = {
|
|||||||
pathLabel: 'パス',
|
pathLabel: 'パス',
|
||||||
resolvedSource: '解決後ソース',
|
resolvedSource: '解決後ソース',
|
||||||
loadingMarkdown: 'Markdownコンテンツを読み込み中...',
|
loadingMarkdown: 'Markdownコンテンツを読み込み中...',
|
||||||
|
loadingSettings: '設定内容を読み込み中...',
|
||||||
failedLoadContent: 'コンテンツの読み込みに失敗しました',
|
failedLoadContent: 'コンテンツの読み込みに失敗しました',
|
||||||
retryContent: '再読み込み',
|
retryContent: '再読み込み',
|
||||||
noMarkdown: 'Markdownコンテンツはありません。',
|
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: {
|
accountCardStats: {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export function AccountsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">{t('accountsPage.workspaceBadge')}</Badge>
|
<Badge variant="outline">{t('accountsPage.workspaceBadge')}</Badge>
|
||||||
<Badge variant="secondary">{t('accountsPage.historySyncBadge')}</Badge>
|
<Badge variant="secondary">{t('accountsPage.historySyncBadge')}</Badge>
|
||||||
|
<Badge variant="secondary">{t('accountsPage.resourcesBadge')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">
|
||||||
{t('accountsPage.authAccounts')}
|
{t('accountsPage.authAccounts')}
|
||||||
|
|||||||
+218
-22
@@ -6,6 +6,8 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { CodeEditor } from '@/components/shared/code-editor';
|
||||||
|
import { useAccounts } from '@/hooks/use-accounts';
|
||||||
import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
|
import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import '@/lib/i18n';
|
import '@/lib/i18n';
|
||||||
@@ -14,6 +16,8 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bot,
|
Bot,
|
||||||
|
Box,
|
||||||
|
FileJson,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -21,7 +25,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type TabType = 'commands' | 'skills' | 'agents';
|
type TabType = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings';
|
||||||
|
|
||||||
export function SharedPage() {
|
export function SharedPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -30,6 +34,8 @@ export function SharedPage() {
|
|||||||
commands: t('sharedPage.commands'),
|
commands: t('sharedPage.commands'),
|
||||||
skills: t('sharedPage.skills'),
|
skills: t('sharedPage.skills'),
|
||||||
agents: t('sharedPage.agents'),
|
agents: t('sharedPage.agents'),
|
||||||
|
plugins: t('sharedPage.plugins'),
|
||||||
|
settings: t('sharedPage.settings'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -41,6 +47,7 @@ export function SharedPage() {
|
|||||||
error: summaryError,
|
error: summaryError,
|
||||||
refetch: refetchSummary,
|
refetch: refetchSummary,
|
||||||
} = useSharedSummary();
|
} = useSharedSummary();
|
||||||
|
const { data: accountsView } = useAccounts();
|
||||||
const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab);
|
const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab);
|
||||||
const allItems = items?.items ?? [];
|
const allItems = items?.items ?? [];
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
@@ -71,23 +78,43 @@ export function SharedPage() {
|
|||||||
return filteredItems.find((item) => item.path === selectedItemPath) ?? filteredItems[0];
|
return filteredItems.find((item) => item.path === selectedItemPath) ?? filteredItems[0];
|
||||||
}, [filteredItems, selectedItemPath]);
|
}, [filteredItems, selectedItemPath]);
|
||||||
|
|
||||||
|
const contentPath =
|
||||||
|
tab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null);
|
||||||
const {
|
const {
|
||||||
data: selectedItemContent,
|
data: selectedItemContent,
|
||||||
isLoading: isContentLoading,
|
isLoading: isContentLoading,
|
||||||
isError: isContentError,
|
isError: isContentError,
|
||||||
error: contentError,
|
error: contentError,
|
||||||
refetch: refetchContent,
|
refetch: refetchContent,
|
||||||
} = useSharedItemContent(tab, selectedItem?.path ?? null);
|
} = useSharedItemContent(tab, 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: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 },
|
||||||
{ id: 'skills', label: tabLabels.skills, icon: Sparkles, count: summary?.skills ?? 0 },
|
{ id: 'skills', label: tabLabels.skills, icon: Sparkles, count: summary?.skills ?? 0 },
|
||||||
{ id: 'agents', label: tabLabels.agents, icon: Bot, count: summary?.agents ?? 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 = tab === 'settings' ? settingsCount : allItems.length;
|
||||||
|
const currentVisibleCount = tab === 'settings' ? settingsCount : filteredItems.length;
|
||||||
|
const showListStatus = tab === 'settings' || (!isLoading && !isError);
|
||||||
|
|
||||||
const hasNoItems = !isLoading && !isError && allItems.length === 0;
|
const hasNoItems = !isLoading && !isError && allItems.length === 0;
|
||||||
const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.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."
|
// TODO i18n: missing key for "Shared item totals could not be loaded. Listing still works."
|
||||||
const summaryErrorMessage = getSharedErrorMessage(
|
const summaryErrorMessage = getSharedErrorMessage(
|
||||||
@@ -104,7 +131,7 @@ export function SharedPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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="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="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -136,8 +163,8 @@ export function SharedPage() {
|
|||||||
<div className="flex flex-col gap-2 lg:items-end">
|
<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">
|
<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={t('sharedPage.totalShared')} value={totalSharedItems} />
|
||||||
<HeaderMetricCard label={tabLabels[tab]} value={allItems.length} />
|
<HeaderMetricCard label={tabLabels[tab]} value={currentItemCount} />
|
||||||
<HeaderMetricCard label={t('sharedPage.visible')} value={filteredItems.length} />
|
<HeaderMetricCard label={t('sharedPage.visible')} value={currentVisibleCount} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
@@ -183,21 +210,29 @@ export function SharedPage() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{accountsView ? (
|
||||||
|
<ResourcePoliciesPanel
|
||||||
|
sharedCount={sharedResourceAccounts.length}
|
||||||
|
profileLocalCount={profileLocalResourceAccounts.length}
|
||||||
|
profileLocalNames={profileLocalResourceAccounts.map((account) => account.name)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 px-6 pb-6">
|
<div className="px-6 pb-6 lg:flex-1 lg:min-h-0">
|
||||||
<div className="h-full rounded-lg border overflow-hidden bg-background">
|
<div className="rounded-lg border overflow-hidden bg-background lg:h-full">
|
||||||
<div className="grid h-full min-h-0 lg:grid-cols-[320px_minmax(0,1fr)]">
|
<div className="grid lg:h-full lg: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="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="p-4 border-b bg-background space-y-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<FolderOpen className="w-4 h-4 text-primary shrink-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[tab]}</h2>
|
||||||
</div>
|
</div>
|
||||||
{!isLoading && !isError && (
|
{showListStatus && (
|
||||||
<Badge variant="outline" className="text-[10px] h-5">
|
<Badge variant="outline" className="text-[10px] h-5">
|
||||||
{filteredItems.length}/{allItems.length}
|
{currentVisibleCount}/{currentItemCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,11 +248,11 @@ export function SharedPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && !isError && (
|
{showListStatus && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('sharedPage.showing', {
|
{t('sharedPage.showing', {
|
||||||
visible: filteredItems.length,
|
visible: currentVisibleCount,
|
||||||
total: allItems.length,
|
total: currentItemCount,
|
||||||
tab,
|
tab,
|
||||||
})}
|
})}
|
||||||
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
|
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
|
||||||
@@ -226,8 +261,35 @@ export function SharedPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="max-h-[360px] lg:max-h-none lg:flex-1 lg:min-h-0">
|
||||||
{isLoading ? (
|
{tab === '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">
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
{t('sharedPage.loadingShared', { tab })}
|
{t('sharedPage.loadingShared', { tab })}
|
||||||
</div>
|
</div>
|
||||||
@@ -289,12 +351,94 @@ export function SharedPage() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 min-h-0 flex flex-col bg-muted/20">
|
<div className="min-w-0 min-h-[360px] flex flex-col bg-muted/20 lg:min-h-0">
|
||||||
{!selectedItem ? (
|
{!selectedItem && tab !== 'settings' ? (
|
||||||
<div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground">
|
<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: tab.slice(0, -1) })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : tab === '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="px-4 py-3 border-b bg-background">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -359,7 +503,7 @@ export function SharedPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,6 +512,58 @@ export function SharedPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function HeaderMetricCard({ label, value }: { label: string; value: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ function requestUrl(input: RequestInfo | URL): string {
|
|||||||
return input.url;
|
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', () => {
|
describe('SharedPage', () => {
|
||||||
const fetchMock = vi.fn<typeof fetch>();
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
@@ -41,10 +64,15 @@ describe('SharedPage', () => {
|
|||||||
commands: 0,
|
commands: 0,
|
||||||
skills: 0,
|
skills: 0,
|
||||||
agents: 0,
|
agents: 0,
|
||||||
|
plugins: 0,
|
||||||
|
settings: false,
|
||||||
total: 0,
|
total: 0,
|
||||||
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (url.endsWith('/api/accounts')) {
|
||||||
|
return accountsResponse();
|
||||||
|
}
|
||||||
if (url.endsWith('/api/shared/commands')) {
|
if (url.endsWith('/api/shared/commands')) {
|
||||||
return jsonResponse({ error: 'Backend unavailable' }, 500);
|
return jsonResponse({ error: 'Backend unavailable' }, 500);
|
||||||
}
|
}
|
||||||
@@ -67,10 +95,15 @@ describe('SharedPage', () => {
|
|||||||
commands: 1,
|
commands: 1,
|
||||||
skills: 0,
|
skills: 0,
|
||||||
agents: 0,
|
agents: 0,
|
||||||
|
plugins: 0,
|
||||||
|
settings: false,
|
||||||
total: 1,
|
total: 1,
|
||||||
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (url.endsWith('/api/accounts')) {
|
||||||
|
return accountsResponse();
|
||||||
|
}
|
||||||
if (url.endsWith('/api/shared/commands')) {
|
if (url.endsWith('/api/shared/commands')) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
items: [
|
items: [
|
||||||
@@ -109,6 +142,115 @@ describe('SharedPage', () => {
|
|||||||
expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument();
|
expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads plugin directory 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: 'cache',
|
||||||
|
description: 'Directory with 2 items: payloads/, plugin-index.json',
|
||||||
|
path: '/tmp/plugins/cache',
|
||||||
|
type: 'plugin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/api/shared/content?') && url.includes('type=plugins')) {
|
||||||
|
return jsonResponse({
|
||||||
|
content:
|
||||||
|
'# Plugin directory: cache\n\n## Directory contents\n\n- payloads/\n- plugin-index.json',
|
||||||
|
contentPath: '/tmp/plugins/cache',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('tab', { name: /Plugins/ }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Directory with 2 items: payloads/, plugin-index.json')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('plugin-index.json')).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();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows offline guidance when network request fails', async () => {
|
it('shows offline guidance when network request fails', async () => {
|
||||||
fetchMock.mockImplementation(async (input) => {
|
fetchMock.mockImplementation(async (input) => {
|
||||||
const url = requestUrl(input);
|
const url = requestUrl(input);
|
||||||
|
|||||||
Reference in New Issue
Block a user