diff --git a/cli/README.md b/cli/README.md index 277cc70e..98d93fda 100644 --- a/cli/README.md +++ b/cli/README.md @@ -75,6 +75,7 @@ set SKILLHUB_REGISTRY=https://skillhub.yourcompany.com - `skillhub version` - Display CLI version - `skillhub help` - Show help information +- `skillhub doctor [--json]` - Scan the current project for installed skills and merge findings into the local inventory. Existing entries outside the scan are preserved; conflicts are reported but unrelated records are not deleted. ## 💡 Examples diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index 47bf5fca..fa876ad8 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -87,7 +87,7 @@ export class SkillHubClient { throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry }) } if (!response.ok) { - throw new CliError(`download failed with status ${response.status}`, EXIT.network, { registry: this.registry }) + throw new CliError(`download failed with status ${response.status}`, EXIT.generic, { registry: this.registry }) } return response } @@ -134,7 +134,7 @@ export class SkillHubClient { } if (!response.ok) { const text = await response.text().catch(() => '') - throw new CliError(`registry returned ${response.status}`, EXIT.network, { registry: this.registry, detail: text }) + throw new CliError(`registry returned ${response.status}`, EXIT.generic, { registry: this.registry, detail: text }) } const body = await response.json() return body.data as T diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 5d8ef28e..965ec8ab 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -11,8 +11,10 @@ export async function doctorCommand(options: DoctorCommandOptions): Promise 0 + ? `Preserved (outside scan): ${result.itemsPreserved} items, ${result.targetsPreserved} targets` + : null, result.skipped.length > 0 ? `Skipped: ${result.skipped.length} directories` : null, result.conflicts.length > 0 ? `Conflicts: ${result.conflicts.length} groups` : null ].filter(Boolean) diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index ee482942..e463a39e 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -47,7 +47,7 @@ export const commands = { examples: ['skillhub remove pdf-parser', 'skillhub remove pdf-parser --remote --hard'] }, doctor: { - summary: 'Rebuild local inventory', + summary: 'Scan project and merge into local inventory (preserves entries outside scan scope)', usage: 'skillhub doctor [--json]', examples: ['skillhub doctor', 'skillhub doctor --json'] }, diff --git a/cli/src/index.ts b/cli/src/index.ts index ba9d8974..97f3f34f 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -268,7 +268,7 @@ cli }) cli - .command('doctor', 'Rebuild local inventory') + .command('doctor', 'Scan project and merge into local inventory') .option('--json', 'Output JSON') .action((options: { json?: boolean }) => { return runCommand(() => doctorCommand(options), Boolean(options.json)) diff --git a/cli/src/services/doctor-service.ts b/cli/src/services/doctor-service.ts index 0ebd5add..cea2e201 100644 --- a/cli/src/services/doctor-service.ts +++ b/cli/src/services/doctor-service.ts @@ -16,8 +16,10 @@ interface MetadataJson { interface DoctorResult { inventoryPath: string backupPath: string | null - itemsRestored: number - targetsRestored: number + itemsScanned: number + targetsScanned: number + itemsPreserved: number + targetsPreserved: number skipped: Array<{ path: string; reason: string }> conflicts: Array<{ key: string; versions: string[] }> } @@ -38,8 +40,8 @@ export async function runDoctor(cwd: string, home?: string): Promise e.metadata.version)) if (versions.size > 1) { @@ -53,7 +55,7 @@ export async function runDoctor(cwd: string, home?: string): Promise() + for (const item of scannedItems) { + for (const target of item.targets) { + scannedInstallDirs.add(target.installDir) + } + } + + // Preserve old items where installDir is not in scanned set + // This allows the same slug to coexist in different installDirs (e.g., different projects) + const preservedItems: InventoryItem[] = [] + for (const oldItem of oldInventory.items) { + const preservedTargets = oldItem.targets.filter(t => !scannedInstallDirs.has(t.installDir)) + if (preservedTargets.length > 0) { + preservedItems.push({ + ...oldItem, + targets: preservedTargets + }) + } + } + + // Merge scanned and preserved items + const items = [...scannedItems, ...preservedItems] + // Backup old inventory let backupPath: string | null = null try { @@ -79,8 +113,10 @@ export async function runDoctor(cwd: string, home?: string): Promise sum + item.targets.length, 0), + itemsScanned: scannedItems.length, + targetsScanned: scannedItems.reduce((sum, item) => sum + item.targets.length, 0), + itemsPreserved: preservedItems.length, + targetsPreserved: preservedItems.reduce((sum, item) => sum + item.targets.length, 0), skipped, conflicts } diff --git a/cli/test/integration/doctor-command.test.ts b/cli/test/integration/doctor-command.test.ts index 6896dabb..f2afecb1 100644 --- a/cli/test/integration/doctor-command.test.ts +++ b/cli/test/integration/doctor-command.test.ts @@ -44,8 +44,10 @@ describe('doctor command', () => { expect(json.inventoryPath).toContain('.skillhub') expect(json.inventoryPath).toContain('inventory.json') expect(json.backupPath).toBeNull() - expect(json.itemsRestored).toBe(0) - expect(json.targetsRestored).toBe(0) + expect(json.itemsScanned).toBe(0) + expect(json.targetsScanned).toBe(0) + expect(json.itemsPreserved).toBe(0) + expect(json.targetsPreserved).toBe(0) expect(Array.isArray(json.skipped)).toBe(true) expect(Array.isArray(json.conflicts)).toBe(true) }) @@ -62,7 +64,7 @@ describe('doctor command', () => { expect(result.stdout).toContain('Inventory:') expect(result.stdout).toContain('.skillhub') expect(result.stdout).toContain('inventory.json') - expect(result.stdout).toContain('Restored: 0 items, 0 targets') + expect(result.stdout).toContain('Scanned: 0 items, 0 targets') expect(result.stdout).not.toContain('Backup:') }) @@ -132,8 +134,8 @@ describe('doctor command', () => { const json = JSON.parse(result.stdout) expect(json.ok).toBe(true) - expect(json.itemsRestored).toBe(1) - expect(json.targetsRestored).toBe(1) + expect(json.itemsScanned).toBe(1) + expect(json.targetsScanned).toBe(1) expect(json.backupPath).toBeNull() expect(Array.isArray(json.skipped)).toBe(true) expect(json.conflicts).toHaveLength(0) @@ -164,4 +166,65 @@ describe('doctor command', () => { expect(result.stdout).toContain('Backup:') expect(result.stdout).toContain('inventory.json.bak') }) + + test('doctor merges with existing inventory and preserves out-of-cwd entries', async () => { + const { home, cwd } = await createTempHome() + + const skillhubDir = join(home, '.skillhub') + await mkdir(skillhubDir, { recursive: true }) + const inventoryPath = join(skillhubDir, 'inventory.json') + + await writeFile(inventoryPath, JSON.stringify({ + items: [ + { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'external-skill', + version: '1.0.0', + targets: [ + { + agent: 'claude-code', + rootDir: '/external/project/.claude', + installDir: '/external/project/.claude/skills/external-skill', + installedAt: '2026-04-01T00:00:00Z' + } + ] + } + ] + })) + + await seedSkill(cwd, { + agentDir: '.claude', + slug: 'local-skill', + metadata: { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'local-skill', + version: '2.0.0', + agent: 'claude-code', + installedAt: '2026-04-21T09:00:00Z' + } + }) + + const result = await runCli(['doctor', '--json'], { + HOME: home, + USERPROFILE: home + }, { cwd }) + + expect(result.exitCode).toBe(0) + + const json = JSON.parse(result.stdout) + expect(json.itemsScanned).toBe(1) + expect(json.itemsPreserved).toBe(1) + expect(json.targetsPreserved).toBe(1) + + const raw = await readFile(inventoryPath, 'utf-8') + const inventory = JSON.parse(raw) as { + items: Array<{ slug: string }> + } + + expect(inventory.items.map(item => item.slug)).toEqual( + expect.arrayContaining(['external-skill', 'local-skill']) + ) + }) }) diff --git a/cli/test/integration/publish-command.test.ts b/cli/test/integration/publish-command.test.ts index 4fde1012..5f382fdf 100644 --- a/cli/test/integration/publish-command.test.ts +++ b/cli/test/integration/publish-command.test.ts @@ -221,9 +221,9 @@ describe('publish command — P1', () => { expect(json.detailUrl).toContain(encodeURIComponent(json.slug)) }) - test('server error during publish returns EXIT.network', async () => { + test('server error during publish returns EXIT.generic', async () => { const env = await createTempHome() - // 'server_error' returns HTTP 500, which the client maps to EXIT.network. + // 'server_error' returns HTTP 500; request reached registry but failed, so EXIT.generic. registry = await startFakeRegistry({ token: 'sk_ok', failures: { publish: 'server_error' } }) await login(env, registry.url) @@ -233,7 +233,7 @@ describe('publish command — P1', () => { USERPROFILE: env.home }) - expect(result.exitCode).toBe(3) + expect(result.exitCode).toBe(1) expect(result.stderr).toContain('registry') }) }) diff --git a/cli/test/unit/clients/skillhub-client.test.ts b/cli/test/unit/clients/skillhub-client.test.ts index a4d9fac8..3ed6f19e 100644 --- a/cli/test/unit/clients/skillhub-client.test.ts +++ b/cli/test/unit/clients/skillhub-client.test.ts @@ -54,6 +54,24 @@ describe('SkillHubClient', () => { await err.toHaveProperty('exitCode', EXIT.generic) }) + test('download() throws generic error on 400', async () => { + const fetchImpl = (async () => new Response(null, { status: 400 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + const err = expect(client.download('ns', 'slug')).rejects + await err.toBeInstanceOf(CliError) + await err.toHaveProperty('message', 'download failed with status 400') + await err.toHaveProperty('exitCode', EXIT.generic) + }) + + test('download() throws generic error on 500', async () => { + const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + const err = expect(client.download('ns', 'slug')).rejects + await err.toBeInstanceOf(CliError) + await err.toHaveProperty('message', 'download failed with status 500') + await err.toHaveProperty('exitCode', EXIT.generic) + }) + test('download() throws network error on fetch failure', async () => { const fetchImpl = (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) @@ -139,6 +157,24 @@ describe('SkillHubClient', () => { expect(capturedUrl).toContain('?version=2.0.0') }) + // --- handleJsonResponse() non-2xx classification --- + + test('whoami() throws generic error on 500', async () => { + const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + const err = expect(client.whoami()).rejects + await err.toBeInstanceOf(CliError) + await err.toHaveProperty('exitCode', EXIT.generic) + }) + + test('search() throws generic error on 502', async () => { + const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + const err = expect(client.search('test', 20)).rejects + await err.toBeInstanceOf(CliError) + await err.toHaveProperty('exitCode', EXIT.generic) + }) + // --- deleteRemote() (P1) --- test('deleteRemote() returns result on success', async () => { diff --git a/cli/test/unit/services/doctor-service.test.ts b/cli/test/unit/services/doctor-service.test.ts index 7272d6d0..8f24fc89 100644 --- a/cli/test/unit/services/doctor-service.test.ts +++ b/cli/test/unit/services/doctor-service.test.ts @@ -1,9 +1,17 @@ import { describe, expect, test } from 'bun:test' -import { mkdtemp, mkdir, writeFile, symlink } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile, symlink, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { runDoctor } from '../../../src/services/doctor-service' +type InventoryFile = { + items: Array<{ + slug: string + version: string + targets: unknown[] + }> +} + async function setupSkillDir(cwd: string, agentDir: string, slug: string, metadata: Record) { const skillDir = join(cwd, agentDir, 'skills', slug) const metaDir = join(skillDir, '.skillhub') @@ -27,8 +35,8 @@ describe('doctor-service', () => { }) const result = await runDoctor(cwd, home) - expect(result.itemsRestored).toBe(1) - expect(result.targetsRestored).toBe(1) + expect(result.itemsScanned).toBe(1) + expect(result.targetsScanned).toBe(1) expect(result.skipped).toHaveLength(0) expect(result.conflicts).toHaveLength(0) }) @@ -40,7 +48,7 @@ describe('doctor-service', () => { await mkdir(join(cwd, '.codex', 'skills', 'no-meta'), { recursive: true }) const result = await runDoctor(cwd, home) - expect(result.itemsRestored).toBe(0) + expect(result.itemsScanned).toBe(0) expect(result.skipped).toHaveLength(1) expect(result.skipped[0]!.reason).toBe('no .skillhub directory') }) @@ -59,7 +67,7 @@ describe('doctor-service', () => { }) const result = await runDoctor(cwd, home) - expect(result.itemsRestored).toBe(0) + expect(result.itemsScanned).toBe(0) expect(result.conflicts).toHaveLength(1) expect(result.conflicts[0]!.versions).toContain('1.0.0') expect(result.conflicts[0]!.versions).toContain('2.0.0') @@ -79,7 +87,7 @@ describe('doctor-service', () => { await symlink(realDir, join(skillsDir, 'symlink-skill')) const result = await runDoctor(cwd, home) - expect(result.itemsRestored).toBe(1) + expect(result.itemsScanned).toBe(1) expect(result.skipped.some(s => s.reason === 'not a regular directory')).toBe(true) }) @@ -95,8 +103,8 @@ describe('doctor-service', () => { await symlink(join(cwd, '.codex'), join(cwd, '.fake-agent')) const result = await runDoctor(cwd, home) - expect(result.itemsRestored).toBe(1) - expect(result.targetsRestored).toBe(1) + expect(result.itemsScanned).toBe(1) + expect(result.targetsScanned).toBe(1) }) test('skips symlinked .skillhub directories', async () => { @@ -118,7 +126,177 @@ describe('doctor-service', () => { await symlink(realMetaDir, join(skillDir, '.skillhub')) const result = await runDoctor(cwd, home) - expect(result.itemsRestored).toBe(0) + expect(result.itemsScanned).toBe(0) expect(result.skipped.some(s => s.reason === '.skillhub is not a regular directory')).toBe(true) }) + + test('merges scan results with existing inventory', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'doctor-test-')) + const home = await mkdtemp(join(tmpdir(), 'doctor-home-')) + + // Pre-seed old inventory with one item NOT in current cwd + const inventoryPath = join(home, '.skillhub', 'inventory.json') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(inventoryPath, JSON.stringify({ + items: [ + { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'external-skill', + version: '1.0.0', + targets: [ + { + agent: 'codex', + rootDir: '/external/project/.codex', + installDir: '/external/project/.codex/skills/external-skill', + installedAt: '2026-04-01T10:00:00Z' + } + ] + } + ] + })) + + // Scan cwd finds one new item + await setupSkillDir(cwd, '.claude', 'local-skill', { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'local-skill', + version: '2.0.0', + agent: 'claude-code', + installedAt: '2026-04-20T12:00:00Z' + }) + + const result = await runDoctor(cwd, home) + + // Final inventory should contain BOTH items + expect(result.itemsScanned).toBe(1) + expect(result.targetsScanned).toBe(1) + expect(result.itemsPreserved).toBe(1) + expect(result.targetsPreserved).toBe(1) + expect(result.conflicts).toHaveLength(0) + + const finalInventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as InventoryFile + expect(finalInventory.items).toHaveLength(2) + + const externalSkillItem = finalInventory.items.find(item => item.slug === 'external-skill')! + expect(externalSkillItem).toBeDefined() + expect(externalSkillItem.version).toBe('1.0.0') + expect(externalSkillItem.targets).toHaveLength(1) + + const localSkillItem = finalInventory.items.find(item => item.slug === 'local-skill')! + expect(localSkillItem).toBeDefined() + expect(localSkillItem.version).toBe('2.0.0') + expect(localSkillItem.targets).toHaveLength(1) + }) + + test('refreshes old record when scan hits same installDir', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'doctor-test-')) + const home = await mkdtemp(join(tmpdir(), 'doctor-home-')) + + const installDir = join(cwd, '.codex', 'skills', 'pdf-parser') + + // Pre-seed old inventory with pdf-parser v1.0.0 at specific installDir + const inventoryPath = join(home, '.skillhub', 'inventory.json') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(inventoryPath, JSON.stringify({ + items: [ + { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'pdf-parser', + version: '1.0.0', + targets: [ + { + agent: 'codex', + rootDir: join(cwd, '.codex'), + installDir, + installedAt: '2026-04-01T10:00:00Z' + } + ] + } + ] + })) + + // Place metadata for pdf-parser v2.0.0 at the SAME installDir + await setupSkillDir(cwd, '.codex', 'pdf-parser', { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'pdf-parser', + version: '2.0.0', + agent: 'codex', + installedAt: '2026-04-20T12:00:00Z' + }) + + const result = await runDoctor(cwd, home) + + // Final inventory should have 1 item with version 2.0.0 and 1 target + expect(result.itemsScanned).toBe(1) + expect(result.targetsScanned).toBe(1) + expect(result.conflicts).toHaveLength(0) + + // Verify the inventory file has the updated version + const finalInventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) + expect(finalInventory.items).toHaveLength(1) + expect(finalInventory.items[0].version).toBe('2.0.0') + expect(finalInventory.items[0].targets).toHaveLength(1) + }) + + test('conflict groups do not delete unrelated old items', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'doctor-test-')) + const home = await mkdtemp(join(tmpdir(), 'doctor-home-')) + + // Pre-seed old inventory with image-resizer at /external/proj (NOT in cwd) + const inventoryPath = join(home, '.skillhub', 'inventory.json') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(inventoryPath, JSON.stringify({ + items: [ + { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'image-resizer', + version: '3.0.0', + targets: [ + { + agent: 'codex', + rootDir: '/external/proj/.codex', + installDir: '/external/proj/.codex/skills/image-resizer', + installedAt: '2026-03-15T08:00:00Z' + } + ] + } + ] + })) + + // In cwd, create conflict: pdf-parser v1.0.0 in .codex, pdf-parser v2.0.0 in .claude + await setupSkillDir(cwd, '.codex', 'pdf-parser', { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'pdf-parser', + version: '1.0.0', + agent: 'codex', + installedAt: '2026-04-20T12:00:00Z' + }) + await setupSkillDir(cwd, '.claude', 'pdf-parser', { + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'pdf-parser', + version: '2.0.0', + agent: 'claude-code', + installedAt: '2026-04-20T12:00:00Z' + }) + + const result = await runDoctor(cwd, home) + + // Should detect conflict + expect(result.conflicts).toHaveLength(1) + expect(result.conflicts[0]!.versions).toContain('1.0.0') + expect(result.conflicts[0]!.versions).toContain('2.0.0') + + // Final inventory should still contain image-resizer + const finalInventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as InventoryFile + const imageResizerItem = finalInventory.items.find(item => item.slug === 'image-resizer')! + expect(imageResizerItem).toBeDefined() + expect(imageResizerItem.version).toBe('3.0.0') + expect(imageResizerItem.targets).toHaveLength(1) + }) })