Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>", "scope filter")
.choices(INVENTORY_SCOPES as unknown as string[])
.default("all"),
)
.addOption(
new Option("--kind <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 <path>",
"additional project-scope root (repeatable); defaults to cwd when omitted",
collectRepeatable,
[] as string[],
)
.addOption(
new Option("--format <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.",
Expand Down Expand Up @@ -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;
}
Expand Down
274 changes: 274 additions & 0 deletions src/commands/inventory-command.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** 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<string, string>;
}

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;
}
Loading
Loading