Skip to content

Commit 620b112

Browse files
feat(cli): add inventory subcommand to enumerate KB-known AI artifacts (#51)
`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 <user|project|all> scope filter (default: all) --kind <skills|configs|all> artifact kind filter (default: all) --only-existing filter to paths that exist on disk --workspace <path> project-scope root (repeatable; defaults to cwd) --format <text|json> 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.
1 parent 713c00c commit 620b112

4 files changed

Lines changed: 704 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
type RemediationRunnerResult,
4747
} from "./layer4-remediation/remediation-runner.js";
4848
import { undoLatestSession } from "./commands/undo.js";
49+
import { runInventory, type InventorySummary } from "./commands/inventory-command.js";
4950
import { executeScanCommand } from "./commands/scan-command.js";
5051
import {
5152
executeScanContentCommand,
@@ -873,6 +874,102 @@ function addInitCommand(program: Command, deps: CliDeps): void {
873874
});
874875
}
875876

877+
const INVENTORY_SCOPES = ["user", "project", "all"] as const;
878+
const INVENTORY_KINDS = ["skills", "configs", "all"] as const;
879+
880+
type InventoryScope = (typeof INVENTORY_SCOPES)[number];
881+
type InventoryKind = (typeof INVENTORY_KINDS)[number];
882+
883+
interface InventoryCliOptions {
884+
scope?: InventoryScope;
885+
kind?: InventoryKind;
886+
onlyExisting?: boolean;
887+
workspace?: string[];
888+
format?: "text" | "json";
889+
}
890+
891+
function addInventoryCommand(program: Command, deps: CliDeps): void {
892+
program
893+
.command("inventory")
894+
.description(
895+
"List the AI-tool config + skill artifacts the knowledge base knows about, resolved against this machine.",
896+
)
897+
.addOption(
898+
new Option("--scope <scope>", "scope filter")
899+
.choices(INVENTORY_SCOPES as unknown as string[])
900+
.default("all"),
901+
)
902+
.addOption(
903+
new Option("--kind <kind>", "artifact kind filter")
904+
.choices(INVENTORY_KINDS as unknown as string[])
905+
.default("all"),
906+
)
907+
.option("--only-existing", "return only items that currently exist on disk")
908+
.option(
909+
"--workspace <path>",
910+
"additional project-scope root (repeatable); defaults to cwd when omitted",
911+
collectRepeatable,
912+
[] as string[],
913+
)
914+
.addOption(
915+
new Option("--format <format>", "output format").choices(["text", "json"]).default("text"),
916+
)
917+
.addHelpText(
918+
"after",
919+
renderExampleHelp([
920+
"codegate inventory",
921+
"codegate inventory --format json --kind skills --only-existing",
922+
"codegate inventory --scope user --format json",
923+
"codegate inventory --workspace . --workspace /path/to/other/repo",
924+
]),
925+
)
926+
.action((options: InventoryCliOptions) => {
927+
try {
928+
const home = deps.homeDir?.() ?? homedir();
929+
const explicitWorkspaces = options.workspace ?? [];
930+
const workspaces =
931+
explicitWorkspaces.length > 0
932+
? explicitWorkspaces.map((w) => resolve(deps.cwd(), w))
933+
: [deps.cwd()];
934+
935+
const summary: InventorySummary = runInventory({
936+
scope: options.scope ?? "all",
937+
kind: options.kind ?? "all",
938+
onlyExisting: options.onlyExisting === true,
939+
workspaces,
940+
homeDir: home,
941+
});
942+
943+
if (options.format === "json") {
944+
deps.stdout(JSON.stringify(summary, null, 2));
945+
} else {
946+
renderInventoryText(summary, deps.stdout);
947+
}
948+
deps.setExitCode(0);
949+
} catch (error) {
950+
const message = error instanceof Error ? error.message : String(error);
951+
deps.stderr(`Inventory failed: ${message}`);
952+
deps.setExitCode(3);
953+
}
954+
});
955+
}
956+
957+
function collectRepeatable(value: string, previous: string[]): string[] {
958+
return [...previous, value];
959+
}
960+
961+
function renderInventoryText(summary: InventorySummary, stdout: (line: string) => void): void {
962+
stdout(`Knowledge base v${summary.kb_version}`);
963+
stdout(`Tools: ${summary.tools.map((t) => t.name).join(", ")}`);
964+
stdout(`Items: ${summary.items.length}`);
965+
stdout("");
966+
for (const item of summary.items) {
967+
const mark = item.exists ? "✓" : "·";
968+
const tag = item.kind === "skill" ? `${item.kind}:${item.type ?? "?"}` : item.kind;
969+
stdout(` ${mark} [${item.tool}] ${tag} (${item.scope}) ${item.path}`);
970+
}
971+
}
972+
876973
function addUpdateCommands(program: Command, deps: CliDeps): void {
877974
const guidance = [
878975
"Updates are bundled with CodeGate releases in v1/v2.",
@@ -944,6 +1041,7 @@ export function createCli(
9441041
addRunCommand(program, version, deps);
9451042
addUndoCommand(program, deps);
9461043
addInitCommand(program, deps);
1044+
addInventoryCommand(program, deps);
9471045
addUpdateCommands(program, deps);
9481046
return program;
9491047
}

src/commands/inventory-command.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { existsSync, readdirSync, statSync } from "node:fs";
2+
import { join, relative, resolve, sep } from "node:path";
3+
4+
import {
5+
loadKnowledgeBase,
6+
type KnowledgeBaseEntry,
7+
type KnowledgeBasePathEntry,
8+
type KnowledgeBaseSkillEntry,
9+
} from "../layer1-discovery/knowledge-base.js";
10+
11+
/** One resolved artifact the scanner knows about. */
12+
export interface InventoryItem {
13+
tool: string;
14+
kind: "config" | "skill";
15+
/** Only set for skill entries; mirrors KB `skill_paths[].type`. */
16+
type?: string;
17+
scope: "user" | "project";
18+
/** Pattern as declared in the KB (relative, may contain wildcards). */
19+
pattern: string;
20+
/** Absolute resolved filesystem path (concrete, not the pattern). */
21+
path: string;
22+
/** True if the filesystem shows the path exists. */
23+
exists: boolean;
24+
risk_surface: string[];
25+
/** Only populated for config entries that declare them. */
26+
fields_of_interest?: Record<string, string>;
27+
/** Resolution root used (e.g., the home dir or a workspace root). */
28+
resolved_against: string;
29+
}
30+
31+
export interface InventorySummary {
32+
kb_version: string;
33+
/** Known tools (from KB file names) with their version ranges. */
34+
tools: Array<{ name: string; version_range: string }>;
35+
items: InventoryItem[];
36+
}
37+
38+
export interface InventoryOptions {
39+
scope: "user" | "project" | "all";
40+
kind: "skills" | "configs" | "all";
41+
onlyExisting: boolean;
42+
/** Roots for project-scope resolution. Empty if project scope is skipped. */
43+
workspaces: string[];
44+
homeDir: string;
45+
/** Optional injection for tests. */
46+
kbBaseDir?: string;
47+
}
48+
49+
const MAX_WILDCARD_DEPTH = 8;
50+
const MAX_WILDCARD_MATCHES = 2000;
51+
52+
export function runInventory(options: InventoryOptions): InventorySummary {
53+
const kb = loadKnowledgeBase(options.kbBaseDir);
54+
const includeConfigs = options.kind === "all" || options.kind === "configs";
55+
const includeSkills = options.kind === "all" || options.kind === "skills";
56+
57+
const rawItems: InventoryItem[] = [];
58+
59+
for (const entry of kb.entries) {
60+
if (includeConfigs) {
61+
for (const cp of entry.config_paths) {
62+
rawItems.push(...resolveConfigEntry(entry.tool, cp, options));
63+
}
64+
}
65+
if (includeSkills) {
66+
for (const sp of entry.skill_paths ?? []) {
67+
rawItems.push(...resolveSkillEntry(entry.tool, sp, options));
68+
}
69+
}
70+
}
71+
72+
const items = options.onlyExisting ? rawItems.filter((item) => item.exists) : rawItems;
73+
74+
// Stable ordering: by tool, then kind, then scope, then path.
75+
items.sort((a, b) => {
76+
if (a.tool !== b.tool) return a.tool.localeCompare(b.tool);
77+
if (a.kind !== b.kind) return a.kind.localeCompare(b.kind);
78+
if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
79+
return a.path.localeCompare(b.path);
80+
});
81+
82+
return {
83+
kb_version: kb.schemaVersion,
84+
tools: kb.entries
85+
.map((entry: KnowledgeBaseEntry) => ({
86+
name: entry.tool,
87+
version_range: entry.version_range,
88+
}))
89+
.sort((a, b) => a.name.localeCompare(b.name)),
90+
items,
91+
};
92+
}
93+
94+
function resolveConfigEntry(
95+
tool: string,
96+
cp: KnowledgeBasePathEntry,
97+
options: InventoryOptions,
98+
): InventoryItem[] {
99+
if (!scopeIncluded(cp.scope, options.scope)) return [];
100+
const roots = rootsFor(cp.scope, options);
101+
const items: InventoryItem[] = [];
102+
for (const root of roots) {
103+
items.push(
104+
...resolvePattern({
105+
tool,
106+
kind: "config",
107+
scope: cp.scope,
108+
pattern: cp.path,
109+
root,
110+
riskSurface: cp.risk_surface,
111+
fieldsOfInterest: cp.fields_of_interest,
112+
}),
113+
);
114+
}
115+
return items;
116+
}
117+
118+
function resolveSkillEntry(
119+
tool: string,
120+
sp: KnowledgeBaseSkillEntry,
121+
options: InventoryOptions,
122+
): InventoryItem[] {
123+
if (!scopeIncluded(sp.scope, options.scope)) return [];
124+
const roots = rootsFor(sp.scope, options);
125+
const items: InventoryItem[] = [];
126+
for (const root of roots) {
127+
items.push(
128+
...resolvePattern({
129+
tool,
130+
kind: "skill",
131+
type: sp.type,
132+
scope: sp.scope,
133+
pattern: sp.path,
134+
root,
135+
riskSurface: sp.risk_surface,
136+
}),
137+
);
138+
}
139+
return items;
140+
}
141+
142+
function scopeIncluded(
143+
entryScope: "user" | "project",
144+
optionScope: InventoryOptions["scope"],
145+
): boolean {
146+
if (optionScope === "all") return true;
147+
return entryScope === optionScope;
148+
}
149+
150+
function rootsFor(entryScope: "user" | "project", options: InventoryOptions): string[] {
151+
if (entryScope === "user") return [options.homeDir];
152+
if (options.workspaces.length === 0) return [];
153+
return options.workspaces;
154+
}
155+
156+
interface ResolvePatternInput {
157+
tool: string;
158+
kind: "config" | "skill";
159+
type?: string;
160+
scope: "user" | "project";
161+
pattern: string;
162+
root: string;
163+
riskSurface: string[];
164+
fieldsOfInterest?: Record<string, string>;
165+
}
166+
167+
function resolvePattern(input: ResolvePatternInput): InventoryItem[] {
168+
const normalized = normalizePattern(input.pattern);
169+
const hasWildcard = /[*?]/.test(normalized);
170+
171+
if (!hasWildcard) {
172+
const absolute = resolve(input.root, normalized);
173+
return [makeItem(input, absolute, existsSync(absolute))];
174+
}
175+
176+
const matches = expandWildcard(input.root, normalized);
177+
return matches.map((absolute) => makeItem(input, absolute, true));
178+
}
179+
180+
function makeItem(input: ResolvePatternInput, absolute: string, exists: boolean): InventoryItem {
181+
return {
182+
tool: input.tool,
183+
kind: input.kind,
184+
type: input.type,
185+
scope: input.scope,
186+
pattern: input.pattern,
187+
path: absolute,
188+
exists,
189+
risk_surface: input.riskSurface,
190+
fields_of_interest: input.fieldsOfInterest,
191+
resolved_against: input.root,
192+
};
193+
}
194+
195+
function normalizePattern(pattern: string): string {
196+
return pattern.replace(/^~\//, "").replace(/^\/+/, "");
197+
}
198+
199+
function escapeRegex(value: string): string {
200+
return value.replace(/[|\\{}()[\]^$+?.*]/g, "\\$&");
201+
}
202+
203+
function wildcardToRegex(pattern: string): RegExp {
204+
let escaped = escapeRegex(pattern);
205+
escaped = escaped.replace(/\\\*\\\*\//g, "(?:[^/]+/)*");
206+
escaped = escaped.replace(/\\\*\\\*/g, ".*");
207+
escaped = escaped.replace(/\\\*/g, "[^/]*");
208+
escaped = escaped.replace(/\\\?/g, "[^/]");
209+
return new RegExp(`^${escaped}$`);
210+
}
211+
212+
function fixedPrefix(pattern: string): string {
213+
const firstStar = pattern.indexOf("*");
214+
const firstQuestion = pattern.indexOf("?");
215+
const firstWildcard =
216+
firstStar === -1
217+
? firstQuestion
218+
: firstQuestion === -1
219+
? firstStar
220+
: Math.min(firstStar, firstQuestion);
221+
if (firstWildcard === -1) return pattern;
222+
const prefix = pattern.slice(0, firstWildcard);
223+
const lastSlash = prefix.lastIndexOf("/");
224+
return lastSlash === -1 ? "" : prefix.slice(0, lastSlash);
225+
}
226+
227+
function expandWildcard(root: string, pattern: string): string[] {
228+
const matchRegex = wildcardToRegex(pattern);
229+
const prefix = fixedPrefix(pattern);
230+
const baseDir = prefix ? resolve(root, prefix) : resolve(root);
231+
if (!existsSync(baseDir)) return [];
232+
try {
233+
if (!statSync(baseDir).isDirectory()) return [];
234+
} catch {
235+
return [];
236+
}
237+
238+
const matches: string[] = [];
239+
const queue: Array<{ dir: string; depth: number }> = [{ dir: baseDir, depth: 0 }];
240+
241+
while (queue.length > 0 && matches.length < MAX_WILDCARD_MATCHES) {
242+
const current = queue.pop();
243+
if (!current) break;
244+
245+
let entries;
246+
try {
247+
entries = readdirSync(current.dir, { withFileTypes: true });
248+
} catch {
249+
continue;
250+
}
251+
252+
for (const entry of entries) {
253+
if (matches.length >= MAX_WILDCARD_MATCHES) break;
254+
const absolute = join(current.dir, entry.name);
255+
if (entry.isSymbolicLink()) continue;
256+
257+
if (entry.isDirectory()) {
258+
if (current.depth < MAX_WILDCARD_DEPTH) {
259+
queue.push({ dir: absolute, depth: current.depth + 1 });
260+
}
261+
continue;
262+
}
263+
264+
if (!entry.isFile()) continue;
265+
266+
const rel = relative(root, absolute).split(sep).join("/");
267+
if (rel.startsWith("..")) continue;
268+
if (!matchRegex.test(rel)) continue;
269+
matches.push(absolute);
270+
}
271+
}
272+
273+
return matches;
274+
}

0 commit comments

Comments
 (0)