diff --git a/.claude/settings.json b/.claude/settings.json index 66345f1d..cd123e37 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,7 +31,7 @@ "hooks": [ { "type": "command", - "command": "jq -r '.tool_input.file_path' | xargs -I {} bun run format:file {}" + "command": "bun run format:file \"$(jq -r .tool_input.file_path)\" || true" } ] } diff --git a/.githooks b/.githooks new file mode 100644 index 00000000..99cd4de2 --- /dev/null +++ b/.githooks @@ -0,0 +1,43 @@ +# Git 2.54+ config-based hooks for the archgate CLI repo. +# +# Activate (once per clone): +# git config --local include.path ../.githooks +# +# Opt out of a specific hook without removing config: +# git config --local hook..enabled false +# +# List active hooks: +# git hook list pre-commit +# git hook list pre-push +# +# Skip hooks for a single commit (escape hatch, not routine): +# git commit --no-verify + +# --------------------------------------------------------------------------- +# pre-commit — fast lint/typecheck/format gate +# --------------------------------------------------------------------------- +# Catches syntax errors, type violations, and formatting drift before +# a commit is created. Runs in ~15s (lint + typecheck + format:check). +# Full validation (tests, ADR checks, knip, build) runs in pre-push. + +[hook "lint"] + event = pre-commit + command = bun run lint + +[hook "typecheck"] + event = pre-commit + command = bun run typecheck + +[hook "format-check"] + event = pre-commit + command = bun run format:check + +# --------------------------------------------------------------------------- +# pre-push — full CI validation gate +# --------------------------------------------------------------------------- +# Runs the complete validation pipeline before pushing. Mirrors the CI +# job in .github/workflows/code-pull-request.yml. Takes ~60s. + +[hook "validate"] + event = pre-push + command = bun run validate diff --git a/CLAUDE.md b/CLAUDE.md index a1368fe6..08579321 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,21 @@ bun run commit # conventional commit wizard **`bun run validate` must pass before any task is considered complete.** Fail-fast pipeline: lint → typecheck → format → test → ADR check → knip → build check. Mirrors CI in `.github/workflows/code-pull-request.yml`. +## Git Hooks (Git 2.54+) + +Config-based hooks in `.githooks` run validation locally before commits and pushes: + +- **pre-commit:** lint + typecheck + format:check (~15s) +- **pre-push:** full `bun run validate` (~60s, mirrors CI) + +Activate once per clone: + +```bash +git config --local include.path ../.githooks +``` + +Opt out of a specific hook: `git config --local hook..enabled false`. Skip all hooks for a single commit: `git commit --no-verify`. + ## Architecture ### Commands @@ -75,7 +90,7 @@ YAML frontmatter (`id`, `title`, `domain`, `rules`, optional `files`). Sections: Editor integrations share the `EditorTarget` union. Adding a new editor requires coordinated edits — missing any one breaks detection, init, or tests: 1. `src/helpers/init-project.ts` — extend `EditorTarget` union, `EDITOR_LABELS`, the `configureEditorSettings` switch, and (when authenticated install applies) the `tryInstallPlugin` branch -2. `src/helpers/plugin-install.ts` — add `isCliAvailable()` and any install/download helper +2. `src/helpers/plugin-install.ts` — add `isCliAvailable()` and any install/download helper. For tarball-based editors (no marketplace CLI), use `installEditorPluginBundle()` — it handles directory creation, old-file cleanup, and tarball extraction in one call 3. `src/helpers/editor-detect.ts` — append to the `Promise.all` and the returned array 4. `src/commands/init.ts` — extend `EDITOR_DIRS`, `SIGNUP_EDITORS`, the `--editor` `.choices([...] as const)`, and `printManualInstructions` 5. `src/commands/plugin/install.ts` — extend `.choices([...] as const)` and add a case to `installForEditor` + the manual-instructions `catch` diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index ebb18b64..b3429569 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -91,12 +91,12 @@ export async function installForEditor( break; } case "opencode": { - // Writing agent files to `~/.config/opencode/agents/` is only useful - // if opencode is actually installed. Skip the install and surface a - // clear message otherwise, matching every other editor's guard. + // Writing files to `~/.config/opencode/{agents,skills}/` is only + // useful if opencode is actually installed. Skip the install and + // surface a clear message otherwise, matching every other editor's guard. if (!(await isOpencodeCliAvailable())) { logWarn( - "opencode CLI not found on PATH — skipping agent install.", + "opencode CLI not found on PATH — skipping plugin install.", "Install opencode from https://opencode.ai/docs/, then re-run:" ); console.log( @@ -105,7 +105,7 @@ export async function installForEditor( break; } await installOpencodePlugin(token); - logInfo(`Archgate agents installed for ${label}.`); + logInfo(`Archgate plugin installed for ${label}.`); break; } case "vscode": { diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 3c3737ac..d775c478 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -2,11 +2,11 @@ // Copyright 2026 Archgate /** Download and install the archgate plugin for supported editors. */ -import { existsSync, mkdirSync, unlinkSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { logDebug } from "./log"; -import { cursorUserDir, internalPath, opencodeAgentsDir } from "./paths"; +import { cursorUserDir, internalPath, opencodeConfigDir } from "./paths"; import { resolveCommand } from "./platform"; const PLUGINS_API = "https://plugins.archgate.dev"; @@ -142,13 +142,11 @@ export async function installClaudePlugin(): Promise { */ export async function installCursorPlugin(token: string): Promise { const cursorDir = cursorUserDir(); - mkdirSync(join(cursorDir, "skills"), { recursive: true }); - mkdirSync(join(cursorDir, "agents"), { recursive: true }); - await downloadAndExtractTarball({ + await installEditorPluginBundle({ + baseDir: cursorDir, apiPath: "/api/cursor", token, - targetDir: cursorDir, label: "Cursor", tempFile: "archgate-cursor.tar.gz", }); @@ -224,25 +222,60 @@ async function downloadPluginAsset( return response.arrayBuffer(); } +// --------------------------------------------------------------------------- +// Shared — editor plugin bundle install (agents + skills) +// --------------------------------------------------------------------------- + /** - * Download and extract a tarball from the plugins API into a target directory. + * Install an archgate editor plugin bundle (agents + skills). + * + * Shared by Cursor and opencode — both follow the same pattern: + * 1. Ensure `agents/` and `skills/` subdirectories exist + * 2. Clean previous archgate files (avoids dangling/renamed artifacts) + * 3. Download and extract the authenticated tarball * - * Shared by `installCursorPlugin` and `installOpencodePlugin` — both follow - * the same pattern: authenticated download → write to temp → tar extract → - * cleanup temp file. + * Old archgate files are removed via `Bun.Glob` before extraction so + * renamed or removed components don't linger. Only `archgate-*` entries + * are touched — other editors'/users' files are left untouched. * * Uses `tar` via `Bun.spawn` (ARCH-007) — `tar` is available on macOS, * Linux, and modern Windows (bsdtar ships with Windows 10+). + * + * Editor-specific post-install steps (hooks merging, settings config) are + * handled by each editor's install function after this returns. */ -async function downloadAndExtractTarball(opts: { +async function installEditorPluginBundle(opts: { + baseDir: string; apiPath: string; token: string; - targetDir: string; label: string; tempFile: string; }): Promise { - const tarballPath = internalPath(opts.tempFile); + const agentsDir = join(opts.baseDir, "agents"); + const skillsDir = join(opts.baseDir, "skills"); + mkdirSync(agentsDir, { recursive: true }); + mkdirSync(skillsDir, { recursive: true }); + + // Clean old archgate agents (flat .md files) + for (const file of new Bun.Glob("archgate-*.md").scanSync({ + cwd: agentsDir, + dot: true, + })) { + unlinkSync(join(agentsDir, file)); + } + + // Clean old archgate skill directories (archgate-*/SKILL.md) + const staleSkillDirs = new Set( + [ + ...new Bun.Glob("archgate-*/*").scanSync({ cwd: skillsDir, dot: true }), + ].map((f) => f.split(/[/\\]/u)[0]) + ); + for (const dir of staleSkillDirs) { + rmSync(join(skillsDir, dir), { recursive: true, force: true }); + } + // Download and extract the tarball + const tarballPath = internalPath(opts.tempFile); const buffer = await downloadPluginAsset(opts.apiPath, opts.token); logDebug( `Downloaded ${opts.label} bundle (${Math.round(buffer.byteLength / 1024)} KB)` @@ -250,14 +283,8 @@ async function downloadAndExtractTarball(opts: { await Bun.write(tarballPath, buffer); try { - logDebug(`Extracting ${opts.label} components into ${opts.targetDir}`); - const result = await run([ - "tar", - "-xzf", - tarballPath, - "-C", - opts.targetDir, - ]); + logDebug(`Extracting ${opts.label} components into ${opts.baseDir}`); + const result = await run(["tar", "-xzf", tarballPath, "-C", opts.baseDir]); if (result.exitCode !== 0) { throw new Error( `tar -xzf failed (exit ${result.exitCode}) while extracting ${opts.label} components` @@ -273,7 +300,7 @@ async function downloadAndExtractTarball(opts: { } // --------------------------------------------------------------------------- -// opencode — download agent bundle into user-scope agents dir +// opencode — download plugin bundle into user-scope config dir // --------------------------------------------------------------------------- /** @@ -286,24 +313,23 @@ export async function isOpencodeCliAvailable(): Promise { } /** - * Install the archgate opencode agents into the user-scope agents directory. + * Install archgate agents and skills into opencode's user-scope directories. * - * Opencode has no plugin marketplace — agents are plain markdown files. - * Archgate ships them as an authenticated tarball at `/api/opencode`. The - * tarball contains `archgate-*.md` files at its root which extract directly - * into the resolved `opencodeAgentsDir()`. + * Opencode has no plugin marketplace — agents and skills are plain markdown + * files. Archgate ships them as an authenticated tarball at `/api/opencode`. + * The tarball contains `agents/` and `skills/` directories which extract + * into the resolved `opencodeConfigDir()`. * * Throws on download or extraction failure so callers can surface a manual * retry hint. */ export async function installOpencodePlugin(token: string): Promise { - const agentsDir = opencodeAgentsDir(); - mkdirSync(agentsDir, { recursive: true }); + const baseDir = opencodeConfigDir(); - await downloadAndExtractTarball({ + await installEditorPluginBundle({ + baseDir, apiPath: "/api/opencode", token, - targetDir: agentsDir, label: "opencode", tempFile: "archgate-opencode.tar.gz", }); diff --git a/tests/helpers/plugin-install-cleanup.test.ts b/tests/helpers/plugin-install-cleanup.test.ts new file mode 100644 index 00000000..83805139 --- /dev/null +++ b/tests/helpers/plugin-install-cleanup.test.ts @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// Module mocks — declared before imports that use them. +// --------------------------------------------------------------------------- + +const mockResolveCommand = mock<(name: string) => Promise>(() => + Promise.resolve(null) +); +mock.module("../../src/helpers/platform", () => ({ + resolveCommand: mockResolveCommand, +})); + +// Mock paths so cursorUserDir can be redirected without modifying +// Bun.env.HOME — env changes leak to parallel test files because Bun +// runs all tests in a single process sharing Bun.env. +let cursorDirOverride: string | undefined; +mock.module("../../src/helpers/paths", () => ({ + cursorUserDir: () => + cursorDirOverride ?? + join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".cursor"), + internalPath: (...parts: string[]) => + join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".archgate", ...parts), + opencodeConfigDir: () => { + const xdg = Bun.env.XDG_CONFIG_HOME; + const base = + xdg && xdg !== "undefined" + ? xdg + : join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".config"); + return join(base, "opencode"); + }, +})); + +// --------------------------------------------------------------------------- +// Imports under test — loaded AFTER mocks are registered. +// --------------------------------------------------------------------------- + +import { + installCursorPlugin, + installOpencodePlugin, +} from "../../src/helpers/plugin-install"; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +let originalFetch: typeof globalThis.fetch; + +function fakeSpawnResult( + exitCode: number, + stdout = "", + stderr = "" +): ReturnType { + return { + stdout: new Response(stdout).body!, + stderr: new Response(stderr).body!, + exited: Promise.resolve(exitCode), + pid: 0, + exitCode: null, + signalCode: null, + killed: false, + stdin: null as never, + ref: () => {}, + unref: () => {}, + kill: () => {}, + readable: new ReadableStream(), + [Symbol.asyncDispose]: () => Promise.resolve(), + } as unknown as ReturnType; +} + +function mockFetch(status: number, body: ArrayBuffer | null = null): void { + globalThis.fetch = (() => + Promise.resolve({ + status, + ok: status >= 200 && status < 300, + arrayBuffer: () => Promise.resolve(body ?? new ArrayBuffer(0)), + })) as unknown as typeof fetch; +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +let spawnSpy: ReturnType; +let tempDir: string; +let savedHome: string | undefined; +let savedXdg: string | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + mockResolveCommand.mockReset(); + mockResolveCommand.mockImplementation(() => Promise.resolve(null)); + spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => fakeSpawnResult(0)); + + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "archgate-plugin-test-"))); + savedHome = Bun.env.HOME; + savedXdg = Bun.env.XDG_CONFIG_HOME; + Bun.env.XDG_CONFIG_HOME = tempDir; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + spawnSpy.mockRestore(); + mock.restore(); + Bun.env.HOME = savedHome; + Bun.env.XDG_CONFIG_HOME = savedXdg; + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // SQLite handles may persist on Windows + } +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("plugin install — stale file cleanup", () => { + describe("opencode", () => { + test("removes archgate-* agents and skills before extraction", async () => { + const agentsDir = join(tempDir, "opencode", "agents"); + const skillsDir = join(tempDir, "opencode", "skills"); + mkdirSync(agentsDir, { recursive: true }); + mkdirSync(skillsDir, { recursive: true }); + + // Seed old archgate files that should be cleaned + writeFileSync(join(agentsDir, "archgate-developer.md"), "old"); + writeFileSync(join(agentsDir, "archgate-planner.md"), "old"); + mkdirSync(join(skillsDir, "archgate-reviewer")); + writeFileSync(join(skillsDir, "archgate-reviewer", "SKILL.md"), "old"); + + mockFetch(200, new ArrayBuffer(64)); + + await installOpencodePlugin("test-token"); + + expect(existsSync(join(agentsDir, "archgate-developer.md"))).toBe(false); + expect(existsSync(join(agentsDir, "archgate-planner.md"))).toBe(false); + expect(existsSync(join(skillsDir, "archgate-reviewer"))).toBe(false); + }); + + test("preserves non-archgate files during cleanup", async () => { + const agentsDir = join(tempDir, "opencode", "agents"); + const skillsDir = join(tempDir, "opencode", "skills"); + mkdirSync(agentsDir, { recursive: true }); + mkdirSync(skillsDir, { recursive: true }); + + // User's own agent and skill — must survive + writeFileSync(join(agentsDir, "my-custom-agent.md"), "keep"); + mkdirSync(join(skillsDir, "my-custom-skill")); + writeFileSync(join(skillsDir, "my-custom-skill", "SKILL.md"), "keep"); + + // Archgate file that should be cleaned + writeFileSync(join(agentsDir, "archgate-old.md"), "old"); + + mockFetch(200, new ArrayBuffer(64)); + + await installOpencodePlugin("test-token"); + + expect(existsSync(join(agentsDir, "my-custom-agent.md"))).toBe(true); + expect(existsSync(join(skillsDir, "my-custom-skill", "SKILL.md"))).toBe( + true + ); + expect(existsSync(join(agentsDir, "archgate-old.md"))).toBe(false); + }); + + test("handles clean install with no pre-existing files", async () => { + mockFetch(200, new ArrayBuffer(64)); + + await installOpencodePlugin("test-token"); + + expect(existsSync(join(tempDir, "opencode", "agents"))).toBe(true); + expect(existsSync(join(tempDir, "opencode", "skills"))).toBe(true); + }); + + test("extracts into config dir, not agents subdir", async () => { + mockFetch(200, new ArrayBuffer(64)); + + await installOpencodePlugin("test-token"); + + const callArgs = spawnSpy.mock.calls[0][0] as string[]; + const targetIdx = callArgs.indexOf("-C"); + expect(targetIdx).toBeGreaterThanOrEqual(0); + const targetDir = callArgs[targetIdx + 1]; + // Must end with /opencode (config dir), not /opencode/agents + expect(targetDir).toMatch(/opencode$/u); + expect(targetDir).not.toMatch(/agents$/u); + }); + }); + + describe("cursor", () => { + // Redirect cursorUserDir() via mock — NOT via Bun.env.HOME, because + // env changes leak to parallel test files in Bun's single-process runner. + beforeEach(() => { + cursorDirOverride = join(tempDir, ".cursor"); + }); + afterEach(() => { + cursorDirOverride = undefined; + }); + + test("removes archgate-* agents and skills before extraction", async () => { + const agentsDir = join(tempDir, ".cursor", "agents"); + const skillsDir = join(tempDir, ".cursor", "skills"); + mkdirSync(agentsDir, { recursive: true }); + mkdirSync(skillsDir, { recursive: true }); + + writeFileSync(join(agentsDir, "archgate-developer.md"), "old"); + mkdirSync(join(skillsDir, "archgate-reviewer")); + writeFileSync(join(skillsDir, "archgate-reviewer", "SKILL.md"), "old"); + + mockFetch(200, new ArrayBuffer(64)); + + await installCursorPlugin("test-token"); + + expect(existsSync(join(agentsDir, "archgate-developer.md"))).toBe(false); + expect(existsSync(join(skillsDir, "archgate-reviewer"))).toBe(false); + }); + + test("extracts into cursor user dir, not a subdirectory", async () => { + mockFetch(200, new ArrayBuffer(64)); + + await installCursorPlugin("test-token"); + + const callArgs = spawnSpy.mock.calls[0][0] as string[]; + const targetIdx = callArgs.indexOf("-C"); + expect(targetIdx).toBeGreaterThanOrEqual(0); + const targetDir = callArgs[targetIdx + 1]; + expect(targetDir).toMatch(/\.cursor$/u); + }); + }); +});