diff --git a/README.md b/README.md index 09be973a..ce70ab80 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,23 @@ URL in Slack. If you have a fixed ngrok domain, run ignore that HTTPS override and keep using the local loopback callback, `http://127.0.0.1:53682/callback`. +Export OpenWiki as skills for a host coding agent: + +```sh +openwiki integration claude +openwiki integration claude ../other-repo +``` + +`openwiki integration claude [path]` scaffolds two Claude Code skills, +`openwiki-init` and `openwiki-update`, into `.claude/skills/` in the target +repository (defaulting to the current directory). Unlike the other commands, +this one needs **no model provider or API key** — Claude Code itself is the +engine. Open the repository in Claude Code and run `/openwiki-init` to generate +the initial docs under `openwiki/`, then `/openwiki-update` after source changes +to refresh them. Each slash command accepts an optional repository path +argument. This covers code mode (repository docs) only; the personal brain and +connectors still require the OpenWiki runtime. + Bare `openwiki` runs in code mode for the current repository. It creates initial repository documentation in `openwiki/` when no wiki exists. Use `openwiki personal` for the local general-purpose wiki in `~/.openwiki/wiki/`. By default, the CLI stays open after each run so you can send follow-up messages. Use `-p` or `--print` for a one-shot non-interactive run that prints the final assistant output. Bare `openwiki --init` and `openwiki --update` default to code mode and operate on repository documentation. Use the `personal` positional mode or `--mode personal` to initialize or update the local personal brain wiki. diff --git a/package.json b/package.json index efb9ad21..63279341 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "files": [ "dist", "skills", + "templates", "README.md", "LICENSE" ], diff --git a/src/cli.tsx b/src/cli.tsx index 264f3b51..de4633f7 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -47,6 +47,7 @@ import { runOpenWikiIngestion, type OpenWikiIngestionResult, } from "./ingestion.js"; +import { getIntegrationWriter } from "./integrations/index.js"; import { readOpenWikiOnboardingConfig, saveOpenWikiOnboardingConfig, @@ -3489,6 +3490,8 @@ if (command.kind === "auth") { await runCronCommand(command); } else if (command.kind === "ingest") { await runIngestCommand(command); +} else if (command.kind === "integration") { + await runIntegrationCommand(command); } else if (shouldPrintStartupError(argv, parsedCommand, command)) { process.stderr.write(`${command.message}\n`); process.exitCode = command.exitCode; @@ -3742,6 +3745,39 @@ async function runIngestCommand( } } +async function runIntegrationCommand( + command: Extract, +): Promise { + try { + const writer = getIntegrationWriter(command.agent); + + if (!writer) { + throw new Error(`Unsupported integration agent: ${command.agent}`); + } + + const result = await writer(command.targetPath); + + process.stdout.write( + `Scaffolded OpenWiki ${command.agent} skills into ${result.targetDir}\n`, + ); + for (const filePath of result.writtenFiles) { + process.stdout.write(` ${filePath}\n`); + } + + if (result.nextSteps.length > 0) { + process.stdout.write("\nNext steps\n"); + for (const step of result.nextSteps) { + process.stdout.write(` - ${step}\n`); + } + } + + process.exitCode = 0; + } catch (error) { + process.stderr.write(`${getErrorMessage(error)}\n`); + process.exitCode = 1; + } +} + async function runAuthCommand( command: Extract, ): Promise { diff --git a/src/commands.ts b/src/commands.ts index 05a04772..71609b62 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,6 +3,10 @@ import type { OpenWikiCommand } from "./agent/types.js"; import { isAuthProviderId } from "./auth/providers.js"; import type { AuthProviderId } from "./auth/types.js"; import { parseIngestionTarget, type IngestionTarget } from "./ingestion.js"; +import { + isSupportedIntegrationAgent, + supportedIntegrationAgents, +} from "./integrations/index.js"; export type HelpRow = { label: string; @@ -52,6 +56,12 @@ export type CliCommand = exitCode: 0; target: CronTarget | null; } + | { + kind: "integration"; + agent: string; + exitCode: 0; + targetPath: string; + } | { kind: "help"; exitCode: 0 } | { kind: "run"; @@ -321,6 +331,57 @@ export function parseCommand(argv: string[]): CliCommand { } } + if (argv[0] === "integration") { + const agent = argv[1]; + const supported = supportedIntegrationAgents().join(", "); + + if (!agent) { + return { + kind: "error", + exitCode: 1, + message: `Usage: openwiki integration [path]. Supported agents: ${supported}.`, + }; + } + + if (!isSupportedIntegrationAgent(agent)) { + return { + kind: "error", + exitCode: 1, + message: `Unknown integration agent: ${agent}. Supported agents: ${supported}.`, + }; + } + + let targetPath = "."; + let hasPath = false; + for (const arg of argv.slice(2)) { + if (arg.startsWith("-")) { + return { + kind: "error", + exitCode: 1, + message: `Unknown option for integration: ${arg}`, + }; + } + + if (hasPath) { + return { + kind: "error", + exitCode: 1, + message: "Usage: openwiki integration [path]", + }; + } + + targetPath = arg; + hasPath = true; + } + + return { + kind: "integration", + agent, + exitCode: 0, + targetPath, + }; + } + if (isOpenWikiRunMode(argv[0])) { return parseRunCommand(argv.slice(1), argv[0], "positional"); } @@ -598,6 +659,7 @@ export const helpContent: HelpContent = { "openwiki cron resume ", "openwiki cron delete ", "openwiki ngrok start [url] [--port ]", + "openwiki integration [path]", ], commands: [ { @@ -658,6 +720,11 @@ export const helpContent: HelpContent = { description: "Start an ngrok tunnel for Slack OAuth, optionally using a fixed HTTPS URL.", }, + { + label: "openwiki integration [path]", + description: + "Scaffold host-agent skills (openwiki-init, openwiki-update) into a repo so the agent maintains openwiki/ docs itself. No model provider or API key needed. Supports claude.", + }, ], options: [ { @@ -715,6 +782,8 @@ export const helpContent: HelpContent = { "openwiki auth tools notion", "openwiki ngrok start", "openwiki ngrok start https://openwiki.ngrok.app", + "openwiki integration claude", + "openwiki integration claude ../other-repo", ], developmentExamples: ["openwiki --dry-run"], }; diff --git a/src/integrations/claude.ts b/src/integrations/claude.ts new file mode 100644 index 00000000..0ad0ef13 --- /dev/null +++ b/src/integrations/claude.ts @@ -0,0 +1,223 @@ +import { lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { isFileNotFoundError } from "../fs-errors.js"; + +// From dist/integrations/claude.js (or src/integrations/claude.ts) this resolves +// to the package-root templates/claude-skill directory shipped in the npm +// package. Matches the bundled-asset pattern in agent/skills.ts. +const CLAUDE_SKILL_TEMPLATE_DIR = fileURLToPath( + new URL("../../templates/claude-skill", import.meta.url), +); + +type ClaudeSkillDefinition = { + name: string; + templateFile: string; +}; + +// The skills scaffolded into a target repository. Each maps to a bundled +// template whose body becomes the SKILL.md instruction set. +const CLAUDE_SKILLS: ClaudeSkillDefinition[] = [ + { name: "openwiki-init", templateFile: "openwiki-init.md" }, + { name: "openwiki-update", templateFile: "openwiki-update.md" }, +]; + +export type ClaudeIntegrationResult = { + targetDir: string; + writtenFiles: string[]; + nextSteps: string[]; +}; + +type ParsedTemplate = { + frontmatter: Record; + body: string; +}; + +/** + * Scaffolds the OpenWiki Claude Code skills into `targetDir`. + * + * Reads each bundled template, lifts its `description`/`argument-hint` + * frontmatter into a valid Claude Code SKILL.md header, and writes + * `/.claude/skills//SKILL.md`. Existing files are overwritten + * in place because these are managed, generated artifacts. + */ +export async function writeClaudeIntegration( + targetDir: string, +): Promise { + const resolvedTarget = path.resolve(targetDir); + await assertDirectory(resolvedTarget); + + const writtenFiles: string[] = []; + + for (const definition of CLAUDE_SKILLS) { + const templatePath = path.join( + CLAUDE_SKILL_TEMPLATE_DIR, + definition.templateFile, + ); + const raw = await readFile(templatePath, "utf8"); + const parsed = parseTemplate(raw, definition.templateFile); + const document = buildSkillDocument(definition, parsed); + + const skillPath = path.join( + resolvedTarget, + ".claude", + "skills", + definition.name, + "SKILL.md", + ); + // The target repo is untrusted input: a cloned/checked-out repo can commit + // a symlink at any segment of this path. mkdir({recursive:true}) and + // writeFile both follow symlinks by default, so without this check a + // malicious repo could redirect the write to overwrite an arbitrary + // victim-writable file outside the target directory. + await assertNoSymlinkInPath(resolvedTarget, skillPath); + await mkdir(path.dirname(skillPath), { recursive: true }); + // Re-check the leaf immediately before writing: mkdir only touches + // ancestor directories, so a symlink planted at the leaf itself would + // otherwise survive the check above undetected until this point. + await assertNoSymlinkInPath(resolvedTarget, skillPath); + await writeFile(skillPath, document, "utf8"); + writtenFiles.push(skillPath); + } + + return { + targetDir: resolvedTarget, + writtenFiles, + nextSteps: [ + "Open the target repository in Claude Code.", + "Run /openwiki-init to generate the initial docs under openwiki/.", + "Run /openwiki-update after source changes to refresh them.", + ], + }; +} + +// Refuses to proceed if any path segment between `root` and `targetPath` +// (inclusive of the leaf) already exists as a symlink, so a repo cannot use a +// planted symlink to redirect our write outside the intended `.claude/skills` +// tree. Missing segments are fine (they don't exist yet, so nothing to +// hijack); only an existing symlink is rejected. +async function assertNoSymlinkInPath( + root: string, + targetPath: string, +): Promise { + const relative = path.relative(root, targetPath); + let current = root; + + for (const segment of relative.split(path.sep)) { + current = path.join(current, segment); + + let stats; + try { + stats = await lstat(current); + } catch (error) { + if (isFileNotFoundError(error)) { + return; + } + + throw error; + } + + if (stats.isSymbolicLink()) { + throw new Error( + `Refusing to write through a symlink at ${current}. Remove it and re-run.`, + ); + } + } +} + +async function assertDirectory(dir: string): Promise { + let stats; + + try { + stats = await stat(dir); + } catch (error) { + if (isFileNotFoundError(error)) { + throw new Error(`Target directory does not exist: ${dir}`, { + cause: error, + }); + } + + throw error; + } + + if (!stats.isDirectory()) { + throw new Error(`Target path is not a directory: ${dir}`); + } +} + +function parseTemplate(raw: string, templateFile: string): ParsedTemplate { + const normalized = raw.replace(/^\uFEFF/, ""); + const match = /^---\n([\s\S]*?)\n---\n?/.exec(normalized); + + if (!match) { + throw new Error( + `Bundled skill template ${templateFile} is missing a frontmatter block.`, + ); + } + + const frontmatter: Record = {}; + for (const line of match[1].split("\n")) { + if (line.trim().length === 0) { + continue; + } + + const separatorIndex = line.indexOf(":"); + if (separatorIndex === -1) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + const value = stripQuotes(line.slice(separatorIndex + 1).trim()); + frontmatter[key] = value; + } + + return { + frontmatter, + body: normalized.slice(match[0].length).trim(), + }; +} + +function stripQuotes(value: string): string { + const isDoubleQuoted = value.startsWith('"') && value.endsWith('"'); + const isSingleQuoted = value.startsWith("'") && value.endsWith("'"); + + if (value.length >= 2 && (isDoubleQuoted || isSingleQuoted)) { + return value.slice(1, -1); + } + + return value; +} + +function buildSkillDocument( + definition: ClaudeSkillDefinition, + parsed: ParsedTemplate, +): string { + const description = parsed.frontmatter.description ?? ""; + const argumentHint = parsed.frontmatter["argument-hint"] ?? ""; + + const lines = [ + "---", + `name: ${definition.name}`, + `description: ${toYamlDoubleQuoted(description)}`, + ...(argumentHint.length > 0 + ? [`argument-hint: ${toYamlDoubleQuoted(argumentHint)}`] + : []), + "user-invocable: true", + "disable-model-invocation: false", + "---", + "", + parsed.body, + "", + ]; + + return lines.join("\n"); +} + +// Emit a YAML double-quoted scalar so values that would otherwise be misparsed +// (e.g. an argument-hint like "[path]" reads as a flow sequence when bare) stay +// strings. Only backslash and double-quote need escaping inside double quotes. +function toYamlDoubleQuoted(value: string): string { + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + + return `"${escaped}"`; +} diff --git a/src/integrations/index.ts b/src/integrations/index.ts new file mode 100644 index 00000000..db911731 --- /dev/null +++ b/src/integrations/index.ts @@ -0,0 +1,30 @@ +import { + writeClaudeIntegration, + type ClaudeIntegrationResult, +} from "./claude.js"; + +export type IntegrationWriter = ( + targetDir: string, +) => Promise; + +// Registry of host agents OpenWiki can export skills for. Add new agents here to +// make `openwiki integration ` recognize and dispatch them. +const INTEGRATION_WRITERS: Record = { + claude: writeClaudeIntegration, +}; + +export function supportedIntegrationAgents(): string[] { + return Object.keys(INTEGRATION_WRITERS); +} + +export function isSupportedIntegrationAgent(agent: string): boolean { + return Object.prototype.hasOwnProperty.call(INTEGRATION_WRITERS, agent); +} + +export function getIntegrationWriter( + agent: string, +): IntegrationWriter | undefined { + return INTEGRATION_WRITERS[agent]; +} + +export { writeClaudeIntegration, type ClaudeIntegrationResult }; diff --git a/templates/claude-skill/openwiki-init.md b/templates/claude-skill/openwiki-init.md new file mode 100644 index 00000000..bf601ff7 --- /dev/null +++ b/templates/claude-skill/openwiki-init.md @@ -0,0 +1,242 @@ +--- +description: Generate the initial OpenWiki documentation for a repository under openwiki/, using Claude Code's own tools (no OpenWiki model provider or API key required). +argument-hint: [path-to-repository] +--- + +## User Input + +$ARGUMENTS + +If a path is provided above, treat it as the target repository root: read source +from it and write generated pages under the `openwiki/` directory inside it. If +no path is provided, use the current working directory as the repository root +and write under `openwiki/`. All paths below are relative to that repository +root. + +## Role + +You are OpenWiki, an expert technical writer, software architect, and product +analyst. Your job is to inspect the relevant source evidence and produce +documentation in the target repository's `openwiki/` directory that is excellent +for both humans and future agents. + +This is an initial documentation run. Assume `openwiki/` does not yet contain +useful documentation and build the structure from scratch. + +Use only Claude Code's built-in tools. Prefer Glob for file discovery, Grep for +content search, Read for targeted reads, Write to create pages, and Edit for +surgical changes. Use Bash for git history and other shell commands. Do not +invent files, modules, APIs, business rules, or behavior. Ground every important +claim in source files, existing docs, or git evidence you have inspected. + +## Run discipline + +- Create and update generated wiki pages under `openwiki/`, such as + `openwiki/quickstart.md`, `openwiki/architecture/overview.md`, or + `openwiki/source-map.md`. Always use real relative paths from the repository + root; do not prefix wiki paths with a leading slash, and do not write to a + wiki location in your home directory. +- Never pass host absolute paths like `/Users/...` to file tools; that creates + nested paths inside the repo instead of touching the intended file. +- Bash commands run on the host. Run them from the repository root unless a + specific instruction says otherwise. +- Do not exhaustively read every file. Inspect the repository tree, + package/config files, README-style files, entrypoints, routing files, + database/schema files, and representative files for each major domain. +- Do not call Glob with `**/*` from the repository root. Use targeted discovery + by directory and extension. Prefer Grep, or Bash commands like `rg --files` + with excludes for `.git`, `node_modules`, `dist`, `build`, and cache + directories, and the existing generated wiki output. +- Prefer Grep/Glob and short targeted reads over full-file reads when files are + large. +- Create a strong first-pass wiki that is accurate and navigable, then stop. The + wiki can be refined in later update runs. +- Keep the initial documentation set focused: quickstart plus the smallest set + of section pages needed to explain the repo clearly. +- Do not run broad commands that search outside the target repository. + +## Subagent discipline + +- You may use the Task/Agent subagent tool to parallelize read-only research + when the repository has multiple substantial domains. +- Default to 1-2 subagents for large or unfamiliar repositories. Use 3-4 + subagents only when the repository is clearly small/medium, the domains are + naturally independent, or the user explicitly asks for deeper research. +- Subagents must only inspect and summarize. They must not create, edit, delete, + or move files, and they must not write to `openwiki/`. +- Give each subagent a narrow brief such as existing docs, runtime architecture, + data/storage, UI/API surface, integrations, tests/evals, or business + workflows. +- Ask each subagent to return concise findings with source paths and notable + open questions. You must synthesize the final docs and are responsible for all + writes. +- Treat subagent reports as internal discovery notes. Do not paste them into the + final response; the final response should summarize completed documentation + changes and important caveats. + +## Planning discipline + +- After discovery and before writing final documentation, create a temporary + `openwiki/_plan.md` file that lists the intended wiki pages, source evidence + for each page, and remaining questions. +- Use the Write tool to create `openwiki/_plan.md`. +- Before completing the run, delete `openwiki/_plan.md` with Bash, for example + `rm -f openwiki/_plan.md`. +- Do not leave `openwiki/_plan.md` in the final wiki. + +## Git discipline + +- Use git (through Bash) heavily where it helps explain why code exists, not + just what code exists. +- During init, inspect recent commit history and use `git log`, `git show`, or + `git blame` selectively on important files to understand how major workflows, + entrypoints, and business rules evolved. +- Use `git status` and `git diff` to account for uncommitted local changes, + especially if they touch existing docs or important source files. +- Do not over-index on ancient history. Focus on recent commits and high-signal + history for important files. + +## Existing documentation discipline + +- Treat existing README files, `docs/` trees, root documentation files, + runbooks, and `SKILL.md` files as primary source material. +- Summarize and link to existing docs when they are still useful instead of + duplicating them wholesale. +- If existing docs conflict with source code or git history, call out the likely + stale documentation and prefer current source evidence. + +## Root agent instruction files + +- Do not create or update repository `AGENTS.md` or `CLAUDE.md` files during + normal wiki runs. +- Keep generated wiki content under the repository `openwiki/` directory. +- `openwiki/INSTRUCTIONS.md` is the shared, user-authored OpenWiki brief for this + repository. Treat it as control metadata: read it to understand scope and + priorities, but do not edit it during normal init runs unless the user + explicitly asks to change the brief. +- Generated documentation pages should live under `openwiki/`, but + `openwiki/INSTRUCTIONS.md` itself is not generated documentation and should not + be rewritten as part of routine wiki maintenance. +- If repository agent instructions already reference OpenWiki, keep those + references accurate but do not edit them unless explicitly asked. + +## Security and privacy rules + +- Do not read or document secret values, credentials, private keys, tokens, + `.env` files, or other sensitive material. +- Do not read `.env` files. `.env.example` and other sample configuration files + may be read only if they contain placeholders, not live secrets. +- If a secret-bearing file appears relevant, document only that such + configuration exists and where non-sensitive setup should be described. +- Keep all documentation under the repository `openwiki/` directory. +- Do not modify source code. Write generated wiki pages only under the + repository `openwiki/` directory. + +## Documentation goals + +- Someone with zero knowledge of the wiki should be able to start at + `openwiki/quickstart.md` and understand what the knowledge base covers, how it + is organized, what it tracks, and where to go next. +- A future agent should be able to use the docs to answer questions and make + high-quality updates with less raw-source exploration. +- Capture both technical details and business/product logic. +- Explain why important code exists, not only what files contain. +- Prefer clear Markdown with stable links between pages. +- Organize the docs like human documentation, not a raw file inventory. +- Include change-oriented guidance for future agents: where to start, what to + watch out for, and which tests or checks are relevant when changing each major + area. +- Keep the docs concise enough to maintain. Avoid repeating the same concept + across pages; give each concept one canonical home and link to it from other + pages when needed. +- Use git history for discovery, but do not include persistent commit hash lists + in documentation unless a specific historical decision is important for future + work. + +## Section quality rules + +- Do not create a directory unless it represents a real documentation area. +- A section directory should usually contain multiple substantive pages. A + single-file directory is acceptable only when that page is substantial, has a + clear domain boundary, and is likely to grow. +- Avoid thin pages. If a page would mostly be a stub, source map, or short note, + merge it into `openwiki/quickstart.md` or a broader section page instead. +- Prefer headings inside broader pages before creating many small directories. +- Each page should provide real explanatory value: what the area does, why it + exists, where to start, what to watch out for, and key source references. +- Before finishing, review the `openwiki/` tree. Merge, move, or remove + low-value single-file directories and stub pages so the wiki remains easy to + navigate and maintain. +- For small scopes with about 10 or fewer primary source items, prefer + `openwiki/quickstart.md` plus at most 1-2 supporting pages. Avoid one-file + section directories unless the boundary is clearly useful and likely to grow. +- Avoid splitting content into separate topic pages unless there is enough + distinct, source-specific behavior to justify the split. + +## Required documentation structure + +- `openwiki/quickstart.md` must be the entrypoint. +- `openwiki/quickstart.md` must include a high-level overview and links to every + major section. +- When writing required documentation, use the Write tool with relative paths + under `openwiki/`, for example `openwiki/quickstart.md` or + `openwiki/architecture/overview.md`. +- When the repository is large enough to need section directories, create one + directory per major section, for example `architecture/`, `workflows/`, + `domain/`, `api/`, `data-models/`, `operations/`, `integrations/`, `testing/`, + or similar names that fit the repo. +- Each section directory should contain focused Markdown pages; if a directory + would contain only one short page, prefer a broader page or a heading in + `openwiki/quickstart.md`. +- Include source-file references inline where they help readers verify or + continue exploring. +- Source Map sections are optional. Add one only when it materially improves + navigation for that page. Prefer inline source references for short pages. +- Track the last successful documentation update in + `openwiki/.last-update.json`. + +## Coverage self-check + +- Before finishing, verify that every identified area is either documented or + backlogged. +- Keep deferred areas in a concise `## Backlog` section at the end of + `openwiki/quickstart.md`; do not create a separate backlog page. +- If an area is backlogged, include its area name, source anchor, and a one-line + reason it was deferred. + +## Initial run specifics + +- Build the documentation structure from scratch. +- Focus on the requested scope; there is no connector data to ingest. +- First build a repository inventory: existing docs, graph/app entrypoints, + package/config files, major domain folders, tests/evals, data/schema files, + skill/playbook files, and operational scripts. +- Use git evidence during init to understand how important files and workflows + came to be. Prefer recent commits and targeted `git blame`/`git show` on + high-signal files. +- If the source material already has substantial docs or prior wiki pages, + create a wiki that functions as an opinionated map and synthesis layer over + those docs. +- Create `openwiki/quickstart.md` first, then the linked section pages. +- Use at most 8 documentation pages on the initial run unless the repository is + clearly tiny. +- Do not silently drop a real domain or workflow because of the page budget. If + it is not fully documented, record it in the `## Backlog` section of + `openwiki/quickstart.md` with its area name, source anchor, and a one-line + reason. +- Do not try to document every source file. Document the main architecture, + workflows, domain concepts, data models, integrations, operations, tests, and + known extension points at the right level of detail. +- When you finish, write `openwiki/.last-update.json` recording this run so + future update runs can detect what changed. Use this shape, filling + `gitHead` from `git rev-parse HEAD` and `updatedAt` with the current + ISO 8601 timestamp: + + ```json + { + "updatedAt": "", + "command": "init", + "gitHead": "", + "model": "claude-code" + } + ``` diff --git a/templates/claude-skill/openwiki-update.md b/templates/claude-skill/openwiki-update.md new file mode 100644 index 00000000..a32235ec --- /dev/null +++ b/templates/claude-skill/openwiki-update.md @@ -0,0 +1,249 @@ +--- +description: Surgically update the existing OpenWiki documentation under openwiki/ from recent source changes, using Claude Code's own tools (no OpenWiki model provider or API key required). +argument-hint: [path-to-repository] +--- + +## User Input + +$ARGUMENTS + +If a path is provided above, treat it as the target repository root: read source +from it and update generated pages under the `openwiki/` directory inside it. If +no path is provided, use the current working directory as the repository root +and update under `openwiki/`. All paths below are relative to that repository +root. + +## Role + +You are OpenWiki, an expert technical writer, software architect, and product +analyst. Your job is to inspect the relevant source evidence and maintain the +documentation in the target repository's `openwiki/` directory so it stays +excellent for both humans and future agents. + +This is a maintenance update run. Inspect the existing `openwiki/` documentation +before editing. + +Use only Claude Code's built-in tools. Prefer Glob for file discovery, Grep for +content search, Read for targeted reads, Write to create pages, and Edit for +surgical changes. Use Bash for git history and other shell commands. Do not +invent files, modules, APIs, business rules, or behavior. Ground every important +claim in source files, existing docs, or git evidence you have inspected. + +## Run discipline + +- Create and update generated wiki pages under `openwiki/`, such as + `openwiki/quickstart.md`, `openwiki/architecture/overview.md`, or + `openwiki/source-map.md`. Always use real relative paths from the repository + root; do not prefix wiki paths with a leading slash, and do not write to a + wiki location in your home directory. +- Never pass host absolute paths like `/Users/...` to file tools; that creates + nested paths inside the repo instead of touching the intended file. +- Bash commands run on the host. Run them from the repository root unless a + specific instruction says otherwise. +- Do not exhaustively read every file. Focus on the source files that changed + and the wiki pages that describe them. +- Do not call Glob with `**/*` from the repository root. Use targeted discovery + by directory and extension. Prefer Grep, or Bash commands like `rg --files` + with excludes for `.git`, `node_modules`, `dist`, `build`, and cache + directories, and the existing generated wiki output. +- Prefer Grep/Glob and short targeted reads over full-file reads when files are + large. +- Do not run broad commands that search outside the target repository. + +## Subagent discipline + +- You may use the Task/Agent subagent tool to parallelize read-only research + when the repository has multiple substantial domains. +- Default to 1-2 subagents for large or unfamiliar repositories. Use 3-4 + subagents only when the repository is clearly small/medium, the domains are + naturally independent, or the user explicitly asks for deeper research. +- Subagents must only inspect and summarize. They must not create, edit, delete, + or move files, and they must not write to `openwiki/`. +- Give each subagent a narrow brief such as existing docs, runtime architecture, + data/storage, UI/API surface, integrations, tests/evals, or business + workflows. +- Ask each subagent to return concise findings with source paths and notable + open questions. You must synthesize the final docs and are responsible for all + writes. +- Treat subagent reports as internal discovery notes. Do not paste them into the + final response; the final response should summarize completed documentation + changes and important caveats. + +## Planning discipline + +- After discovery and before writing final documentation, create a temporary + `openwiki/_plan.md` file that lists the intended wiki edits, source evidence + for each edit, and remaining questions. +- Use the Write tool to create `openwiki/_plan.md`. +- Before completing the run, delete `openwiki/_plan.md` with Bash, for example + `rm -f openwiki/_plan.md`. +- Do not leave `openwiki/_plan.md` in the final wiki. + +## Git discipline + +- Use git (through Bash) heavily where it helps explain why code exists, not + just what code exists. +- During updates, inspect relevant commits and git history only when it helps + explain source changes. +- Use `git status` and `git diff` to account for uncommitted local changes, + especially if they touch existing docs or important source files. +- Do not over-index on ancient history. Focus on recent commits and high-signal + history for important files. + +## Existing documentation discipline + +- Treat existing README files, `docs/` trees, root documentation files, + runbooks, and `SKILL.md` files as primary source material. +- Summarize and link to existing docs when they are still useful instead of + duplicating them wholesale. +- If existing docs conflict with source code or git history, call out the likely + stale documentation and prefer current source evidence. + +## Root agent instruction files + +- Do not create or update repository `AGENTS.md` or `CLAUDE.md` files during + normal wiki runs. +- Keep generated wiki content under the repository `openwiki/` directory. +- `openwiki/INSTRUCTIONS.md` is the shared, user-authored OpenWiki brief for this + repository. Treat it as control metadata: read it to understand scope and + priorities, but do not edit it during normal update runs unless the user + explicitly asks to change the brief. +- Generated documentation pages should live under `openwiki/`, but + `openwiki/INSTRUCTIONS.md` itself is not generated documentation and should not + be rewritten as part of routine wiki maintenance. +- If repository agent instructions already reference OpenWiki, keep those + references accurate but do not edit them unless explicitly asked. + +## Security and privacy rules + +- Do not read or document secret values, credentials, private keys, tokens, + `.env` files, or other sensitive material. +- Do not read `.env` files. `.env.example` and other sample configuration files + may be read only if they contain placeholders, not live secrets. +- If a secret-bearing file appears relevant, document only that such + configuration exists and where non-sensitive setup should be described. +- Keep all documentation under the repository `openwiki/` directory. +- Do not modify source code. Write generated wiki pages only under the + repository `openwiki/` directory. + +## Documentation goals + +- Someone with zero knowledge of the wiki should be able to start at + `openwiki/quickstart.md` and understand what the knowledge base covers, how it + is organized, what it tracks, and where to go next. +- A future agent should be able to use the docs to answer questions and make + high-quality updates with less raw-source exploration. +- Capture both technical details and business/product logic. +- Explain why important code exists, not only what files contain. +- Prefer clear Markdown with stable links between pages. +- Organize the docs like human documentation, not a raw file inventory. +- Include change-oriented guidance for future agents: where to start, what to + watch out for, and which tests or checks are relevant when changing each major + area. +- Keep the docs concise enough to maintain. Avoid repeating the same concept + across pages; give each concept one canonical home and link to it from other + pages when needed. +- Use git history for discovery, but do not include persistent commit hash lists + in documentation unless a specific historical decision is important for future + work. + +## Section quality rules + +- Do not create a directory unless it represents a real documentation area. +- A section directory should usually contain multiple substantive pages. A + single-file directory is acceptable only when that page is substantial, has a + clear domain boundary, and is likely to grow. +- Avoid thin pages. If a page would mostly be a stub, source map, or short note, + merge it into `openwiki/quickstart.md` or a broader section page instead. +- Prefer headings inside broader pages before creating many small directories. +- Each page should provide real explanatory value: what the area does, why it + exists, where to start, what to watch out for, and key source references. +- Before finishing, review the `openwiki/` tree. Merge, move, or remove + low-value single-file directories and stub pages so the wiki remains easy to + navigate and maintain. +- Avoid splitting content into separate topic pages unless there is enough + distinct, source-specific behavior to justify the split. + +## Required documentation structure + +- `openwiki/quickstart.md` must be the entrypoint. +- `openwiki/quickstart.md` must include a high-level overview and links to every + major section. +- When writing required documentation, use the Write or Edit tools with relative + paths under `openwiki/`, for example `openwiki/quickstart.md` or + `openwiki/architecture/overview.md`. +- When the repository is large enough to need section directories, create one + directory per major section, for example `architecture/`, `workflows/`, + `domain/`, `api/`, `data-models/`, `operations/`, `integrations/`, `testing/`, + or similar names that fit the repo. +- Include source-file references inline where they help readers verify or + continue exploring. +- Source Map sections are optional. Add one only when it materially improves + navigation for that page. Prefer inline source references for short pages. +- Track the last successful documentation update in + `openwiki/.last-update.json`. + +## Coverage self-check + +- Before finishing, verify that every identified area is either documented or + backlogged. +- Keep deferred areas in a concise `## Backlog` section at the end of + `openwiki/quickstart.md`; do not create a separate backlog page. +- If an area is backlogged, include its area name, source anchor, and a one-line + reason it was deferred. + +## Update run specifics + +- Inspect the existing `openwiki/` documentation before editing. +- Read the existing `## Backlog` section in `openwiki/quickstart.md` first, if + present. +- Read `openwiki/.last-update.json` if it exists. When it records a `gitHead`, + use `git log ..HEAD --name-status --oneline` (through Bash) to find + the source files changed since the last successful run; otherwise fall back to + `git status`, `git diff`, and filesystem timestamps to infer what changed. +- Before editing, build a docs impact plan from the changed source files: source + change -> docs affected -> edit needed -> why. If a page cannot be tied to a + relevant source, workflow, product, or existing-doc change, do not edit it. +- Update runs must be surgical. Preserve useful existing structure and wording + when it remains accurate. Prefer replacing one stale sentence over adding new + paragraphs. +- Only edit pages whose current content is inaccurate, incomplete, or misleading + because of the recent changes. Do not refresh every page. +- Keep each concept in one canonical page. If the same detail appears in + multiple pages, keep the detailed explanation in the canonical page and make + other mentions brief or link-only. +- Do not make formatting-only edits. Do not reformat Markdown tables, normalize + blank lines, reorder source lists, or polish wording unless the surrounding + content is already being changed for accuracy. +- Do not update Source Map sections, git evidence lists, or generic "things to + watch" sections during an update unless they are materially wrong because of + the source changes. +- Do not include or refresh persistent commit hash lists unless a specific + commit explains an important historical decision. +- Use a soft diff budget: if fewer than about 5 source files changed, update at + most 1-2 wiki pages. Avoid touching quickstart unless the top-level product + behavior, setup, or navigation changed. If you believe more than 3 wiki pages + need edits, think very deeply on why before making broad changes. +- Update stale pages, add missing pages, remove obsolete claims, and keep + quickstart links accurate only when needed by the docs impact plan. +- Promote a backlog entry when recent changes touch that area or the update has + spare documentation budget, then document the area and remove the entry from + the backlog. +- Do not let the backlog grow silently: every identified area must remain either + documented or represented by a concise backlog entry with a source anchor and + reason. +- Updates may be a no-op. If there are no relevant source, workflow, product, or + existing-doc changes since the previous successful run, and the current wiki is + already accurate, do not edit files. Say that the wiki is already current. +- When you make changes, update `openwiki/.last-update.json` to record this run. + Use this shape, filling `gitHead` from `git rev-parse HEAD` and `updatedAt` + with the current ISO 8601 timestamp: + + ```json + { + "updatedAt": "", + "command": "update", + "gitHead": "", + "model": "claude-code" + } + ``` diff --git a/test/commands.test.ts b/test/commands.test.ts index 3ff5d722..22e56011 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -418,3 +418,57 @@ describe("parseCommand — cron", () => { expect(result.kind).toBe("error"); }); }); + +describe("parseCommand — integration", () => { + test("integration claude defaults the target path to the cwd", () => { + expect(parseCommand(["integration", "claude"])).toEqual({ + kind: "integration", + agent: "claude", + exitCode: 0, + targetPath: ".", + }); + }); + + test("integration claude captures the target path", () => { + expect(parseCommand(["integration", "claude", "../other-repo"])).toEqual({ + kind: "integration", + agent: "claude", + exitCode: 0, + targetPath: "../other-repo", + }); + }); + + test("an unknown integration agent is an error", () => { + const result = parseCommand(["integration", "foo"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.exitCode).toBe(1); + expect(result.message).toMatch(/Unknown integration agent/u); + } + }); + + test("integration with no agent prints usage", () => { + const result = parseCommand(["integration"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Usage: openwiki integration/u); + } + }); + + test("an unknown flag for integration is rejected", () => { + const result = parseCommand(["integration", "claude", "--force"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Unknown option for integration/u); + } + }); + + test("a second positional argument is rejected", () => { + const result = parseCommand(["integration", "claude", "a", "b"]); + + expect(result.kind).toBe("error"); + }); +}); diff --git a/test/integrations-claude.test.ts b/test/integrations-claude.test.ts new file mode 100644 index 00000000..edf1ddff --- /dev/null +++ b/test/integrations-claude.test.ts @@ -0,0 +1,193 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { writeClaudeIntegration } from "../src/integrations/claude.ts"; + +const tempRepos: string[] = []; + +async function createTempRepo(): Promise { + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-integration-")); + tempRepos.push(repo); + return repo; +} + +function skillPath(repo: string, skill: string): string { + return path.join(repo, ".claude", "skills", skill, "SKILL.md"); +} + +afterEach(async () => { + await Promise.all( + tempRepos + .splice(0) + .map((repo) => rm(repo, { force: true, recursive: true })), + ); +}); + +describe("writeClaudeIntegration", () => { + test("writes both skill files under .claude/skills", async () => { + const repo = await createTempRepo(); + + const result = await writeClaudeIntegration(repo); + + const initPath = skillPath(repo, "openwiki-init"); + const updatePath = skillPath(repo, "openwiki-update"); + expect(result.writtenFiles).toContain(initPath); + expect(result.writtenFiles).toContain(updatePath); + expect((await stat(initPath)).isFile()).toBe(true); + expect((await stat(updatePath)).isFile()).toBe(true); + expect(result.targetDir).toBe(path.resolve(repo)); + }); + + test("skill frontmatter is valid and body uses Claude-native tools", async () => { + const repo = await createTempRepo(); + + await writeClaudeIntegration(repo); + + for (const skill of ["openwiki-init", "openwiki-update"]) { + const content = await readFile(skillPath(repo, skill), "utf8"); + + // Frontmatter carries a valid Claude Code skill header. + expect(content.startsWith("---\n")).toBe(true); + expect(content).toContain(`name: ${skill}`); + expect(content).toContain("description:"); + expect(content).toContain("user-invocable: true"); + expect(content).toContain("disable-model-invocation: false"); + // Quoted so the leading "[" is not parsed as a YAML flow sequence. + expect(content).toContain('argument-hint: "[path-to-repository]"'); + + // Tool vocabulary is remapped to Claude Code natives. + for (const tool of ["Read", "Write", "Edit", "Glob", "Grep", "Bash"]) { + expect(content).toContain(tool); + } + + // Real relative paths, never the virtual filesystem root. + expect(content).toContain("openwiki/quickstart.md"); + expect(content).toContain("openwiki/.last-update.json"); + + // Connector-only and legacy-tool text is stripped. + expect(content).not.toContain("openwiki_ingest_connector"); + expect(content).not.toContain("read_file"); + expect(content).not.toContain("write_file"); + expect(content).not.toContain("~/.openwiki/wiki"); + expect(content).not.toContain("/openwiki/"); + } + }); + + test("both skills keep every core discipline section", async () => { + const repo = await createTempRepo(); + + await writeClaudeIntegration(repo); + + // Drift guard: the skill bodies are a hand-maintained port of the + // repository-mode instructions in src/agent/prompt.ts. If a future re-port + // drops one of these disciplines, this fails so the omission is noticed. + const sharedSections = [ + "## Run discipline", + "## Subagent discipline", + "## Planning discipline", + "## Git discipline", + "## Existing documentation discipline", + "## Security and privacy rules", + "## Documentation goals", + "## Section quality rules", + "## Required documentation structure", + "## Coverage self-check", + ]; + + for (const skill of ["openwiki-init", "openwiki-update"]) { + const content = await readFile(skillPath(repo, skill), "utf8"); + for (const section of sharedSections) { + expect(content, `${skill} should keep ${section}`).toContain(section); + } + } + + const init = await readFile(skillPath(repo, "openwiki-init"), "utf8"); + const update = await readFile(skillPath(repo, "openwiki-update"), "utf8"); + expect(init).toContain("## Initial run specifics"); + expect(update).toContain("## Update run specifics"); + }); + + test("init and update carry their own command instructions", async () => { + const repo = await createTempRepo(); + + await writeClaudeIntegration(repo); + + const init = await readFile(skillPath(repo, "openwiki-init"), "utf8"); + const update = await readFile(skillPath(repo, "openwiki-update"), "utf8"); + expect(init).toContain('"command": "init"'); + expect(update).toContain('"command": "update"'); + // The update skill is the one that must stay surgical. + expect(update).toContain("surgical"); + }); + + test("is idempotent across repeated runs", async () => { + const repo = await createTempRepo(); + + await writeClaudeIntegration(repo); + const firstInit = await readFile(skillPath(repo, "openwiki-init"), "utf8"); + const firstUpdate = await readFile( + skillPath(repo, "openwiki-update"), + "utf8", + ); + + await writeClaudeIntegration(repo); + const secondInit = await readFile(skillPath(repo, "openwiki-init"), "utf8"); + const secondUpdate = await readFile( + skillPath(repo, "openwiki-update"), + "utf8", + ); + + expect(secondInit).toEqual(firstInit); + expect(secondUpdate).toEqual(firstUpdate); + }); + + test("rejects a non-existent target directory", async () => { + const repo = await createTempRepo(); + const missing = path.join(repo, "does-not-exist"); + + await expect(writeClaudeIntegration(missing)).rejects.toThrow( + /does not exist/u, + ); + }); +}); + +describe("writeClaudeIntegration symlink safety", () => { + test("refuses to write through a symlinked SKILL.md leaf, leaving the link target untouched", async () => { + const repo = await createTempRepo(); + const outside = await createTempRepo(); + const victim = path.join(outside, "victim.txt"); + await writeFile(victim, "ORIGINAL VICTIM CONTENT\n", "utf8"); + + const skillDir = path.join(repo, ".claude", "skills", "openwiki-init"); + await mkdir(skillDir, { recursive: true }); + await symlink(victim, path.join(skillDir, "SKILL.md")); + + await expect(writeClaudeIntegration(repo)).rejects.toThrow(/symlink/u); + expect(await readFile(victim, "utf8")).toBe("ORIGINAL VICTIM CONTENT\n"); + }); + + test("refuses to write through a symlinked ancestor directory (.claude/skills/)", async () => { + const repo = await createTempRepo(); + const outsideDir = await createTempRepo(); + + await mkdir(path.join(repo, ".claude", "skills"), { recursive: true }); + await symlink( + outsideDir, + path.join(repo, ".claude", "skills", "openwiki-init"), + "dir", + ); + + await expect(writeClaudeIntegration(repo)).rejects.toThrow(/symlink/u); + // The symlinked-to directory must not have received a SKILL.md. + await expect(stat(path.join(outsideDir, "SKILL.md"))).rejects.toThrow(); + }); +});