diff --git a/.agents/skills/agent-customization/SKILL.md b/.agents/skills/agent-customization/SKILL.md new file mode 100644 index 0000000..26f5204 --- /dev/null +++ b/.agents/skills/agent-customization/SKILL.md @@ -0,0 +1,39 @@ +--- +name: agent-customization +description: LLM-powered injection of project context into installed agent templates via `aspens customize agents` +--- + +## Activation + +This skill triggers when editing agent-customization files: +- `src/commands/customize.js` +- `src/prompts/customize-agents.md` + +--- + +You are working on **agent customization** — the feature that reads a project's skills and AGENTS.md, then uses Claude CLI to inject project-specific context into generic agent files in `.claude/agents/`. + +## Key Files +- `src/commands/customize.js` — Main command: finds agents, gathers context, calls Claude, writes results +- `src/prompts/customize-agents.md` — System prompt telling Claude how to customize agents +- `src/lib/runner.js` — `runClaude()`, `loadPrompt()`, `parseFileOutput()` shared across commands +- `src/lib/skill-writer.js` — `writeSkillFiles()` writes parsed output to disk +- `src/lib/timeout.js` — `resolveTimeout()` for timeout handling (default 300s) + +## Key Concepts +- **Claude-only feature:** Customize command reads `.aspens.json` and throws `CliError` if repo is configured for Codex-only (`targets: ['codex']`). Codex CLI has no agent concept. +- **Context gathering:** `gatherProjectContext()` reads AGENTS.md (truncated at 3000 chars), all `.claude/skills/**/*.md` in full, and lists `.claude/guidelines/` paths without reading their contents. +- **Agent discovery:** `findAgents()` recursively walks `.claude/agents/`, reads `.md` files, extracts `name:` via regex — falls back to filename if no frontmatter match. +- **Read-only tools:** Claude is invoked with `allowedTools: ['Read', 'Glob', 'Grep']` and no maxTokens cap (unlike doc-init which sets per-call limits). +- **Output parsing:** Claude returns `content` XML tags, parsed by `parseFileOutput()`. Only `.claude/` paths are allowed. + +## Critical Rules +- **Claude-only** — throws `CliError` for Codex-only repos. Checks `readConfig(repoPath)` for target config. +- **Read-only tools only** — Claude agents never get write tools. All output goes through `parseFileOutput()` → `writeSkillFiles()`. +- **Context truncation** — AGENTS.md is capped at 3000 chars to avoid blowing up prompt size. Skills are read in full. +- **Path safety** — `parseFileOutput()` only allows writes to `.claude/` prefixed paths. Customized agents stay in `.claude/agents/`. +- **Dry-run support** — `--dry-run` flag previews output without writing. Confirmation prompt shown before writes. +- **Model override** — `--model` flag passed through to `runClaude()` for model selection. + +--- +**Last Updated:** 2026-04-02 diff --git a/.agents/skills/architecture/SKILL.md b/.agents/skills/architecture/SKILL.md new file mode 100644 index 0000000..ddacf67 --- /dev/null +++ b/.agents/skills/architecture/SKILL.md @@ -0,0 +1,20 @@ +--- +name: architecture +description: > + Use when modifying imports, creating new files, refactoring modules, + or understanding how components relate. Not needed for simple single-file edits. +--- + +# Architecture + +This skill provides codebase structure and import graph data. + +When you need to understand file relationships, hub files, or domain clusters, +check `references/code-map.md` for the full import graph analysis. + +## Key Rules + +- Check hub files (high fan-in) before modifying - changes propagate widely +- Respect domain cluster boundaries - keep related files together +- Check cross-domain dependencies before creating new imports + diff --git a/.agents/skills/architecture/references/code-map.md b/.agents/skills/architecture/references/code-map.md new file mode 100644 index 0000000..91cf850 --- /dev/null +++ b/.agents/skills/architecture/references/code-map.md @@ -0,0 +1,22 @@ +# Code Map + +## Key Files + +**Hub files (most depended-on):** +- `src/lib/runner.js` - 8 dependents +- `src/lib/errors.js` - 7 dependents +- `src/lib/scanner.js` - 7 dependents +- `src/lib/target.js` - 7 dependents +- `src/lib/skill-writer.js` - 6 dependents + +**Domain clusters:** + +| Domain | Files | Top entries | +|--------|-------|-------------| +| src | 37 | `src/lib/runner.js`, `src/commands/doc-init.js`, `src/commands/doc-sync.js` | + +**High-churn hotspots:** +- `src/commands/doc-init.js` - 27 changes +- `src/commands/doc-sync.js` - 19 changes +- `src/lib/runner.js` - 16 changes + diff --git a/.agents/skills/base/SKILL.md b/.agents/skills/base/SKILL.md new file mode 100644 index 0000000..04ef8c3 --- /dev/null +++ b/.agents/skills/base/SKILL.md @@ -0,0 +1,68 @@ +--- +name: base +description: Core conventions, tech stack, and project structure for aspens +--- + +## Activation + +This is a **base skill** that always loads when working in this repository. + +--- + +You are working in **aspens** — a CLI tool that generates and maintains AI-ready documentation (skill files + AGENTS.md) for any codebase. Supports multiple output targets (Claude Code, Codex CLI). + +## Tech Stack +Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors + +## Commands +- `npm test` — Run vitest suite +- `npm start` / `node bin/cli.js` — Run CLI +- `aspens scan [path]` — Deterministic repo analysis (no LLM) +- `aspens doc init [path]` — Generate skills + hooks + AGENTS.md (supports `--target claude|codex|all`, `--backend claude|codex`) +- `aspens doc sync [path]` — Incremental skill updates from git diffs +- `aspens doc graph [path]` — Rebuild import graph cache (`.claude/graph.json`) +- `aspens add [name]` — Install templates (agents, commands, hooks) +- `aspens customize agents` — Inject project context into installed agents + +## Architecture +CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules (`src/lib/`) + +- `src/lib/scanner.js` — Deterministic repo scanner (languages, frameworks, domains, structure) +- `src/lib/graph-builder.js` — Static import analysis via es-module-lexer (hub files, clusters, priority) +- `src/lib/graph-persistence.js` — Graph serialization, subgraph extraction, code-map + index generation +- `src/lib/runner.js` — Claude/Codex CLI wrapper (`runClaude` for stream-json, `runCodex` for Codex JSONL) +- `src/lib/context-builder.js` — Assembles repo files into prompt-friendly context +- `src/lib/skill-writer.js` — Writes skill files and directory-scoped files, generates skill-rules.json, merges settings +- `src/lib/skill-reader.js` — Parses skill files, frontmatter, activation patterns, keywords +- `src/lib/diff-helpers.js` — Targeted file diffs and prioritized diff truncation for doc-sync +- `src/lib/git-helpers.js` — Git repo detection, diff retrieval, log formatting +- `src/lib/git-hook.js` — Post-commit git hook installation/removal for auto doc-sync +- `src/lib/timeout.js` — Timeout resolution (`--timeout` flag > `ASPENS_TIMEOUT` env > default) +- `src/lib/errors.js` — `CliError` class (structured errors caught by CLI top-level handler) +- `src/lib/target.js` — Target definitions (claude/codex), config persistence (`.aspens.json`) +- `src/lib/target-transform.js` — Transforms Claude-format output to other target formats +- `src/lib/backend.js` — Backend detection and resolution (which CLI generates content) +- `src/prompts/` — Prompt templates with `{{partial}}` and `{{variable}}` substitution +- `src/templates/` — Bundled agents, commands, hooks, and settings for `aspens add` / `doc init` + +## Critical Conventions +- **Pure ESM** — `"type": "module"` throughout; use `import`/`export`, never `require()` +- **es-module-lexer WASM** — must `await init` before calling `parse()` in graph-builder +- **Claude CLI execution** — `runClaude()` spawns `claude -p` with stream-json; always use `--verbose` flag with stream-json +- **Codex CLI execution** — `runCodex()` spawns `codex exec --json --sandbox read-only --ask-for-approval never --ephemeral`; returns `{ text, usage }` matching `runClaude` interface +- **Path sanitization** — `parseFileOutput()` restricts writes to `.claude/` and `AGENTS.md` by default; accepts `allowedPaths` override for multi-target +- **Prompt partials** — `{{name}}` in prompt files resolves to `src/prompts/partials/name.md` first, then falls back to template variables +- **Target/Backend distinction** — Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json` +- **Scanner is deterministic** — no LLM calls; pure filesystem analysis +- **CliError pattern** — command handlers throw `CliError` instead of calling `process.exit()`; caught at top level in `bin/cli.js` + +## Structure +- `bin/` — CLI entry point (commander setup, CliError handler) +- `src/commands/` — Command handlers (scan, doc-init, doc-sync, doc-graph, add, customize) +- `src/lib/` — Core library modules +- `src/prompts/` — Prompt templates + partials +- `src/templates/` — Installable agents, commands, hooks, settings +- `tests/` — Vitest test files + +--- +**Last Updated:** 2026-04-02 diff --git a/.agents/skills/claude-runner/SKILL.md b/.agents/skills/claude-runner/SKILL.md new file mode 100644 index 0000000..65234b6 --- /dev/null +++ b/.agents/skills/claude-runner/SKILL.md @@ -0,0 +1,53 @@ +--- +name: claude-runner +description: Claude/Codex CLI execution layer — prompt loading, stream-json parsing, file output extraction, path sanitization, skill file writing, and skill rule generation +--- + +## Activation + +This skill triggers when editing claude-runner files: +- `src/lib/runner.js` +- `src/lib/skill-writer.js` +- `src/lib/skill-reader.js` +- `src/lib/timeout.js` +- `src/prompts/**/*.md` +- `tests/*extract*`, `tests/*parse*`, `tests/*prompt*`, `tests/*skill-writer*`, `tests/*skill-mapper*`, `tests/*timeout*` + +--- + +You are working on the **CLI execution layer** — the bridge between assembled prompts and the `claude -p` / `codex exec` CLIs, plus skill file I/O. + +## Key Files +- `src/lib/runner.js` — `runClaude()`, `runCodex()`, `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()`, `extractResultFromStream()` (exported); `extractResultFromCodexStream()`, `normalizeCodexItemType()`, `collectCodexText()`, `handleStreamEvent()`, `sanitizePath()` (internal) +- `src/lib/skill-writer.js` — `writeSkillFiles()`, `writeTransformedFiles()`, `extractRulesFromSkills()`, `generateDomainPatterns()`, `mergeSettings()` +- `src/lib/skill-reader.js` — `findSkillFiles()`, `parseFrontmatter()`, `parseActivationPatterns()`, `parseKeywords()`, `fileMatchesActivation()`, `getActivationBlock()`, `GENERIC_PATH_SEGMENTS` +- `src/lib/timeout.js` — `resolveTimeout()` — priority: `--timeout` flag > `ASPENS_TIMEOUT` env var > caller fallback +- `src/prompts/` — Markdown prompt templates; `partials/` subdir holds `skill-format.md`, `guideline-format.md`, `examples.md` + +## Key Concepts +- **Stream-JSON protocol (Claude):** `runClaude()` always passes `--verbose --output-format stream-json`. Output is NDJSON: `type: 'result'` has final text + usage; `type: 'assistant'` has text/tool_use blocks; `type: 'user'` has tool_result blocks. +- **JSONL protocol (Codex):** `runCodex()` spawns `codex exec --json --sandbox read-only --ask-for-approval never --ephemeral`. Prompt is passed via **stdin** (`'-'` placeholder arg) to avoid shell arg length limits. Stdin write happens **after** event handlers are attached so fast failures are captured. Events: `item.completed`/`item.updated` with normalized types. +- **Unified routing:** `runLLM(prompt, options, backendId)` is the shared entry point — dispatches to `runClaude()` or `runCodex()` based on `backendId`. Exported from `runner.js` so command handlers no longer need local routing helpers. +- **Codex internals (private):** `normalizeCodexItemType()` converts PascalCase/kebab-case to snake_case. `collectCodexText()` recursively extracts text from nested event content. Both are internal to runner.js. +- **Prompt templating:** `loadPrompt(name, vars)` resolves `{{partial-name}}` from `src/prompts/partials/` first, then substitutes `{{varName}}` from `vars`. Target-specific vars (`skillsDir`, `skillFilename`, `instructionsFile`, `configDir`) are passed by command handlers. +- **File output parsing:** Primary: `content` XML tags. Fallback: `` comment markers. `parseFileOutput(output, allowedPaths)` accepts optional `{ dirPrefixes, exactFiles }` to override default allowed paths. +- **Path sanitization:** `sanitizePath(rawPath, allowedPaths)` (internal) blocks `..` traversal, absolute paths. Defaults: `.claude/` prefix + `AGENTS.md` exact. Multi-target callers pass expanded allowed paths via `getAllowedPaths()` from `target.js`. +- **Validation:** `validateSkillFiles()` checks for truncation (XML tag collisions), missing frontmatter, missing sections, bad file path references. +- **Skill rules generation:** `extractRulesFromSkills()` reads all skills via `skill-reader.js`, produces `skill-rules.json` (v2.0) with file patterns, keywords, and intent patterns. +- **Domain patterns:** `generateDomainPatterns()` converts file patterns to bash `detect_skill_domain()` function using `BEGIN/END` markers. +- **Settings merge:** `mergeSettings()` merges aspens hook config into existing `settings.json`, detecting aspens-managed hooks by `ASPENS_HOOK_MARKERS` (`skill-activation-prompt`, `post-tool-use-tracker`). +- **Directory-scoped writes:** `writeTransformedFiles()` handles files outside `.claude/` (e.g., `src/billing/AGENTS.md`) with explicit path allowlist — only `AGENTS.md`, `AGENTS.md` exact files and `.claude/`, `.agents/`, `.codex/` prefixes are permitted. +- **`findSkillFiles` matching:** Only matches the exact `skillFilename` (e.g., `skill.md` or `SKILL.md`), not arbitrary `.md` files in the skills directory. + +## Critical Rules +- **Both `--verbose` and `--output-format stream-json` are required for Claude** — omitting either breaks stream parsing. +- **Codex uses `--json --sandbox read-only --ask-for-approval never --ephemeral`** — `--sandbox read-only` restricts filesystem access, `--ask-for-approval never` skips prompts, `--ephemeral` avoids persisting conversation. Prompt goes via stdin, not as a CLI arg. +- **Codex stdin write order matters** — event handlers (`stdout`, `stderr`, `close`, `error`) must be attached before writing to stdin, so fast failures are captured. +- **Path sanitization is non-negotiable** — `sanitizePath()` blocks `..` traversal, absolute paths, and any path not in the allowed set. +- **Prompt partials resolve before variables** — `{{skill-format}}` resolves to `partials/skill-format.md` first. If no file, falls through to variable substitution. +- **Timeout resolution:** `resolveTimeout(flagValue, fallbackSeconds)` — `--timeout` flag wins, then `ASPENS_TIMEOUT` env, then caller-provided fallback. Size-based defaults (small: 120s, medium: 300s, large: 600s, very-large: 900s) are set by command handlers, not runner. +- **`mergeSettings` preserves non-aspens hooks** — identifies aspens hooks by `ASPENS_HOOK_MARKERS`, replaces matching entries, preserves everything else. +- **Debug mode:** Set `ASPENS_DEBUG=1` to dump raw stream-json to `$TMPDIR/aspens-debug-stream.json` (Claude) or `$TMPDIR/aspens-debug-codex-stream.json` (Codex). Codex also logs exit code and output length to stderr. + +--- +**Last Updated:** 2026-04-07 diff --git a/.agents/skills/codex-support/SKILL.md b/.agents/skills/codex-support/SKILL.md new file mode 100644 index 0000000..8ea6c23 --- /dev/null +++ b/.agents/skills/codex-support/SKILL.md @@ -0,0 +1,52 @@ +--- +name: codex-support +description: Multi-target output system — target abstraction, backend routing, content transforms for Codex CLI and future targets +--- + +## Activation + +This skill triggers when editing codex-support files: +- `src/lib/target.js` +- `src/lib/target-transform.js` +- `src/lib/backend.js` +- `tests/target.test.js` +- `tests/target-transform.test.js` +- `tests/backend.test.js` + +Keywords: codex, target, backend, AGENTS.md, directory-scoped, transform, multi-target + +--- + +You are working on **multi-target output support** — the system that lets aspens generate documentation for Claude Code, Codex CLI, or both simultaneously. + +## Key Files +- `src/lib/target.js` — Target definitions (`TARGETS`), `getAllowedPaths()`, path helpers, config persistence (`.aspens.json`) +- `src/lib/target-transform.js` — Transforms Claude-format output to other target formats; `projectCodexDomainDocs()`, `validateTransformedFiles()`, content sanitization +- `src/lib/backend.js` — Backend detection (`detectAvailableBackends`) and resolution (`resolveBackend`) with fallback logic + +## Key Concepts +- **Target vs Backend:** Target = where output goes (claude → `.claude/skills/`, codex → `.agents/skills/` + directory-scoped `AGENTS.md`). Backend = which LLM CLI generates the content (`claude -p` or `codex exec`). +- **Target definitions:** `TARGETS.claude` (centralized) and `TARGETS.codex` (directory-scoped). Each defines paths and capability flags: `supportsHooks`, `supportsSettings`, `supportsGraph`, `supportsSkills`, `needsActivationSection`, `needsCodeMapEmbed`, `supportsMCP`. Codex also has `maxInstructionsBytes` (32 KiB) and `userSkillsDir`. +- **Canonical generation:** Generation always produces Claude-canonical format first. Prompts always receive `CANONICAL_VARS` (hardcoded Claude paths from `doc-init.js`). Transforms run **after** generation to produce other target formats. +- **Content transform:** `transformForTarget()` remaps paths and content. For Codex: base skill → root `AGENTS.md`, domain skills → both `.agents/skills/{domain}/SKILL.md` and source directory `AGENTS.md`. `generateCodexSkillReferences()` creates `.agents/skills/architecture/` with code-map data. +- **Content sanitization:** `sanitizeCodexInstructions()` and `sanitizeCodexSkill()` strip Claude-specific references (hooks, skill-rules.json, Claude Code mentions) from Codex output. +- **`getAllowedPaths(targets)`** — Returns `{ dirPrefixes, exactFiles }` union across all active targets. Dir prefixes use **full** target paths (e.g., `.agents/skills/`, not `.agents/`), providing tighter path validation. +- **Backend detection:** `detectAvailableBackends()` checks if `claude` and `codex` CLIs are installed. `resolveBackend()` picks best match: explicit flag > target match > fallback. +- **Config persistence:** `.aspens.json` at repo root stores `{ targets, backend, version }`. `readConfig()` returns `null` if missing **or if the config is structurally invalid** — callers default to `'claude'` target. Validation via internal `isValidConfig()` ensures `targets` is a non-empty array of known target keys, `backend` (if present) is a known target key, and `version` (if present) is a string. +- **Multi-target publish:** `doc-sync` uses `publishFilesForTargets()` to generate output for all configured targets from a single LLM run — source target files kept as-is, other targets get transforms applied. +- **Codex inference tightened:** `inferConfig()` only adds `'codex'` to inferred targets when `.codex/` config dir or `.agents/skills/` dir exists — a standalone `AGENTS.md` without either is not sufficient. +- **Conditional architecture ref:** Codex `buildCodexSkillRefs()` only includes the architecture skill reference when a graph was actually serialized (`hasGraph` parameter). + +## Critical Rules +- **Generation always targets Claude canonical format first** — transforms run after, never during. Prompts always receive `CANONICAL_VARS`. +- **Split write logic:** `writeSkillFiles()` handles direct-write files (`.claude/`, `.agents/`, `AGENTS.md`, root `AGENTS.md`). `writeTransformedFiles()` handles directory-scoped `AGENTS.md` (e.g., `src/billing/AGENTS.md`) with an explicit path allowlist and warn-and-skip policy. +- **Path safety:** `validateTransformedFiles()` in `target-transform.js` rejects absolute paths, traversal, and unexpected filenames. `writeTransformedFiles()` enforces the same checks plus an allowlist (`AGENTS.md`/`AGENTS.md` exact, `.claude/`/`.agents/`/`.codex/` prefixes). +- **Codex-only restrictions:** `add agent/command/hook` and `customize agents` throw `CliError` for Codex-only repos. `add skill` works for both targets. +- **Graph/hooks are Claude-only** — `persistGraphArtifacts()` returns data without writing files when `target.supportsGraph === false`. Hook installation skipped when `supportsHooks === false`. +- **Config validation is defensive** — `readConfig()` treats malformed but parseable JSON (e.g., wrong types for `targets`/`backend`/`version`) as invalid and returns `null`, same as missing config. + +## References +- **Patterns:** See `src/lib/target.js` for all target property definitions + +--- +**Last Updated:** 2026-04-07 diff --git a/.agents/skills/doc-sync/SKILL.md b/.agents/skills/doc-sync/SKILL.md new file mode 100644 index 0000000..a7f2f75 --- /dev/null +++ b/.agents/skills/doc-sync/SKILL.md @@ -0,0 +1,65 @@ +--- +name: doc-sync +description: Incremental skill updater that maps git diffs to affected skills and optionally auto-syncs via a post-commit hook +--- + +## Activation + +This skill triggers when editing doc-sync-related files: +- `src/commands/doc-sync.js` +- `src/prompts/doc-sync.md` +- `src/prompts/doc-sync-refresh.md` +- `src/lib/git-helpers.js` +- `src/lib/diff-helpers.js` +- `src/lib/git-hook.js` + +Keywords: doc-sync, refresh, sync, git-hook + +--- + +You are working on **doc-sync**, the incremental skill update command (`aspens doc sync`). + +## Key Files +- `src/commands/doc-sync.js` — Main command: git diff → graph rebuild → skill mapping → LLM update → publish for targets → write. Also contains refresh mode and `skillToDomain()` export. +- `src/prompts/doc-sync.md` — System prompt for diff-based sync (uses `{{skill-format}}` partial, target-specific path variables) +- `src/prompts/doc-sync-refresh.md` — System prompt for `--refresh` mode (full skill review) +- `src/lib/git-helpers.js` — `isGitRepo()`, `getGitDiff()`, `getGitLog()`, `getChangedFiles()` — git primitives +- `src/lib/diff-helpers.js` — `getSelectedFilesDiff()`, `buildPrioritizedDiff()`, `truncateDiff()`, `truncate()` — diff budgeting +- `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync +- `src/lib/context-builder.js` — `buildDomainContext()`, `buildBaseContext()` used by refresh mode +- `src/lib/runner.js` — `runLLM()`, `loadPrompt()`, `parseFileOutput()` shared across commands +- `src/lib/skill-writer.js` — `writeSkillFiles()`, `writeTransformedFiles()`, `extractRulesFromSkills()` for output +- `src/lib/target-transform.js` — `projectCodexDomainDocs()`, `transformForTarget()` for multi-target publish + +## Key Concepts +- **Multi-target publish:** `configuredTargets()` reads `.aspens.json` for all configured targets. `chooseSyncSourceTarget()` picks the best source (prefers Claude if both exist). LLM generates for the source target; `publishFilesForTargets()` transforms output for all other configured targets. `graphSerialized` is passed through to control conditional architecture references. +- **Backend routing:** `runLLM()` from `runner.js` dispatches to `runClaude()` or `runCodex()` based on `config.backend` (defaults to source target's id). +- **Diff-based flow:** Gets `git diff HEAD~N..HEAD` and `git log`, feeds them plus existing skill contents and graph context to the selected backend. +- **Prompt path variables:** Passes `{ skillsDir, skillFilename, instructionsFile, configDir }` from source target to `loadPrompt()` for path substitution in prompts. +- **Refresh mode (`--refresh`):** Skips diff entirely. Reviews every skill against the current codebase. Base skill refreshed first, then domain skills in parallel batches of `PARALLEL_LIMIT` (3). Also refreshes instructions file and reports uncovered domains. +- **Graph rebuild on every sync:** Calls `buildRepoGraph` + `persistGraphArtifacts` (with source target) to keep graph fresh. `graphSerialized` return value is captured and forwarded to `publishFilesForTargets` for conditional Codex architecture refs. Graph failure is non-fatal. +- **Unparseable response detection:** After LLM returns, if output has content but no `` tags at all, throws `CliError` instead of silently treating it as "no updates needed". +- **Graph-aware skill mapping:** `mapChangesToSkills()` checks direct file matches via `fileMatchesActivation()` (from `skill-reader.js`) and also whether changed files are imported by files matching a skill's activation block. +- **Interactive file picker:** When diff exceeds 80k chars and TTY is available, offers multiselect with skill-relevant files pre-selected. +- **Prioritized diff:** `buildPrioritizedDiff()` gives skill-relevant files 60k char budget, everything else 20k (80k total). Cuts at `diff --git` boundaries. +- **Token optimization:** Affected skills sent in full; non-affected skills send only path + description line. +- **Split writes:** Direct-write files (`.claude/`, `AGENTS.md`, root `AGENTS.md`) use `writeSkillFiles()`. Directory-scoped `AGENTS.md` files (e.g. `src/AGENTS.md`) use `writeTransformedFiles()`. +- **Skill-rules regeneration:** After writing, regenerates `skill-rules.json` via `extractRulesFromSkills()` — only for targets with `supportsHooks: true` (Claude). Uses `hookTarget` from publish targets list. +- **`findExistingSkills` is target-aware:** Uses `target.skillsDir` and `target.skillFilename` to locate skills for any target. +- **Git hook:** `installGitHook()` creates a `post-commit` hook with 5-minute cooldown lock file. Hook skips aspens-only commits (filters `.claude/`, `.codex/`, `.agents/`, `AGENTS.md`, `AGENTS.md`, `.aspens.json`). Works for all configured targets. +- **Force writes:** doc-sync always calls `writeSkillFiles` with `force: true`. + +## Critical Rules +- `runLLM` is called with `allowedTools: ['Read', 'Glob', 'Grep']` — doc-sync must never grant write tools. +- `parseOutput` restricts paths based on `getAllowedPaths([sourceTarget])` — paths outside the allowed set are silently dropped. +- **Unparseable output is an error** — if LLM returns text without any `` tags, doc-sync throws `CliError` rather than silently proceeding with zero files. +- `getGitDiff` gracefully falls back from N commits to 1 if fewer available. `actualCommits` tracks what was used. +- The command exits early with `CliError` if the source target's skills directory doesn't exist. +- `checkMissingHooks()` in `bin/cli.js` only checks for Claude skills (not Codex — Codex doesn't use hooks). +- `dedupeFiles()` ensures no duplicate paths when publishing across multiple targets. + +## References +- **Patterns:** `src/lib/skill-reader.js` — `GENERIC_PATH_SEGMENTS`, `fileMatchesActivation()`, `getActivationBlock()` + +--- +**Last Updated:** 2026-04-07 diff --git a/.agents/skills/import-graph/SKILL.md b/.agents/skills/import-graph/SKILL.md new file mode 100644 index 0000000..ecc7a8f --- /dev/null +++ b/.agents/skills/import-graph/SKILL.md @@ -0,0 +1,61 @@ +--- +name: import-graph +description: Static import analysis that builds dependency graphs, domain clusters, hub files, git churn hotspots, and file priority rankings +--- + +## Activation + +This skill triggers when editing import-graph-related files: +- `src/lib/graph-builder.js` +- `src/lib/graph-persistence.js` +- `src/commands/doc-graph.js` +- `src/templates/hooks/graph-context-prompt.mjs` +- `src/templates/hooks/graph-context-prompt.sh` +- `tests/graph-builder.test.js` +- `tests/graph-persistence.test.js` + +Keywords: graph, import graph, dependency, hub files, clustering, code-map, graph-index, subgraph + +--- + +You are working on the **import graph system** — static analysis that parses JS/TS and Python source files to produce dependency graphs, plus persistence/query layers for runtime use. + +## Key Files +- `src/lib/graph-builder.js` — Core graph logic: walk, parse, metrics, ranking, clustering (690 lines) +- `src/lib/graph-persistence.js` — Serialize, persist, load, subgraph extraction, code-map, graph-index +- `src/commands/doc-graph.js` — Standalone `aspens doc graph` command +- `src/lib/scanner.js` — Provides `detectEntryPoints()`, only internal dependency of graph-builder +- `src/templates/hooks/graph-context-prompt.mjs` — Standalone hook mirroring `extractSubgraph` logic +- `tests/graph-builder.test.js` — Graph builder tests using temp fixture directories +- `tests/graph-persistence.test.js` — Persistence layer tests + +## Key Concepts +**graph-builder.js** — `buildRepoGraph(repoPath, languages?)` runs a 9-step pipeline: +1. Walk source files → 2. Parse imports → 3. Reverse edges → 4. Git churn → 5. Per-file metrics → 6. Priority ranking → 7. Hub detection → 8. Domain clustering → 9. Hotspots + +**graph-persistence.js** — Persistence and query layer: +- `serializeGraph()` converts raw graph to indexed format (O(1) lookups, file→cluster mapping) +- `persistGraphArtifacts(repoPath, rawGraph, options?)` writes `.claude/graph.json` + `.claude/code-map.md` + `.claude/graph-index.json` + auto-gitignores them. **Target-aware:** if `options.target.supportsGraph === false`, returns serialized data without writing files. +- `extractSubgraph(graph, filePaths)` returns 1-hop neighborhood of mentioned files + relevant hubs/hotspots/clusters +- `formatNavigationContext(subgraph)` renders compact markdown (~50 line budget) for prompt injection +- `extractFileReferences(prompt, graph)` tiered extraction: explicit paths → bare filenames → cluster keywords +- `generateCodeMap()` / `writeCodeMap()` standalone overview for graph hook consumption +- `generateGraphIndex()` / `saveGraphIndex()` tiny inverted index (export names → files, hub basenames, cluster labels) + +**doc-graph.js** — Target-aware: reads `.aspens.json` config, passes target to `persistGraphArtifacts()`. Shows different completion message for Codex target (artifacts not written). + +## Critical Rules +- **`await init` before any `parseJsImports` call.** es-module-lexer requires WASM initialization. +- **Priority formula is load-bearing:** `fanIn * 3.0 + exportCount * 1.5 + (isEntry ? 10.0 : 0) + churn * 2.0 + (1/(depth+1)) * 1.0`. Downstream consumers depend on this ranking. +- **All paths are repo-relative strings**, never absolute. Resolution functions convert abs→relative. +- **Graph artifacts are gitignored** — `ensureGraphGitignore()` (internal to persistence) adds `.claude/graph.json`, `.claude/graph-index.json`, `.claude/code-map.md` to prevent commit loops. +- **Graph artifacts are Claude-only** — when target has `supportsGraph: false`, `persistGraphArtifacts` returns serialized data for embedding (e.g., condensed code-map in root AGENTS.md) but writes no files. +- **Errors are swallowed, not thrown** in graph-builder — parse failures return empty/null. The graph must always complete. +- **`extractSubgraph` logic is mirrored** in `graph-context-prompt.mjs` (`buildNeighborhood()`). Keep both in sync. +- **doc-sync rebuilds graph on every sync** — calls `buildRepoGraph` + `persistGraphArtifacts` (with target) to keep it fresh. + +## References +- **Hook mirror:** `src/templates/hooks/graph-context-prompt.mjs` + +--- +**Last Updated:** 2026-04-02 diff --git a/.agents/skills/repo-scanning/SKILL.md b/.agents/skills/repo-scanning/SKILL.md new file mode 100644 index 0000000..3531f57 --- /dev/null +++ b/.agents/skills/repo-scanning/SKILL.md @@ -0,0 +1,49 @@ +--- +name: repo-scanning +description: Deterministic repo analysis — language/framework detection, structure mapping, domain discovery, health checks, and import graph integration +--- + +## Activation + +This skill triggers when editing repo-scanning files: +- `src/lib/scanner.js` +- `src/commands/scan.js` +- `tests/scanner.test.js` + +Keywords: scanRepo, detectLanguages, detectFrameworks, detectDomains, detectEntryPoints, health check + +--- + +You are working on **aspens' repo scanning system** — a fully deterministic analyzer (no LLM calls) that detects languages, frameworks, structure, domains, entry points, size, and health issues for any repository. + +## Key Files +- `src/lib/scanner.js` — Core `scanRepo()` function and all detection logic (languages, frameworks, structure, domains, entry points, size, health) +- `src/commands/scan.js` — CLI command that calls `scanRepo()`, optionally builds import graph via `graph-builder.js`, and renders pretty or JSON output. Contains `formatGraphForDisplay()` which transforms raw graph data into display-ready shape +- `src/lib/graph-builder.js` — Builds import graph; imports `detectEntryPoints` from scanner. Called by `scanCommand` but graph failure is non-fatal +- `tests/scanner.test.js` — Uses temporary fixture directories created in `tests/fixtures/scanner/`, cleaned up in `afterAll` + +## Key Concepts +- **scanRepo() return shape:** `{ path, name, languages[], frameworks[], structure, domains[], entryPoints[], hasClaudeConfig, hasClaudeMd, hasCodexConfig, hasAgentsMd, repoType, size, health }` — order matters: `repoType` and `health` depend on prior fields +- **Multi-target detection:** Scanner checks for both `.claude` dir + `AGENTS.md` (Claude Code) and `.codex` dir + `AGENTS.md` (Codex CLI) to inform target selection during `doc init` +- **Detection via marker files:** Languages detected by presence of files like `package.json`, `go.mod`, `Cargo.toml` — not by scanning source extensions +- **Framework detection:** JS/TS from `package.json` deps, Python from `requirements.txt`/`pyproject.toml`/`Pipfile`, Go from `go.mod` contents, Ruby from `Gemfile` +- **Domain detection:** Scans dirs under source root + repo root, skips `SKIP_DIR_NAMES` set (structural/build/IDE dirs), requires at least one source file via `collectModules()` +- **extraDomains:** User-specified domains merged via `mergeExtraDomains()` — marked with `userSpecified: true`, resolved against source root then repo root +- **Source root:** First match of `src`, `app`, `lib`, `server`, `pages` via `findSourceRoot()` +- **Size estimation:** Lines estimated at ~40 bytes/line from `stat.size`, walk capped at depth 5 +- **Graph is opt-out:** `scanCommand` builds graph by default (`options.graph !== false`); errors are caught and only logged with `--verbose` + +## Critical Rules +- **`SOURCE_EXTS`**: Only `.py`, `.ts`, `.js`, `.tsx`, `.jsx`, `.rb`, `.go`, `.rs` — adding a language requires updating this set AND the `detectLanguages` indicators +- **`SKIP_DIR_NAMES`**: Directories like `src`, `app`, `dist`, `node_modules` are skipped in domain detection — adding a skip dir here affects all repos +- **`BOILERPLATE_STEMS`**: `__init__`, `index`, `mod` are excluded from module collection — don't add real module names here +- **TypeScript implies JavaScript**: TS detection in `detectLanguages()` automatically adds JS to the languages array +- **Graph failure is non-fatal**: `buildRepoGraph` errors in `scanCommand()` are caught and silently ignored unless `--verbose` +- **Tests use real filesystem fixtures**, not mocks — create fixtures with `createFixture(name, files)` pattern, always clean up +- **`detectEntryPoints` is exported** and reused by `graph-builder.js` — changing its signature breaks the graph builder + +## References +- **No guidelines directory** — `.claude/guidelines/` does not exist yet for this domain + +--- +**Last Updated:** 2026-04-02 diff --git a/.agents/skills/skill-generation/SKILL.md b/.agents/skills/skill-generation/SKILL.md new file mode 100644 index 0000000..6db7f3d --- /dev/null +++ b/.agents/skills/skill-generation/SKILL.md @@ -0,0 +1,65 @@ +--- +name: skill-generation +description: LLM-powered generation pipeline for Claude Code skills and AGENTS.md — doc-init command, prompt system, context building, and output parsing +--- + +## Activation + +This skill triggers when editing skill-generation files: +- `src/commands/doc-init.js` +- `src/lib/runner.js` +- `src/lib/skill-writer.js` +- `src/lib/skill-reader.js` +- `src/lib/git-hook.js` +- `src/lib/timeout.js` +- `src/prompts/**/*` + +Keywords: doc-init, generate skills, discovery agents, chunked generation + +--- + +You are working on **aspens' skill generation pipeline** — the system that scans repos and uses Claude/Codex CLI to generate skills, hooks, and instructions files. + +## Key Files +- `src/commands/doc-init.js` — Main pipeline: backend selection → target selection → scan → graph → discovery → strategy → mode → generate → validate → transform → write → hooks → config +- `src/lib/runner.js` — `runClaude()`, `runCodex()`, `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()` +- `src/lib/skill-writer.js` — Writes files, generates `skill-rules.json`, domain bash patterns, merges `settings.json` +- `src/lib/skill-reader.js` — Parses skill frontmatter, activation patterns, keywords (used by skill-writer) +- `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync +- `src/lib/timeout.js` — `resolveTimeout()` for auto-scaled + user-override timeouts +- `src/lib/target.js` — Target definitions, `resolveTarget()`, `getAllowedPaths()`, `writeConfig()` +- `src/lib/backend.js` — Backend detection/resolution (`detectAvailableBackends()`, `resolveBackend()`) +- `src/lib/target-transform.js` — `transformForTarget()` converts Claude output to other target formats +- `src/prompts/` — `doc-init.md` (base), `doc-init-domain.md`, `doc-init-claudemd.md`, `discover-domains.md`, `discover-architecture.md` + +## Key Concepts +- **Pipeline steps:** (1) detect backends (2) **backend selection** (3) **target selection** (4) scan + graph (5) existing docs discovery check (6) parallel discovery agents (7) strategy (8) mode (9) generate (10) validate (11) transform for non-Claude targets (12) show files + dry-run (13) write (14) install hooks (Claude-only) (15) persist config to `.aspens.json` +- **Backend before target:** Backend selection (step 2) happens before target selection (step 3). If both CLIs available, user picks backend first, then targets. Pre-selects matching target in the multiselect. +- **Canonical generation:** All prompts receive `CANONICAL_VARS` (hardcoded Claude paths: `.claude/skills/`, `skill.md`, `AGENTS.md`). Generation always produces Claude-canonical format regardless of target. Non-Claude targets are produced by post-generation transform. +- **`parseLLMOutput` with strict single-file fallback:** Codex often returns plain markdown without `` tags. `parseLLMOutput(text, allowedPaths, expectedPath)` only wraps tagless text as the expected file for **true single-file prompts** (exactly one `exactFile` in allowedPaths, no `dirPrefixes`). Multi-file prompts require proper `` tags. +- **Existing docs reuse:** When existing Claude docs are found and strategy is `improve`, reuse is handled as improvement context without a separate loading spinner. Supports cross-target reuse (e.g., existing Claude docs → generate Codex output). +- **Domain reuse helpers:** `loadReusableDomains()` tries `loadReusableDomainsFromRules()` (reads `skill-rules.json` from source target, falls back to `.claude/skills/` for non-Claude targets) first. Falls back to `findSkillFiles()` with `extractKeyFilePatterns()` to derive file patterns from `## Key Files` sections when activation patterns are missing. +- **Target selection:** `--target claude|codex|all` or interactive multiselect if both CLIs available. Stored in `.aspens.json`. +- **Backend routing:** `runLLM()` imported from `runner.js` dispatches to `runClaude()` or `runCodex()` based on `_backendId`. `--backend` flag overrides auto-detection. +- **Content transform (step 11):** Canonical files preserved as originals. Non-Claude targets get `transformForTarget()` applied. If Claude not in target list, canonical files are filtered out of final output. +- **Split writes:** Direct-write files (`.claude/`, `.agents/`, `AGENTS.md`, root `AGENTS.md`) use `writeSkillFiles()`. Directory-scoped files (e.g., `src/billing/AGENTS.md`) use `writeTransformedFiles()` with warn-and-skip policy. +- **Dynamic labels:** `baseArtifactLabel()` and `instructionsArtifactLabel()` return target-appropriate names ("base skill" vs "root AGENTS.md") for spinner messages. +- **Parallel discovery:** Two agents run via `Promise.all` — domain discovery and architecture analysis — before any user prompt. +- **Generation modes:** `all-at-once` = single call; `chunked` = base + per-domain (up to 3 parallel) + instructions file; `base-only` = just base skill; `pick` = interactive domain picker +- **Retry logic:** Base skill and instructions file retry up to 2 times if `parseLLMOutput` returns empty (format correction prompt asking for `` tags). +- **Hook installation:** Only for targets with `supportsHooks: true` (Claude). Generates `skill-rules.json`, copies hook scripts, merges `settings.json`. + +## Critical Rules +- **Base skill + instructions file are essential** — pipeline retries automatically with format correction. Domain skill failures are acceptable (user retries with `--domains`). +- **`improve` strategy preserves hand-written content** — LLM must read existing skills first and not discard human-authored rules. +- **Discovery runs before user prompt** — domain picker shows discovered domains, not scanner directory names. Discovery can be skipped if existing docs are found and user opts to reuse. +- **PARALLEL_LIMIT = 3** — domain skills generate in batches of 3 concurrent calls. Base skill always sequential first. Instructions file always sequential last. +- **CliError, not process.exit()** — all error exits throw `CliError`; cancellations `return` early. +- **`--hooks-only` is Claude-only** — hardcoded to `TARGETS.claude` regardless of config. + +## References +- **Prompts:** `src/prompts/doc-init*.md`, `src/prompts/discover-*.md` +- **Partials:** `src/prompts/partials/skill-format.md`, `src/prompts/partials/examples.md` + +--- +**Last Updated:** 2026-04-07 diff --git a/.agents/skills/template-library/SKILL.md b/.agents/skills/template-library/SKILL.md new file mode 100644 index 0000000..9dbab7d --- /dev/null +++ b/.agents/skills/template-library/SKILL.md @@ -0,0 +1,50 @@ +--- +name: template-library +description: Bundled agents, commands, hooks, and settings that users install via `aspens add` and `aspens doc init` into their .claude/ directories +--- + +## Activation + +This skill triggers when editing template-library files: +- `src/commands/add.js` +- `src/templates/**/*` + +Keywords: template, add agent, add command, add hook, add skill + +--- + +You are working on the **template library** — bundled agents, slash commands, hooks, and settings that users browse and install into their repos. + +## Key Files +- `src/commands/add.js` — Core `aspens add [name]` command; copies templates to `.claude/` dirs, scaffolds/generates custom skills +- `src/templates/agents/*.md` — Agent persona templates (11 bundled) +- `src/templates/commands/*.md` — Slash command templates (2 bundled) +- `src/templates/hooks/` — Hook scripts (5 bundled): `skill-activation-prompt.sh/mjs`, `graph-context-prompt.sh/mjs`, `post-tool-use-tracker.sh` +- `src/templates/settings/settings.json` — Default settings with hook configuration +- `src/prompts/add-skill.md` — System prompt for LLM-powered skill generation from reference docs + +## Key Concepts +- **Four resource types for `add`:** `agent` → `.claude/agents`, `command` → `.claude/commands`, `hook` → `.claude/hooks`. A fourth type `skill` is handled separately (not template-based). +- **Codex-only restriction:** `add agent`, `add command`, and `add hook` throw `CliError` for Codex-only repos (checked via `readConfig()`). Skills work with both targets — `add skill` is always available. +- **Target-aware skill commands:** `addSkillCommand` and `generateSkillFromDoc` resolve the active target via `resolveSkillTarget(config)`. Skill paths use `target.skillsDir` and `target.skillFilename` (not hardcoded `.claude/.agents/skills/skills/SKILL.md`). +- **Backend-aware generation:** `generateSkillFromDoc` uses `runLLM()` imported from `runner.js` to dispatch to Claude or Codex based on config. `getAllowedPaths([target])` provides path safety for `parseFileOutput`. +- **Skill subcommand:** `aspens add skill ` scaffolds a blank skill template. `--from ` generates a skill from a reference doc using the configured backend. `--list` shows installed skills. +- **Hook templates:** `skill-activation-prompt` reads `skill-rules.json` and injects relevant skills into prompts. `graph-context-prompt` loads graph data for code navigation. `post-tool-use-tracker` detects skill domains from file access patterns. +- **`doc init` hook installation (step 13):** Generates `skill-rules.json` from skills, copies hook files, generates `post-tool-use-tracker.sh` with domain patterns (via `BEGIN/END` markers), merges `settings.json` with backup. +- **Template discovery:** `listAvailable()` reads template dir, filters `.md`/`.sh` files, regex-parses `name:` and `description:`. +- **No-overwrite policy:** `addResource()` skips files that already exist via `existsSync` check. Same for `addSkillCommand`. +- **Plan/execute gitignore:** Adding `plan` or `execute` agents auto-adds `dev/` to `.gitignore` for plan storage. +- **Conditional post-add tips:** Skill rules update and `--hooks-only` tip only shown for Claude target. Codex target gets no hook-related messaging. + +## Critical Rules +- Template files **must** contain `name: ` and `description: ` lines parseable by regex. +- Only `.md` and `.sh` extensions are discovered by `listAvailable()`. `.mjs` files are copied by `doc init` directly, not by `add`. +- The templates dir resolves from `src/commands/` via `join(__dirname, '..', 'templates')` — moving `add.js` breaks template resolution. +- Skill names are sanitized to lowercase alphanumeric + hyphens. Invalid names throw `CliError`. +- Commands throw `CliError` for expected failures instead of calling `process.exit()`. + +## References +- **Customize flow:** `.agents/skills/agent-customization/SKILL.md` + +--- +**Last Updated:** 2026-04-07 diff --git a/.aspens.json b/.aspens.json new file mode 100644 index 0000000..31a9638 --- /dev/null +++ b/.aspens.json @@ -0,0 +1,8 @@ +{ + "targets": [ + "claude", + "codex" + ], + "backend": "claude", + "version": "1.0" +} diff --git a/.claude/hooks/graph-context-prompt.sh b/.claude/hooks/graph-context-prompt.sh new file mode 100644 index 0000000..b66cd33 --- /dev/null +++ b/.claude/hooks/graph-context-prompt.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Graph Context Prompt Hook — Shell Wrapper +# Called by Claude Code on every UserPromptSubmit. +# Loads the persisted import graph, extracts a relevant subgraph based on +# file references in the prompt, and injects navigation context into Claude. +# Always exits 0 — NEVER blocks the user's prompt. +# +# Note: No set -e — hook failures must not block prompts. + +# --------------------------------------------------------------------------- +# Debug logging (opt-in via ASPENS_DEBUG=1) +# --------------------------------------------------------------------------- +log_debug() { + if [ "$ASPENS_DEBUG" = "1" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [graph] $1" >> "${TMPDIR:-/tmp}/claude-graph-hook-debug-$(id -u).log" + fi +} + +log_debug "HOOK SCRIPT STARTED - PID $$" + +# --------------------------------------------------------------------------- +# Resolve script directory (handles symlinks — essential for hub support) +# --------------------------------------------------------------------------- +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" || return 1 + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +log_debug "SCRIPT_DIR=$SCRIPT_DIR" + +cd "$SCRIPT_DIR" || { echo "[Graph] Failed to cd to $SCRIPT_DIR" >&2; exit 0; } + +# --------------------------------------------------------------------------- +# Capture stdin +# --------------------------------------------------------------------------- +INPUT=$(cat) +log_debug "Input received: ${INPUT:0:200}..." + +# --------------------------------------------------------------------------- +# Run graph context engine with clean stdout/stderr separation +# --------------------------------------------------------------------------- +STDOUT_FILE=$(mktemp) +STDERR_FILE=$(mktemp) +trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT + +printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node graph-context-prompt.mjs \ + >"$STDOUT_FILE" 2>"$STDERR_FILE" +EXIT_CODE=$? + +log_debug "Exit code: $EXIT_CODE" +log_debug "Stderr: $(cat "$STDERR_FILE" 2>/dev/null | head -5)" + +# --------------------------------------------------------------------------- +# Terminal status output (stderr) +# --------------------------------------------------------------------------- +if [ $EXIT_CODE -ne 0 ]; then + log_debug "ERROR: Hook failed with exit code $EXIT_CODE" +fi + +GRAPH_LINE=$(grep -o '\[Graph\] .*' "$STDERR_FILE" | head -1) +if [ -n "$GRAPH_LINE" ]; then + echo "$GRAPH_LINE" >&2 +fi + +# --------------------------------------------------------------------------- +# Emit pristine stdout (injected into Claude's context) +# --------------------------------------------------------------------------- +cat "$STDOUT_FILE" + +exit 0 diff --git a/.claude/skills/agent-customization/skill.md b/.claude/skills/agent-customization/skill.md index bf256b9..c32b8a4 100644 --- a/.claude/skills/agent-customization/skill.md +++ b/.claude/skills/agent-customization/skill.md @@ -21,20 +21,19 @@ You are working on **agent customization** — the feature that reads a project' - `src/lib/timeout.js` — `resolveTimeout()` for timeout handling (default 300s) ## Key Concepts +- **Claude-only feature:** Customize command reads `.aspens.json` and throws `CliError` if repo is configured for Codex-only (`targets: ['codex']`). Codex CLI has no agent concept. - **Context gathering:** `gatherProjectContext()` reads CLAUDE.md (truncated at 3000 chars), all `.claude/skills/**/*.md` in full, and lists `.claude/guidelines/` paths without reading their contents. - **Agent discovery:** `findAgents()` recursively walks `.claude/agents/`, reads `.md` files, extracts `name:` via regex — falls back to filename if no frontmatter match. - **Read-only tools:** Claude is invoked with `allowedTools: ['Read', 'Glob', 'Grep']` and no maxTokens cap (unlike doc-init which sets per-call limits). - **Output parsing:** Claude returns `content` XML tags, parsed by `parseFileOutput()`. Only `.claude/` paths are allowed. ## Critical Rules +- **Claude-only** — throws `CliError` for Codex-only repos. Checks `readConfig(repoPath)` for target config. - **Read-only tools only** — Claude agents never get write tools. All output goes through `parseFileOutput()` → `writeSkillFiles()`. - **Context truncation** — CLAUDE.md is capped at 3000 chars to avoid blowing up prompt size. Skills are read in full. - **Path safety** — `parseFileOutput()` only allows writes to `.claude/` prefixed paths. Customized agents stay in `.claude/agents/`. - **Dry-run support** — `--dry-run` flag previews output without writing. Confirmation prompt shown before writes. - **Model override** — `--model` flag passed through to `runClaude()` for model selection. -## References -- **Patterns:** `.claude/guidelines/claude-runner/patterns.md` - --- -**Last Updated:** 2026-03-28 +**Last Updated:** 2026-04-02 diff --git a/.claude/skills/base/skill.md b/.claude/skills/base/skill.md index e37c44f..bf3e0a6 100644 --- a/.claude/skills/base/skill.md +++ b/.claude/skills/base/skill.md @@ -9,7 +9,7 @@ This is a **base skill** that always loads when working in this repository. --- -You are working in **aspens** — a CLI tool that generates and maintains AI-ready documentation (skill files + CLAUDE.md) for any codebase. +You are working in **aspens** — a CLI tool that generates and maintains AI-ready documentation (skill files + CLAUDE.md) for any codebase. Supports multiple output targets (Claude Code, Codex CLI). ## Tech Stack Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolors @@ -18,7 +18,7 @@ Node.js (ESM) | Commander | Vitest | es-module-lexer | @clack/prompts | picocolo - `npm test` — Run vitest suite - `npm start` / `node bin/cli.js` — Run CLI - `aspens scan [path]` — Deterministic repo analysis (no LLM) -- `aspens doc init [path]` — Generate skills + hooks + CLAUDE.md +- `aspens doc init [path]` — Generate skills + hooks + CLAUDE.md (supports `--target claude|codex|all`, `--backend claude|codex`) - `aspens doc sync [path]` — Incremental skill updates from git diffs - `aspens doc graph [path]` — Rebuild import graph cache (`.claude/graph.json`) - `aspens add [name]` — Install templates (agents, commands, hooks) @@ -30,15 +30,18 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `src/lib/scanner.js` — Deterministic repo scanner (languages, frameworks, domains, structure) - `src/lib/graph-builder.js` — Static import analysis via es-module-lexer (hub files, clusters, priority) - `src/lib/graph-persistence.js` — Graph serialization, subgraph extraction, code-map + index generation -- `src/lib/runner.js` — Claude CLI wrapper (`claude -p --output-format stream-json`) +- `src/lib/runner.js` — Claude/Codex CLI wrapper (`runClaude` for stream-json, `runCodex` for Codex JSONL) - `src/lib/context-builder.js` — Assembles repo files into prompt-friendly context -- `src/lib/skill-writer.js` — Writes skill files, generates skill-rules.json, merges settings +- `src/lib/skill-writer.js` — Writes skill files and directory-scoped files, generates skill-rules.json, merges settings - `src/lib/skill-reader.js` — Parses skill files, frontmatter, activation patterns, keywords - `src/lib/diff-helpers.js` — Targeted file diffs and prioritized diff truncation for doc-sync - `src/lib/git-helpers.js` — Git repo detection, diff retrieval, log formatting - `src/lib/git-hook.js` — Post-commit git hook installation/removal for auto doc-sync - `src/lib/timeout.js` — Timeout resolution (`--timeout` flag > `ASPENS_TIMEOUT` env > default) - `src/lib/errors.js` — `CliError` class (structured errors caught by CLI top-level handler) +- `src/lib/target.js` — Target definitions (claude/codex), config persistence (`.aspens.json`) +- `src/lib/target-transform.js` — Transforms Claude-format output to other target formats +- `src/lib/backend.js` — Backend detection and resolution (which CLI generates content) - `src/prompts/` — Prompt templates with `{{partial}}` and `{{variable}}` substitution - `src/templates/` — Bundled agents, commands, hooks, and settings for `aspens add` / `doc init` @@ -46,8 +49,10 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - **Pure ESM** — `"type": "module"` throughout; use `import`/`export`, never `require()` - **es-module-lexer WASM** — must `await init` before calling `parse()` in graph-builder - **Claude CLI execution** — `runClaude()` spawns `claude -p` with stream-json; always use `--verbose` flag with stream-json -- **Path sanitization** — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md` only; no absolute paths or `..` traversal +- **Codex CLI execution** — `runCodex()` spawns `codex exec --json --sandbox read-only --ask-for-approval never --ephemeral`; returns `{ text, usage }` matching `runClaude` interface +- **Path sanitization** — `parseFileOutput()` restricts writes to `.claude/` and `CLAUDE.md` by default; accepts `allowedPaths` override for multi-target - **Prompt partials** — `{{name}}` in prompt files resolves to `src/prompts/partials/name.md` first, then falls back to template variables +- **Target/Backend distinction** — Target = output format/location; Backend = which LLM CLI generates content. Config persisted in `.aspens.json` - **Scanner is deterministic** — no LLM calls; pure filesystem analysis - **CliError pattern** — command handlers throw `CliError` instead of calling `process.exit()`; caught at top level in `bin/cli.js` @@ -60,4 +65,4 @@ CLI entry (`bin/cli.js`) → command handlers (`src/commands/`) → lib modules - `tests/` — Vitest test files --- -**Last Updated:** 2026-03-28 +**Last Updated:** 2026-04-02 diff --git a/.claude/skills/claude-runner/skill.md b/.claude/skills/claude-runner/skill.md index 7e28cc4..fcb0a67 100644 --- a/.claude/skills/claude-runner/skill.md +++ b/.claude/skills/claude-runner/skill.md @@ -1,6 +1,6 @@ --- name: claude-runner -description: Claude CLI execution layer — prompt loading, stream-json parsing, file output extraction, path sanitization, skill file writing, and skill rule generation +description: Claude/Codex CLI execution layer — prompt loading, stream-json parsing, file output extraction, path sanitization, skill file writing, and skill rule generation --- ## Activation @@ -15,31 +15,39 @@ This skill triggers when editing claude-runner files: --- -You are working on the **Claude CLI execution layer** — the bridge between assembled prompts and the `claude -p` CLI, plus skill file I/O. +You are working on the **CLI execution layer** — the bridge between assembled prompts and the `claude -p` / `codex exec` CLIs, plus skill file I/O. ## Key Files -- `src/lib/runner.js` — `runClaude()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()`, `extractResultFromStream()` -- `src/lib/skill-writer.js` — `writeSkillFiles()`, `extractRulesFromSkills()`, `generateDomainPatterns()`, `mergeSettings()` +- `src/lib/runner.js` — `runClaude()`, `runCodex()`, `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()`, `extractResultFromStream()` (exported); `extractResultFromCodexStream()`, `normalizeCodexItemType()`, `collectCodexText()`, `handleStreamEvent()`, `sanitizePath()` (internal) +- `src/lib/skill-writer.js` — `writeSkillFiles()`, `writeTransformedFiles()`, `extractRulesFromSkills()`, `generateDomainPatterns()`, `mergeSettings()` - `src/lib/skill-reader.js` — `findSkillFiles()`, `parseFrontmatter()`, `parseActivationPatterns()`, `parseKeywords()`, `fileMatchesActivation()`, `getActivationBlock()`, `GENERIC_PATH_SEGMENTS` - `src/lib/timeout.js` — `resolveTimeout()` — priority: `--timeout` flag > `ASPENS_TIMEOUT` env var > caller fallback -- `src/prompts/` — Markdown prompt templates; `partials/` subdir holds reusable fragments (`skill-format`, `guideline-format`, `examples`) +- `src/prompts/` — Markdown prompt templates; `partials/` subdir holds `skill-format.md`, `guideline-format.md`, `examples.md` ## Key Concepts -- **Stream-JSON protocol:** `runClaude()` always passes `--verbose --output-format stream-json`. Output is NDJSON: `type: 'result'` has final text + usage; `type: 'assistant'` has text/tool_use blocks; `type: 'user'` has tool_result blocks. -- **Prompt templating:** `loadPrompt(name, vars)` resolves `{{partial-name}}` from `src/prompts/partials/` first, then substitutes `{{varName}}` from `vars`. -- **File output parsing:** Primary: `content` XML tags. Fallback: `` comment markers. Handles code fences correctly. +- **Stream-JSON protocol (Claude):** `runClaude()` always passes `--verbose --output-format stream-json`. Output is NDJSON: `type: 'result'` has final text + usage; `type: 'assistant'` has text/tool_use blocks; `type: 'user'` has tool_result blocks. +- **JSONL protocol (Codex):** `runCodex()` spawns `codex exec --json --sandbox read-only --ask-for-approval never --ephemeral`. Prompt is passed via **stdin** (`'-'` placeholder arg) to avoid shell arg length limits. Stdin write happens **after** event handlers are attached so fast failures are captured. Events: `item.completed`/`item.updated` with normalized types. +- **Unified routing:** `runLLM(prompt, options, backendId)` is the shared entry point — dispatches to `runClaude()` or `runCodex()` based on `backendId`. Exported from `runner.js` so command handlers no longer need local routing helpers. +- **Codex internals (private):** `normalizeCodexItemType()` converts PascalCase/kebab-case to snake_case. `collectCodexText()` recursively extracts text from nested event content. Both are internal to runner.js. +- **Prompt templating:** `loadPrompt(name, vars)` resolves `{{partial-name}}` from `src/prompts/partials/` first, then substitutes `{{varName}}` from `vars`. Target-specific vars (`skillsDir`, `skillFilename`, `instructionsFile`, `configDir`) are passed by command handlers. +- **File output parsing:** Primary: `content` XML tags. Fallback: `` comment markers. `parseFileOutput(output, allowedPaths)` accepts optional `{ dirPrefixes, exactFiles }` to override default allowed paths. +- **Path sanitization:** `sanitizePath(rawPath, allowedPaths)` (internal) blocks `..` traversal, absolute paths. Defaults: `.claude/` prefix + `CLAUDE.md` exact. Multi-target callers pass expanded allowed paths via `getAllowedPaths()` from `target.js`. - **Validation:** `validateSkillFiles()` checks for truncation (XML tag collisions), missing frontmatter, missing sections, bad file path references. - **Skill rules generation:** `extractRulesFromSkills()` reads all skills via `skill-reader.js`, produces `skill-rules.json` (v2.0) with file patterns, keywords, and intent patterns. - **Domain patterns:** `generateDomainPatterns()` converts file patterns to bash `detect_skill_domain()` function using `BEGIN/END` markers. -- **Settings merge:** `mergeSettings()` merges aspens hook config into existing `settings.json`, detecting aspens-managed hooks by command path markers. +- **Settings merge:** `mergeSettings()` merges aspens hook config into existing `settings.json`, detecting aspens-managed hooks by `ASPENS_HOOK_MARKERS` (`skill-activation-prompt`, `post-tool-use-tracker`). +- **Directory-scoped writes:** `writeTransformedFiles()` handles files outside `.claude/` (e.g., `src/billing/AGENTS.md`) with explicit path allowlist — only `CLAUDE.md`, `AGENTS.md` exact files and `.claude/`, `.agents/`, `.codex/` prefixes are permitted. +- **`findSkillFiles` matching:** Only matches the exact `skillFilename` (e.g., `skill.md` or `SKILL.md`), not arbitrary `.md` files in the skills directory. ## Critical Rules -- **Both `--verbose` and `--output-format stream-json` are required** — omitting either breaks stream parsing. -- **Path sanitization is non-negotiable** — `sanitizePath()` blocks `..` traversal, absolute paths, and any path not under `.claude/` or exactly `CLAUDE.md`. +- **Both `--verbose` and `--output-format stream-json` are required for Claude** — omitting either breaks stream parsing. +- **Codex uses `--json --sandbox read-only --ask-for-approval never --ephemeral`** — `--sandbox read-only` restricts filesystem access, `--ask-for-approval never` skips prompts, `--ephemeral` avoids persisting conversation. Prompt goes via stdin, not as a CLI arg. +- **Codex stdin write order matters** — event handlers (`stdout`, `stderr`, `close`, `error`) must be attached before writing to stdin, so fast failures are captured. +- **Path sanitization is non-negotiable** — `sanitizePath()` blocks `..` traversal, absolute paths, and any path not in the allowed set. - **Prompt partials resolve before variables** — `{{skill-format}}` resolves to `partials/skill-format.md` first. If no file, falls through to variable substitution. - **Timeout resolution:** `resolveTimeout(flagValue, fallbackSeconds)` — `--timeout` flag wins, then `ASPENS_TIMEOUT` env, then caller-provided fallback. Size-based defaults (small: 120s, medium: 300s, large: 600s, very-large: 900s) are set by command handlers, not runner. -- **`mergeSettings` preserves non-aspens hooks** — identifies aspens hooks by `ASPENS_HOOK_MARKERS` (`skill-activation-prompt`, `post-tool-use-tracker`), replaces matching entries, preserves everything else. -- **Debug mode:** Set `ASPENS_DEBUG=1` to dump raw stream-json to `/tmp/aspens-debug-stream.json`. +- **`mergeSettings` preserves non-aspens hooks** — identifies aspens hooks by `ASPENS_HOOK_MARKERS`, replaces matching entries, preserves everything else. +- **Debug mode:** Set `ASPENS_DEBUG=1` to dump raw stream-json to `$TMPDIR/aspens-debug-stream.json` (Claude) or `$TMPDIR/aspens-debug-codex-stream.json` (Codex). Codex also logs exit code and output length to stderr. --- -**Last Updated:** 2026-03-28 +**Last Updated:** 2026-04-07 diff --git a/.claude/skills/codex-support/skill.md b/.claude/skills/codex-support/skill.md new file mode 100644 index 0000000..a408480 --- /dev/null +++ b/.claude/skills/codex-support/skill.md @@ -0,0 +1,52 @@ +--- +name: codex-support +description: Multi-target output system — target abstraction, backend routing, content transforms for Codex CLI and future targets +--- + +## Activation + +This skill triggers when editing codex-support files: +- `src/lib/target.js` +- `src/lib/target-transform.js` +- `src/lib/backend.js` +- `tests/target.test.js` +- `tests/target-transform.test.js` +- `tests/backend.test.js` + +Keywords: codex, target, backend, AGENTS.md, directory-scoped, transform, multi-target + +--- + +You are working on **multi-target output support** — the system that lets aspens generate documentation for Claude Code, Codex CLI, or both simultaneously. + +## Key Files +- `src/lib/target.js` — Target definitions (`TARGETS`), `getAllowedPaths()`, path helpers, config persistence (`.aspens.json`) +- `src/lib/target-transform.js` — Transforms Claude-format output to other target formats; `projectCodexDomainDocs()`, `validateTransformedFiles()`, content sanitization +- `src/lib/backend.js` — Backend detection (`detectAvailableBackends`) and resolution (`resolveBackend`) with fallback logic + +## Key Concepts +- **Target vs Backend:** Target = where output goes (claude → `.claude/skills/`, codex → `.agents/skills/` + directory-scoped `AGENTS.md`). Backend = which LLM CLI generates the content (`claude -p` or `codex exec`). +- **Target definitions:** `TARGETS.claude` (centralized) and `TARGETS.codex` (directory-scoped). Each defines paths and capability flags: `supportsHooks`, `supportsSettings`, `supportsGraph`, `supportsSkills`, `needsActivationSection`, `needsCodeMapEmbed`, `supportsMCP`. Codex also has `maxInstructionsBytes` (32 KiB) and `userSkillsDir`. +- **Canonical generation:** Generation always produces Claude-canonical format first. Prompts always receive `CANONICAL_VARS` (hardcoded Claude paths from `doc-init.js`). Transforms run **after** generation to produce other target formats. +- **Content transform:** `transformForTarget()` remaps paths and content. For Codex: base skill → root `AGENTS.md`, domain skills → both `.agents/skills/{domain}/SKILL.md` and source directory `AGENTS.md`. `generateCodexSkillReferences()` creates `.agents/skills/architecture/` with code-map data. +- **Content sanitization:** `sanitizeCodexInstructions()` and `sanitizeCodexSkill()` strip Claude-specific references (hooks, skill-rules.json, Claude Code mentions) from Codex output. +- **`getAllowedPaths(targets)`** — Returns `{ dirPrefixes, exactFiles }` union across all active targets. Dir prefixes use **full** target paths (e.g., `.agents/skills/`, not `.agents/`), providing tighter path validation. +- **Backend detection:** `detectAvailableBackends()` checks if `claude` and `codex` CLIs are installed. `resolveBackend()` picks best match: explicit flag > target match > fallback. +- **Config persistence:** `.aspens.json` at repo root stores `{ targets, backend, version }`. `readConfig()` returns `null` if missing **or if the config is structurally invalid** — callers default to `'claude'` target. Validation via internal `isValidConfig()` ensures `targets` is a non-empty array of known target keys, `backend` (if present) is a known target key, and `version` (if present) is a string. +- **Multi-target publish:** `doc-sync` uses `publishFilesForTargets()` to generate output for all configured targets from a single LLM run — source target files kept as-is, other targets get transforms applied. +- **Codex inference tightened:** `inferConfig()` only adds `'codex'` to inferred targets when `.codex/` config dir or `.agents/skills/` dir exists — a standalone `AGENTS.md` without either is not sufficient. +- **Conditional architecture ref:** Codex `buildCodexSkillRefs()` only includes the architecture skill reference when a graph was actually serialized (`hasGraph` parameter). + +## Critical Rules +- **Generation always targets Claude canonical format first** — transforms run after, never during. Prompts always receive `CANONICAL_VARS`. +- **Split write logic:** `writeSkillFiles()` handles direct-write files (`.claude/`, `.agents/`, `CLAUDE.md`, root `AGENTS.md`). `writeTransformedFiles()` handles directory-scoped `AGENTS.md` (e.g., `src/billing/AGENTS.md`) with an explicit path allowlist and warn-and-skip policy. +- **Path safety:** `validateTransformedFiles()` in `target-transform.js` rejects absolute paths, traversal, and unexpected filenames. `writeTransformedFiles()` enforces the same checks plus an allowlist (`CLAUDE.md`/`AGENTS.md` exact, `.claude/`/`.agents/`/`.codex/` prefixes). +- **Codex-only restrictions:** `add agent/command/hook` and `customize agents` throw `CliError` for Codex-only repos. `add skill` works for both targets. +- **Graph/hooks are Claude-only** — `persistGraphArtifacts()` returns data without writing files when `target.supportsGraph === false`. Hook installation skipped when `supportsHooks === false`. +- **Config validation is defensive** — `readConfig()` treats malformed but parseable JSON (e.g., wrong types for `targets`/`backend`/`version`) as invalid and returns `null`, same as missing config. + +## References +- **Patterns:** See `src/lib/target.js` for all target property definitions + +--- +**Last Updated:** 2026-04-07 diff --git a/.claude/skills/doc-sync/skill.md b/.claude/skills/doc-sync/skill.md index 7845839..91096f1 100644 --- a/.claude/skills/doc-sync/skill.md +++ b/.claude/skills/doc-sync/skill.md @@ -20,37 +20,46 @@ Keywords: doc-sync, refresh, sync, git-hook You are working on **doc-sync**, the incremental skill update command (`aspens doc sync`). ## Key Files -- `src/commands/doc-sync.js` — Main command: git diff → graph rebuild → skill mapping → Claude update → write. Also contains refresh mode and `skillToDomain()` export. -- `src/prompts/doc-sync.md` — System prompt for diff-based sync (uses `{{skill-format}}` partial) +- `src/commands/doc-sync.js` — Main command: git diff → graph rebuild → skill mapping → LLM update → publish for targets → write. Also contains refresh mode and `skillToDomain()` export. +- `src/prompts/doc-sync.md` — System prompt for diff-based sync (uses `{{skill-format}}` partial, target-specific path variables) - `src/prompts/doc-sync-refresh.md` — System prompt for `--refresh` mode (full skill review) - `src/lib/git-helpers.js` — `isGitRepo()`, `getGitDiff()`, `getGitLog()`, `getChangedFiles()` — git primitives -- `src/lib/diff-helpers.js` — `getSelectedFilesDiff()`, `buildPrioritizedDiff()`, `truncateDiff()` — diff budgeting +- `src/lib/diff-helpers.js` — `getSelectedFilesDiff()`, `buildPrioritizedDiff()`, `truncateDiff()`, `truncate()` — diff budgeting - `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync - `src/lib/context-builder.js` — `buildDomainContext()`, `buildBaseContext()` used by refresh mode -- `src/lib/runner.js` — `runClaude()`, `loadPrompt()`, `parseFileOutput()` shared across commands -- `src/lib/skill-writer.js` — `writeSkillFiles()`, `extractRulesFromSkills()` for output +- `src/lib/runner.js` — `runLLM()`, `loadPrompt()`, `parseFileOutput()` shared across commands +- `src/lib/skill-writer.js` — `writeSkillFiles()`, `writeTransformedFiles()`, `extractRulesFromSkills()` for output +- `src/lib/target-transform.js` — `projectCodexDomainDocs()`, `transformForTarget()` for multi-target publish ## Key Concepts -- **Diff-based flow:** Gets `git diff HEAD~N..HEAD` and `git log`, feeds them plus existing skill contents and graph context to Claude. -- **Refresh mode (`--refresh`):** Skips diff entirely. Reviews every skill against the current codebase. Base skill refreshed first, then domain skills in parallel batches of `PARALLEL_LIMIT` (3). Also refreshes CLAUDE.md and reports uncovered domains. -- **Graph rebuild on every sync:** Calls `buildRepoGraph` + `persistGraphArtifacts` to keep `.claude/graph.json` fresh. Graph failure is non-fatal. +- **Multi-target publish:** `configuredTargets()` reads `.aspens.json` for all configured targets. `chooseSyncSourceTarget()` picks the best source (prefers Claude if both exist). LLM generates for the source target; `publishFilesForTargets()` transforms output for all other configured targets. `graphSerialized` is passed through to control conditional architecture references. +- **Backend routing:** `runLLM()` from `runner.js` dispatches to `runClaude()` or `runCodex()` based on `config.backend` (defaults to source target's id). +- **Diff-based flow:** Gets `git diff HEAD~N..HEAD` and `git log`, feeds them plus existing skill contents and graph context to the selected backend. +- **Prompt path variables:** Passes `{ skillsDir, skillFilename, instructionsFile, configDir }` from source target to `loadPrompt()` for path substitution in prompts. +- **Refresh mode (`--refresh`):** Skips diff entirely. Reviews every skill against the current codebase. Base skill refreshed first, then domain skills in parallel batches of `PARALLEL_LIMIT` (3). Also refreshes instructions file and reports uncovered domains. +- **Graph rebuild on every sync:** Calls `buildRepoGraph` + `persistGraphArtifacts` (with source target) to keep graph fresh. `graphSerialized` return value is captured and forwarded to `publishFilesForTargets` for conditional Codex architecture refs. Graph failure is non-fatal. +- **Unparseable response detection:** After LLM returns, if output has content but no `` tags at all, throws `CliError` instead of silently treating it as "no updates needed". - **Graph-aware skill mapping:** `mapChangesToSkills()` checks direct file matches via `fileMatchesActivation()` (from `skill-reader.js`) and also whether changed files are imported by files matching a skill's activation block. - **Interactive file picker:** When diff exceeds 80k chars and TTY is available, offers multiselect with skill-relevant files pre-selected. - **Prioritized diff:** `buildPrioritizedDiff()` gives skill-relevant files 60k char budget, everything else 20k (80k total). Cuts at `diff --git` boundaries. - **Token optimization:** Affected skills sent in full; non-affected skills send only path + description line. -- **Skill-rules regeneration:** After writing, regenerates `skill-rules.json` via `extractRulesFromSkills()` so hooks see updated activation patterns. -- **Git hook:** `installGitHook()` creates a `post-commit` hook with 5-minute cooldown lock file (`/tmp/aspens-sync-*.lock` keyed by repo path hash). `removeGitHook()` removes via `>>>` / `<<<` markers. +- **Split writes:** Direct-write files (`.claude/`, `CLAUDE.md`, root `AGENTS.md`) use `writeSkillFiles()`. Directory-scoped `AGENTS.md` files (e.g. `src/AGENTS.md`) use `writeTransformedFiles()`. +- **Skill-rules regeneration:** After writing, regenerates `skill-rules.json` via `extractRulesFromSkills()` — only for targets with `supportsHooks: true` (Claude). Uses `hookTarget` from publish targets list. +- **`findExistingSkills` is target-aware:** Uses `target.skillsDir` and `target.skillFilename` to locate skills for any target. +- **Git hook:** `installGitHook()` creates a `post-commit` hook with 5-minute cooldown lock file. Hook skips aspens-only commits (filters `.claude/`, `.codex/`, `.agents/`, `CLAUDE.md`, `AGENTS.md`, `.aspens.json`). Works for all configured targets. - **Force writes:** doc-sync always calls `writeSkillFiles` with `force: true`. ## Critical Rules -- `runClaude` is called with `allowedTools: ['Read', 'Glob', 'Grep']` — doc-sync must never grant write tools. -- `parseFileOutput` restricts paths to `.claude/` prefix and `CLAUDE.md` exactly — any other path is silently dropped. +- `runLLM` is called with `allowedTools: ['Read', 'Glob', 'Grep']` — doc-sync must never grant write tools. +- `parseOutput` restricts paths based on `getAllowedPaths([sourceTarget])` — paths outside the allowed set are silently dropped. +- **Unparseable output is an error** — if LLM returns text without any `` tags, doc-sync throws `CliError` rather than silently proceeding with zero files. - `getGitDiff` gracefully falls back from N commits to 1 if fewer available. `actualCommits` tracks what was used. -- The command exits early with `CliError` if `.claude/skills/` doesn't exist. -- `checkMissingHooks()` in `bin/cli.js` warns when skills exist but hooks are missing (pre-0.2.2 installs). +- The command exits early with `CliError` if the source target's skills directory doesn't exist. +- `checkMissingHooks()` in `bin/cli.js` only checks for Claude skills (not Codex — Codex doesn't use hooks). +- `dedupeFiles()` ensures no duplicate paths when publishing across multiple targets. ## References - **Patterns:** `src/lib/skill-reader.js` — `GENERIC_PATH_SEGMENTS`, `fileMatchesActivation()`, `getActivationBlock()` --- -**Last Updated:** 2026-03-28 +**Last Updated:** 2026-04-07 diff --git a/.claude/skills/import-graph/skill.md b/.claude/skills/import-graph/skill.md index 456a2a5..ecc7a8f 100644 --- a/.claude/skills/import-graph/skill.md +++ b/.claude/skills/import-graph/skill.md @@ -14,6 +14,8 @@ This skill triggers when editing import-graph-related files: - `tests/graph-builder.test.js` - `tests/graph-persistence.test.js` +Keywords: graph, import graph, dependency, hub files, clustering, code-map, graph-index, subgraph + --- You are working on the **import graph system** — static analysis that parses JS/TS and Python source files to produce dependency graphs, plus persistence/query layers for runtime use. @@ -33,24 +35,27 @@ You are working on the **import graph system** — static analysis that parses J **graph-persistence.js** — Persistence and query layer: - `serializeGraph()` converts raw graph to indexed format (O(1) lookups, file→cluster mapping) -- `persistGraphArtifacts()` writes `.claude/graph.json` + `.claude/code-map.md` + `.claude/graph-index.json` + auto-gitignores them +- `persistGraphArtifacts(repoPath, rawGraph, options?)` writes `.claude/graph.json` + `.claude/code-map.md` + `.claude/graph-index.json` + auto-gitignores them. **Target-aware:** if `options.target.supportsGraph === false`, returns serialized data without writing files. - `extractSubgraph(graph, filePaths)` returns 1-hop neighborhood of mentioned files + relevant hubs/hotspots/clusters - `formatNavigationContext(subgraph)` renders compact markdown (~50 line budget) for prompt injection - `extractFileReferences(prompt, graph)` tiered extraction: explicit paths → bare filenames → cluster keywords - `generateCodeMap()` / `writeCodeMap()` standalone overview for graph hook consumption - `generateGraphIndex()` / `saveGraphIndex()` tiny inverted index (export names → files, hub basenames, cluster labels) +**doc-graph.js** — Target-aware: reads `.aspens.json` config, passes target to `persistGraphArtifacts()`. Shows different completion message for Codex target (artifacts not written). + ## Critical Rules - **`await init` before any `parseJsImports` call.** es-module-lexer requires WASM initialization. - **Priority formula is load-bearing:** `fanIn * 3.0 + exportCount * 1.5 + (isEntry ? 10.0 : 0) + churn * 2.0 + (1/(depth+1)) * 1.0`. Downstream consumers depend on this ranking. - **All paths are repo-relative strings**, never absolute. Resolution functions convert abs→relative. - **Graph artifacts are gitignored** — `ensureGraphGitignore()` (internal to persistence) adds `.claude/graph.json`, `.claude/graph-index.json`, `.claude/code-map.md` to prevent commit loops. +- **Graph artifacts are Claude-only** — when target has `supportsGraph: false`, `persistGraphArtifacts` returns serialized data for embedding (e.g., condensed code-map in root AGENTS.md) but writes no files. - **Errors are swallowed, not thrown** in graph-builder — parse failures return empty/null. The graph must always complete. - **`extractSubgraph` logic is mirrored** in `graph-context-prompt.mjs` (`buildNeighborhood()`). Keep both in sync. -- **doc-sync rebuilds graph on every sync** — calls `buildRepoGraph` + `persistGraphArtifacts` to keep it fresh. +- **doc-sync rebuilds graph on every sync** — calls `buildRepoGraph` + `persistGraphArtifacts` (with target) to keep it fresh. ## References - **Hook mirror:** `src/templates/hooks/graph-context-prompt.mjs` --- -**Last Updated:** 2026-03-28 +**Last Updated:** 2026-04-02 diff --git a/.claude/skills/repo-scanning/skill.md b/.claude/skills/repo-scanning/skill.md index 24aecdc..78b9acb 100644 --- a/.claude/skills/repo-scanning/skill.md +++ b/.claude/skills/repo-scanning/skill.md @@ -23,7 +23,8 @@ You are working on **aspens' repo scanning system** — a fully deterministic an - `tests/scanner.test.js` — Uses temporary fixture directories created in `tests/fixtures/scanner/`, cleaned up in `afterAll` ## Key Concepts -- **scanRepo() return shape:** `{ path, name, languages[], frameworks[], structure, domains[], entryPoints[], hasClaudeConfig, hasClaudeMd, repoType, size, health }` — order matters: `repoType` and `health` depend on prior fields +- **scanRepo() return shape:** `{ path, name, languages[], frameworks[], structure, domains[], entryPoints[], hasClaudeConfig, hasClaudeMd, hasCodexConfig, hasAgentsMd, repoType, size, health }` — order matters: `repoType` and `health` depend on prior fields +- **Multi-target detection:** Scanner checks for both `.claude` dir + `CLAUDE.md` (Claude Code) and `.codex` dir + `AGENTS.md` (Codex CLI) to inform target selection during `doc init` - **Detection via marker files:** Languages detected by presence of files like `package.json`, `go.mod`, `Cargo.toml` — not by scanning source extensions - **Framework detection:** JS/TS from `package.json` deps, Python from `requirements.txt`/`pyproject.toml`/`Pipfile`, Go from `go.mod` contents, Ruby from `Gemfile` - **Domain detection:** Scans dirs under source root + repo root, skips `SKIP_DIR_NAMES` set (structural/build/IDE dirs), requires at least one source file via `collectModules()` @@ -45,4 +46,4 @@ You are working on **aspens' repo scanning system** — a fully deterministic an - **No guidelines directory** — `.claude/guidelines/` does not exist yet for this domain --- -**Last Updated:** 2026-03-28 +**Last Updated:** 2026-04-02 diff --git a/.claude/skills/skill-generation/skill.md b/.claude/skills/skill-generation/skill.md index c6f2db6..32288e9 100644 --- a/.claude/skills/skill-generation/skill.md +++ b/.claude/skills/skill-generation/skill.md @@ -18,40 +18,48 @@ Keywords: doc-init, generate skills, discovery agents, chunked generation --- -You are working on **aspens' skill generation pipeline** — the system that scans repos and uses Claude CLI to generate `.claude/skills/` files, hooks, and `CLAUDE.md`. +You are working on **aspens' skill generation pipeline** — the system that scans repos and uses Claude/Codex CLI to generate skills, hooks, and instructions files. ## Key Files -- `src/commands/doc-init.js` — Main 9-step pipeline: scan → graph → discovery → strategy → mode → generate → validate → write → hooks -- `src/lib/runner.js` — `runClaude()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()` +- `src/commands/doc-init.js` — Main pipeline: backend selection → target selection → scan → graph → discovery → strategy → mode → generate → validate → transform → write → hooks → config +- `src/lib/runner.js` — `runClaude()`, `runCodex()`, `runLLM()`, `loadPrompt()`, `parseFileOutput()`, `validateSkillFiles()` - `src/lib/skill-writer.js` — Writes files, generates `skill-rules.json`, domain bash patterns, merges `settings.json` - `src/lib/skill-reader.js` — Parses skill frontmatter, activation patterns, keywords (used by skill-writer) - `src/lib/git-hook.js` — `installGitHook()` / `removeGitHook()` for post-commit auto-sync - `src/lib/timeout.js` — `resolveTimeout()` for auto-scaled + user-override timeouts +- `src/lib/target.js` — Target definitions, `resolveTarget()`, `getAllowedPaths()`, `writeConfig()` +- `src/lib/backend.js` — Backend detection/resolution (`detectAvailableBackends()`, `resolveBackend()`) +- `src/lib/target-transform.js` — `transformForTarget()` converts Claude output to other target formats - `src/prompts/` — `doc-init.md` (base), `doc-init-domain.md`, `doc-init-claudemd.md`, `discover-domains.md`, `discover-architecture.md` ## Key Concepts -- **9-step pipeline:** (1) scan + graph (2) parallel discovery agents (3) strategy (4) mode (5) generate (6) validate (7) show files + dry-run (8) write (9) install hooks -- **Parallel discovery:** Two agents run via `Promise.all` — domain discovery and architecture analysis — before any user prompt. Uses `buildGraphContextForDiscovery()` (local) for targeted graph context per agent. -- **Generation modes:** `all-at-once` = single Claude call; `chunked` = base + per-domain (up to 3 parallel via `PARALLEL_LIMIT`) + CLAUDE.md; `base-only` = just base skill; `pick` = interactive domain picker (becomes chunked) -- **`--domains` flag:** Filters which domains to generate in chunked mode; combined with `--mode chunked` enables `domainsOnly` mode that skips base + CLAUDE.md (for retrying failed domains) -- **`--hooks-only` flag:** Skips generation entirely, just installs/updates hooks from existing skills -- **`--strategy` flag:** `improve` (read existing, update), `rewrite` (ignore existing), `skip` (only new domains). Interactive prompt if not specified. -- **Retry logic:** Base skill and CLAUDE.md retry up to 2 times if `parseFileOutput` returns empty (format correction prompt asking for `` tags) -- **Validation:** `validateSkillFiles()` checks for truncation, missing frontmatter, missing sections, bad file path references. Truncated files are removed from output. -- **Hook installation (step 9):** Generates `skill-rules.json`, copies hook scripts, generates `post-tool-use-tracker.sh` with domain patterns, merges `settings.json` -- **Local helpers in doc-init.js:** `buildGraphContext()`, `buildDomainGraphContext()`, `buildGraphContextForDiscovery()`, `buildScanSummary()`, `sanitizeInline()`, `tokenTracker`, `autoTimeout()` -- **Token tracking:** `tokenTracker` aggregates prompt/output/tool-use tokens across all Claude calls; displayed with elapsed time at end of pipeline +- **Pipeline steps:** (1) detect backends (2) **backend selection** (3) **target selection** (4) scan + graph (5) existing docs discovery check (6) parallel discovery agents (7) strategy (8) mode (9) generate (10) validate (11) transform for non-Claude targets (12) show files + dry-run (13) write (14) install hooks (Claude-only) (15) persist config to `.aspens.json` +- **Backend before target:** Backend selection (step 2) happens before target selection (step 3). If both CLIs available, user picks backend first, then targets. Pre-selects matching target in the multiselect. +- **Canonical generation:** All prompts receive `CANONICAL_VARS` (hardcoded Claude paths: `.claude/skills/`, `skill.md`, `CLAUDE.md`). Generation always produces Claude-canonical format regardless of target. Non-Claude targets are produced by post-generation transform. +- **`parseLLMOutput` with strict single-file fallback:** Codex often returns plain markdown without `` tags. `parseLLMOutput(text, allowedPaths, expectedPath)` only wraps tagless text as the expected file for **true single-file prompts** (exactly one `exactFile` in allowedPaths, no `dirPrefixes`). Multi-file prompts require proper `` tags. +- **Existing docs reuse:** When existing Claude docs are found and strategy is `improve`, reuse is handled as improvement context without a separate loading spinner. Supports cross-target reuse (e.g., existing Claude docs → generate Codex output). +- **Domain reuse helpers:** `loadReusableDomains()` tries `loadReusableDomainsFromRules()` (reads `skill-rules.json` from source target, falls back to `.claude/skills/` for non-Claude targets) first. Falls back to `findSkillFiles()` with `extractKeyFilePatterns()` to derive file patterns from `## Key Files` sections when activation patterns are missing. +- **Target selection:** `--target claude|codex|all` or interactive multiselect if both CLIs available. Stored in `.aspens.json`. +- **Backend routing:** `runLLM()` imported from `runner.js` dispatches to `runClaude()` or `runCodex()` based on `_backendId`. `--backend` flag overrides auto-detection. +- **Content transform (step 11):** Canonical files preserved as originals. Non-Claude targets get `transformForTarget()` applied. If Claude not in target list, canonical files are filtered out of final output. +- **Split writes:** Direct-write files (`.claude/`, `.agents/`, `CLAUDE.md`, root `AGENTS.md`) use `writeSkillFiles()`. Directory-scoped files (e.g., `src/billing/AGENTS.md`) use `writeTransformedFiles()` with warn-and-skip policy. +- **Dynamic labels:** `baseArtifactLabel()` and `instructionsArtifactLabel()` return target-appropriate names ("base skill" vs "root AGENTS.md") for spinner messages. +- **Parallel discovery:** Two agents run via `Promise.all` — domain discovery and architecture analysis — before any user prompt. +- **Generation modes:** `all-at-once` = single call; `chunked` = base + per-domain (up to 3 parallel) + instructions file; `base-only` = just base skill; `pick` = interactive domain picker +- **Retry logic:** Base skill and instructions file retry up to 2 times if `parseLLMOutput` returns empty (format correction prompt asking for `` tags). +- **Hook installation:** Only for targets with `supportsHooks: true` (Claude). Generates `skill-rules.json`, copies hook scripts, merges `settings.json`. ## Critical Rules -- **Base skill + CLAUDE.md are essential** — pipeline retries automatically with format correction. Domain skill failures are acceptable (user retries with `--domains`). -- **`improve` strategy preserves hand-written content** — Claude must read existing skills first and not discard human-authored rules. -- **Discovery runs before user prompt** — domain picker shows Claude-discovered domains, not scanner directory names. -- **PARALLEL_LIMIT = 3** — domain skills generate in batches of 3 concurrent Claude calls. Base skill always sequential first. CLAUDE.md always sequential last. +- **Base skill + instructions file are essential** — pipeline retries automatically with format correction. Domain skill failures are acceptable (user retries with `--domains`). +- **`improve` strategy preserves hand-written content** — LLM must read existing skills first and not discard human-authored rules. +- **Discovery runs before user prompt** — domain picker shows discovered domains, not scanner directory names. Discovery can be skipped if existing docs are found and user opts to reuse. +- **PARALLEL_LIMIT = 3** — domain skills generate in batches of 3 concurrent calls. Base skill always sequential first. Instructions file always sequential last. - **CliError, not process.exit()** — all error exits throw `CliError`; cancellations `return` early. +- **`--hooks-only` is Claude-only** — hardcoded to `TARGETS.claude` regardless of config. ## References - **Prompts:** `src/prompts/doc-init*.md`, `src/prompts/discover-*.md` - **Partials:** `src/prompts/partials/skill-format.md`, `src/prompts/partials/examples.md` --- -**Last Updated:** 2026-03-28 +**Last Updated:** 2026-04-07 diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json index a65ee60..1aa67bd 100644 --- a/.claude/skills/skill-rules.json +++ b/.claude/skills/skill-rules.json @@ -70,6 +70,7 @@ "claude", "runner", "claude runner", + "claude/codex", "execution", "layer", "prompt", @@ -88,6 +89,47 @@ ] } }, + "codex-support": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/lib/target.js", + "src/lib/target-transform.js", + "src/lib/backend.js", + "tests/target.test.js", + "tests/target-transform.test.js", + "tests/backend.test.js" + ], + "promptTriggers": { + "keywords": [ + "codex", + "target", + "backend", + "AGENTS.md", + "directory-scoped", + "transform", + "multi-target", + "support", + "codex support", + "output", + "system", + "abstraction", + "target-transform", + "tests", + "target.test", + "target-transform.test", + "backend.test" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*codex support", + "codex support.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*codex.*support" + ] + } + }, "doc-sync": { "type": "domain", "enforcement": "suggest", @@ -145,13 +187,18 @@ ], "promptTriggers": { "keywords": [ - "import", "graph", "import graph", + "dependency", + "hub files", + "clustering", + "code-map", + "graph-index", + "subgraph", + "import", "static", "analysis", "builds", - "dependency", "graph-builder", "graph-persistence", "commands", @@ -166,6 +213,8 @@ "intentPatterns": [ "(create|update|fix|add|modify|change|debug|refactor|implement|build).*import graph", "import graph.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*hub files", + "hub files.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", "(create|update|fix|add|modify|change|debug|refactor|implement|build).*import.*graph" ] } diff --git a/.claude/skills/template-library/skill.md b/.claude/skills/template-library/skill.md index a6b7a1a..69ef9dc 100644 --- a/.claude/skills/template-library/skill.md +++ b/.claude/skills/template-library/skill.md @@ -25,12 +25,16 @@ You are working on the **template library** — bundled agents, slash commands, ## Key Concepts - **Four resource types for `add`:** `agent` → `.claude/agents`, `command` → `.claude/commands`, `hook` → `.claude/hooks`. A fourth type `skill` is handled separately (not template-based). -- **Skill subcommand:** `aspens add skill ` scaffolds a blank skill template. `--from ` generates a skill from a reference doc using Claude (LLM-powered). `--list` shows installed skills. +- **Codex-only restriction:** `add agent`, `add command`, and `add hook` throw `CliError` for Codex-only repos (checked via `readConfig()`). Skills work with both targets — `add skill` is always available. +- **Target-aware skill commands:** `addSkillCommand` and `generateSkillFromDoc` resolve the active target via `resolveSkillTarget(config)`. Skill paths use `target.skillsDir` and `target.skillFilename` (not hardcoded `.claude/skills/skill.md`). +- **Backend-aware generation:** `generateSkillFromDoc` uses `runLLM()` imported from `runner.js` to dispatch to Claude or Codex based on config. `getAllowedPaths([target])` provides path safety for `parseFileOutput`. +- **Skill subcommand:** `aspens add skill ` scaffolds a blank skill template. `--from ` generates a skill from a reference doc using the configured backend. `--list` shows installed skills. - **Hook templates:** `skill-activation-prompt` reads `skill-rules.json` and injects relevant skills into prompts. `graph-context-prompt` loads graph data for code navigation. `post-tool-use-tracker` detects skill domains from file access patterns. -- **`doc init` hook installation (step 9):** Generates `skill-rules.json` from skills, copies hook files, generates `post-tool-use-tracker.sh` with domain patterns (via `BEGIN/END` markers), merges `settings.json` with backup. +- **`doc init` hook installation (step 13):** Generates `skill-rules.json` from skills, copies hook files, generates `post-tool-use-tracker.sh` with domain patterns (via `BEGIN/END` markers), merges `settings.json` with backup. - **Template discovery:** `listAvailable()` reads template dir, filters `.md`/`.sh` files, regex-parses `name:` and `description:`. - **No-overwrite policy:** `addResource()` skips files that already exist via `existsSync` check. Same for `addSkillCommand`. - **Plan/execute gitignore:** Adding `plan` or `execute` agents auto-adds `dev/` to `.gitignore` for plan storage. +- **Conditional post-add tips:** Skill rules update and `--hooks-only` tip only shown for Claude target. Codex target gets no hook-related messaging. ## Critical Rules - Template files **must** contain `name: ` and `description: ` lines parseable by regex. @@ -43,4 +47,4 @@ You are working on the **template library** — bundled agents, slash commands, - **Customize flow:** `.claude/skills/agent-customization/skill.md` --- -**Last Updated:** 2026-03-28 +**Last Updated:** 2026-04-07 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d368bf0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +## Release + +- Release workflow: `/Users/MV/aspenkit/dev/release.md` + +## Key Files + +**Hub files (most depended-on):** +- `src/lib/runner.js` - 8 dependents +- `src/lib/errors.js` - 7 dependents +- `src/lib/scanner.js` - 7 dependents +- `src/lib/target.js` - 7 dependents +- `src/lib/skill-writer.js` - 6 dependents + +**Domain clusters:** + +| Domain | Files | Top entries | +|--------|-------|-------------| +| src | 37 | `src/lib/runner.js`, `src/commands/doc-init.js`, `src/commands/doc-sync.js` | + +**High-churn hotspots:** +- `src/commands/doc-init.js` - 27 changes +- `src/commands/doc-sync.js` - 19 changes +- `src/lib/runner.js` - 16 changes diff --git a/CHANGELOG.md b/CHANGELOG.md index da98b82..b30d449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [0.6.0] - 2026-04-07 + +### Changed +- **Codex support hardening** — shared backend routing now uses a single `runLLM` implementation across commands, and Codex execution runs under read-only sandboxing with `--ask-for-approval never` instead of `--full-auto` +- **Config recovery** — `.aspens.json` parsing now validates schema before use, malformed configs fall back to inference, and recovered multi-target configs are rewritten safely +- **Multi-target publishing** — `doc sync` now forwards serialized graph data into target transforms so Codex architecture skill output is emitted when graph artifacts exist +- **Path allowlisting** — target path validation is stricter for transformed writes and parsed output, keeping `.claude/`, `.agents/skills/`, `.codex/`, `CLAUDE.md`, and `AGENTS.md` scoped correctly +- **Skill discovery** — reusable-domain loading now falls back to skill rules and key-file extraction when Codex-transformed skills omit `## Activation` + +### Fixed +- **Silent sync success on bad model output** — `doc sync` now treats non-empty unparseable replies as errors instead of reporting “Docs are up to date” +- **Single-file fallback wrapping** — `doc init` only wraps tagless model output when the prompt truly targets a single file, preventing multi-file replies from collapsing into `CLAUDE.md` +- **Customize validation order** — `aspens customize agents` now reports unknown targets before applying Codex-only gating +- **Prompt templates** — `doc-sync` and `doc-sync-refresh` no longer hardcode `billing` in output paths +- **Skill reader scope** — `findSkillFiles()` now matches the configured skill filename only, avoiding accidental reads of unrelated markdown files +- **Hook log output** — graph hook stderr extraction now preserves quoted path segments in `[Graph] ...` messages + +### Security +- **Vite advisory remediation** — upgraded `vitest` to `4.1.3`, which updates transitive `vite` to `8.0.7` and clears the current Dependabot alerts for `server.fs.deny` bypass, arbitrary file read via dev-server WebSocket, and optimized deps `.map` path traversal + +### Tests +- **Config validation coverage** — added tests for invalid but parseable `.aspens.json` files and updated target-path expectations for the narrowed allowlist + ## [0.5.0] - 2026-03-28 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index b60ed5f..2514f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,82 +1,36 @@ # aspens -CLI tool that generates and maintains AI-ready documentation (skills + CLAUDE.md) for any codebase. Built with Node.js, ESM, Commander, and Vitest. +CLI for generating and maintaining AI-ready repo docs for Claude Code and Codex CLI. Stack: Node.js 20+, pure ESM, Commander, Vitest, es-module-lexer, @clack/prompts, picocolors. Entry point: `src/index.js` and CLI at `bin/cli.js`. -## Quick reference +## Skills -```bash -npm test # vitest run -npm start # node bin/cli.js -aspens scan [path] # detect tech stack, domains, structure -aspens doc init # generate skills + hooks + CLAUDE.md -aspens doc sync # update skills from recent commits -aspens doc graph # rebuild import graph cache -aspens add # install agents/commands/hooks from template library -aspens customize agents # inject project context into installed agents -``` +- `.claude/skills/base/skill.md` — Base repo skill; load whenever working in this repo. Use it for project structure, architecture notes, and repo-specific conventions. -## Architecture +## Commands -``` -bin/cli.js # entry point — Commander program, CliError handler -src/commands/ # command handlers: scan, doc-init, doc-sync, doc-graph, add, customize -src/lib/ - scanner.js # deterministic repo analysis (languages, frameworks, domains) - graph-builder.js # static import graph, domain clusters, hub detection - graph-persistence.js # graph serialization, subgraph extraction, code-map, graph-index - context-builder.js # assembles context payloads for Claude prompts - runner.js # Claude CLI execution, stream-json parsing, file output extraction - skill-writer.js # writes skill .md files, generates skill-rules.json, merges settings - skill-reader.js # parses skill frontmatter, activation patterns, keywords - diff-helpers.js # git diff parsing and change extraction - git-helpers.js # git operations (log, diff, rev-parse) - git-hook.js # post-commit hook install/uninstall for doc-sync - timeout.js # timeout calculation (auto-scales by repo size) - errors.js # CliError class for structured error handling -src/prompts/ # prompt templates + partials/ subdir for reusable fragments -src/templates/ - agents/ # 11 agent templates (.md) - commands/ # 2 command templates (.md) - hooks/ # 5 hook templates (.sh + .mjs) - settings/ # settings templates -tests/ # vitest tests + fixtures -``` +- `npm test` — run Vitest (`vitest run`) +- `npm start` — run the CLI (`node bin/cli.js`) +- `npm run lint` — no-op check (`echo 'No linter configured yet' && exit 0`) +- `aspens scan [path]` — deterministic repo scan +- `aspens doc init [path]` — generate skills, hooks, and instructions file (`--target claude|codex|all`) +- `aspens doc sync [path]` — update docs from recent diffs +- `aspens doc graph [path]` — rebuild `.claude/graph.json` +- `aspens add [name]` — install bundled templates +- `aspens customize agents` — inject project context into installed agents -## Skills (Claude Code integration) +## Release -The project ships as both a CLI and a set of Claude Code skills registered in the system. The seven skill domains are: +- Release workflow: `/Users/MV/aspenkit/dev/release.md` -| Skill | Description | -|---|---| -| agent-customization | LLM-powered injection of project context into agents | -| claude-runner | Prompt loading, stream-json parsing, file output extraction, skill rule generation | -| doc-sync | Maps git diffs to affected skills, optional post-commit hook | -| import-graph | Dependency graphs, domain clusters, hub files, churn hotspots, graph persistence | -| repo-scanning | Language/framework detection, structure mapping, domain discovery | -| skill-generation | LLM generation pipeline for skills, hooks, and CLAUDE.md | -| template-library | Bundled agents, commands, hooks, settings installed via `aspens add` | - -## Dev docs - -Extended dev documentation lives outside this repo at `../dev/`: - -- `release.md` — release workflow, publish steps, git tagging, GitHub Discussions -- `roadmap.md` — planned features and direction - - -## Code review - -```bash -cr review --plain # run CodeRabbit review from CLI -``` +## Conventions -Or comment `@coderabbitai review` on any open PR. +- ESM only: use `import`/`export`; never `require()`. +- Prefer `CliError` from command handlers; top-level handling lives in `bin/cli.js`. +- `es-module-lexer` must be initialized before `parse()`. +- Keep target/backend semantics straight: target is output format/location; backend is the generating CLI. Persist config in `.aspens.json`. +- Do not duplicate base-skill guidance here; consult `.claude/skills/base/skill.md` for deeper repo context. -## Conventions +## Behavior -- **ESM only** — `"type": "module"` everywhere, no CommonJS -- **Node >= 20** required -- **CliError pattern** — command handlers throw `CliError` (not `process.exit()`); caught at top level in `bin/cli.js` -- No linter configured yet; `npm run lint` is a no-op -- Dependencies: commander, es-module-lexer, picocolors, @clack/prompts -- Tests live in `tests/` and use vitest — run with `npm test` +- **Verify before claiming** — Never state that something is configured, running, scheduled, or complete without confirming it first. If you haven't verified it in this session, say so rather than assuming. +- **Make sure code is running** — If you suggest code changes, ensure the code is running and tested before claiming the task is done. diff --git a/README.md b/README.md index a509c2f..8728602 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ # aspens -### Stop correcting Claude. Start shipping. +## Stop re-explaining your repo. Start shipping. [![npm version](https://img.shields.io/npm/v/aspens.svg)](https://www.npmjs.com/package/aspens) [![npm downloads](https://img.shields.io/npm/dm/aspens.svg)](https://www.npmjs.com/package/aspens) [![GitHub stars](https://img.shields.io/github/stars/aspenkit/aspens)](https://github.com/aspenkit/aspens) [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -Claude Code writes code that ignores your patterns, uses wrong abstractions, and breaks your rules. -Aspens scans your repo, discovers what matters, and generates context that stays updated on every commit — so every Claude Code session starts on track. +Claude, Codex, and other coding agents write better code when they start with the right repo context. +Aspens scans your repo, discovers what matters, and generates context that stays updated on every commit — so each session starts on track. @@ -22,12 +22,12 @@ Aspens scans your repo, discovers what matters, and generates context that stays | Without aspens | With aspens | |---|---| -| Claude ignores your conventions | Claude follows your patterns from the first prompt | -| Claude builds components from scratch instead of reusing yours | Skills tell Claude exactly what exists and where | -| You manually write and maintain CLAUDE.md | Skills auto-generated and updated on every commit | -| Claude spends half its tool calls Bash/Grep searching for files | Import graph tells Claude which files actually matter | +| Agents ignore your conventions | Claude and Codex start with repo-specific instructions | +| Agents rebuild things that already exist | Skills and docs point them to the right abstractions | +| You manually maintain AI context files | Aspens generates and updates them for you | +| Agents spend half their tool calls searching for files | Import graph tells them which files actually matter | | Your codebase gets fragmented and inconsistent over time | Domain-specific skills with critical rules and anti-patterns | -| Burns through tokens searching, reading, and rebuilding | Claude already knows what matters — dramatically fewer tool calls | +| Burns through tokens searching, reading, and rebuilding | Your AI tools already know what matters — dramatically fewer tool calls | --- @@ -37,17 +37,50 @@ npx aspens doc init . ![aspens demo](demo/demo-full.gif) -**What are skills?** Concise markdown files (~35 lines) that Claude Code loads automatically when you work in specific parts of your codebase. They give Claude the context it needs to write correct code — key files, patterns, conventions, critical rules. +**What are skills?** Concise markdown files (~35 lines) that give coding agents the context they need to write correct code: key files, patterns, conventions, and critical rules. ## Quick Start ```bash npx aspens scan . # See what's in your repo -npx aspens doc init . # Generate skills + CLAUDE.md -npx aspens doc sync --install-hook # Auto-update on every commit +npx aspens doc init . # Generate repo docs for the active target +npx aspens doc init --target codex # Generate AGENTS.md + .agents/skills +npx aspens doc sync --install-hook # Auto-update generated docs on every commit ``` -Requires [Node.js 20+](https://nodejs.org) and [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code). +Requires [Node.js 20+](https://nodejs.org) and at least one supported backend CLI such as [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) or Codex CLI. + +## Target Support + +Aspens supports different AI tools through different output targets: + +- `claude`: `CLAUDE.md` + `.claude/skills` + Claude hooks +- `codex`: `AGENTS.md` + `.agents/skills` + directory `AGENTS.md` +- `all`: generate both sets together + +Short version: + +- Claude support is hook-aware and document-aware +- Codex support is document-driven, not hook-driven + +Important distinction: + +- Claude activation hooks are Claude-only +- The git post-commit `aspens doc sync` hook works for all configured targets + +If your repo already has Claude docs and you want to add Codex, you do not need to start from zero: + +```bash +aspens doc init --target codex +``` + +Or regenerate both targets together: + +```bash +aspens doc init --target all +``` + +See [docs/target-support.md](docs/target-support.md) for the full target model and migration notes. ## Commands @@ -106,14 +139,16 @@ $ aspens scan . ### `aspens doc init [path]` -Generate skills and CLAUDE.md. Runs parallel discovery agents to understand your architecture, then generates skills based on what it found. +Generate repo docs for Claude, Codex, or both. Runs parallel discovery calls through the selected backend to understand your architecture, then generates skills/docs based on what it found. The flow: 1. **Scan + Import Graph** — builds dependency map, finds hub files -2. **Parallel Discovery** — 2 Claude agents explore simultaneously (domains + architecture) +2. **Parallel Discovery** — 2 backend-guided discovery passes explore simultaneously (domains + architecture) 3. **User picks domains** — from the discovered feature domains 4. **Parallel Generation** — generates 3 domain skills at a time +Claude-target example: + ``` $ aspens doc init . @@ -161,17 +196,21 @@ $ aspens doc init . |--------|-------------| | `--dry-run` | Preview without writing files | | `--force` | Overwrite existing skills | -| `--timeout ` | Claude timeout (default: 300) | +| `--timeout ` | Backend timeout (default: 300) | | `--mode ` | `all`, `chunked`, or `base-only` (skips interactive prompt) | | `--strategy ` | `improve`, `rewrite`, or `skip` for existing docs (skips interactive prompt) | | `--domains ` | Additional domains to include (comma-separated) | | `--no-graph` | Skip import graph analysis | -| `--model ` | Claude model (e.g., sonnet, opus, haiku) | -| `--verbose` | Show what Claude is reading in real time | +| `--model ` | Model for the selected backend | +| `--verbose` | Show backend reads/activity in real time | +| `--target ` | Output target: `claude`, `codex`, or `all` | +| `--backend ` | Generation backend: `claude` or `codex` | ### `aspens doc sync [path]` -Update skills based on recent git commits. Reads the diff, maps changes to affected skills, and has Claude update only what changed. +Update generated docs based on recent git commits. Reads the diff, maps changes to affected docs, and updates only what changed. + +If your repo is configured for multiple targets, `doc sync` updates all configured outputs from one run. Claude activation hooks remain Claude-only, but the git post-commit sync hook can keep both Claude and Codex docs current. ``` $ aspens doc sync . @@ -200,12 +239,12 @@ $ aspens doc sync . | `--commits ` | Number of commits to analyze (default: 1) | | `--refresh` | Review all skills against current codebase (no git diff needed) | | `--no-graph` | Skip import graph analysis | -| `--install-hook` | Install git post-commit hook for auto-sync | -| `--remove-hook` | Remove the post-commit hook | +| `--install-hook` | Install git post-commit auto-sync for all configured targets | +| `--remove-hook` | Remove the git post-commit auto-sync hook | | `--dry-run` | Preview without writing files | -| `--timeout ` | Claude timeout (default: 300) | -| `--model ` | Claude model (e.g., sonnet, opus, haiku) | -| `--verbose` | Show what Claude is reading in real time | +| `--timeout ` | Backend timeout (default: 300) | +| `--model ` | Model for the selected backend | +| `--verbose` | Show backend reads/activity in real time | ### `aspens doc graph [path]` @@ -220,7 +259,7 @@ aspens doc graph . Add individual components from the bundled library, or create custom skills. ```bash -aspens add agent all # Add all 9 AI agents +aspens add agent all # Add all 11 AI agents aspens add agent code-reviewer # Add a specific agent aspens add agent --list # Browse available agents aspens add hook skill-activation # Add auto-triggering hooks @@ -238,7 +277,7 @@ aspens add skill --list # Show existing skills ### `aspens customize agents` -Inject your project's tech stack, conventions, and file paths into installed agents. Reads your skills and CLAUDE.md, then tailors each agent with project-specific context. +Inject your project's tech stack, conventions, and file paths into installed Claude agents. Reads your skills and `CLAUDE.md`, then tailors each agent with project-specific context. ```bash aspens customize agents # Customize all installed agents @@ -255,15 +294,15 @@ aspens customize agents --dry-run # Preview changes ## How It Works ``` -Your Repo ──▶ Scanner ──▶ Import Graph ──▶ Discovery Agents ──▶ Skill Generation - (detect (parse imports, (2 parallel Claude (3 domains at a - stack, hub files, agents: domains + time, guided by +Your Repo ──▶ Scanner ──▶ Import Graph ──▶ Discovery Passes ──▶ Skill Generation + (detect (parse imports, (2 parallel backend (3 domains at a + stack, hub files, calls: domains + time, guided by domains) coupling) architecture) graph + findings) ``` 1. **Scanner** detects your tech stack, frameworks, structure, and domains. Deterministic — no LLM, instant, free. 2. **Import Graph** parses imports across JS/TS/Python, resolves `@/` aliases from tsconfig, builds a dependency map with hub files, coupling analysis, git churn hotspots, and file priority ranking. -3. **Discovery Agents** (2 parallel Claude calls) explore the codebase guided by the graph. One discovers feature domains, the other analyzes architecture and patterns. Results are merged. +3. **Discovery Passes** (2 parallel backend calls) explore the codebase guided by the graph. One discovers feature domains, the other analyzes architecture and patterns. Results are merged. 4. **Skill Generation** uses the graph + discovery findings to write concise, actionable skills. Runs up to 3 domains in parallel. Doc sync keeps skills current: on each commit, it reads the diff, identifies affected skills, and updates them. @@ -299,18 +338,19 @@ You are working on **billing, Stripe integration, and usage limits**. - Cancel = `cancel_at_period_end: true` (user keeps access until period end) ``` -~35 lines. This is what Claude reads when you touch billing files. +~35 lines. This is the kind of focused context aspens generates for agent-specific docs. ## Save Tokens -Without context, Claude burns through your usage searching for files, reading code it doesn't need, and rebuilding things that already exist. With aspens, Claude knows your codebase structure before it writes a single line — fewer tool calls, fewer wasted reads, fewer rewrites. +Without context, coding agents burn through usage searching for files, reading code they don't need, and rebuilding things that already exist. With aspens, they know your codebase structure before writing a single line — fewer tool calls, fewer wasted reads, fewer rewrites. Less context searching. More code shipping. ## Requirements - **Node.js 20+** -- **Claude Code CLI** — `npm install -g @anthropic-ai/claude-code` +- **Claude Code CLI** for Claude-target generation — `npm install -g @anthropic-ai/claude-code` +- **Codex CLI** for Codex-target generation ## License diff --git a/bin/cli.js b/bin/cli.js index 70ea724..fbe7e66 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -43,16 +43,18 @@ function showWelcome() { ${pc.bold('Quick Start')} ${pc.green('aspens scan')} See your repo's tech stack and domains - ${pc.green('aspens doc init')} Generate skills + hooks + CLAUDE.md - ${pc.green('aspens doc sync --install-hook')} Auto-update on every commit + ${pc.green('aspens doc init')} Generate target docs for Claude, Codex, or both + ${pc.green('aspens doc init --target codex')} Generate AGENTS.md + .agents/skills + ${pc.green('aspens doc sync --install-hook')} Auto-update Claude docs on every commit ${pc.bold('Generate & Sync')} - ${pc.green('aspens doc init')} ${pc.dim('[path]')} Generate skills from your code + ${pc.green('aspens doc init')} ${pc.dim('[path]')} Generate docs from your code ${pc.green('aspens doc init --dry-run')} Preview without writing ${pc.green('aspens doc init --mode chunked')} One domain at a time (large repos) - ${pc.green('aspens doc init --model haiku')} Use a specific Claude model - ${pc.green('aspens doc init --verbose')} See what Claude is reading - ${pc.green('aspens doc sync')} ${pc.dim('[path]')} Update skills from recent commits + ${pc.green('aspens doc init --target all')} Generate Claude + Codex docs together + ${pc.green('aspens doc init --model haiku')} Use a specific backend model + ${pc.green('aspens doc init --verbose')} See backend activity in real time + ${pc.green('aspens doc sync')} ${pc.dim('[path]')} Update generated docs from recent commits ${pc.green('aspens doc sync --commits 5')} Sync from last 5 commits ${pc.green('aspens doc sync --refresh')} Refresh all skills from current code @@ -65,19 +67,25 @@ function showWelcome() { ${pc.green('aspens customize agents')} Inject project context into agents ${pc.bold('Options')} - ${pc.yellow('--dry-run')} Preview without writing ${pc.yellow('--verbose')} See Claude's activity - ${pc.yellow('--force')} Overwrite existing files ${pc.yellow('--model')} ${pc.dim('')} Choose Claude model + ${pc.yellow('--dry-run')} Preview without writing ${pc.yellow('--verbose')} See backend activity + ${pc.yellow('--force')} Overwrite existing files ${pc.yellow('--model')} ${pc.dim('')} Choose backend model ${pc.yellow('--mode')} ${pc.dim('')} all, chunked, base-only ${pc.yellow('--timeout')} ${pc.dim('')} Seconds per call ${pc.yellow('--strategy')} ${pc.dim('')} improve, rewrite, skip ${pc.yellow('--json')} JSON output (scan) - ${pc.yellow('--no-hooks')} Skip hook installation ${pc.yellow('--hooks-only')} Update hooks only + ${pc.yellow('--target')} ${pc.dim('')} claude, codex, all ${pc.yellow('--backend')} ${pc.dim('')} Generate with claude or codex + ${pc.yellow('--no-hooks')} Skip Claude hook installation ${pc.yellow('--hooks-only')} Update Claude hooks only ${pc.yellow('--no-graph')} Skip import graph analysis ${pc.bold('Typical Workflow')} ${pc.dim('$')} aspens scan ${pc.dim('1. See what\'s in your repo')} - ${pc.dim('$')} aspens doc init ${pc.dim('2. Generate skills + CLAUDE.md')} - ${pc.dim('$')} aspens add agent all ${pc.dim('3. Add AI agents')} - ${pc.dim('$')} aspens customize agents ${pc.dim('4. Tailor agents to your project')} - ${pc.dim('$')} aspens doc sync --install-hook ${pc.dim('5. Auto-update on every commit')} + ${pc.dim('$')} aspens doc init --target all ${pc.dim('2. Generate CLAUDE.md + AGENTS.md outputs')} + ${pc.dim('$')} aspens add agent all ${pc.dim('3. Add Claude-side AI agents')} + ${pc.dim('$')} aspens customize agents ${pc.dim('4. Tailor Claude agents to your project')} + ${pc.dim('$')} aspens doc sync --install-hook ${pc.dim('5. Auto-update Claude docs on every commit')} + + ${pc.bold('Target Notes')} + ${pc.dim('Claude:')} ${pc.cyan('CLAUDE.md + .claude/skills + hooks')} + ${pc.dim('Codex: ')} ${pc.cyan('AGENTS.md + .agents/skills + directory AGENTS.md')} + ${pc.dim('Hooks are Claude-only today. Codex is instruction-file driven.')} ${pc.dim('Run')} ${pc.cyan('aspens --help')} ${pc.dim('for detailed usage.')} @@ -87,17 +95,20 @@ function showWelcome() { } /** - * Check if a target repo has skills but is missing hooks. + * Check if a target repo has Claude skills but is missing hooks. + * Only relevant for Claude Code target (Codex doesn't use hooks). * Warns users who ran doc init before hooks were available (pre-0.2.2). */ function checkMissingHooks(repoPath) { const skillsDir = join(repoPath, '.claude', 'skills'); + if (!existsSync(skillsDir)) return; // no Claude skills — nothing to check + const hookFile = join(repoPath, '.claude', 'hooks', 'skill-activation-prompt.sh'); const rulesFile = join(repoPath, '.claude', 'skills', 'skill-rules.json'); - if (existsSync(skillsDir) && (!existsSync(hookFile) || !existsSync(rulesFile))) { + if (!existsSync(hookFile) || !existsSync(rulesFile)) { console.log( - pc.yellow('\n ⚠ Skills found but activation hooks are missing.') + + pc.yellow('\n ⚠ Claude skills found but activation hooks are missing.') + pc.dim('\n Skills won\'t auto-activate without hooks.') + '\n Run: ' + pc.cyan('aspens doc init --hooks-only') + pc.dim(' to install them.\n') @@ -136,16 +147,18 @@ doc .argument('[path]', 'Path to repo', '.') .option('--dry-run', 'Preview without writing files') .option('--force', 'Overwrite existing skills') - .option('--timeout ', 'Claude timeout in seconds', parseTimeout, 300) + .option('--timeout ', 'Backend timeout in seconds', parseTimeout, 300) .option('--mode ', 'Generation mode: all, chunked, base-only (skips interactive prompt)') .option('--strategy ', 'Existing docs: improve, rewrite, skip (skips interactive prompt)') .option('--domains ', 'Additional domains to include (comma-separated, e.g., "backtest,advisory")') - .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') + .option('--model ', 'Model to use for the selected backend') .option('--no-hook', 'Skip post-commit hook prompt') - .option('--verbose', 'Show what Claude is reading/doing in real time') - .option('--no-hooks', 'Skip hook/rules/settings installation') - .option('--hooks-only', 'Skip skill generation, just install/update hooks') + .option('--verbose', 'Show backend reads/activity in real time') + .option('--no-hooks', 'Skip Claude hook/rules/settings installation') + .option('--hooks-only', 'Skip doc generation, just install/update Claude hooks') .option('--no-graph', 'Skip import graph analysis') + .option('--target ', 'Output target: claude, codex, all') + .option('--backend ', 'Generation backend: claude, codex (default: matches target)') .action(docInitCommand); doc @@ -154,12 +167,12 @@ doc .argument('[path]', 'Path to repo', '.') .option('--commits ', 'Number of commits to analyze', parseCommits, 1) .option('--refresh', 'Refresh all skills from current codebase state (no git diff)') - .option('--install-hook', 'Install git post-commit hook') - .option('--remove-hook', 'Remove git post-commit hook') + .option('--install-hook', 'Install Claude git post-commit hook') + .option('--remove-hook', 'Remove Claude git post-commit hook') .option('--dry-run', 'Preview without writing files') - .option('--timeout ', 'Claude timeout in seconds', parseTimeout, 300) - .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') - .option('--verbose', 'Show what Claude is reading/doing in real time') + .option('--timeout ', 'Backend timeout in seconds', parseTimeout, 300) + .option('--model ', 'Model to use for the selected backend') + .option('--verbose', 'Show backend reads/activity in real time') .option('--no-graph', 'Skip import graph analysis') .action((path, options) => { checkMissingHooks(resolve(path)); @@ -168,7 +181,7 @@ doc doc .command('graph') - .description('Rebuild the import graph cache (.claude/graph.json)') + .description('Rebuild the import graph cache') .argument('[path]', 'Path to repo', '.') .option('--verbose', 'Show detailed graph info') .action(docGraphCommand); @@ -181,9 +194,9 @@ program .argument('[name]', 'Name of the resource') .option('--list', 'List available resources') .option('--from ', 'Generate skill from a reference document (skill type only)') - .option('--timeout ', 'Claude timeout in seconds (skill --from)', parseTimeout) - .option('--model ', 'Claude model to use (skill --from)') - .option('--verbose', 'Show Claude activity (skill --from)') + .option('--timeout ', 'Backend timeout in seconds (skill --from)', parseTimeout) + .option('--model ', 'Model to use for skill --from generation') + .option('--verbose', 'Show backend activity (skill --from)') .action((type, name, options) => { checkMissingHooks(resolve('.')); return addCommand(type, name, options); diff --git a/docs/target-support.md b/docs/target-support.md new file mode 100644 index 0000000..71bfde9 --- /dev/null +++ b/docs/target-support.md @@ -0,0 +1,159 @@ +# Target Support + +This document defines how aspens supports Claude and Codex today, and how to add Codex to a repo that already uses Claude. + +## Target Model + +Aspens separates two concepts: + +- `target`: where generated docs are written and how the AI tool discovers them +- `backend`: which CLI generates the content + +Examples: + +- `--target claude --backend claude` +- `--target codex --backend codex` +- `--target all --backend claude` + +The target controls the published artifacts. The backend controls which LLM CLI is used to generate them. + +## Claude Support + +Claude support is hook-aware and file-aware. + +Published artifacts: + +- `CLAUDE.md` +- `.claude/skills//skill.md` +- `.claude/hooks/...` +- `.claude/settings.json` + +Operational model: + +- Claude reads `CLAUDE.md` +- Claude can auto-activate skills through hooks and activation rules +- `aspens doc sync --install-hook` installs a Claude post-commit hook for automatic updates + +## Codex Support + +Codex support is document-driven rather than hook-driven. + +Published artifacts: + +- root `AGENTS.md` +- `.agents/skills//SKILL.md` +- directory-scoped `AGENTS.md` files for local context when needed + +Operational model: + +- Codex reads the root `AGENTS.md` as the repo-wide instruction file +- Codex can use directory `AGENTS.md` files for local instructions +- Codex can use reusable `.agents/skills/.../SKILL.md` documents for deeper topic guidance +- Codex does not use the Claude hook system + +Current limitation: + +- Claude hooks are first-class in aspens today +- Codex does not have a matching hook layer in aspens +- Codex support is based on writing the right instruction files in the appropriate places + +## CLAUDE.md vs AGENTS.md + +These files play the same top-level role for different targets: + +- Claude repo-wide instructions: `CLAUDE.md` +- Codex repo-wide instructions: `AGENTS.md` + +If you want repo-level rules, conventions, commands, or architecture notes for Codex, they belong in root `AGENTS.md`. + +## Adding Codex To A Repo That Already Uses Claude + +You do not need to treat Codex setup as a completely separate project. + +Aspens already uses a canonical generation model internally: + +1. generate canonical repo instructions and skills +2. project them into target-specific output + +That means an existing Claude setup can be a strong starting point for Codex. + +Recommended flow: + +```bash +aspens doc init --target codex +``` + +Or, if you want both targets refreshed together: + +```bash +aspens doc init --target all +``` + +What this should do conceptually: + +- reuse existing repo understanding where possible +- improve existing docs when they already exist +- publish Codex-native artifacts without forcing you to rebuild your repo docs from scratch + +In practice, this means aspens should use the existing `CLAUDE.md` and `.claude/skills` as useful source context when generating or improving Codex output, instead of pretending the repo has no AI docs yet. + +## Command Behavior By Target + +### `aspens doc init` + +Claude target: + +- generates `CLAUDE.md` +- generates `.claude/skills/.../skill.md` +- can install/update Claude hooks and settings + +Codex target: + +- generates `AGENTS.md` +- generates `.agents/skills/.../SKILL.md` +- generates directory `AGENTS.md` files when needed +- does not install Claude hooks + +All targets: + +- updates both Claude and Codex artifacts from one run + +### `aspens doc sync` + +Claude target: + +- updates Claude docs from recent commits +- can install/remove the Claude post-commit hook + +Codex target: + +- updates Codex docs from recent commits +- should refresh derived Codex directory docs from the current skill set + +### `aspens add skill` + +Claude target: + +- scaffolds `.claude/skills//skill.md` + +Codex target: + +- scaffolds `.agents/skills//SKILL.md` + +## Product Guidance + +Keep the user-facing language aligned with the active target. + +Good Claude-facing language: + +- `CLAUDE.md` +- Claude hooks +- Claude skills + +Good Codex-facing language: + +- `AGENTS.md` +- `.agents/skills` +- directory `AGENTS.md` + +Avoid leaking canonical internal terms like `base skill` or `Generate CLAUDE.md` in Codex mode. diff --git a/package-lock.json b/package-lock.json index eb3657e..60e3fd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aspens", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aspens", - "version": "0.5.0", + "version": "0.6.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -19,7 +19,7 @@ "aspens": "bin/cli.js" }, "devDependencies": { - "vitest": "^4.1.0" + "vitest": "^4.1.3" }, "engines": { "node": ">=20" @@ -47,9 +47,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "optional": true, @@ -59,9 +59,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, @@ -88,36 +88,28 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", "dev": true, "license": "MIT", "funding": { @@ -125,9 +117,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", "cpu": [ "arm64" ], @@ -142,9 +134,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", "cpu": [ "arm64" ], @@ -159,9 +151,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", "cpu": [ "x64" ], @@ -176,9 +168,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", "cpu": [ "x64" ], @@ -193,9 +185,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", "cpu": [ "arm" ], @@ -210,9 +202,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", "cpu": [ "arm64" ], @@ -227,9 +219,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", "cpu": [ "arm64" ], @@ -244,9 +236,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", "cpu": [ "ppc64" ], @@ -261,9 +253,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", "cpu": [ "s390x" ], @@ -278,9 +270,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", "cpu": [ "x64" ], @@ -295,9 +287,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", "cpu": [ "x64" ], @@ -312,9 +304,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", "cpu": [ "arm64" ], @@ -329,9 +321,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", "cpu": [ "wasm32" ], @@ -339,16 +331,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", "cpu": [ "arm64" ], @@ -363,9 +357,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", "cpu": [ "x64" ], @@ -380,9 +374,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", "dev": true, "license": "MIT" }, @@ -430,31 +424,31 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -463,7 +457,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -475,26 +469,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.3", "pathe": "^2.0.3" }, "funding": { @@ -502,14 +496,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -518,9 +512,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", "dev": true, "license": "MIT", "funding": { @@ -528,15 +522,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1004,14 +998,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1020,21 +1014,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" } }, "node_modules/siginfo": { @@ -1127,17 +1121,16 @@ "optional": true }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", + "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", + "rolldown": "1.0.0-rc.13", "tinyglobby": "^0.2.15" }, "bin": { @@ -1154,8 +1147,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -1206,19 +1199,19 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -1229,8 +1222,8 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -1246,13 +1239,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -1273,6 +1268,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, diff --git a/package.json b/package.json index 442c20d..83af9ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspens", - "version": "0.5.0", + "version": "0.6.0", "description": "Generate and maintain AI-ready documentation for any codebase", "type": "module", "bin": { @@ -23,7 +23,7 @@ "test": "vitest run", "start": "node bin/cli.js", "lint": "echo 'No linter configured yet' && exit 0", - "postinstall": "echo '\n 📌 aspens v0.2.2: Skill activation hooks are now required.\n If you have existing skills, run: aspens doc init --hooks-only\n\n 🌲 aspens is in active development — please keep it up to date.\n Run into issues? Let us know: https://github.com/aspenkit/aspens/issues\n'" + "postinstall": "echo '\n 📌 aspens v0.6.0: Existing repos should refresh generated docs after upgrading.\n Recommended: aspens doc sync --refresh\n\n 🌲 aspens is in active development — please keep it up to date.\n Run into issues? Let us know: https://github.com/aspenkit/aspens/issues\n'" }, "engines": { "node": ">=20" @@ -35,6 +35,6 @@ "picocolors": "^1.1.0" }, "devDependencies": { - "vitest": "^4.1.0" + "vitest": "^4.1.3" } } diff --git a/src/commands/add.js b/src/commands/add.js index c95a128..44dc319 100644 --- a/src/commands/add.js +++ b/src/commands/add.js @@ -5,9 +5,10 @@ import pc from 'picocolors'; import * as p from '@clack/prompts'; import { CliError } from '../lib/errors.js'; import { resolveTimeout } from '../lib/timeout.js'; -import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; +import { runLLM, loadPrompt, parseFileOutput } from '../lib/runner.js'; import { extractRulesFromSkills } from '../lib/skill-writer.js'; import { findSkillFiles } from '../lib/skill-reader.js'; +import { TARGETS, getAllowedPaths, readConfig } from '../lib/target.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', 'templates'); @@ -36,6 +37,17 @@ const RESOURCE_TYPES = { export async function addCommand(type, name, options) { const repoPath = resolve('.'); + // Check if target is Codex-only — agents, commands, hooks are Claude-only + const config = readConfig(repoPath); + const isCodexOnly = config?.targets?.length === 1 && config.targets[0] === 'codex'; + if (isCodexOnly && ['agent', 'command', 'hook'].includes(type)) { + throw new CliError( + `"aspens add ${type}" is only available for Claude Code targets. ` + + `This repo is configured for Codex CLI only.\n` + + `Use "aspens add skill" instead — skills work with both targets.` + ); + } + // Skill type — handled separately (not template-based) if (type === 'skill') { return addSkillCommand(repoPath, name, options); @@ -132,6 +144,14 @@ ${available.map(a => ` ${pc.green(a.name)} — ${a.description}`).join('\n')} console.log(); } +function resolveSkillTarget(config) { + const targetIds = config?.targets || ['claude']; + if (targetIds.length === 1 && targetIds[0] === 'codex') { + return TARGETS.codex; + } + return TARGETS.claude; +} + function showCustomizeTip() { console.log(); console.log(pc.dim(' Tip: Run ') + pc.cyan('aspens customize agents') + pc.dim(' to inject your project\'s')); @@ -225,11 +245,15 @@ function ensureDevGitignore(repoPath) { // --- Custom skill --- async function addSkillCommand(repoPath, name, options) { - const skillsDir = join(repoPath, '.claude', 'skills'); + const config = readConfig(repoPath); + const target = resolveSkillTarget(config); + const skillsDir = join(repoPath, target.skillsDir); + const skillFilename = target.skillFilename; + const relSkillsDir = target.skillsDir; // --list mode: show existing skills if (options.list) { - const skills = existsSync(skillsDir) ? findSkillFiles(skillsDir) : []; + const skills = existsSync(skillsDir) ? findSkillFiles(skillsDir, { skillFilename }) : []; console.log(` ${pc.bold('Skills')} ${pc.dim(`(${skills.length} installed)`)} ${pc.dim('Custom skills for conventions, workflows, and processes.')} @@ -275,8 +299,8 @@ async function addSkillCommand(repoPath, name, options) { } const skillDir = join(skillsDir, safeName); - const skillPath = join(skillDir, 'skill.md'); - const relPath = `.claude/skills/${safeName}/skill.md`; + const skillPath = join(skillDir, skillFilename); + const relPath = `${relSkillsDir}/${safeName}/${skillFilename}`; if (existsSync(skillPath)) { console.log(pc.yellow(`\n Skill already exists: ${relPath}`)); @@ -325,20 +349,30 @@ You are working on **${safeName}**. console.log(`\n ${pc.green('+')} ${relPath}`); console.log(pc.dim(`\n Edit the skill to add your conventions and file patterns.`)); - console.log(pc.dim(` Then run ${pc.cyan('aspens doc init --hooks-only')} to update activation rules.\n`)); + if (target.id === 'claude') { + console.log(pc.dim(` Then run ${pc.cyan('aspens doc init --hooks-only')} to update activation rules.\n`)); + } else { + console.log(); + } - updateSkillRules(skillsDir); + if (target.id === 'claude') { + updateSkillRules(skillsDir); + } } async function generateSkillFromDoc(repoPath, skillName, options) { + const config = readConfig(repoPath); + const target = resolveSkillTarget(config); + const backendId = config?.backend || target.id; const fromPath = resolve(options.from); if (!existsSync(fromPath)) { throw new CliError(`Reference file not found: ${options.from}`); } - const skillDir = join(repoPath, '.claude', 'skills', skillName); - const relPath = `.claude/skills/${skillName}/skill.md`; + const skillDir = join(repoPath, target.skillsDir, skillName); + const relPath = `${target.skillsDir}/${skillName}/${target.skillFilename}`; const verbose = !!options.verbose; + const allowedPaths = getAllowedPaths([target]); const { timeoutMs } = resolveTimeout(options.timeout, 120); @@ -370,19 +404,20 @@ ${refContent} let result; try { - result = await runClaude(fullPrompt, { + result = await runLLM(fullPrompt, { timeout: timeoutMs, allowedTools: ['Read', 'Glob', 'Grep'], verbose, model: options.model || null, onActivity: verbose ? (msg) => genSpinner.message(pc.dim(msg)) : null, - }); + cwd: repoPath, + }, backendId); } catch (err) { genSpinner.stop(pc.red('Failed')); throw new CliError(err.message, { cause: err }); } - const files = parseFileOutput(result.text); + const files = parseFileOutput(result.text, allowedPaths); if (files.length === 0) { genSpinner.stop(pc.red('No skill generated')); throw new CliError('Claude did not produce a skill file. Try a different reference document or write the skill manually.'); @@ -399,11 +434,17 @@ ${refContent} console.log(`\n ${pc.green('+')} ${file.path}`); } - const skillsDir = join(repoPath, '.claude', 'skills'); - updateSkillRules(skillsDir); + if (target.id === 'claude') { + const skillsDir = join(repoPath, target.skillsDir); + updateSkillRules(skillsDir); + } console.log(pc.dim(`\n Review the generated skill and adjust as needed.`)); - console.log(pc.dim(` Run ${pc.cyan('aspens doc init --hooks-only')} to update activation hooks.\n`)); + if (target.id === 'claude') { + console.log(pc.dim(` Run ${pc.cyan('aspens doc init --hooks-only')} to update activation hooks.\n`)); + } else { + console.log(); + } } function updateSkillRules(skillsDir) { diff --git a/src/commands/customize.js b/src/commands/customize.js index a46d07c..d0b4d48 100644 --- a/src/commands/customize.js +++ b/src/commands/customize.js @@ -6,14 +6,12 @@ import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; import { writeSkillFiles } from '../lib/skill-writer.js'; import { CliError } from '../lib/errors.js'; import { resolveTimeout } from '../lib/timeout.js'; +import { readConfig } from '../lib/target.js'; const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; export async function customizeCommand(what, options) { const repoPath = resolve('.'); - const { timeoutMs, envWarning } = resolveTimeout(options.timeout, 300); - if (envWarning) p.log.warn('ASPENS_TIMEOUT is not a valid number — using default timeout.'); - const verbose = !!options.verbose; if (what !== 'agents') { console.log(` @@ -25,6 +23,19 @@ export async function customizeCommand(what, options) { throw new CliError(`Unknown target: ${what}`, { logged: true }); } + // Customize is Claude-only — Codex has no agent concept + const config = readConfig(repoPath); + const isCodexOnly = config?.targets?.length === 1 && config.targets[0] === 'codex'; + if (isCodexOnly) { + throw new CliError( + '"aspens customize agents" is only available for Claude Code targets. ' + + 'This repo is configured for Codex CLI only.' + ); + } + const { timeoutMs, envWarning } = resolveTimeout(options.timeout, 300); + if (envWarning) p.log.warn('ASPENS_TIMEOUT is not a valid number — using default timeout.'); + const verbose = !!options.verbose; + p.intro(pc.cyan('aspens customize agents')); // Step 1: Find agents in the repo diff --git a/src/commands/doc-graph.js b/src/commands/doc-graph.js index 2c0c8ab..fa4facc 100644 --- a/src/commands/doc-graph.js +++ b/src/commands/doc-graph.js @@ -5,10 +5,16 @@ import { scanRepo } from '../lib/scanner.js'; import { buildRepoGraph } from '../lib/graph-builder.js'; import { persistGraphArtifacts } from '../lib/graph-persistence.js'; import { CliError } from '../lib/errors.js'; +import { TARGETS, readConfig } from '../lib/target.js'; export async function docGraphCommand(path, options) { const repoPath = resolve(path); + // Check target — graph artifacts are Claude-only + const config = readConfig(repoPath); + const targetId = config?.targets?.[0] || 'claude'; + const target = TARGETS[targetId] || TARGETS.claude; + p.intro(pc.cyan('aspens doc graph')); const spinner = p.spinner(); @@ -26,13 +32,17 @@ export async function docGraphCommand(path, options) { } try { - persistGraphArtifacts(repoPath, repoGraph); + persistGraphArtifacts(repoPath, repoGraph, { target }); } catch (err) { spinner.stop(pc.red('Failed to save graph')); throw new CliError(`Failed to persist graph artifacts: ${err.message}`); } - spinner.stop(pc.green('Graph saved')); + if (!target.supportsGraph) { + spinner.stop(pc.dim('Graph built (artifacts not written — Codex target)')); + } else { + spinner.stop(pc.green('Graph saved')); + } // Print stats console.log(); @@ -51,5 +61,5 @@ export async function docGraphCommand(path, options) { console.log(); } - p.outro(pc.dim('Saved to .claude/graph.json + .claude/code-map.md')); + p.outro(pc.dim(target.supportsGraph ? 'Saved graph artifacts' : 'Graph analysis complete')); } diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index d1bfb81..e7eef22 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -5,12 +5,16 @@ import pc from 'picocolors'; import * as p from '@clack/prompts'; import { scanRepo } from '../lib/scanner.js'; import { buildRepoGraph } from '../lib/graph-builder.js'; -import { runClaude, loadPrompt, parseFileOutput, validateSkillFiles } from '../lib/runner.js'; -import { writeSkillFiles, extractRulesFromSkills, generateDomainPatterns, mergeSettings } from '../lib/skill-writer.js'; +import { runLLM, loadPrompt, parseFileOutput, validateSkillFiles } from '../lib/runner.js'; +import { writeSkillFiles, writeTransformedFiles, extractRulesFromSkills, generateDomainPatterns, mergeSettings } from '../lib/skill-writer.js'; import { persistGraphArtifacts } from '../lib/graph-persistence.js'; import { installGitHook } from '../lib/git-hook.js'; import { CliError } from '../lib/errors.js'; import { resolveTimeout } from '../lib/timeout.js'; +import { TARGETS, resolveTarget, getAllowedPaths, writeConfig } from '../lib/target.js'; +import { detectAvailableBackends, resolveBackend } from '../lib/backend.js'; +import { transformForTarget, validateTransformedFiles } from '../lib/target-transform.js'; +import { findSkillFiles } from '../lib/skill-reader.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', 'templates'); @@ -34,6 +38,7 @@ function makeClaudeOptions(timeoutMs, verbose, model, spinner) { verbose, model: model || null, onActivity: verbose && spinner ? (msg) => spinner.message(pc.dim(msg)) : null, + cwd: _repoPath, }; } @@ -46,9 +51,60 @@ function sanitizeInline(content, maxLen = MAX_INLINE_CHARS) { return text; } +/** + * Parse files from LLM output, with Codex fallback. + * Codex often returns plain markdown without tags. + * When that happens, wrap the text as the primary target's instructions file. + */ +function parseLLMOutput(text, allowedPaths, expectedPath) { + let files = parseFileOutput(text, allowedPaths); + const exactFiles = allowedPaths?.exactFiles || []; + const dirPrefixes = allowedPaths?.dirPrefixes || []; + const isSingleFilePrompt = + !!expectedPath && + exactFiles.length === 1 && + exactFiles[0] === expectedPath && + dirPrefixes.length === 0; + + // If no tags found (common with Codex), wrap only for true single-file prompts. + if (files.length === 0 && text.trim().length > 50 && expectedPath && isSingleFilePrompt) { + files = [{ path: expectedPath, content: text.trim() + '\n' }]; + } + return files; +} + +// Canonical (Claude) vars for prompts — generation always uses Claude format. +// Codex output is produced by transforming canonical output. +const CANONICAL_VARS = { + skillsDir: '.claude/skills', + skillFilename: 'skill.md', + instructionsFile: 'CLAUDE.md', + configDir: '.claude', +}; +const CANONICAL_ALLOWED_PATHS = getAllowedPaths([TARGETS.claude]); + +// Active backend for this run (set at start of docInitCommand, used by runLLM) +let _backendId = 'claude'; +let _primaryTarget = TARGETS.claude; +let _allowedPaths = null; +let _repoPath = null; +let _reuseSourceTarget = null; + // Track token usage across all calls const tokenTracker = { promptTokens: 0, toolResultTokens: 0, output: 0, toolUses: 0, calls: 0 }; +function isCodexPrimary() { + return _primaryTarget?.id === 'codex'; +} + +function baseArtifactLabel() { + return isCodexPrimary() ? 'root AGENTS.md' : 'base skill'; +} + +function instructionsArtifactLabel() { + return isCodexPrimary() ? 'root AGENTS.md' : 'CLAUDE.md'; +} + function trackUsage(usage, promptLength) { if (usage) { tokenTracker.promptTokens += Math.ceil((promptLength || 0) / 4); @@ -61,16 +117,17 @@ function trackUsage(usage, promptLength) { export async function docInitCommand(path, options) { const repoPath = resolve(path); + _repoPath = repoPath; const verbose = !!options.verbose; const model = options.model || null; // --hooks-only: skip skill generation, just install/update hooks if (options.hooksOnly) { p.intro(pc.cyan('aspens doc init --hooks-only')); - const skillsDir = join(repoPath, '.claude', 'skills'); + const hooksTarget = TARGETS.claude; // hooks are Claude-only + const skillsDir = join(repoPath, hooksTarget.skillsDir); if (!existsSync(skillsDir)) { - p.log.error('No skills found in .claude/skills/. Run `aspens doc init` first.'); - process.exit(1); + throw new CliError(`No skills found in ${hooksTarget.skillsDir}/. Run \`aspens doc init\` first.`); } await installHooks(repoPath, options); p.outro(pc.green('Hooks updated')); @@ -92,6 +149,67 @@ export async function docInitCommand(path, options) { p.intro(pc.cyan('aspens doc init')); + // --- Step 0: Detect available backends --- + const available = detectAvailableBackends(); + if (!available.claude && !available.codex) { + throw new CliError( + 'aspens requires either Claude CLI or Codex CLI.\n' + + ' Install Claude CLI: https://docs.anthropic.com/claude-code\n' + + ' Install Codex CLI: https://github.com/openai/codex' + ); + } + + // --- Step 1: Backend selection (which AI generates) --- + let backendResult; + if (options.backend) { + backendResult = resolveBackend({ backendFlag: options.backend, available }); + } else if (available.claude && available.codex) { + const backendChoice = await p.select({ + message: 'Which AI should generate the docs?', + options: [ + { value: 'claude', label: 'Claude CLI', hint: 'uses your Anthropic subscription' }, + { value: 'codex', label: 'Codex CLI', hint: 'uses your OpenAI subscription' }, + ], + }); + if (p.isCancel(backendChoice)) { p.cancel('Aborted'); return; } + backendResult = resolveBackend({ backendFlag: backendChoice, available }); + } else { + // Only one available — use it + backendResult = resolveBackend({ available }); + } + const { backend, warning: backendWarning } = backendResult; + if (backendWarning) p.log.warn(backendWarning); + _backendId = backend.id; + + // --- Step 2: Target selection (what to generate FOR) --- + let targetIds; + if (options.target) { + targetIds = options.target === 'all' ? ['claude', 'codex'] : [options.target]; + } else if (available.claude && available.codex) { + const selected = await p.multiselect({ + message: 'Generate docs for which coding agents?', + options: [ + { value: 'claude', label: 'Claude Code', hint: 'CLAUDE.md + .claude/skills/ + hooks' }, + { value: 'codex', label: 'Codex CLI', hint: 'AGENTS.md + .agents/skills/' }, + ], + initialValues: [backend.id], // pre-select matching target + required: true, + }); + if (p.isCancel(selected)) { p.cancel('Aborted'); return; } + targetIds = selected; + } else { + // Only one CLI — generate for matching target + targetIds = [available.claude ? 'claude' : 'codex']; + } + const targets = targetIds.map(id => resolveTarget(id)); + const primaryTarget = targets[0]; + _primaryTarget = primaryTarget; + _allowedPaths = null; // canonical generation uses defaults + + console.log(pc.dim(` Target: ${targets.map(t => t.label).join(' + ')}`)); + console.log(pc.dim(` Backend: ${backend.label}`)); + console.log(); + // Step 1: Scan const scanSpinner = p.spinner(); scanSpinner.start('Scanning repository...'); @@ -99,12 +217,14 @@ export async function docInitCommand(path, options) { // Build import graph let repoGraph = null; + let graphSerialized = null; if (options.graph !== false) { try { repoGraph = await buildRepoGraph(repoPath, scan.languages); // Persist graph, code-map skill, and index for runtime use + // For Codex-only target, this returns serialized data without writing files try { - persistGraphArtifacts(repoPath, repoGraph); + graphSerialized = persistGraphArtifacts(repoPath, repoGraph, { target: primaryTarget }); } catch { /* graph persistence failed — non-fatal */ } } catch { /* graph building failed — continue without it */ } } @@ -135,10 +255,27 @@ export async function docInitCommand(path, options) { // Step 2: Parallel Discovery — runs immediately, no user input needed let discoveryFindings = null; let discoveredDomains = []; + let reusedDomains = []; const isBaseOnly = options.mode === 'base-only'; const isDomainsOnly = options.mode === 'chunked' && extraDomains && extraDomains.length > 0; - if (repoGraph && repoGraph.stats.totalFiles > 0 && !isBaseOnly && !isDomainsOnly) { + // When existing docs are found, ask whether to run discovery or reuse existing domains + const hasClaudeDocs = scan.hasClaudeConfig || scan.hasClaudeMd; + const hasCodexDocs = scan.hasAgentsMd; + const hasExistingDocs = hasClaudeDocs || hasCodexDocs; + _reuseSourceTarget = chooseReuseSourceTarget(targets, hasClaudeDocs, hasCodexDocs); + let skipDiscovery = false; + if (hasExistingDocs && !isBaseOnly && !isDomainsOnly && options.strategy !== 'rewrite') { + const existingSource = hasClaudeDocs && hasCodexDocs ? 'Claude + Codex' + : hasClaudeDocs ? 'Claude' : 'Codex'; + const reuse = await p.confirm({ + message: `Existing ${existingSource} docs found. Skip discovery and reuse existing domains?`, + initialValue: true, + }); + if (p.isCancel(reuse)) { p.cancel('Aborted'); return; } + skipDiscovery = reuse; + } + if (repoGraph && repoGraph.stats.totalFiles > 0 && !isBaseOnly && !isDomainsOnly && !skipDiscovery) { console.log(pc.dim(' Running 2 discovery agents in parallel...')); console.log(); const discoverSpinner = p.spinner(); @@ -158,7 +295,7 @@ export async function docInitCommand(path, options) { try { const context = `\n\n---\n\nRepository: ${repoPath}\n\n${scanSummary}\n\n${domainDiscoveryContext}`; const prompt = loadPrompt('discover-domains') + context; - const { text, usage } = await runClaude(prompt, makeClaudeOptions(timeoutMs, verbose, model, null)); + const { text, usage } = await runLLM(prompt, makeClaudeOptions(timeoutMs, verbose, model, null), _backendId); trackUsage(usage, prompt.length); const match = text.match(/([\s\S]*?)<\/findings>/); return match ? match[1].trim() : null; @@ -169,7 +306,7 @@ export async function docInitCommand(path, options) { try { const context = `\n\n---\n\nRepository: ${repoPath}\n\n${scanSummary}\n\n${archDiscoveryContext}`; const prompt = loadPrompt('discover-architecture') + context; - const { text, usage } = await runClaude(prompt, makeClaudeOptions(timeoutMs, verbose, model, null)); + const { text, usage } = await runLLM(prompt, makeClaudeOptions(timeoutMs, verbose, model, null), _backendId); trackUsage(usage, prompt.length); const match = text.match(/([\s\S]*?)<\/findings>/); return match ? match[1].trim() : null; @@ -231,7 +368,23 @@ export async function docInitCommand(path, options) { } // Use discovered domains if available, otherwise fall back to scanner domains - const effectiveDomains = discoveredDomains.length > 0 ? discoveredDomains : scan.domains; + if (skipDiscovery && _reuseSourceTarget) { + reusedDomains = loadReusableDomains(repoPath, _reuseSourceTarget); + if (reusedDomains.length > 0) { + console.log(pc.dim(` Reusing ${reusedDomains.length} ${_reuseSourceTarget.label} skill domains:`)); + for (const d of reusedDomains) { + const hint = d.description || d.files?.slice(0, 2).join(', '); + console.log(pc.dim(' ') + pc.green(d.name) + (hint ? pc.dim(` — ${hint.slice(0, 120)}`) : '')); + } + console.log(); + } + } + + const effectiveDomains = discoveredDomains.length > 0 + ? discoveredDomains + : reusedDomains.length > 0 + ? reusedDomains + : scan.domains; // Step 3: Strategy for existing docs let existingDocsStrategy = 'fresh'; @@ -241,14 +394,36 @@ export async function docInitCommand(path, options) { if (!['improve', 'rewrite', 'skip-existing', 'fresh'].includes(existingDocsStrategy)) { throw new CliError(`Unknown strategy: ${options.strategy}. Use: improve, rewrite, or skip`); } - } else if ((scan.hasClaudeConfig || scan.hasClaudeMd) && !options.force && !isDomainsOnly) { + } else if ((scan.hasClaudeConfig || scan.hasClaudeMd || scan.hasAgentsMd) && !options.force && !isDomainsOnly) { + // Detect what actually exists per-target + const hasClaudeDocs = scan.hasClaudeConfig || scan.hasClaudeMd; + const hasCodexDocs = scan.hasAgentsMd; + const isCodexTarget = targetIds.includes('codex'); + const isClaudeTarget = targetIds.includes('claude'); + + // Build accurate message + let existingMsg; + if (hasClaudeDocs && hasCodexDocs) { + existingMsg = 'Existing Claude and Codex docs detected. How to proceed:'; + } else if (hasClaudeDocs && isCodexTarget && !hasCodexDocs) { + existingMsg = 'Existing Claude docs detected. Reuse them to generate Codex output?'; + } else if (hasCodexDocs && isClaudeTarget && !hasClaudeDocs) { + existingMsg = 'Existing Codex docs detected. Reuse them to generate Claude output?'; + } else if (hasClaudeDocs) { + existingMsg = 'Existing CLAUDE.md and/or skills detected. How to proceed:'; + } else { + existingMsg = 'Existing AGENTS.md detected. How to proceed:'; + } + + const strategyOptions = [ + { value: 'improve', label: 'Improve existing (recommended)', hint: hasClaudeDocs && isCodexTarget && !hasCodexDocs ? 'reuse Claude docs as context for Codex' : 'read current docs, update based on actual code' }, + { value: 'rewrite', label: 'Rewrite from scratch', hint: 'ignore existing, generate fresh' }, + { value: 'skip-existing', label: 'Keep existing, skip', hint: 'only generate skills for new domains' }, + ]; + const strategy = await p.select({ - message: 'Existing CLAUDE.md and/or skills detected. How to proceed:', - options: [ - { value: 'improve', label: 'Improve existing (recommended)', hint: 'read current docs, update based on actual code' }, - { value: 'rewrite', label: 'Rewrite from scratch', hint: 'ignore existing, generate fresh' }, - { value: 'skip-existing', label: 'Keep existing, skip', hint: 'only generate skills for new domains' }, - ], + message: existingMsg, + options: strategyOptions, }); if (p.isCancel(strategy)) { @@ -286,7 +461,7 @@ export async function docInitCommand(path, options) { p.log.info(`Generating ${selectedDomains.length} domain(s): ${selectedDomains.map(d => d.name).join(', ')}`); } } else if (effectiveDomains.length === 0) { - p.log.info('No domains detected — generating base skill only.'); + p.log.info(`No domains detected — generating ${baseArtifactLabel()} only.`); mode = 'base-only'; } else { // Smart defaults based on repo size @@ -296,17 +471,18 @@ export async function docInitCommand(path, options) { let defaultMode = 'all-at-once'; if (isLarge || domainCount > 6) defaultMode = 'chunked'; - // Estimate Claude calls for each mode - const chunkedCalls = domainCount + 2; // base + N domains + CLAUDE.md + // Estimate LLM calls for each mode + const chunkedCalls = domainCount + 2; // base + N domains + instructions file + const backendName = _backendId === 'codex' ? 'Codex' : 'Claude'; const modeChoice = await p.select({ message: `${domainCount} domains detected. Generate skills:`, initialValue: defaultMode, options: [ - { value: 'all-at-once', label: 'All at once', hint: isLarge ? 'may timeout on this repo — 1 call' : 'faster — 1 Claude call' }, - { value: 'chunked', label: 'One domain at a time', hint: `reliable — ${chunkedCalls} Claude calls` }, + { value: 'all-at-once', label: 'All at once', hint: isLarge ? 'may timeout on this repo — 1 call' : `faster — 1 ${backendName} call` }, + { value: 'chunked', label: 'One domain at a time', hint: `reliable — ${chunkedCalls} ${backendName} calls` }, { value: 'pick', label: 'Pick specific domains', hint: 'choose which domains to generate' }, - { value: 'base-only', label: 'Base skill only', hint: '2 Claude calls' }, + { value: 'base-only', label: isCodexPrimary() ? 'Root AGENTS only' : 'Base skill only', hint: `2 ${backendName} calls` }, ], }); @@ -338,6 +514,13 @@ export async function docInitCommand(path, options) { // Step 5: Generate skills let allFiles = []; + const reuseExistingCanonical = ( + existingDocsStrategy === 'improve' && + _reuseSourceTarget?.id === 'claude' + ); + if (reuseExistingCanonical) { + p.log.info(pc.dim(`Using existing ${_reuseSourceTarget.label} docs as improvement context.`)); + } if (mode === 'all-at-once') { allFiles = await generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings, !!options.mode); @@ -348,7 +531,8 @@ export async function docInitCommand(path, options) { if (allFiles.length === 0) { if (tokenTracker.calls > 0) { - console.log(pc.dim(` ${tokenTracker.calls} Claude call(s) made, but no parseable output.`)); + const backendLabel = _backendId === 'codex' ? 'Codex' : 'Claude'; + console.log(pc.dim(` ${tokenTracker.calls} ${backendLabel} call(s) made, but no parseable output.`)); } throw new CliError('No skill files generated.', { logged: true }); } @@ -378,6 +562,46 @@ export async function docInitCommand(path, options) { console.log(); } + // Step 6.5: Transform canonical output for each target + // Generation always produces Claude-canonical format (.claude/skills/, CLAUDE.md). + // For Claude target: canonical files are the final output (no transform needed). + // For non-Claude targets: transform canonical → target format. + // For --target all: keep canonical + add transformed for each non-Claude target. + const canonicalFiles = [...allFiles]; // preserve originals + const nonClaudeTargets = targets.filter(t => t.id !== 'claude'); + + if (nonClaudeTargets.length > 0) { + for (const target of nonClaudeTargets) { + const transformSpinner = p.spinner(); + transformSpinner.start(`Transforming output for ${target.label}...`); + + const transformed = transformForTarget(canonicalFiles, TARGETS.claude, target, { + scanResult: scan, + graphSerialized, + }); + + const transformValidation = validateTransformedFiles(transformed); + if (!transformValidation.valid) { + p.log.warn(`Transform issues for ${target.label}:`); + for (const issue of transformValidation.issues) { + console.log(pc.dim(' ') + pc.yellow('!') + ' ' + issue); + } + } + + const validTransformed = transformValidation.valid + ? transformed + : transformed.filter(f => validateTransformedFiles([f]).valid); + + allFiles = [...allFiles, ...validTransformed]; + transformSpinner.stop(`Transformed ${validTransformed.length} files for ${target.label}`); + } + + // If no Claude target requested, remove the canonical Claude files from output + if (!targets.some(t => t.id === 'claude')) { + allFiles = allFiles.filter(f => !canonicalFiles.includes(f)); + } + } + // Step 7: Show what will be written const shouldForce = options.force || existingDocsStrategy === 'improve' || existingDocsStrategy === 'rewrite'; console.log(); @@ -415,9 +639,17 @@ export async function docInitCommand(path, options) { } // Step 8: Write files + // Split: Claude-target files use writeSkillFiles (standard paths), + // directory-scoped files (e.g., src/billing/AGENTS.md) use writeTransformedFiles (warn-and-skip) const writeSpinner = p.spinner(); writeSpinner.start('Writing files...'); - const results = writeSkillFiles(repoPath, allFiles, { force: shouldForce }); + const directWriteFiles = allFiles.filter(f => !(f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md')); + const dirScopedFiles = allFiles.filter(f => f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md'); + + const results = [ + ...writeSkillFiles(repoPath, directWriteFiles, { force: shouldForce }), + ...writeTransformedFiles(repoPath, dirScopedFiles, { force: shouldForce }), + ]; writeSpinner.stop('Done'); // Summary @@ -435,13 +667,18 @@ export async function docInitCommand(path, options) { } // Step 9: Generate skill-rules.json + install hooks (unless --no-hooks) - if (options.hooks !== false) { + // Only for targets that support hooks (Claude) + const hasHookTarget = targets.some(t => t.supportsHooks); + if (options.hooks !== false && hasHookTarget) { await installHooks(repoPath, options); } + // Step 10: Persist target config + writeConfig(repoPath, { targets: targetIds, backend: backend.id }); + showTokenSummary(startTime); - // Offer auto-sync hook + // Offer auto-sync git hook (works for all targets — runs `aspens doc sync` on commit) if (options.hook !== false && !options.dryRun && existsSync(join(repoPath, '.git'))) { const hookPath = join(repoPath, '.git', 'hooks', 'post-commit'); const hookInstalled = existsSync(hookPath) && @@ -790,18 +1027,186 @@ function buildDomainGraphContext(graph, domain) { function buildStrategyInstruction(strategy) { if (strategy === 'improve') { - return `\n\n**IMPORTANT — Improve mode:** This repo already has existing CLAUDE.md and/or skills in .claude/skills/. Read them first. Preserve ALL explicitly written instructions, conventions, gotchas, and team decisions in the existing CLAUDE.md — these were hand-written for a reason and must not be lost or summarized away. Update what's outdated, add what's missing, improve structure, but treat existing human-written content as authoritative.`; + return `\n\n**IMPORTANT — Improve mode:** This repo already has existing project docs and/or skills. Read them first. Preserve ALL explicitly written instructions, conventions, gotchas, and team decisions in the existing docs — these were hand-written for a reason and must not be lost or summarized away. Update what's outdated, add what's missing, improve structure, but treat existing human-written content as authoritative.`; } if (strategy === 'skip-existing') { - return `\n\n**IMPORTANT — Skip existing mode:** This repo already has existing CLAUDE.md and/or skills. Do NOT regenerate files that already exist. Only generate skills for domains that don't have a skill file yet. Read existing .claude/skills/ to see what's already covered.`; + return `\n\n**IMPORTANT — Skip existing mode:** This repo already has existing project docs and/or skills. Do NOT regenerate files that already exist. Only generate skills for domains that don't have a skill file yet. Read the existing docs first to see what's already covered.`; } // 'rewrite' or 'fresh' — no special instruction return ''; } +function chooseReuseSourceTarget(targets, hasClaudeDocs, hasCodexDocs) { + const wantsClaude = targets.some(t => t.id === 'claude'); + const wantsCodex = targets.some(t => t.id === 'codex'); + + if (hasClaudeDocs && !hasCodexDocs) return TARGETS.claude; + if (hasCodexDocs && !hasClaudeDocs) return TARGETS.codex; + if (wantsCodex && !wantsClaude && hasClaudeDocs) return TARGETS.claude; + if (wantsClaude && !wantsCodex && hasCodexDocs) return TARGETS.codex; + if (hasClaudeDocs) return TARGETS.claude; + if (hasCodexDocs) return TARGETS.codex; + return null; +} + +function loadReusableDomains(repoPath, sourceTarget) { + if (!sourceTarget?.skillsDir) return []; + + const rulesDomains = loadReusableDomainsFromRules(repoPath, sourceTarget); + if (rulesDomains.length > 0) { + return rulesDomains; + } + + const skillsDir = join(repoPath, sourceTarget.skillsDir); + const skillFiles = findSkillFiles(skillsDir, { skillFilename: sourceTarget.skillFilename }); + return skillFiles + .filter(skill => !['base', 'architecture'].includes(skill.name)) + .map(skill => { + const fallbackFiles = extractKeyFilePatterns(skill.content); + const files = (skill.activationPatterns && skill.activationPatterns.length > 0) + ? skill.activationPatterns + : fallbackFiles; + + return { + name: skill.frontmatter?.name || skill.name, + description: skill.frontmatter?.description || '', + directories: [...new Set( + files + .filter(file => file.includes('/')) + .map(file => file.split('/').slice(0, -1).join('/')) + .filter(Boolean) + )], + files, + }; + }) + .filter(skill => skill.name); +} + +function loadReusableDomainsFromRules(repoPath, sourceTarget) { + const candidatePaths = []; + if (sourceTarget?.skillsDir) { + candidatePaths.push(join(repoPath, sourceTarget.skillsDir, 'skill-rules.json')); + } + if (sourceTarget?.id !== 'claude') { + candidatePaths.push(join(repoPath, '.claude', 'skills', 'skill-rules.json')); + } + + for (const rulesPath of candidatePaths) { + if (!existsSync(rulesPath)) continue; + + try { + const rules = JSON.parse(readFileSync(rulesPath, 'utf8')); + const skills = rules?.skills || {}; + const domains = []; + + for (const [name, config] of Object.entries(skills)) { + if (name === 'base' || config?.type === 'base') continue; + + const patterns = Array.isArray(config?.filePatterns) ? config.filePatterns.filter(Boolean) : []; + const directories = [...new Set( + patterns + .filter(file => file.includes('/')) + .map(file => file.split('/').slice(0, -1).join('/')) + .filter(Boolean) + )]; + + domains.push({ + name, + description: '', + directories, + files: patterns, + }); + } + + if (domains.length > 0) { + return domains; + } + } catch { + // Fall through to skill-file parsing. + } + } + + return []; +} + +function extractKeyFilePatterns(content) { + if (!content || typeof content !== 'string') return []; + const keyFilesMatch = content.match(/## Key Files[\s\S]*?(?=\n## |\n---|$)/); + if (!keyFilesMatch) return []; + + const patterns = []; + const lineRegex = /^[\s]*-\s*`([^`]+)`/gm; + let match; + while ((match = lineRegex.exec(keyFilesMatch[0])) !== null) { + const pattern = match[1].trim(); + if (pattern && /[/.]/.test(pattern)) { + patterns.push(pattern); + } + } + + return [...new Set(patterns)]; +} + +function loadTargetFiles(repoPath, sourceTarget, domains, options = {}) { + const { includeInstructions = true, includeBase = true, includeDomains = true } = options; + const files = []; + + if (includeInstructions && sourceTarget.instructionsFile) { + const instructionsPath = join(repoPath, sourceTarget.instructionsFile); + if (existsSync(instructionsPath)) { + files.push({ + path: sourceTarget.instructionsFile, + content: readFileSync(instructionsPath, 'utf8'), + }); + } + } + + if (includeBase && sourceTarget.skillsDir) { + const basePath = join(repoPath, sourceTarget.skillsDir, 'base', sourceTarget.skillFilename); + if (existsSync(basePath)) { + files.push({ + path: join(sourceTarget.skillsDir, 'base', sourceTarget.skillFilename), + content: readFileSync(basePath, 'utf8'), + }); + } + } + + if (includeDomains && sourceTarget.skillsDir) { + for (const domain of domains) { + if (!domain?.name || domain.name.includes('..') || domain.name.startsWith('/')) continue; + const skillPath = join(repoPath, sourceTarget.skillsDir, domain.name, sourceTarget.skillFilename); + if (!existsSync(skillPath)) continue; + files.push({ + path: join(sourceTarget.skillsDir, domain.name, sourceTarget.skillFilename), + content: readFileSync(skillPath, 'utf8'), + }); + } + } + + return files; +} + +function loadExistingDocsContext(repoPath, sourceTarget, domains, options = {}) { + const files = loadTargetFiles(repoPath, sourceTarget, domains, options); + const sections = []; + + for (const file of files) { + const label = file.path === sourceTarget.instructionsFile + ? `Existing ${sourceTarget.instructionsFile}` + : file.path.includes(`/base/${sourceTarget.skillFilename}`) + ? 'Existing base skill' + : `Existing skill: ${file.path.split('/').slice(-2, -1)[0]}`; + sections.push(`### ${label}\n\`\`\`\n${sanitizeInline(file.content)}\n\`\`\``); + } + + return sections.length > 0 + ? `\n\n## Existing Docs (improve these — preserve hand-written rules, update what's outdated, add what's missing)\n${sections.join('\n\n')}` + : ''; +} + async function generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, timeoutMs, strategy, verbose, model, findings, nonInteractive = false) { const today = new Date().toISOString().split('T')[0]; - const systemPrompt = loadPrompt('doc-init'); + const systemPrompt = loadPrompt('doc-init', CANONICAL_VARS); const scanSummary = buildScanSummary(scan); const graphContext = buildGraphContext(repoGraph); const strategyNote = buildStrategyInstruction(strategy); @@ -810,19 +1215,11 @@ async function generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, tim // When improving, include existing content so Claude can build on it let existingSection = ''; if (strategy === 'improve') { - const parts = []; - const claudeMdPath = join(repoPath, 'CLAUDE.md'); - if (existsSync(claudeMdPath)) { - const existing = readFileSync(claudeMdPath, 'utf8'); - parts.push(`### Existing CLAUDE.md\n\`\`\`\n${sanitizeInline(existing)}\n\`\`\``); - } - const basePath = join(repoPath, '.claude', 'skills', 'base', 'skill.md'); - if (existsSync(basePath)) { - parts.push(`### Existing base skill\n\`\`\`\n${sanitizeInline(readFileSync(basePath, 'utf8'))}\n\`\`\``); - } - if (parts.length > 0) { - existingSection = `\n\n## Existing Docs (improve these — preserve hand-written rules, update what's outdated, add what's missing)\n${parts.join('\n\n')}`; - } + existingSection = loadExistingDocsContext(repoPath, _reuseSourceTarget || TARGETS.claude, selectedDomains, { + includeInstructions: true, + includeBase: true, + includeDomains: true, + }); } const fullPrompt = `${systemPrompt}${strategyNote}\n\n---\n\nGenerate skills for this repository at ${repoPath}. Today's date is ${today}.\n\n${scanSummary}\n\n${graphContext}${findingsSection}${existingSection}`; @@ -831,12 +1228,13 @@ async function generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, tim claudeSpinner.start('Exploring repo and generating skills...'); try { - const { text, usage } = await runClaude(fullPrompt, makeClaudeOptions(timeoutMs, verbose, model, claudeSpinner)); + const { text, usage } = await runLLM(fullPrompt, makeClaudeOptions(timeoutMs, verbose, model, claudeSpinner), _backendId); trackUsage(usage, fullPrompt.length); - let files = parseFileOutput(text); - // Enforce skip-existing: filter out CLAUDE.md if it already exists - if (strategy === 'skip-existing' && existsSync(join(repoPath, 'CLAUDE.md'))) { - files = files.filter(f => f.path !== 'CLAUDE.md'); + let files = parseLLMOutput(text, _allowedPaths, 'CLAUDE.md'); + // Enforce skip-existing: filter out instructions file if it already exists + const instrFile = 'CLAUDE.md'; + if (strategy === 'skip-existing' && existsSync(join(repoPath, instrFile))) { + files = files.filter(f => f.path !== instrFile); } claudeSpinner.stop(`Generated ${pc.bold(files.length)} files`); return files; @@ -876,52 +1274,58 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim let baseSkillContent = null; if (domainsOnly) { // Load existing base skill for context (used in domain prompts) - const existingBase = join(repoPath, '.claude', 'skills', 'base', 'skill.md'); + const baseTarget = _reuseSourceTarget || TARGETS.claude; + const existingBase = join(repoPath, baseTarget.skillsDir, 'base', baseTarget.skillFilename); if (existsSync(existingBase)) { baseSkillContent = readFileSync(existingBase, 'utf8'); } } else { const baseSpinner = p.spinner(); - baseSpinner.start('Generating base skill...'); + const baseLabel = baseArtifactLabel(); + baseSpinner.start(`Generating ${baseLabel}...`); // When improving, include existing base skill content so Claude can build on it let existingBaseSection = ''; if (strategy === 'improve') { - const existingBasePath = join(repoPath, '.claude', 'skills', 'base', 'skill.md'); - if (existsSync(existingBasePath)) { - existingBaseSection = `\n\n## Existing Base Skill (improve this — preserve hand-written rules, update what's outdated, add what's missing)\n\`\`\`\n${sanitizeInline(readFileSync(existingBasePath, 'utf8'))}\n\`\`\``; - } + existingBaseSection = loadExistingDocsContext(repoPath, _reuseSourceTarget || TARGETS.claude, domains, { + includeInstructions: false, + includeBase: true, + includeDomains: false, + }); } - const basePrompt = loadPrompt('doc-init') + strategyNote + + const basePrompt = loadPrompt('doc-init', CANONICAL_VARS) + strategyNote + `\n\n---\n\nGenerate ONLY the base skill for this repository at ${repoPath} (no domain skills, no CLAUDE.md). Today's date is ${today}.\n\n${scanSummary}\n\n${graphContext}${findingsSection}${existingBaseSection}`; + // Generation always canonical — expected base skill path is always Claude format + const expectedBasePath = '.claude/skills/base/skill.md'; + try { - let { text, usage } = await runClaude(basePrompt, makeClaudeOptions(timeoutMs, verbose, model, baseSpinner)); + let { text, usage } = await runLLM(basePrompt, makeClaudeOptions(timeoutMs, verbose, model, baseSpinner), _backendId); trackUsage(usage, basePrompt.length); - let files = parseFileOutput(text); + let files = parseLLMOutput(text, _allowedPaths, expectedBasePath); - // Retry up to 2 times if Claude didn't wrap output in tags + // Retry up to 2 times if LLM didn't produce parseable output const MAX_RETRIES = 2; for (let attempt = 0; attempt < MAX_RETRIES && files.length === 0; attempt++) { - baseSpinner.message(`Base skill missing file tags — retry ${attempt + 1}/${MAX_RETRIES}...`); + baseSpinner.message(`${baseLabel} missing file tags — retry ${attempt + 1}/${MAX_RETRIES}...`); const retryPrompt = `Your previous response did not include the required content XML tags. I need you to output the base skill wrapped in exactly this format:\n\n\n---\nname: base\ndescription: ...\n---\n[skill content]\n\n\nHere is your previous output — please re-wrap it correctly:\n\n${text}`; - const retry = await runClaude(retryPrompt, makeClaudeOptions(timeoutMs, verbose, model, null)); + const retry = await runLLM(retryPrompt, makeClaudeOptions(timeoutMs, verbose, model, null), _backendId); trackUsage(retry.usage, retryPrompt.length); - files = parseFileOutput(retry.text); + files = parseLLMOutput(retry.text, _allowedPaths, expectedBasePath); text = retry.text; } if (files.length === 0) { - baseSpinner.stop(pc.yellow('Base skill — failed after retries')); - p.log.warn('Could not generate base skill. Try again with: aspens doc init --strategy rewrite --mode base-only'); + baseSpinner.stop(pc.yellow(`${baseLabel} — failed after retries`)); + p.log.warn(`Could not generate ${baseLabel}. Try again with: aspens doc init --strategy rewrite --mode base-only`); } else { allFiles.push(...files); baseSkillContent = files.find(f => f.path.includes('/base/'))?.content; - baseSpinner.stop(pc.green('Base skill generated')); + baseSpinner.stop(pc.green(`${baseLabel} generated`)); } } catch (err) { - baseSpinner.stop(pc.red('Base skill failed')); + baseSpinner.stop(pc.red(`${baseLabel} failed`)); p.log.error(err.message); return allFiles; } @@ -959,22 +1363,25 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim if (domain.name.includes('..') || domain.name.startsWith('/')) { return { domain: domain.name, files: [], success: false }; } - const existingDomainPath = join(repoPath, '.claude', 'skills', domain.name, 'skill.md'); - if (existsSync(existingDomainPath)) { - existingDomainSection = `\n\n## Existing Skill (improve this — preserve hand-written rules, update what's outdated, add what's missing)\n\`\`\`\n${sanitizeInline(readFileSync(existingDomainPath, 'utf8'))}\n\`\`\``; - } + existingDomainSection = loadExistingDocsContext(repoPath, _reuseSourceTarget || TARGETS.claude, [domain], { + includeInstructions: false, + includeBase: false, + includeDomains: true, + }); } const baseRef = summarizeBaseSkill(baseSkillContent, scan); const domainPrompt = loadPrompt('doc-init-domain', { + ...CANONICAL_VARS, domainName: domain.name, }) + strategyNote + `\n\n---\n\nRepository path: ${repoPath}\nToday's date is ${today}.\n\n${baseRef}\n\n${domainInfo}\n\n${domainGraph}${domainFindings}${existingDomainSection}`; try { - const { text, usage } = await runClaude(domainPrompt, makeClaudeOptions(timeoutMs, verbose, model, null)); + const { text, usage } = await runLLM(domainPrompt, makeClaudeOptions(timeoutMs, verbose, model, null), _backendId); trackUsage(usage, domainPrompt.length); - const files = parseFileOutput(text); + const expectedDomainPath = `.claude/skills/${domain.name}/skill.md`; + const files = parseLLMOutput(text, _allowedPaths, expectedDomainPath); return { domain: domain.name, files, success: true }; } catch { return { domain: domain.name, files: [], success: false }; @@ -1014,7 +1421,7 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim const claudeMdExists = existsSync(join(repoPath, 'CLAUDE.md')); if (allFiles.length > 0 && !domainsOnly && !(strategy === 'skip-existing' && claudeMdExists)) { const claudeMdSpinner = p.spinner(); - claudeMdSpinner.start('Generating CLAUDE.md...'); + claudeMdSpinner.start(`Generating ${instructionsArtifactLabel()}...`); const skillSummaries = allFiles.map(f => { const descMatch = f.content.match(/description:\s*(.+)/); @@ -1024,41 +1431,42 @@ async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, tim // When improving, include existing CLAUDE.md so Claude can build on it let existingClaudeMdSection = ''; - if (strategy === 'improve' && claudeMdExists) { - try { - const existing = readFileSync(join(repoPath, 'CLAUDE.md'), 'utf8'); - existingClaudeMdSection = `\n\n## Existing CLAUDE.md (improve this — preserve hand-written rules, update what's outdated, add what's missing)\n\`\`\`\n${sanitizeInline(existing)}\n\`\`\``; - } catch { /* non-fatal */ } + if (strategy === 'improve' && (_reuseSourceTarget?.instructionsFile || claudeMdExists)) { + existingClaudeMdSection = loadExistingDocsContext(repoPath, _reuseSourceTarget || TARGETS.claude, domains, { + includeInstructions: true, + includeBase: false, + includeDomains: false, + }); } - const claudeMdPrompt = loadPrompt('doc-init-claudemd') + + const claudeMdPrompt = loadPrompt('doc-init-claudemd', CANONICAL_VARS) + `\n\n---\n\nRepository path: ${repoPath}\n\n## Scan Results\nRepo: ${scan.name} (${scan.repoType})\nLanguages: ${scan.languages.join(', ')}\nFrameworks: ${scan.frameworks.join(', ')}\nEntry points: ${scan.entryPoints.join(', ')}\n\n## Generated Skills\n${skillSummaries}${existingClaudeMdSection}`; try { - let { text, usage } = await runClaude(claudeMdPrompt, makeClaudeOptions(timeoutMs, verbose, model, claudeMdSpinner)); + let { text, usage } = await runLLM(claudeMdPrompt, makeClaudeOptions(timeoutMs, verbose, model, claudeMdSpinner), _backendId); trackUsage(usage, claudeMdPrompt.length); - let files = parseFileOutput(text); + let files = parseLLMOutput(text, _allowedPaths, 'CLAUDE.md'); - // Retry up to 2 times if Claude didn't wrap output in tags + // Retry up to 2 times if LLM didn't produce parseable output const MAX_RETRIES = 2; for (let attempt = 0; attempt < MAX_RETRIES && files.length === 0; attempt++) { - claudeMdSpinner.message(`CLAUDE.md missing file tags — retry ${attempt + 1}/${MAX_RETRIES}...`); + claudeMdSpinner.message(`${instructionsArtifactLabel()} missing file tags — retry ${attempt + 1}/${MAX_RETRIES}...`); const retryPrompt = `Your previous response did not include the required content XML tags. I need you to output CLAUDE.md wrapped in exactly this format:\n\n\n# project-name\n[CLAUDE.md content]\n\n\nHere is your previous output — please re-wrap it correctly:\n\n${text}`; - const retry = await runClaude(retryPrompt, makeClaudeOptions(timeoutMs, verbose, model, null)); + const retry = await runLLM(retryPrompt, makeClaudeOptions(timeoutMs, verbose, model, null), _backendId); trackUsage(retry.usage, retryPrompt.length); - files = parseFileOutput(retry.text); + files = parseLLMOutput(retry.text, _allowedPaths, 'CLAUDE.md'); text = retry.text; } if (files.length === 0) { - claudeMdSpinner.stop(pc.yellow('CLAUDE.md — failed after retries')); - p.log.warn('Could not generate CLAUDE.md. Try: aspens doc init --strategy rewrite --mode base-only'); + claudeMdSpinner.stop(pc.yellow(`${instructionsArtifactLabel()} — failed after retries`)); + p.log.warn(`Could not generate ${instructionsArtifactLabel()}. Try: aspens doc init --strategy rewrite --mode base-only`); } else { allFiles.push(...files); - claudeMdSpinner.stop(pc.green('CLAUDE.md generated')); + claudeMdSpinner.stop(pc.green(`${instructionsArtifactLabel()} generated`)); } } catch (err) { - claudeMdSpinner.stop(pc.yellow('CLAUDE.md — failed, skipped')); + claudeMdSpinner.stop(pc.yellow(`${instructionsArtifactLabel()} — failed, skipped`)); } } diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 609ba61..707bdbe 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -3,8 +3,8 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import pc from 'picocolors'; import * as p from '@clack/prompts'; import { scanRepo } from '../lib/scanner.js'; -import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; -import { writeSkillFiles, extractRulesFromSkills } from '../lib/skill-writer.js'; +import { runLLM, loadPrompt, parseFileOutput } from '../lib/runner.js'; +import { writeSkillFiles, writeTransformedFiles, extractRulesFromSkills } from '../lib/skill-writer.js'; import { buildRepoGraph } from '../lib/graph-builder.js'; import { persistGraphArtifacts, loadGraph, extractSubgraph, formatNavigationContext } from '../lib/graph-persistence.js'; import { findSkillFiles, parseActivationPatterns, getActivationBlock, fileMatchesActivation } from '../lib/skill-reader.js'; @@ -13,11 +13,74 @@ import { CliError } from '../lib/errors.js'; import { resolveTimeout } from '../lib/timeout.js'; import { installGitHook, removeGitHook } from '../lib/git-hook.js'; import { isGitRepo, getGitDiff, getGitLog, getChangedFiles } from '../lib/git-helpers.js'; +import { TARGETS, getAllowedPaths, loadConfig } from '../lib/target.js'; import { getSelectedFilesDiff, buildPrioritizedDiff, truncate } from '../lib/diff-helpers.js'; +import { projectCodexDomainDocs, transformForTarget } from '../lib/target-transform.js'; const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; const PARALLEL_LIMIT = 3; +function parseOutput(text, allowedPaths) { + return parseFileOutput(text, allowedPaths); +} + +function buildDerivedCodexFiles(files, target, scan) { + if (target.id !== 'codex') return []; + return projectCodexDomainDocs(files, target, scan); +} + +function dedupeFiles(files) { + const byPath = new Map(); + for (const file of files) { + byPath.set(file.path, file); + } + return [...byPath.values()]; +} + +function configuredTargets(repoPath) { + const { config } = loadConfig(repoPath); + const targetIds = Array.isArray(config?.targets) && config.targets.length > 0 + ? config.targets + : ['claude']; + return targetIds + .map(id => TARGETS[id]) + .filter(Boolean); +} + +function chooseSyncSourceTarget(repoPath, targets) { + const claudeTarget = targets.find(t => t.id === 'claude'); + if (claudeTarget && existsSync(join(repoPath, claudeTarget.skillsDir))) { + return claudeTarget; + } + + for (const target of targets) { + if (target.skillsDir && existsSync(join(repoPath, target.skillsDir))) { + return target; + } + } + + return targets[0] || TARGETS.claude; +} + +function publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, graphSerialized = null) { + const published = []; + + for (const target of publishTargets) { + if (target.id === sourceTarget.id) { + published.push(...baseFiles, ...buildDerivedCodexFiles(baseFiles, target, scan)); + continue; + } + + const transformed = transformForTarget(baseFiles, sourceTarget, target, { + scanResult: scan, + graphSerialized, + }); + published.push(...transformed); + } + + return dedupeFiles(published); +} + export async function docSyncCommand(path, options) { const repoPath = resolve(path); const verbose = !!options.verbose; @@ -31,9 +94,21 @@ export async function docSyncCommand(path, options) { return removeGitHook(repoPath); } + // Determine configured publish targets and the best source target for sync. + const { config, recovered } = loadConfig(repoPath); + const publishTargets = configuredTargets(repoPath); + const sourceTarget = chooseSyncSourceTarget(repoPath, publishTargets); + const backendId = config?.backend || sourceTarget.id; + const allowedPaths = getAllowedPaths([sourceTarget]); + const skillsDir = sourceTarget.skillsDir ? join(repoPath, sourceTarget.skillsDir) : join(repoPath, TARGETS.claude.skillsDir); + + if (recovered && config?.targets?.length) { + p.log.warn(`Recovered missing .aspens.json from existing repo docs (${config.targets.join(', ')}).`); + } + // Refresh mode — skip diff, review all skills against current codebase if (options.refresh) { - return refreshAllSkills(repoPath, options); + return refreshAllSkills(repoPath, options, sourceTarget, publishTargets); } p.intro(pc.cyan('aspens doc sync')); @@ -43,8 +118,8 @@ export async function docSyncCommand(path, options) { throw new CliError('Not a git repository. doc sync requires git history.'); } - if (!existsSync(join(repoPath, '.claude', 'skills'))) { - throw new CliError('No .claude/skills/ found. Run aspens doc init first.'); + if (!existsSync(skillsDir)) { + throw new CliError(`No ${sourceTarget.skillsDir || '.claude/skills'}/ found. Run aspens doc init first.`); } // Step 2: Get git diff @@ -78,15 +153,16 @@ export async function docSyncCommand(path, options) { // Step 3: Find affected skills const scan = scanRepo(repoPath); - const existingSkills = findExistingSkills(repoPath); + const existingSkills = findExistingSkills(repoPath, sourceTarget); // Rebuild graph from current state (keeps graph fresh on every sync) let repoGraph = null; + let graphSerialized = null; let graphContext = ''; if (options.graph !== false) { try { const rawGraph = await buildRepoGraph(repoPath, scan.languages); - persistGraphArtifacts(repoPath, rawGraph); + graphSerialized = persistGraphArtifacts(repoPath, rawGraph, { target: sourceTarget }); repoGraph = loadGraph(repoPath); if (repoGraph) { const subgraph = extractSubgraph(repoGraph, changedFiles); @@ -102,7 +178,7 @@ export async function docSyncCommand(path, options) { if (affectedSkills.length > 0) { p.log.info(`Skills that may need updates: ${affectedSkills.map(s => pc.yellow(s.name)).join(', ')}`); } else { - p.log.info('No skills directly affected, but Claude will check for structural changes.'); + p.log.info('No skills directly affected, but the selected backend will check for structural changes.'); } // Timeout priority: --timeout flag > ASPENS_TIMEOUT env var > auto-scaled default @@ -112,7 +188,13 @@ export async function docSyncCommand(path, options) { // Step 4: Build prompt const today = new Date().toISOString().split('T')[0]; - const systemPrompt = loadPrompt('doc-sync'); + const targetVars = { + skillsDir: sourceTarget.skillsDir || '.claude/skills', + skillFilename: sourceTarget.skillFilename || 'skill.md', + instructionsFile: sourceTarget.instructionsFile || 'CLAUDE.md', + configDir: sourceTarget.configDir || '.claude', + }; + const systemPrompt = loadPrompt('doc-sync', targetVars); // Skill-relevant files (for diff prioritization and interactive picker pre-selection) const relevantFiles = changedFiles.filter(f => @@ -172,8 +254,9 @@ export async function docSyncCommand(path, options) { return `### ${s.path}\n${desc}`; }).join('\n\n'); - const claudeMdContent = existsSync(join(repoPath, 'CLAUDE.md')) - ? readFileSync(join(repoPath, 'CLAUDE.md'), 'utf8') + const instructionsFile = sourceTarget.instructionsFile || 'CLAUDE.md'; + const instructionsContent = existsSync(join(repoPath, instructionsFile)) + ? readFileSync(join(repoPath, instructionsFile), 'utf8') : ''; const userPrompt = `Repository path: ${repoPath} @@ -195,37 +278,44 @@ ${graphContext ? `\n## Import Graph Context\n${graphContext}\n` : ''} ## Existing Skills ${skillContents} -## Existing CLAUDE.md +## Existing ${instructionsFile} \`\`\` -${truncate(claudeMdContent, 5000)} +${truncate(instructionsContent, 5000)} \`\`\``; const fullPrompt = `${systemPrompt}\n\n---\n\n${userPrompt}`; - // Step 5: Run Claude + // Step 5: Run backend const syncSpinner = p.spinner(); syncSpinner.start('Analyzing changes and updating skills...'); let result; try { - result = await runClaude(fullPrompt, { + result = await runLLM(fullPrompt, { timeout: timeoutMs, allowedTools: READ_ONLY_TOOLS, verbose, model: options.model || null, onActivity: verbose ? (msg) => syncSpinner.message(pc.dim(msg)) : null, - }); + cwd: repoPath, + }, backendId); } catch (err) { syncSpinner.stop(pc.red('Failed')); throw new CliError(err.message, { cause: err }); } // Step 6: Parse output - const files = parseFileOutput(result.text); + const baseFiles = parseOutput(result.text, allowedPaths); + const hasFileTags = / 0 && !hasFileTags) { + syncSpinner.stop(pc.red('Unparseable response')); + throw new CliError('LLM returned content without tags. Aborting instead of treating it as "no updates needed".'); + } + const files = publishFilesForTargets(baseFiles, sourceTarget, publishTargets, scan, graphSerialized); if (files.length === 0) { syncSpinner.stop('No updates needed'); - p.outro('Skills are up to date'); + p.outro('Docs are up to date'); return; } @@ -250,7 +340,12 @@ ${truncate(claudeMdContent, 5000)} } // Write - const results = writeSkillFiles(repoPath, files, { force: true }); + const directWriteFiles = files.filter(f => !(f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md')); + const dirScopedFiles = files.filter(f => f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md'); + const results = [ + ...writeSkillFiles(repoPath, directWriteFiles, { force: true }), + ...writeTransformedFiles(repoPath, dirScopedFiles, { force: true }), + ]; console.log(); for (const wr of results) { @@ -258,12 +353,15 @@ ${truncate(claudeMdContent, 5000)} console.log(` ${icon} ${wr.path}`); } - // Regenerate skill-rules.json so hooks see updated activation patterns - try { - const skillsDir = join(repoPath, '.claude', 'skills'); - const rules = extractRulesFromSkills(skillsDir); - writeFileSync(join(skillsDir, 'skill-rules.json'), JSON.stringify(rules, null, 2) + '\n'); - } catch { /* non-fatal */ } + // Regenerate skill-rules.json so hooks see updated activation patterns (Claude-only) + const hookTarget = publishTargets.find(t => t.supportsHooks); + if (hookTarget) { + try { + const hookSkillsDir = join(repoPath, hookTarget.skillsDir); + const rules = extractRulesFromSkills(hookSkillsDir); + writeFileSync(join(hookSkillsDir, 'skill-rules.json'), JSON.stringify(rules, null, 2) + '\n'); + } catch { /* non-fatal */ } + } console.log(); p.outro(`${results.length} file(s) updated`); @@ -271,9 +369,11 @@ ${truncate(claudeMdContent, 5000)} // --- Skill mapping --- -function findExistingSkills(repoPath) { - const skillsDir = join(repoPath, '.claude', 'skills'); - return findSkillFiles(skillsDir).map(s => ({ +function findExistingSkills(repoPath, target) { + const sd = target?.skillsDir || '.claude/skills'; + const sf = target?.skillFilename || 'skill.md'; + const fullDir = join(repoPath, sd); + return findSkillFiles(fullDir, { skillFilename: sf }).map(s => ({ name: s.name, path: relative(repoPath, s.path), content: s.content, @@ -319,18 +419,27 @@ function mapChangesToSkills(changedFiles, existingSkills, scan, repoGraph = null // --- Refresh mode --- -async function refreshAllSkills(repoPath, options) { +async function refreshAllSkills(repoPath, options, sourceTarget, publishTargets = [sourceTarget]) { const verbose = !!options.verbose; + const { config, recovered } = loadConfig(repoPath); + const backendId = config?.backend || sourceTarget?.id || 'claude'; + const allowedPaths = getAllowedPaths([sourceTarget || TARGETS.claude]); + let graphSerialized = null; p.intro(pc.cyan('aspens doc sync --refresh')); + if (recovered && config?.targets?.length) { + p.log.warn(`Recovered missing .aspens.json from existing repo docs (${config.targets.join(', ')}).`); + } + // Prerequisites if (!isGitRepo(repoPath)) { throw new CliError('Not a git repository.'); } - const skillsDir = join(repoPath, '.claude', 'skills'); - if (!existsSync(skillsDir)) { - throw new CliError('No .claude/skills/ found. Run aspens doc init first.'); + const sd = sourceTarget?.skillsDir || '.claude/skills'; + const refreshSkillsDir = join(repoPath, sd); + if (!existsSync(refreshSkillsDir)) { + throw new CliError(`No ${sd}/ found. Run aspens doc init first.`); } // Step 1: Scan + graph @@ -341,7 +450,7 @@ async function refreshAllSkills(repoPath, options) { if (options.graph !== false) { try { const rawGraph = await buildRepoGraph(repoPath, scan.languages); - persistGraphArtifacts(repoPath, rawGraph); + graphSerialized = persistGraphArtifacts(repoPath, rawGraph, { target: sourceTarget }); } catch (err) { p.log.warn(`Graph build failed — continuing without it. (${err.message})`); } @@ -350,9 +459,10 @@ async function refreshAllSkills(repoPath, options) { scanSpinner.stop('Scan complete'); // Step 2: Load existing skills - const existingSkills = findExistingSkills(repoPath); + const existingSkills = findExistingSkills(repoPath, sourceTarget); if (existingSkills.length === 0) { - throw new CliError('No skills found in .claude/skills/. Run aspens doc init first.'); + const sd = sourceTarget?.skillsDir || '.claude/skills'; + throw new CliError(`No skills found in ${sd}/. Run aspens doc init first.`); } const baseSkill = existingSkills.find(s => s.name === 'base'); @@ -365,7 +475,13 @@ async function refreshAllSkills(repoPath, options) { const { timeoutMs: perSkillTimeout } = resolveTimeout(options.timeout, autoTimeout); const today = new Date().toISOString().split('T')[0]; - const systemPrompt = loadPrompt('doc-sync-refresh'); + const refreshVars = { + skillsDir: sourceTarget?.skillsDir || '.claude/skills', + skillFilename: sourceTarget?.skillFilename || 'skill.md', + instructionsFile: sourceTarget?.instructionsFile || 'CLAUDE.md', + configDir: sourceTarget?.configDir || '.claude', + }; + const systemPrompt = loadPrompt('doc-sync-refresh', refreshVars); const allUpdatedFiles = []; // Step 3: Refresh base skill first @@ -377,15 +493,16 @@ async function refreshAllSkills(repoPath, options) { const baseContext = buildBaseContext(repoPath, scan); const prompt = `${systemPrompt}\n\n---\n\nRepository path: ${repoPath}\nToday's date: ${today}\n\n## Existing Skill\n\`\`\`\n${baseSkill.content}\n\`\`\`\n\n## Current Codebase\n${baseContext}`; - const result = await runClaude(prompt, { + const result = await runLLM(prompt, { timeout: perSkillTimeout, allowedTools: READ_ONLY_TOOLS, verbose, model: options.model || null, onActivity: verbose ? (msg) => baseSpinner.message(pc.dim(msg)) : null, - }); + cwd: repoPath, + }, backendId); - const files = parseFileOutput(result.text); + const files = parseOutput(result.text, allowedPaths); if (files.length > 0) { allUpdatedFiles.push(...files); baseSpinner.stop(pc.yellow('base') + ' — updated'); @@ -412,15 +529,16 @@ async function refreshAllSkills(repoPath, options) { const prompt = `${systemPrompt}\n\n---\n\nRepository path: ${repoPath}\nToday's date: ${today}\n\n## Existing Skill\n\`\`\`\n${skill.content}\n\`\`\`\n\n## Current Codebase (${skill.name} domain)\n${domainContext}`; - const result = await runClaude(prompt, { + const result = await runLLM(prompt, { timeout: perSkillTimeout, allowedTools: READ_ONLY_TOOLS, verbose, model: options.model || null, onActivity: verbose ? (msg) => skillSpinner.message(pc.dim(msg)) : null, - }); + cwd: repoPath, + }, backendId); - const files = parseFileOutput(result.text); + const files = parseOutput(result.text, allowedPaths); if (files.length > 0) { skillSpinner.stop(pc.yellow(skill.name) + ' — updated'); return files; @@ -440,14 +558,15 @@ async function refreshAllSkills(repoPath, options) { } } - // Step 5: Refresh CLAUDE.md if it exists - const claudeMdPath = join(repoPath, 'CLAUDE.md'); - if (existsSync(claudeMdPath)) { + // Step 5: Refresh instructions file (CLAUDE.md or AGENTS.md) if it exists + const instrFile = sourceTarget?.instructionsFile || 'CLAUDE.md'; + const instrPath = join(repoPath, instrFile); + if (existsSync(instrPath)) { const claudeSpinner = p.spinner(); - claudeSpinner.start('Checking CLAUDE.md...'); + claudeSpinner.start(`Checking ${instrFile}...`); try { - const claudeMd = readFileSync(claudeMdPath, 'utf8'); + const claudeMd = readFileSync(instrPath, 'utf8'); const skillSummaries = existingSkills.map(s => { const descMatch = s.content.match(/description:\s*(.+)/); return `- **${s.name}**: ${descMatch ? descMatch[1].trim() : ''}`; @@ -455,23 +574,24 @@ async function refreshAllSkills(repoPath, options) { const claudePrompt = `${systemPrompt}\n\n---\n\nRepository path: ${repoPath}\nToday's date: ${today}\n\n## Existing Skill\n\`\`\`\n${claudeMd}\n\`\`\`\n\n## Installed Skills\n${skillSummaries}\n\n## Current Codebase\n${buildBaseContext(repoPath, scan)}`; - const claudeResult = await runClaude(claudePrompt, { + const claudeResult = await runLLM(claudePrompt, { timeout: perSkillTimeout, allowedTools: READ_ONLY_TOOLS, verbose, model: options.model || null, onActivity: verbose ? (msg) => claudeSpinner.message(pc.dim(msg)) : null, - }); + cwd: repoPath, + }, backendId); - const claudeFiles = parseFileOutput(claudeResult.text); + const claudeFiles = parseOutput(claudeResult.text, allowedPaths); if (claudeFiles.length > 0) { allUpdatedFiles.push(...claudeFiles); - claudeSpinner.stop(pc.yellow('CLAUDE.md') + ' — updated'); + claudeSpinner.stop(pc.yellow(instrFile) + ' — updated'); } else { - claudeSpinner.stop(pc.dim('CLAUDE.md') + ' — up to date'); + claudeSpinner.stop(pc.dim(instrFile) + ' — up to date'); } } catch (err) { - claudeSpinner.stop(pc.red('CLAUDE.md — failed: ') + err.message); + claudeSpinner.stop(pc.red(`${instrFile} — failed: `) + err.message); } } @@ -505,7 +625,13 @@ async function refreshAllSkills(repoPath, options) { return; } - const results = writeSkillFiles(repoPath, allUpdatedFiles, { force: true }); + const filesToWrite = publishFilesForTargets(allUpdatedFiles, sourceTarget, publishTargets, scan, graphSerialized); + const directWriteFiles = filesToWrite.filter(f => !(f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md')); + const dirScopedFiles = filesToWrite.filter(f => f.path.endsWith('/AGENTS.md') && f.path !== 'AGENTS.md'); + const results = [ + ...writeSkillFiles(repoPath, directWriteFiles, { force: true }), + ...writeTransformedFiles(repoPath, dirScopedFiles, { force: true }), + ]; console.log(); for (const result of results) { @@ -514,11 +640,15 @@ async function refreshAllSkills(repoPath, options) { } // Step 8: Regenerate skill-rules.json - try { - const rules = extractRulesFromSkills(skillsDir); - writeFileSync(join(skillsDir, 'skill-rules.json'), JSON.stringify(rules, null, 2) + '\n'); - p.log.info('Updated skill-rules.json'); - } catch { /* non-fatal */ } + const hookTarget = publishTargets.find(t => t.supportsHooks); + if (hookTarget) { + try { + const hookSkillsDir = join(repoPath, hookTarget.skillsDir); + const rules = extractRulesFromSkills(hookSkillsDir); + writeFileSync(join(hookSkillsDir, 'skill-rules.json'), JSON.stringify(rules, null, 2) + '\n'); + p.log.info('Updated skill-rules.json'); + } catch { /* non-fatal */ } + } console.log(); p.outro(`${results.length} file(s) refreshed`); diff --git a/src/lib/backend.js b/src/lib/backend.js new file mode 100644 index 0000000..eae86bc --- /dev/null +++ b/src/lib/backend.js @@ -0,0 +1,125 @@ +/** + * Generation backend abstraction — detects and manages LLM backends. + * + * Backend = what generates the content (claude CLI, codex CLI) + * Target = where the output goes (claude, codex, all) + * + * Default: backend matches target. Users can override with --backend. + */ + +import { execSync } from 'child_process'; + +// --------------------------------------------------------------------------- +// Backend definitions +// --------------------------------------------------------------------------- + +export const BACKENDS = { + claude: { + id: 'claude', + label: 'Claude CLI', + command: 'claude', + detectArgs: '--version', + installUrl: 'https://docs.anthropic.com/claude-code', + }, + codex: { + id: 'codex', + label: 'Codex CLI', + command: 'codex', + detectArgs: '--version', + installUrl: 'https://github.com/openai/codex', + }, +}; + +// --------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------- + +/** + * Check if a CLI command is available on the system. + * @param {string} command + * @param {string} args + * @returns {boolean} + */ +function isCommandAvailable(command, args) { + try { + execSync(`${command} ${args}`, { stdio: 'pipe', timeout: 10000 }); + return true; + } catch { + return false; + } +} + +/** + * Detect which backends are installed. + * @returns {{ claude: boolean, codex: boolean }} + */ +export function detectAvailableBackends() { + return { + claude: isCommandAvailable(BACKENDS.claude.command, BACKENDS.claude.detectArgs), + codex: isCommandAvailable(BACKENDS.codex.command, BACKENDS.codex.detectArgs), + }; +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +/** + * Resolve which backend to use. + * + * Priority: + * 1. Explicit --backend flag + * 2. Match target (claude target → claude backend, codex target → codex backend) + * 3. Whatever is available + * + * @param {object} options + * @param {string} [options.backendFlag] — explicit --backend value + * @param {string} [options.targetId] — the chosen target id + * @param {{ claude: boolean, codex: boolean }} options.available — detection result + * @returns {{ backend: object, warning: string|null }} + */ +export function resolveBackend({ backendFlag, targetId, available }) { + // Explicit flag wins + if (backendFlag) { + const backend = BACKENDS[backendFlag]; + if (!backend) { + throw new Error(`Unknown backend: "${backendFlag}". Valid backends: ${Object.keys(BACKENDS).join(', ')}`); + } + if (!available[backendFlag]) { + throw new Error( + `${backend.label} is not installed. Install it: ${backend.installUrl}` + ); + } + return { backend, warning: null }; + } + + // Match target + if (targetId && targetId !== 'all') { + const matchingBackend = BACKENDS[targetId]; + if (matchingBackend && available[targetId]) { + return { backend: matchingBackend, warning: null }; + } + + // Matching backend not available — fall back to the other + const fallbackId = targetId === 'claude' ? 'codex' : 'claude'; + if (available[fallbackId]) { + const fallback = BACKENDS[fallbackId]; + const missing = BACKENDS[targetId]; + return { + backend: fallback, + warning: `${missing.label} not found. Using ${fallback.label} to generate ${targetId} output. For best results, install ${missing.label}: ${missing.installUrl}`, + }; + } + } + + // No target preference or target is 'all' — use whatever is available + if (available.claude) return { backend: BACKENDS.claude, warning: null }; + if (available.codex) return { backend: BACKENDS.codex, warning: null }; + + // Neither available + throw new Error( + 'aspens requires either Claude CLI or Codex CLI.\n' + + ` Install Claude CLI: ${BACKENDS.claude.installUrl}\n` + + ` Install Codex CLI: ${BACKENDS.codex.installUrl}` + ); +} diff --git a/src/lib/context-builder.js b/src/lib/context-builder.js index 8e1106b..3b3c30c 100644 --- a/src/lib/context-builder.js +++ b/src/lib/context-builder.js @@ -111,12 +111,27 @@ export function buildContext(repoPath, scanResult, options = {}) { } } - // 7. Existing CLAUDE.md if present - const claudeMdPath = join(repoPath, 'CLAUDE.md'); - if (existsSync(claudeMdPath)) { - const claudeMd = readFileSafe(claudeMdPath); - if (claudeMd) { - sections.push(`## Existing CLAUDE.md\n\`\`\`markdown\n${claudeMd}\n\`\`\``); + // 7. Existing instructions file (CLAUDE.md or AGENTS.md) if present + const instructionsFile = options.instructionsFile || 'CLAUDE.md'; + const instructionsPath = join(repoPath, instructionsFile); + if (existsSync(instructionsPath)) { + const content = readFileSafe(instructionsPath); + if (content) { + sections.push(`## Existing ${instructionsFile}\n\`\`\`markdown\n${content}\n\`\`\``); + } + } + // Also check alternative instructions files for improve strategy (both may exist) + const defaultAlternatives = instructionsFile === 'CLAUDE.md' ? ['AGENTS.md'] : ['CLAUDE.md']; + const instructionAlternatives = options.instructionsAlternatives + || (options.altInstructionsFile ? [options.altInstructionsFile] : defaultAlternatives); + for (const altInstructionsFile of instructionAlternatives) { + if (!altInstructionsFile || altInstructionsFile === instructionsFile) continue; + const altPath = join(repoPath, altInstructionsFile); + if (existsSync(altPath)) { + const altContent = readFileSafe(altPath); + if (altContent) { + sections.push(`## Existing ${altInstructionsFile}\n\`\`\`markdown\n${altContent}\n\`\`\``); + } } } diff --git a/src/lib/git-hook.js b/src/lib/git-hook.js index 86d8e73..59cbd92 100644 --- a/src/lib/git-hook.js +++ b/src/lib/git-hook.js @@ -37,9 +37,9 @@ __aspens_doc_sync() { ASPENS_LOCK="/tmp/aspens-sync-\${REPO_HASH}.lock" ASPENS_LOG="/tmp/aspens-sync-\${REPO_HASH}.log" - # Skip aspens-only commits (skills, CLAUDE.md, graph artifacts) + # Skip aspens-only commits (skills, CLAUDE.md, AGENTS.md, graph artifacts) CHANGED="\$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null)" - NON_ASPENS="\$(echo "\$CHANGED" | grep -v '^\.claude/' | grep -v '^CLAUDE\.md\$' || true)" + NON_ASPENS="\$(echo "\$CHANGED" | grep -v '^\.claude/' | grep -v '^\.codex/' | grep -v '^\.agents/' | grep -v '^CLAUDE\.md\$' | grep -v '^AGENTS\.md\$' | grep -v '/AGENTS\.md\$' | grep -v '^\.aspens\.json\$' || true)" if [ -z "\$NON_ASPENS" ]; then return 0 fi diff --git a/src/lib/graph-persistence.js b/src/lib/graph-persistence.js index c2e83a2..9149ca9 100644 --- a/src/lib/graph-persistence.js +++ b/src/lib/graph-persistence.js @@ -488,9 +488,20 @@ export function saveGraphIndex(repoPath, index) { /** * Convenience: persist graph, code-map, and index in one call. + * @param {string} repoPath + * @param {object} rawGraph + * @param {object} [options] + * @param {object} [options.target] — target definition. If target.supportsGraph is false, returns serialized data without writing files. */ -export function persistGraphArtifacts(repoPath, rawGraph) { +export function persistGraphArtifacts(repoPath, rawGraph, options = {}) { + const target = options.target; const serialized = serializeGraph(rawGraph, repoPath); + + // If target doesn't support graph artifacts, return serialized data without writing + if (target?.supportsGraph === false) { + return serialized; + } + saveGraph(repoPath, serialized); writeCodeMap(repoPath, serialized); const index = generateGraphIndex(serialized); diff --git a/src/lib/runner.js b/src/lib/runner.js index 97feef9..389166c 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -2,14 +2,15 @@ import { execSync, spawn } from 'child_process'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, dirname, normalize, resolve, relative, sep } from 'path'; import { fileURLToPath } from 'url'; +import { tmpdir } from 'os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROMPTS_DIR = join(__dirname, '..', 'prompts'); const PARTIALS_DIR = join(PROMPTS_DIR, 'partials'); -// Paths that parseFileOutput is allowed to write to -const ALLOWED_DIR_PREFIXES = ['.claude/']; -const ALLOWED_EXACT_FILES = ['CLAUDE.md']; +// Default paths that parseFileOutput is allowed to write to +const DEFAULT_ALLOWED_DIR_PREFIXES = ['.claude/']; +const DEFAULT_ALLOWED_EXACT_FILES = ['CLAUDE.md']; /** * Check if claude CLI is available. @@ -120,6 +121,205 @@ export function runClaude(prompt, options = {}) { }); } +/** + * Route a prompt to the selected backend while preserving the shared options shape. + * Returns the same { text, usage } contract as runClaude/runCodex. + */ +export function runLLM(prompt, options = {}, backendId = 'claude') { + if (backendId === 'codex') { + return runCodex(prompt, { + timeout: options.timeout, + verbose: options.verbose, + onActivity: options.onActivity, + model: options.model, + cwd: options.cwd, + }); + } + return runClaude(prompt, options); +} + +/** + * Execute a prompt via Codex CLI (codex exec). + * Uses --json for JSONL event streaming. + * Returns { text, usage } matching runClaude's interface. + */ +export function runCodex(prompt, options = {}) { + const { timeout = 300000, verbose = false, onActivity = null, model = null, cwd = null } = options; + + const args = [ + 'exec', + '--json', + '--sandbox', + 'read-only', + '--ask-for-approval', + 'never', + '--ephemeral', + ]; + if (model) args.push('--model', model); + if (cwd) args.push('--cd', cwd); + // Pass prompt via stdin (using '-' placeholder) to avoid shell arg length limits + args.push('-'); + + return new Promise((resolve, reject) => { + const child = spawn('codex', args, { + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }); + + const chunks = []; + const errChunks = []; + let lineBuffer = ''; + + child.stdout.on('data', (data) => { + chunks.push(data); + + if (verbose && onActivity) { + lineBuffer += data.toString('utf8'); + const lines = lineBuffer.split('\n'); + lineBuffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + const itemType = normalizeCodexItemType(event.item?.type || event.item?.details?.type); + if ((event.type === 'item.updated' || event.type === 'item.completed') && itemType === 'agent_message') { + onActivity('Codex generating...'); + } else if (event.type === 'item.completed' && itemType === 'command_execution') { + const command = event.item?.command || event.item?.details?.command; + onActivity(`Codex ran: ${command?.slice(0, 60) || 'command'}`); + } + } catch { /* not JSON */ } + } + } + }); + + child.stderr.on('data', (data) => errChunks.push(data)); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + if (process.platform === 'win32' && child.pid) { + try { execSync(`taskkill /pid ${child.pid} /t /f`, { stdio: 'ignore' }); } catch { /* ignore */ } + } else { + child.kill('SIGTERM'); + } + }, timeout); + + child.on('close', (code, signal) => { + clearTimeout(timer); + const stdout = Buffer.concat(chunks).toString('utf8'); + const stderr = Buffer.concat(errChunks).toString('utf8'); + + if (timedOut || signal === 'SIGTERM' || signal === 'SIGKILL') { + reject(new Error(`Codex timed out after ${timeout / 1000}s. Try a smaller repo or increase --timeout.`)); + } else if (code === 0) { + const { text, usage } = extractResultFromCodexStream(stdout); + if (process.env.ASPENS_DEBUG) { + console.error(`[debug] Codex exited 0, stdout ${stdout.length} bytes, parsed text ${text.length} chars`); + } + resolve({ text, usage }); + } else if (stderr.includes('rate limit') || stderr.includes('429')) { + reject(new Error('Codex rate limit hit. Wait a moment and try again.')); + } else { + if (process.env.ASPENS_DEBUG) { + console.error(`[debug] Codex exited ${code}, stderr: ${stderr.slice(0, 1000)}`); + console.error(`[debug] Codex stdout (first 500): ${stdout.slice(0, 500)}`); + } + reject(new Error(`Codex exited with code ${code}${stderr ? ': ' + stderr.slice(0, 500) : ''}`)); + } + }); + + child.on('error', (err) => { + clearTimeout(timer); + reject(new Error(`Codex failed to start: ${err.message}. Is Codex CLI installed?`)); + }); + + // Write prompt to stdin after handlers are attached so fast failures are captured. + const ok = child.stdin.write(prompt); + if (!ok) { + child.stdin.once('drain', () => child.stdin.end()); + } else { + child.stdin.end(); + } + }); +} + +/** + * Extract final text and usage from Codex JSONL stream output. + * Codex events: thread.started, item.started, item.updated, item.completed, turn.completed + */ +function extractResultFromCodexStream(rawOutput) { + const lines = rawOutput.split('\n').filter(l => l.trim()); + const textParts = []; + let usage = { output_tokens: 0, tool_uses: 0, tool_result_chars: 0 }; + + if (process.env.ASPENS_DEBUG) { + try { writeFileSync(join(tmpdir(), 'aspens-debug-codex-stream.json'), rawOutput); } catch {} + } + + for (const line of lines) { + try { + const event = JSON.parse(line); + const itemType = normalizeCodexItemType(event.item?.type || event.item?.details?.type); + + // Collect agent message text from completed items + if (event.type === 'item.completed' && itemType === 'agent_message') { + const content = event.item?.text ?? event.item?.content ?? event.item?.details?.content; + collectCodexText(content, textParts); + } + + // Count tool uses (command executions, file changes) + if (event.type === 'item.completed') { + if (itemType === 'command_execution' || itemType === 'file_change' || itemType === 'mcp_tool_call') { + usage.tool_uses++; + } + } + + // Extract usage from turn.completed + if (event.type === 'turn.completed' && event.usage) { + usage.output_tokens = event.usage.output_tokens || event.usage.outputTokens || 0; + } + } catch { /* not JSON — skip */ } + } + + return { text: textParts.join('\n'), usage }; +} + +function normalizeCodexItemType(type) { + if (!type || typeof type !== 'string') return ''; + return type + .replace(/([a-z])([A-Z])/g, '$1_$2') + .replace(/-/g, '_') + .toLowerCase(); +} + +function collectCodexText(content, parts) { + if (!content) return; + + if (typeof content === 'string') { + parts.push(content); + return; + } + + if (Array.isArray(content)) { + for (const block of content) { + collectCodexText(block, parts); + } + return; + } + + if (typeof content === 'object') { + if (typeof content.text === 'string') { + parts.push(content.text); + return; + } + if (typeof content.content === 'string') { + parts.push(content.content); + return; + } + } +} + /** * Load a prompt template from src/prompts/ and substitute variables. */ @@ -161,7 +361,11 @@ export function loadPrompt(name, vars = {}) { * Fallback: markers (legacy) * Validates paths to prevent traversal. */ -export function parseFileOutput(output) { +/** + * @param {string} output — raw LLM output + * @param {{ dirPrefixes?: string[], exactFiles?: string[] }} [allowedPaths] — override allowed paths (default: .claude/ + CLAUDE.md) + */ +export function parseFileOutput(output, allowedPaths) { let files = []; // Primary: Split on tags and match to next outside code fences. @@ -198,7 +402,7 @@ export function parseFileOutput(output) { let openMatch; while ((openMatch = openTagPattern.exec(output)) !== null) { if (isInsideFence(openMatch.index)) continue; - const filePath = sanitizePath(openMatch[1].trim()); + const filePath = sanitizePath(openMatch[1].trim(), allowedPaths); if (!filePath) continue; const contentStart = openMatch.index + openMatch[0].length; @@ -226,7 +430,7 @@ export function parseFileOutput(output) { const commentPattern = /\s*\n([\s\S]*?)(?=