Files
Michael Ramos db86d38ca4 feat(skills): top-level plannotator knowledge skill, per-host install, and plannotator.ai/llms.txt (#1377)
* feat(skills): add the plannotator knowledge-layer skill with a CLI freshness guard

A new model-invocable core skill (apps/skills/core/plannotator) that teaches
an agent the whole CLI surface: decision guide, per-command reference with
flags and exit codes, env vars, the external-annotations API, and a do-not
list. The existing plannotator-* core skills stay lightweight action stubs.

A freshness test (apps/hook/server/plannotator-skill-reference.test.ts)
parses the skill's documented subcommands and flags and diffs them against
cli.ts usage text plus the CLI arg-parsing sources, in both directions, so
the reference cannot drift from the real CLI without failing the suite.

Installers copy the single-sourced core body into ~/.claude/skills and
~/.agents/skills on all three platforms; uninstall removes it from both
scopes. The skill ships model-invocable as a documented exception to the
locked-by-default rule, asserted both ways in install.test.ts.

* feat(marketing): serve the plannotator knowledge skill as /llms.txt

Single-sourced at build time from apps/skills/core/plannotator/SKILL.md
per the llmstxt.org spec (H1, blockquote, detail sections, Docs link
list), so the CLI freshness guard transitively keeps llms.txt current.

* fix(skills): reach every install path with the plannotator knowledge skill

The knowledge skill reached Claude Code and ~/.agents but was missing from
three install paths. Six fixes from the install-reach review of #1377.

Kiro: the installer's Kiro leg copied only the two action skills, so Kiro
users got launchers and no CLI reference. One copy line per installer, and
"plannotator" joins uninstall.ts's KIRO_SKILLS.

OpenCode npm: @plannotator/opencode's postinstall copied only commands/*.md.
The package now ships the skill (copied at build time like the HTML assets,
gitignored so the shipped copy cannot drift) and postinstall places it under
${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/plannotator/, which is a
path OpenCode really scans ({skill,skills}/**/SKILL.md under xdgConfig/
opencode). Uninstall sweeps it, skills only, so a user's own
opencode/commands/plannotator.md stays out of scope.

Pi npm: vendor.sh copies the skill to apps/pi-extension/skills/plannotator/
and package.json declares it under pi.skills, which Pi resolves relative to
the package root. Neither vendored copy carries the // @generated header the
.ts files use: a SKILL.md must open with its frontmatter on line 1.

llms.txt: the endpoint resolved the skill through process.cwd(), which breaks
under any invocation but --cwd apps/marketing. new URL(import.meta.url) does
not fix it either, because Vite rewrites import.meta.url to the emitted SSR
chunk's location. Inlined with Vite's ?raw, resolved by the bundler relative
to the source file. Also drops the summary paragraph the required blockquote
already carries; SKILL.md itself is unchanged.

Uninstall: KNOWLEDGE_SKILLS is a separate list from CORE_SKILLS precisely so
the bare name "plannotator" cannot leak into LEGACY_COMMAND_NAMES or
STALE_CODEX_SKILLS and delete a user's own files. Nothing tested that; now a
test proves the five installed scopes are removed and commands/plannotator.md
(Claude and OpenCode) plus ~/.codex/skills/plannotator survive. Also
cleanupStaleSkillLayout now knows KNOWLEDGE_SKILLS.

Origins: oh-my-pi (#1373) was missing from SKILL.md's PLANNOTATOR_ORIGIN row.
The guard now imports AGENT_CONFIG and asserts the row names every key and
invents none, and its header comment is narrowed to what it actually proves:
bidirectional for subcommands and origins, one-directional for flags.

AI-assisted (Claude) under maintainer direction.
2026-08-22 12:07:42 -07:00

336 lines
12 KiB
TypeScript

import { GUIDE_CLI_USAGE } from "@plannotator/server/guide-cli";
const HELP_FLAGS = new Set(["--help", "-h"]);
export interface ParsedStrictAnnotateOptions {
requireApproval: boolean;
resultFile?: string;
remainingArgs: string[];
}
export interface ParsedUninstallOptions {
purge: boolean;
yes: boolean;
dryRun: boolean;
}
/**
* Parse the deliberately small, non-overlapping uninstall flag surface.
*/
export function parseUninstallOptions(
args: readonly string[],
): ParsedUninstallOptions {
let purge = false;
let yes = false;
let dryRun = false;
for (const arg of args) {
if (arg === "--purge") {
if (purge) throw new Error("--purge may only be specified once");
purge = true;
} else if (arg === "--yes" || arg === "-y") {
if (yes) throw new Error("--yes/-y may only be specified once");
yes = true;
} else if (arg === "--dry-run") {
if (dryRun) throw new Error("--dry-run may only be specified once");
dryRun = true;
} else {
throw new Error(`Unknown uninstall option: ${arg}`);
}
}
return { purge, yes, dryRun };
}
/**
* Normal uninstall accepts y/yes. Purge intentionally requires an exact,
* explicit word so an accidental return key cannot destroy local data.
*/
export function isUninstallConfirmationAccepted(
answer: string,
purge: boolean,
): boolean {
const normalized = answer.trim().toLowerCase();
return purge
? normalized === "purge"
: normalized === "y" || normalized === "yes";
}
export function parseStrictAnnotateOptions(
args: string[],
): ParsedStrictAnnotateOptions {
let requireApproval = false;
let resultFile: string | undefined;
const remainingArgs: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--require-approval") {
if (requireApproval) {
throw new Error("--require-approval may only be specified once");
}
requireApproval = true;
continue;
}
if (arg === "--result-file") {
if (resultFile !== undefined) {
throw new Error("--result-file may only be specified once");
}
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error("Missing value for --result-file");
}
resultFile = value;
index += 1;
continue;
}
remainingArgs.push(arg);
}
if (!requireApproval && resultFile === undefined) {
return { requireApproval: false, remainingArgs };
}
if (remainingArgs[0] !== "annotate") {
throw new Error(
"--require-approval and --result-file are only valid with annotate",
);
}
if (!remainingArgs.includes("--gate") || !remainingArgs.includes("--json")) {
throw new Error(
"--require-approval and --result-file require --gate --json",
);
}
if (remainingArgs.includes("--hook")) {
throw new Error(
"--require-approval and --result-file cannot be used with --hook",
);
}
return { requireApproval, resultFile, remainingArgs };
}
/** True when any token is a help flag (`--help` / `-h`). */
export function hasHelpFlag(args: string[]): boolean {
return args.some((arg) => HELP_FLAGS.has(arg));
}
export function isTopLevelHelpInvocation(args: string[]): boolean {
return args.length > 0 && HELP_FLAGS.has(args[0]);
}
export function isVersionInvocation(args: string[]): boolean {
return args[0] === "--version" || args[0] === "-v";
}
declare const __CLI_VERSION__: string;
export function formatVersion(): string {
return `plannotator ${typeof __CLI_VERSION__ !== "undefined" ? __CLI_VERSION__ : "dev"}`;
}
export function isInteractiveNoArgInvocation(
args: string[],
stdinIsTTY: boolean | undefined,
): boolean {
return args.length === 0 && stdinIsTTY === true;
}
export function formatTopLevelHelp(): string {
return [
"Usage:",
" plannotator --help",
" plannotator --version, -v",
" plannotator [--browser <name>]",
" plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]",
" plannotator annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--tailscale] [--gate] [--json] [--hook] [--require-approval] [--result-file <path>]",
" plannotator annotate-last [--stdin] [--tailscale] [--gate] [--json] [--hook]",
" plannotator copilot-last [--gate] [--json] [--hook]",
" plannotator setup-goal <interview|facts> <bundle.json | -> [--json]",
" plannotator last",
" plannotator archive",
" plannotator guide list",
" plannotator guide export --id <savedGuideId> | --guide <guide.json> --patch <diff.patch> | --snapshot <snapshot.json> [--out <file.html>]",
" plannotator guide share --id <savedGuideId> | --guide <guide.json> --patch <diff.patch> | --snapshot <snapshot.json> [--public] [--ttl <7d>] [--json]",
" plannotator guide unshare <id> --token <deleteToken>",
" plannotator sessions",
" plannotator uninstall [--purge] [--yes] [--dry-run]",
" plannotator improve-context",
"",
"Run 'plannotator <command> --help' for command-specific usage.",
"",
"Note:",
" running 'plannotator' without arguments is for hook integration and expects JSON on stdin",
].join("\n");
}
// Per-subcommand usage text. Keyed by the canonical subcommand token; aliases
// (e.g. `last` → `annotate-last`) are resolved in formatSubcommandHelp().
//
// These exist so an agent (or human) probing `plannotator <sub> --help` gets
// usage on stdout instead of accidentally launching the browser UI — running
// `review --help` used to fall through to local review mode and open a tab.
// Exported so the plannotator knowledge skill's freshness test
// (plannotator-skill-reference.test.ts) can diff the documented surface
// against the real one.
export const SUBCOMMAND_HELP: Record<string, string> = {
review: [
"Usage:",
" plannotator review [--git | --gitbutler] [--local | --no-local] [--tailscale] [PR_URL]",
"",
"Review local VCS changes or a GitHub/GitLab pull request in the browser.",
"",
"Options:",
" --git Force git as the VCS (skip auto-detection)",
" --gitbutler Force GitButler as the VCS (requires but 0.21.0+)",
" --local For PR review, prepare a local checkout for full file access (default)",
" --no-local For PR review, skip the local checkout (diff only)",
" --tailscale Publish the loopback session over your tailnet via tailscale serve (HTTPS)",
" PR_URL GitHub PR or GitLab MR URL to review",
"",
"Examples:",
" plannotator review",
" plannotator review --git",
" plannotator review --gitbutler",
" plannotator review https://github.com/owner/repo/pull/123",
].join("\n"),
annotate: [
"Usage:",
" plannotator annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--tailscale] [--gate] [--json] [--hook] [--require-approval] [--result-file <path>]",
"",
"Open a markdown/text/HTML file, a URL, or a folder of documents in the annotation UI.",
"",
"Options:",
" --markdown Convert HTML input to markdown instead of rendering it raw",
" --no-jina Fetch URLs with fetch+Turndown instead of Jina Reader",
" --tailscale Publish the loopback session over your tailnet via tailscale serve (HTTPS)",
" --gate Add an Approve button (review-gate UX)",
" --json Emit a structured decision JSON on stdout",
" --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks",
" --require-approval",
" Exit 1 unless the reviewer approves (requires --gate --json;",
" usage/startup errors exit 2)",
" --result-file <path>",
" Atomically publish the stdout JSON (requires --gate --json)",
].join("\n"),
"annotate-last": [
"Usage:",
" plannotator annotate-last [--stdin] [--tailscale] [--gate] [--json] [--hook]",
" plannotator last [--stdin] [--tailscale] [--gate] [--json] [--hook]",
"",
"Annotate the last assistant message from the current agent session.",
"",
"Options:",
" --stdin Read the message content from stdin instead of session logs",
" --tailscale Publish the loopback session over your tailnet via tailscale serve (HTTPS)",
" --gate Add an Approve button (review-gate UX)",
" --json Emit a structured decision JSON on stdout",
" --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks",
].join("\n"),
"copilot-last": [
"Usage:",
" plannotator copilot-last [--gate] [--json] [--hook]",
"",
"Annotate the last assistant message from the live GitHub Copilot CLI session,",
"read from its session-state events.jsonl. Normally invoked by the Copilot",
"plugin's /plannotator-last command.",
"",
"Options:",
" --gate Add an Approve button (review-gate UX)",
" --json Emit a structured decision JSON on stdout",
" --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks",
].join("\n"),
"setup-goal": [
"Usage:",
" plannotator setup-goal <interview|facts> <bundle.json | -> [--json]",
"",
"Open the goal-setup question (interview) or facts-acceptance UI for /goal workflows.",
"Pass '-' to read the bundle JSON from stdin.",
"",
"Options:",
" --json Emit compact JSON instead of pretty-printed output",
].join("\n"),
archive: [
"Usage:",
" plannotator archive",
"",
"Open a read-only browser for saved plan decisions in ~/.plannotator/plans/.",
].join("\n"),
guide: GUIDE_CLI_USAGE,
"improve-context": [
"Usage:",
" plannotator improve-context",
"",
"Hook-integration command spawned by the PreToolUse hook on EnterPlanMode.",
"Reads the hook event on stdin and emits additionalContext JSON (PFM reminder",
"and/or compound improvement hook), or exits silently when nothing is enabled.",
"Not intended to be run directly.",
].join("\n"),
sessions: [
"Usage:",
" plannotator sessions [--open [N]] [--clean]",
"",
"List active Plannotator server sessions.",
"",
"Options:",
" --open [N] Reopen session #N (default 1) in the browser",
" --clean Remove stale session entries",
].join("\n"),
uninstall: [
"Usage:",
" plannotator uninstall [--purge] [--yes | -y] [--dry-run]",
"",
"Remove Plannotator-installed components. Local plans, history, drafts,",
"settings, and other Plannotator data are preserved by default.",
"",
"Options:",
" --purge Also permanently delete known local Plannotator data",
" --yes, -y Skip the interactive confirmation (required without a TTY)",
" --dry-run Preview recognized removal work without changing anything",
"",
"Purge data is local-only: it is not stored on a Plannotator server and",
"cannot be recovered after purge. Unrecognized custom files are preserved.",
].join("\n"),
};
// Aliases share another subcommand's help text.
// Exported for the same freshness test as SUBCOMMAND_HELP.
export const SUBCOMMAND_HELP_ALIASES: Record<string, string> = {
last: "annotate-last",
};
/**
* Returns the canonical subcommand name when `args` is a `<sub> ... --help`
* invocation for a user-facing subcommand, or null otherwise. Lets the CLI
* print usage and exit before a subcommand branch can launch the UI.
*/
export function isSubcommandHelpInvocation(args: string[]): string | null {
const sub = args[0];
if (!sub) return null;
const canonical = SUBCOMMAND_HELP_ALIASES[sub] ?? sub;
if (!(canonical in SUBCOMMAND_HELP)) return null;
return hasHelpFlag(args.slice(1)) ? canonical : null;
}
/** Usage text for a canonical subcommand (falls back to top-level help). */
export function formatSubcommandHelp(subcommand: string): string {
return SUBCOMMAND_HELP[subcommand] ?? formatTopLevelHelp();
}
export function formatInteractiveNoArgClarification(): string {
return [
"plannotator (without arguments) is usually launched automatically by Claude Code hooks.",
"It expects hook JSON on stdin.",
"",
"For interactive use, try:",
" plannotator review",
" plannotator annotate <file.md | file.txt | file.html | https://...>",
" plannotator setup-goal interview bundle.json --json",
" plannotator last",
" plannotator archive",
" plannotator sessions",
" plannotator uninstall",
"",
"Run 'plannotator --help' for top-level usage.",
].join("\n");
}