From 074c0e8f66b24a0e9139f217747ad92f3f0c7b51 Mon Sep 17 00:00:00 2001 From: Jonathan Santilli <1774227+jonathansantilli@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:38:14 +0100 Subject: [PATCH] feat(cli): add `inventory` subcommand to enumerate KB-known AI artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codegate inventory` walks every tool entry in the knowledge base, resolves each `config_paths` and `skill_paths` pattern against `$HOME` (user scope) or the workspace root(s) (project scope), and emits the result as either a human table or machine-readable JSON. Motivation: downstream tooling (IDE extensions, CI agents, dashboards) was maintaining its own hard-coded lists of "places to look for AI skills" because the KB wasn't queryable. Exposing it as a first-class subcommand makes that parallel list unnecessary and keeps every consumer in sync when the KB adds a new tool or layout. ### Usage ``` codegate inventory [options] --scope scope filter (default: all) --kind artifact kind filter (default: all) --only-existing filter to paths that exist on disk --workspace project-scope root (repeatable; defaults to cwd) --format output format (default: text) ``` Example consumer call: ``` codegate inventory --kind skills --scope user --only-existing --format json ``` Returns one entry per resolved skill file across every KB-registered tool (`.claude/skills/*/SKILL.md`, `.codex/skills/**/*.md`, `.opencode/skills/`, `.cline/skills/`, `.gemini/skills/`, `.roo/skills/`, etc.), with `tool`, `type`, `scope`, `path`, `exists`, and `risk_surface` on each item. ### JSON output shape ```json { "kb_version": "1.0.0", "tools": [{"name": "claude-code", "version_range": ">=1.0.0"}, ...], "items": [ { "tool": "claude-code", "kind": "skill", "type": "anthropic_skill", "scope": "user", "pattern": ".claude/skills/*/SKILL.md", "path": "/Users/alice/.claude/skills/foo/SKILL.md", "exists": true, "risk_surface": ["prompt_injection", "unicode_backdoor", "command_exec", "mcp_config"], "resolved_against": "/Users/alice" }, ... ] } ``` ### Implementation notes - Reuses `loadKnowledgeBase()` from `layer1-discovery/knowledge-base`; no duplication of KB parsing. - Wildcard expansion (`*`, `**`, `?`) implemented inline in the command file rather than reaching into `scan.ts`'s private helpers — keeps the command self-contained and avoids widening `scan.ts`'s export surface. - Refuses to follow symlinks during wildcard expansion (parity with the existing scanner). - Deterministic ordering (tool → kind → scope → path) so output is stable across runs on the same machine. - Depth- and count-limited walks (`MAX_WILDCARD_DEPTH = 8`, `MAX_WILDCARD_MATCHES = 2000`) to keep it bounded on large homedirs. ### Tests - `tests/commands/inventory-command.test.ts` — 8 unit tests covering filters, scope resolution, `--only-existing`, wildcard expansion against a seeded temp `$HOME`, empty-workspace edge case, ordering. - `tests/cli/inventory-command.test.ts` — 3 integration tests via `createCli()` + `parseAsync()`: JSON output, `--only-existing --kind skills` end-to-end, text-format rendering. Full test suite: 694/694 pass; typecheck clean; prettier applied. --- src/cli.ts | 98 ++++++++ src/commands/inventory-command.ts | 274 +++++++++++++++++++++++ tests/cli/inventory-command.test.ts | 163 ++++++++++++++ tests/commands/inventory-command.test.ts | 169 ++++++++++++++ 4 files changed, 704 insertions(+) create mode 100644 src/commands/inventory-command.ts create mode 100644 tests/cli/inventory-command.test.ts create mode 100644 tests/commands/inventory-command.test.ts diff --git a/src/cli.ts b/src/cli.ts index df1a581..3e57b9e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,6 +46,7 @@ import { type RemediationRunnerResult, } from "./layer4-remediation/remediation-runner.js"; import { undoLatestSession } from "./commands/undo.js"; +import { runInventory, type InventorySummary } from "./commands/inventory-command.js"; import { executeScanCommand } from "./commands/scan-command.js"; import { executeScanContentCommand, @@ -873,6 +874,102 @@ function addInitCommand(program: Command, deps: CliDeps): void { }); } +const INVENTORY_SCOPES = ["user", "project", "all"] as const; +const INVENTORY_KINDS = ["skills", "configs", "all"] as const; + +type InventoryScope = (typeof INVENTORY_SCOPES)[number]; +type InventoryKind = (typeof INVENTORY_KINDS)[number]; + +interface InventoryCliOptions { + scope?: InventoryScope; + kind?: InventoryKind; + onlyExisting?: boolean; + workspace?: string[]; + format?: "text" | "json"; +} + +function addInventoryCommand(program: Command, deps: CliDeps): void { + program + .command("inventory") + .description( + "List the AI-tool config + skill artifacts the knowledge base knows about, resolved against this machine.", + ) + .addOption( + new Option("--scope ", "scope filter") + .choices(INVENTORY_SCOPES as unknown as string[]) + .default("all"), + ) + .addOption( + new Option("--kind ", "artifact kind filter") + .choices(INVENTORY_KINDS as unknown as string[]) + .default("all"), + ) + .option("--only-existing", "return only items that currently exist on disk") + .option( + "--workspace ", + "additional project-scope root (repeatable); defaults to cwd when omitted", + collectRepeatable, + [] as string[], + ) + .addOption( + new Option("--format ", "output format").choices(["text", "json"]).default("text"), + ) + .addHelpText( + "after", + renderExampleHelp([ + "codegate inventory", + "codegate inventory --format json --kind skills --only-existing", + "codegate inventory --scope user --format json", + "codegate inventory --workspace . --workspace /path/to/other/repo", + ]), + ) + .action((options: InventoryCliOptions) => { + try { + const home = deps.homeDir?.() ?? homedir(); + const explicitWorkspaces = options.workspace ?? []; + const workspaces = + explicitWorkspaces.length > 0 + ? explicitWorkspaces.map((w) => resolve(deps.cwd(), w)) + : [deps.cwd()]; + + const summary: InventorySummary = runInventory({ + scope: options.scope ?? "all", + kind: options.kind ?? "all", + onlyExisting: options.onlyExisting === true, + workspaces, + homeDir: home, + }); + + if (options.format === "json") { + deps.stdout(JSON.stringify(summary, null, 2)); + } else { + renderInventoryText(summary, deps.stdout); + } + deps.setExitCode(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.stderr(`Inventory failed: ${message}`); + deps.setExitCode(3); + } + }); +} + +function collectRepeatable(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function renderInventoryText(summary: InventorySummary, stdout: (line: string) => void): void { + stdout(`Knowledge base v${summary.kb_version}`); + stdout(`Tools: ${summary.tools.map((t) => t.name).join(", ")}`); + stdout(`Items: ${summary.items.length}`); + stdout(""); + for (const item of summary.items) { + const mark = item.exists ? "✓" : "·"; + const tag = item.kind === "skill" ? `${item.kind}:${item.type ?? "?"}` : item.kind; + stdout(` ${mark} [${item.tool}] ${tag} (${item.scope}) ${item.path}`); + } +} + function addUpdateCommands(program: Command, deps: CliDeps): void { const guidance = [ "Updates are bundled with CodeGate releases in v1/v2.", @@ -944,6 +1041,7 @@ export function createCli( addRunCommand(program, version, deps); addUndoCommand(program, deps); addInitCommand(program, deps); + addInventoryCommand(program, deps); addUpdateCommands(program, deps); return program; } diff --git a/src/commands/inventory-command.ts b/src/commands/inventory-command.ts new file mode 100644 index 0000000..49164a1 --- /dev/null +++ b/src/commands/inventory-command.ts @@ -0,0 +1,274 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; + +import { + loadKnowledgeBase, + type KnowledgeBaseEntry, + type KnowledgeBasePathEntry, + type KnowledgeBaseSkillEntry, +} from "../layer1-discovery/knowledge-base.js"; + +/** One resolved artifact the scanner knows about. */ +export interface InventoryItem { + tool: string; + kind: "config" | "skill"; + /** Only set for skill entries; mirrors KB `skill_paths[].type`. */ + type?: string; + scope: "user" | "project"; + /** Pattern as declared in the KB (relative, may contain wildcards). */ + pattern: string; + /** Absolute resolved filesystem path (concrete, not the pattern). */ + path: string; + /** True if the filesystem shows the path exists. */ + exists: boolean; + risk_surface: string[]; + /** Only populated for config entries that declare them. */ + fields_of_interest?: Record; + /** Resolution root used (e.g., the home dir or a workspace root). */ + resolved_against: string; +} + +export interface InventorySummary { + kb_version: string; + /** Known tools (from KB file names) with their version ranges. */ + tools: Array<{ name: string; version_range: string }>; + items: InventoryItem[]; +} + +export interface InventoryOptions { + scope: "user" | "project" | "all"; + kind: "skills" | "configs" | "all"; + onlyExisting: boolean; + /** Roots for project-scope resolution. Empty if project scope is skipped. */ + workspaces: string[]; + homeDir: string; + /** Optional injection for tests. */ + kbBaseDir?: string; +} + +const MAX_WILDCARD_DEPTH = 8; +const MAX_WILDCARD_MATCHES = 2000; + +export function runInventory(options: InventoryOptions): InventorySummary { + const kb = loadKnowledgeBase(options.kbBaseDir); + const includeConfigs = options.kind === "all" || options.kind === "configs"; + const includeSkills = options.kind === "all" || options.kind === "skills"; + + const rawItems: InventoryItem[] = []; + + for (const entry of kb.entries) { + if (includeConfigs) { + for (const cp of entry.config_paths) { + rawItems.push(...resolveConfigEntry(entry.tool, cp, options)); + } + } + if (includeSkills) { + for (const sp of entry.skill_paths ?? []) { + rawItems.push(...resolveSkillEntry(entry.tool, sp, options)); + } + } + } + + const items = options.onlyExisting ? rawItems.filter((item) => item.exists) : rawItems; + + // Stable ordering: by tool, then kind, then scope, then path. + items.sort((a, b) => { + if (a.tool !== b.tool) return a.tool.localeCompare(b.tool); + if (a.kind !== b.kind) return a.kind.localeCompare(b.kind); + if (a.scope !== b.scope) return a.scope.localeCompare(b.scope); + return a.path.localeCompare(b.path); + }); + + return { + kb_version: kb.schemaVersion, + tools: kb.entries + .map((entry: KnowledgeBaseEntry) => ({ + name: entry.tool, + version_range: entry.version_range, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + items, + }; +} + +function resolveConfigEntry( + tool: string, + cp: KnowledgeBasePathEntry, + options: InventoryOptions, +): InventoryItem[] { + if (!scopeIncluded(cp.scope, options.scope)) return []; + const roots = rootsFor(cp.scope, options); + const items: InventoryItem[] = []; + for (const root of roots) { + items.push( + ...resolvePattern({ + tool, + kind: "config", + scope: cp.scope, + pattern: cp.path, + root, + riskSurface: cp.risk_surface, + fieldsOfInterest: cp.fields_of_interest, + }), + ); + } + return items; +} + +function resolveSkillEntry( + tool: string, + sp: KnowledgeBaseSkillEntry, + options: InventoryOptions, +): InventoryItem[] { + if (!scopeIncluded(sp.scope, options.scope)) return []; + const roots = rootsFor(sp.scope, options); + const items: InventoryItem[] = []; + for (const root of roots) { + items.push( + ...resolvePattern({ + tool, + kind: "skill", + type: sp.type, + scope: sp.scope, + pattern: sp.path, + root, + riskSurface: sp.risk_surface, + }), + ); + } + return items; +} + +function scopeIncluded( + entryScope: "user" | "project", + optionScope: InventoryOptions["scope"], +): boolean { + if (optionScope === "all") return true; + return entryScope === optionScope; +} + +function rootsFor(entryScope: "user" | "project", options: InventoryOptions): string[] { + if (entryScope === "user") return [options.homeDir]; + if (options.workspaces.length === 0) return []; + return options.workspaces; +} + +interface ResolvePatternInput { + tool: string; + kind: "config" | "skill"; + type?: string; + scope: "user" | "project"; + pattern: string; + root: string; + riskSurface: string[]; + fieldsOfInterest?: Record; +} + +function resolvePattern(input: ResolvePatternInput): InventoryItem[] { + const normalized = normalizePattern(input.pattern); + const hasWildcard = /[*?]/.test(normalized); + + if (!hasWildcard) { + const absolute = resolve(input.root, normalized); + return [makeItem(input, absolute, existsSync(absolute))]; + } + + const matches = expandWildcard(input.root, normalized); + return matches.map((absolute) => makeItem(input, absolute, true)); +} + +function makeItem(input: ResolvePatternInput, absolute: string, exists: boolean): InventoryItem { + return { + tool: input.tool, + kind: input.kind, + type: input.type, + scope: input.scope, + pattern: input.pattern, + path: absolute, + exists, + risk_surface: input.riskSurface, + fields_of_interest: input.fieldsOfInterest, + resolved_against: input.root, + }; +} + +function normalizePattern(pattern: string): string { + return pattern.replace(/^~\//, "").replace(/^\/+/, ""); +} + +function escapeRegex(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.*]/g, "\\$&"); +} + +function wildcardToRegex(pattern: string): RegExp { + let escaped = escapeRegex(pattern); + escaped = escaped.replace(/\\\*\\\*\//g, "(?:[^/]+/)*"); + escaped = escaped.replace(/\\\*\\\*/g, ".*"); + escaped = escaped.replace(/\\\*/g, "[^/]*"); + escaped = escaped.replace(/\\\?/g, "[^/]"); + return new RegExp(`^${escaped}$`); +} + +function fixedPrefix(pattern: string): string { + const firstStar = pattern.indexOf("*"); + const firstQuestion = pattern.indexOf("?"); + const firstWildcard = + firstStar === -1 + ? firstQuestion + : firstQuestion === -1 + ? firstStar + : Math.min(firstStar, firstQuestion); + if (firstWildcard === -1) return pattern; + const prefix = pattern.slice(0, firstWildcard); + const lastSlash = prefix.lastIndexOf("/"); + return lastSlash === -1 ? "" : prefix.slice(0, lastSlash); +} + +function expandWildcard(root: string, pattern: string): string[] { + const matchRegex = wildcardToRegex(pattern); + const prefix = fixedPrefix(pattern); + const baseDir = prefix ? resolve(root, prefix) : resolve(root); + if (!existsSync(baseDir)) return []; + try { + if (!statSync(baseDir).isDirectory()) return []; + } catch { + return []; + } + + const matches: string[] = []; + const queue: Array<{ dir: string; depth: number }> = [{ dir: baseDir, depth: 0 }]; + + while (queue.length > 0 && matches.length < MAX_WILDCARD_MATCHES) { + const current = queue.pop(); + if (!current) break; + + let entries; + try { + entries = readdirSync(current.dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (matches.length >= MAX_WILDCARD_MATCHES) break; + const absolute = join(current.dir, entry.name); + if (entry.isSymbolicLink()) continue; + + if (entry.isDirectory()) { + if (current.depth < MAX_WILDCARD_DEPTH) { + queue.push({ dir: absolute, depth: current.depth + 1 }); + } + continue; + } + + if (!entry.isFile()) continue; + + const rel = relative(root, absolute).split(sep).join("/"); + if (rel.startsWith("..")) continue; + if (!matchRegex.test(rel)) continue; + matches.push(absolute); + } + } + + return matches; +} diff --git a/tests/cli/inventory-command.test.ts b/tests/cli/inventory-command.test.ts new file mode 100644 index 0000000..4b553d7 --- /dev/null +++ b/tests/cli/inventory-command.test.ts @@ -0,0 +1,163 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createCli, type CliDeps } from "../../src/cli"; +import type { CodeGateConfig } from "../../src/config"; +import type { CodeGateReport } from "../../src/types/report"; + +const BASE_CONFIG: CodeGateConfig = { + severity_threshold: "high", + auto_proceed_below_threshold: true, + output_format: "terminal", + scan_state_path: "/tmp/codegate-scan-state.json", + tui: { enabled: false, colour_scheme: "default", compact_mode: false }, + tool_discovery: { preferred_agent: "claude", agent_paths: {}, skip_tools: [] }, + trusted_directories: [], + blocked_commands: ["bash", "sh", "curl", "wget", "nc", "python", "node"], + known_safe_mcp_servers: [], + known_safe_formatters: [], + known_safe_lsp_servers: [], + known_safe_hooks: [], + unicode_analysis: true, + check_ide_settings: true, + owasp_mapping: true, + trusted_api_domains: [], + suppress_findings: [], +}; + +const EMPTY_REPORT: CodeGateReport = { + version: "0.1.0", + scan_target: ".", + timestamp: "2026-02-28T00:00:00.000Z", + kb_version: "2026-02-28", + tools_detected: [], + findings: [], + summary: { + total: 0, + by_severity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 }, + fixable: 0, + suppressed: 0, + exit_code: 0, + }, +}; + +function makeDeps(overrides: Partial): CliDeps & { stdoutChunks: string[] } { + const stdoutChunks: string[] = []; + const deps: CliDeps = { + cwd: () => process.cwd(), + isTTY: () => false, + homeDir: () => "/tmp/codegate-home", + pathExists: () => false, + resolveConfig: () => BASE_CONFIG, + runScan: async () => EMPTY_REPORT, + stdout: (line) => stdoutChunks.push(line), + stderr: () => {}, + writeFile: () => {}, + setExitCode: () => {}, + ...overrides, + }; + return Object.assign(deps, { stdoutChunks }); +} + +describe("CLI inventory command", () => { + let home: string; + let workspace: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "codegate-inv-cli-home-")); + workspace = mkdtempSync(join(tmpdir(), "codegate-inv-cli-ws-")); + }); + + afterEach(() => { + for (const dir of [home, workspace]) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + }); + + it("emits JSON when --format json is given", async () => { + const deps = makeDeps({ + homeDir: () => home, + cwd: () => workspace, + }); + const cli = createCli("0.0.0-test", deps); + let exitCode = -1; + deps.setExitCode = (code) => { + exitCode = code; + }; + + await cli.parseAsync(["node", "codegate", "inventory", "--format", "json"]); + + const combined = deps.stdoutChunks.join("\n"); + const parsed = JSON.parse(combined); + + expect(parsed.kb_version).toBeTruthy(); + expect(Array.isArray(parsed.tools)).toBe(true); + expect(Array.isArray(parsed.items)).toBe(true); + expect(exitCode).toBe(0); + }); + + it("filters to existing skill items when --only-existing and --kind skills", async () => { + // Seed one Anthropic skill so the filter has something to return + mkdirSync(join(home, ".claude", "skills", "alpha"), { recursive: true }); + writeFileSync(join(home, ".claude", "skills", "alpha", "SKILL.md"), "# alpha"); + + const deps = makeDeps({ + homeDir: () => home, + cwd: () => workspace, + }); + const cli = createCli("0.0.0-test", deps); + + await cli.parseAsync([ + "node", + "codegate", + "inventory", + "--format", + "json", + "--kind", + "skills", + "--only-existing", + ]); + + const combined = deps.stdoutChunks.join("\n"); + const parsed = JSON.parse(combined) as { + items: Array<{ + tool: string; + type?: string; + exists: boolean; + path: string; + }>; + }; + + expect(parsed.items.length).toBeGreaterThan(0); + expect(parsed.items.every((i) => i.exists)).toBe(true); + expect( + parsed.items.some( + (i) => + i.type === "anthropic_skill" && + i.path === join(home, ".claude", "skills", "alpha", "SKILL.md"), + ), + ).toBe(true); + }); + + it("renders human-readable text by default", async () => { + const deps = makeDeps({ + homeDir: () => home, + cwd: () => workspace, + }); + const cli = createCli("0.0.0-test", deps); + + await cli.parseAsync(["node", "codegate", "inventory", "--scope", "user"]); + + const combined = deps.stdoutChunks.join("\n"); + expect(combined).toContain("Knowledge base v"); + expect(combined).toContain("Tools:"); + expect(combined).toContain("Items:"); + }); +}); diff --git a/tests/commands/inventory-command.test.ts b/tests/commands/inventory-command.test.ts new file mode 100644 index 0000000..8f3e9e9 --- /dev/null +++ b/tests/commands/inventory-command.test.ts @@ -0,0 +1,169 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { runInventory } from "../../src/commands/inventory-command"; + +describe("inventory command — runInventory", () => { + let home: string; + let workspace: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "codegate-inv-home-")); + workspace = mkdtempSync(join(tmpdir(), "codegate-inv-ws-")); + }); + + afterEach(() => { + for (const dir of [home, workspace]) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + }); + + it("lists tools + items using the real knowledge base", () => { + const summary = runInventory({ + scope: "all", + kind: "all", + onlyExisting: false, + workspaces: [workspace], + homeDir: home, + }); + + expect(summary.kb_version).toBeTruthy(); + expect(summary.tools.length).toBeGreaterThan(0); + // The real KB ships claude-code; sanity-check that. + expect(summary.tools.some((t) => t.name === "claude-code")).toBe(true); + expect(summary.items.length).toBeGreaterThan(0); + }); + + it("filters by kind=skills and kind=configs", () => { + const skillsOnly = runInventory({ + scope: "all", + kind: "skills", + onlyExisting: false, + workspaces: [workspace], + homeDir: home, + }); + expect(skillsOnly.items.every((i) => i.kind === "skill")).toBe(true); + + const configsOnly = runInventory({ + scope: "all", + kind: "configs", + onlyExisting: false, + workspaces: [workspace], + homeDir: home, + }); + expect(configsOnly.items.every((i) => i.kind === "config")).toBe(true); + }); + + it("filters by scope=user", () => { + const summary = runInventory({ + scope: "user", + kind: "all", + onlyExisting: false, + workspaces: [workspace], + homeDir: home, + }); + expect(summary.items.every((i) => i.scope === "user")).toBe(true); + }); + + it("resolves non-wildcard paths against the correct root", () => { + const summary = runInventory({ + scope: "all", + kind: "configs", + onlyExisting: false, + workspaces: [workspace], + homeDir: home, + }); + const userItem = summary.items.find( + (i) => + i.scope === "user" && i.tool === "claude-code" && i.pattern === ".claude/settings.json", + ); + expect(userItem).toBeDefined(); + expect(userItem?.path.startsWith(home)).toBe(true); + expect(userItem?.exists).toBe(false); + }); + + it("honors --only-existing for non-wildcard entries", () => { + // Create a real file at ~/.claude/settings.json and workspace/.claude/settings.json + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync(join(home, ".claude", "settings.json"), "{}"); + mkdirSync(join(workspace, ".claude"), { recursive: true }); + writeFileSync(join(workspace, ".claude", "settings.json"), "{}"); + + const all = runInventory({ + scope: "all", + kind: "configs", + onlyExisting: true, + workspaces: [workspace], + homeDir: home, + }); + // Only the entries we just created (plus possibly other tools' files if + // the test host happens to have them) should appear. + expect(all.items.every((i) => i.exists)).toBe(true); + expect( + all.items.some( + (i) => i.tool === "claude-code" && i.path === join(home, ".claude", "settings.json"), + ), + ).toBe(true); + expect( + all.items.some( + (i) => i.tool === "claude-code" && i.path === join(workspace, ".claude", "settings.json"), + ), + ).toBe(true); + }); + + it("expands wildcard skill_paths against the filesystem", () => { + // Seed two Anthropic skills under the fake home + mkdirSync(join(home, ".claude", "skills", "alpha"), { recursive: true }); + writeFileSync(join(home, ".claude", "skills", "alpha", "SKILL.md"), "# alpha"); + mkdirSync(join(home, ".claude", "skills", "beta"), { recursive: true }); + writeFileSync(join(home, ".claude", "skills", "beta", "SKILL.md"), "# beta"); + + const summary = runInventory({ + scope: "user", + kind: "skills", + onlyExisting: true, + workspaces: [workspace], + homeDir: home, + }); + + const anthropicSkills = summary.items.filter( + (i) => i.type === "anthropic_skill" && i.scope === "user", + ); + const paths = anthropicSkills.map((i) => i.path).sort(); + expect(paths).toContain(join(home, ".claude", "skills", "alpha", "SKILL.md")); + expect(paths).toContain(join(home, ".claude", "skills", "beta", "SKILL.md")); + }); + + it("skips project scope entries when no workspace is provided", () => { + const summary = runInventory({ + scope: "project", + kind: "all", + onlyExisting: false, + workspaces: [], + homeDir: home, + }); + expect(summary.items.length).toBe(0); + }); + + it("sorts items deterministically", () => { + const summary = runInventory({ + scope: "all", + kind: "all", + onlyExisting: false, + workspaces: [workspace], + homeDir: home, + }); + + const tools = summary.items.map((i) => i.tool); + const sorted = [...tools].sort((a, b) => a.localeCompare(b)); + // Weak check: first tool alphabetically should come first + expect(tools[0]).toBe(sorted[0]); + }); +});