Skip to content

Commit 9355ec7

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 9355ec7

File tree

8 files changed

+1501
-295
lines changed

8 files changed

+1501
-295
lines changed

AGENTS.md

Lines changed: 46 additions & 20 deletions
Large diffs are not rendered by default.

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: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,34 @@
33
*
44
* Provides help information for the CLI.
55
* - `sentry help` or `sentry` (no args): Shows branded help with banner
6-
* - `sentry help <command>`: Shows Stricli's detailed help (--helpAll) for that command
6+
* - `sentry help <command>`: Shows detailed help 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

9-
import { run } from "@stricli/core";
1011
import type { SentryContext } from "../context.js";
1112
import { buildCommand } from "../lib/command.js";
13+
import { OutputError } from "../lib/errors.js";
1214
import { CommandOutput } from "../lib/formatters/output.js";
13-
import { printCustomHelp } from "../lib/help.js";
15+
import {
16+
formatHelpHuman,
17+
introspectAllCommands,
18+
introspectCommand,
19+
printCustomHelp,
20+
} from "../lib/help.js";
1421

1522
export const helpCommand = buildCommand({
1623
docs: {
1724
brief: "Display help for a command",
1825
fullDescription:
1926
"Display help information. Run 'sentry help' for an overview, " +
20-
"or 'sentry help <command>' for detailed help on a specific command.",
27+
"or 'sentry help <command>' for detailed help on a specific command. " +
28+
"Use --json for machine-readable output suitable for AI agents.",
29+
},
30+
output: {
31+
human: formatHelpHuman,
32+
jsonExclude: ["_banner"] as const,
2133
},
22-
output: { human: (s: string) => s.trimEnd() },
2334
parameters: {
2435
flags: {},
2536
positional: {
@@ -33,15 +44,21 @@ export const helpCommand = buildCommand({
3344
},
3445
// biome-ignore lint/complexity/noBannedTypes: Stricli requires empty object for commands with no flags
3546
async *func(this: SentryContext, _flags: {}, ...commandPath: string[]) {
36-
// No args: show branded help
3747
if (commandPath.length === 0) {
38-
return yield new CommandOutput(await printCustomHelp());
48+
// Yield the full command tree. Attach the branded banner for human display;
49+
// jsonExclude strips _banner from JSON output.
50+
const tree = introspectAllCommands();
51+
const banner = await printCustomHelp();
52+
return yield new CommandOutput({ ...tree, _banner: banner });
3953
}
4054

41-
// With args: re-invoke with --helpAll to show full help including hidden items
42-
// Use dynamic imports to avoid circular dependency (app.ts imports helpCommand)
43-
const { app } = await import("../app.js");
44-
const { buildContext } = await import("../context.js");
45-
await run(app, [...commandPath, "--helpAll"], buildContext(this.process));
55+
// Resolve the command path and yield the result.
56+
// This ensures --json mode always gets structured output.
57+
const result = introspectCommand(commandPath);
58+
if ("error" in result) {
59+
// OutputError renders through the output system but exits non-zero
60+
throw new OutputError(result);
61+
}
62+
return yield new CommandOutput(result);
4663
},
4764
});

0 commit comments

Comments
 (0)