Skip to content

Commit ed25c2f

Browse files
committed
feat: add --json flag to help command for agent introspection
Add structured JSON output to the help command so AI agents can discover CLI commands at runtime without relying on static docs. New features: - `sentry help --json` — emit full command tree as JSON - `sentry help --json <group>` — emit route group metadata - `sentry help --json <group> <cmd>` — emit specific command metadata - Framework-injected flags (help, helpAll, log-level, verbose) are stripped from JSON output Architecture: - Extract shared route-tree introspection into src/lib/introspect.ts - Consolidate duplicated type guards from help.ts and generate-skill.ts - generate-skill.ts now imports from introspect.ts (no functional change) Tests: 33 unit + 12 property-based + 10 integration tests
1 parent a3c6a91 commit ed25c2f

File tree

7 files changed

+1453
-266
lines changed

7 files changed

+1453
-266
lines changed

script/generate-skill.ts

Lines changed: 25 additions & 184 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@
1313
*/
1414

1515
import { routes } from "../src/app.js";
16+
import type {
17+
CommandInfo,
18+
FlagInfo,
19+
RouteInfo,
20+
RouteMap,
21+
} from "../src/lib/introspect.js";
22+
import {
23+
buildCommandInfo,
24+
extractRouteGroupCommands,
25+
isCommand,
26+
isRouteMap,
27+
} from "../src/lib/introspect.js";
1628

1729
const OUTPUT_PATH = "plugins/sentry-cli/skills/sentry-cli/SKILL.md";
1830
const DOCS_PATH = "docs/src/content/docs";
@@ -26,61 +38,9 @@ const CODE_BLOCK_REGEX = /```(\w*)\n([\s\S]*?)```/g;
2638
/** Regex to extract npm command from PackageManagerCode Astro component (handles multi-line) */
2739
const PACKAGE_MANAGER_REGEX = /<PackageManagerCode[\s\S]*?npm="([^"]+)"/;
2840

29-
// ─────────────────────────────────────────────────────────────────────────────
30-
// Types for Stricli Route Introspection
31-
//
32-
// Note: While @stricli/core exports RouteMap and Command types, they require
33-
// complex generic parameters (CommandContext) and don't export internal types
34-
// like RouteMapEntry or FlagParameter. These simplified types are purpose-built
35-
// for introspection and documentation generation.
36-
// ─────────────────────────────────────────────────────────────────────────────
37-
38-
type RouteMapEntry = {
39-
name: { original: string };
40-
target: RouteTarget;
41-
hidden: boolean;
42-
};
43-
44-
type RouteTarget = RouteMap | Command;
45-
46-
type RouteMap = {
47-
brief: string;
48-
fullDescription?: string;
49-
getAllEntries: () => RouteMapEntry[];
50-
};
51-
52-
type Command = {
53-
brief: string;
54-
fullDescription?: string;
55-
parameters: {
56-
positional?: PositionalParams;
57-
flags?: Record<string, FlagDef>;
58-
aliases?: Record<string, string>;
59-
};
60-
};
61-
62-
type PositionalParams =
63-
| { kind: "tuple"; parameters: PositionalParam[] }
64-
| { kind: "array"; parameter: PositionalParam };
65-
66-
type PositionalParam = {
67-
brief?: string;
68-
placeholder?: string;
69-
};
70-
71-
type FlagDef = {
72-
kind: "boolean" | "parsed";
73-
brief?: string;
74-
default?: unknown;
75-
optional?: boolean;
76-
variadic?: boolean;
77-
placeholder?: string;
78-
hidden?: boolean;
79-
};
80-
81-
// ─────────────────────────────────────────────────────────────────────────────
41+
// ---------------------------------------------------------------------------
8242
// Markdown Parsing Utilities
83-
// ─────────────────────────────────────────────────────────────────────────────
43+
// ---------------------------------------------------------------------------
8444

8545
/**
8646
* Strip YAML frontmatter from markdown content
@@ -175,9 +135,9 @@ function escapeRegex(str: string): string {
175135
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
176136
}
177137

178-
// ─────────────────────────────────────────────────────────────────────────────
138+
// ---------------------------------------------------------------------------
179139
// Documentation Loading
180-
// ─────────────────────────────────────────────────────────────────────────────
140+
// ---------------------------------------------------------------------------
181141

182142
/**
183143
* Load and parse a documentation file
@@ -398,132 +358,13 @@ async function loadCommandsOverview(): Promise<{
398358
};
399359
}
400360

401-
// ─────────────────────────────────────────────────────────────────────────────
402-
// Route Introspection
403-
// ─────────────────────────────────────────────────────────────────────────────
404-
405-
function isRouteMap(target: RouteTarget): target is RouteMap {
406-
return "getAllEntries" in target;
407-
}
408-
409-
function isCommand(target: RouteTarget): target is Command {
410-
return "parameters" in target && !("getAllEntries" in target);
411-
}
412-
413-
type CommandInfo = {
414-
path: string;
415-
brief: string;
416-
fullDescription?: string;
417-
flags: FlagInfo[];
418-
positional: string;
419-
aliases: Record<string, string>;
420-
examples: string[];
421-
};
422-
423-
type FlagInfo = {
424-
name: string;
425-
brief: string;
426-
kind: "boolean" | "parsed";
427-
default?: unknown;
428-
optional: boolean;
429-
variadic: boolean;
430-
hidden: boolean;
431-
};
432-
433-
type RouteInfo = {
434-
name: string;
435-
brief: string;
436-
commands: CommandInfo[];
437-
};
438-
439-
/**
440-
* Extract positional parameter placeholder string
441-
*/
442-
function getPositionalString(params?: PositionalParams): string {
443-
if (!params) {
444-
return "";
445-
}
446-
447-
if (params.kind === "tuple") {
448-
return params.parameters
449-
.map((p, i) => `<${p.placeholder ?? `arg${i}`}>`)
450-
.join(" ");
451-
}
452-
453-
if (params.kind === "array") {
454-
const placeholder = params.parameter.placeholder ?? "args";
455-
return `<${placeholder}...>`;
456-
}
457-
458-
return "";
459-
}
460-
461-
/**
462-
* Extract flag information from a command
463-
*/
464-
function extractFlags(flags: Record<string, FlagDef> | undefined): FlagInfo[] {
465-
if (!flags) {
466-
return [];
467-
}
468-
469-
return Object.entries(flags).map(([name, def]) => ({
470-
name,
471-
brief: def.brief ?? "",
472-
kind: def.kind,
473-
default: def.default,
474-
optional: def.optional ?? def.kind === "boolean",
475-
variadic: def.variadic ?? false,
476-
hidden: def.hidden ?? false,
477-
}));
478-
}
479-
480-
/**
481-
* Build a CommandInfo from a Command
482-
*/
483-
function buildCommandInfo(
484-
cmd: Command,
485-
path: string,
486-
examples: string[] = []
487-
): CommandInfo {
488-
return {
489-
path,
490-
brief: cmd.brief,
491-
fullDescription: cmd.fullDescription,
492-
flags: extractFlags(cmd.parameters.flags),
493-
positional: getPositionalString(cmd.parameters.positional),
494-
aliases: cmd.parameters.aliases ?? {},
495-
examples,
496-
};
497-
}
498-
499-
/**
500-
* Extract commands from a route group
501-
*/
502-
function extractRouteGroupCommands(
503-
routeMap: RouteMap,
504-
routeName: string,
505-
docExamples: Map<string, string[]>
506-
): CommandInfo[] {
507-
const commands: CommandInfo[] = [];
508-
509-
for (const subEntry of routeMap.getAllEntries()) {
510-
if (subEntry.hidden) {
511-
continue;
512-
}
513-
514-
const subTarget = subEntry.target;
515-
if (isCommand(subTarget)) {
516-
const path = `sentry ${routeName} ${subEntry.name.original}`;
517-
const examples = docExamples.get(path) ?? [];
518-
commands.push(buildCommandInfo(subTarget, path, examples));
519-
}
520-
}
521-
522-
return commands;
523-
}
361+
// ---------------------------------------------------------------------------
362+
// Route Introspection (with async doc loading)
363+
// ---------------------------------------------------------------------------
524364

525365
/**
526-
* Walk the route tree and extract command information
366+
* Walk the route tree and extract command information with doc examples.
367+
* This is the async version that loads documentation examples from disk.
527368
*/
528369
async function extractRoutes(routeMap: RouteMap): Promise<RouteInfo[]> {
529370
const result: RouteInfo[] = [];
@@ -559,9 +400,9 @@ async function extractRoutes(routeMap: RouteMap): Promise<RouteInfo[]> {
559400
return result;
560401
}
561402

562-
// ─────────────────────────────────────────────────────────────────────────────
403+
// ---------------------------------------------------------------------------
563404
// Markdown Generation
564-
// ─────────────────────────────────────────────────────────────────────────────
405+
// ---------------------------------------------------------------------------
565406

566407
/**
567408
* Generate the front matter for the skill file
@@ -782,9 +623,9 @@ async function generateSkillMarkdown(routeMap: RouteMap): Promise<string> {
782623
return sections.join("\n");
783624
}
784625

785-
// ─────────────────────────────────────────────────────────────────────────────
626+
// ---------------------------------------------------------------------------
786627
// Main
787-
// ─────────────────────────────────────────────────────────────────────────────
628+
// ---------------------------------------------------------------------------
788629

789630
const content = await generateSkillMarkdown(routes as unknown as RouteMap);
790631
await Bun.write(OUTPUT_PATH, content);

src/commands/help.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
* Provides help information for the CLI.
55
* - `sentry help` or `sentry` (no args): Shows branded help with banner
66
* - `sentry help <command>`: Shows Stricli's detailed help (--helpAll) for that command
7+
* - `sentry help --json`: Emits full command tree as structured JSON
8+
* - `sentry help --json <command>`: Emits specific command/group metadata as JSON
79
*/
810

911
import { run } from "@stricli/core";
1012
import type { SentryContext } from "../context.js";
1113
import { buildCommand } from "../lib/command.js";
14+
import { writeJson } from "../lib/formatters/json.js";
1215
import { CommandOutput } from "../lib/formatters/output.js";
1316
import { printCustomHelp } from "../lib/help.js";
1417

@@ -17,7 +20,8 @@ export const helpCommand = buildCommand({
1720
brief: "Display help for a command",
1821
fullDescription:
1922
"Display help information. Run 'sentry help' for an overview, " +
20-
"or 'sentry help <command>' for detailed help on a specific command.",
23+
"or 'sentry help <command>' for detailed help on a specific command. " +
24+
"Use --json for machine-readable output suitable for AI agents.",
2125
},
2226
output: { human: (s: string) => s.trimEnd() },
2327
parameters: {
@@ -31,8 +35,48 @@ export const helpCommand = buildCommand({
3135
},
3236
},
3337
},
34-
// biome-ignore lint/complexity/noBannedTypes: Stricli requires empty object for commands with no flags
35-
async *func(this: SentryContext, _flags: {}, ...commandPath: string[]) {
38+
async *func(
39+
this: SentryContext,
40+
flags: { json?: boolean },
41+
...commandPath: string[]
42+
) {
43+
if (flags.json) {
44+
// Dynamic import to avoid circular dependency (app.ts imports helpCommand)
45+
const { routes } = await import("../app.js");
46+
const {
47+
extractAllRoutes,
48+
resolveCommandPath,
49+
cleanCommandInfoForJson,
50+
cleanRouteInfoForJson,
51+
} = await import("../lib/introspect.js");
52+
53+
// Cast to introspection types — Stricli's generic types are compatible
54+
type IntrospectRouteMap = import("../lib/introspect.js").RouteMap;
55+
const routeMap = routes as unknown as IntrospectRouteMap;
56+
57+
if (commandPath.length === 0) {
58+
// Full tree
59+
const allRoutes = extractAllRoutes(routeMap).map(cleanRouteInfoForJson);
60+
writeJson(this.stdout, { routes: allRoutes });
61+
} else {
62+
// Specific command or group
63+
const resolved = resolveCommandPath(routeMap, commandPath);
64+
if (!resolved) {
65+
writeJson(this.stdout, {
66+
error: `Command not found: ${commandPath.join(" ")}`,
67+
});
68+
return;
69+
}
70+
71+
if (resolved.kind === "command") {
72+
writeJson(this.stdout, cleanCommandInfoForJson(resolved.info));
73+
} else {
74+
writeJson(this.stdout, cleanRouteInfoForJson(resolved.info));
75+
}
76+
}
77+
return;
78+
}
79+
3680
// No args: show branded help
3781
if (commandPath.length === 0) {
3882
return yield new CommandOutput(await printCustomHelp());

0 commit comments

Comments
 (0)