From d99fa154a445512c5ae02e26c4eefc2fb660f2ed Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 12:30:23 +0200 Subject: [PATCH 1/9] fix(opencode): extract to config dir and clean stale files on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugins repo PR #117 changed the opencode tarball from flat agent files to nested agents/ + skills/ directories. The CLI was extracting into opencodeAgentsDir() (~/.config/opencode/agents/), which double- nested agents and misplaced skills. - Fix extraction target: opencodeConfigDir() instead of opencodeAgentsDir() - Add cleanArchgateFiles() to remove stale archgate-* agents/skills before extraction, preventing dangling files from renamed components - Extract installEditorPluginBundle() shared by Cursor and opencode: ensure dirs → clean old files → download and extract tarball - Update install command messaging from "agents" to "plugin" Signed-off-by: Rhuan Barreto --- CLAUDE.md | 2 +- src/commands/plugin/install.ts | 10 ++-- src/helpers/plugin-install.ts | 100 +++++++++++++++++++++++++++------ 3 files changed, 90 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a1368fe6..bb2d0487 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,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..576b3bc2 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -2,11 +2,17 @@ // Copyright 2026 Archgate /** Download and install the archgate plugin for supported editors. */ -import { existsSync, mkdirSync, unlinkSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + 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 +148,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", }); @@ -273,7 +277,72 @@ async function downloadAndExtractTarball(opts: { } // --------------------------------------------------------------------------- -// opencode — download agent bundle into user-scope agents dir +// Shared — editor plugin bundle install (agents + skills) +// --------------------------------------------------------------------------- + +/** + * Remove previously-installed archgate agents and skills from an editor's + * discovery directories so fresh extractions don't leave dangling files + * from renamed or removed components. + * + * Only touches `archgate-*` entries — other editors'/users' files are left + * untouched. + */ +function cleanArchgateFiles(baseDir: string): void { + const agentsDir = join(baseDir, "agents"); + const skillsDir = join(baseDir, "skills"); + + if (existsSync(agentsDir)) { + for (const entry of readdirSync(agentsDir)) { + if (entry.startsWith("archgate-") && entry.endsWith(".md")) { + unlinkSync(join(agentsDir, entry)); + } + } + } + + if (existsSync(skillsDir)) { + for (const entry of readdirSync(skillsDir)) { + if (entry.startsWith("archgate-")) { + rmSync(join(skillsDir, entry), { recursive: true, force: true }); + } + } + } +} + +/** + * 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 + * + * Editor-specific post-install steps (hooks merging, settings config) are + * handled by each editor's install function after this returns. + */ +async function installEditorPluginBundle(opts: { + baseDir: string; + apiPath: string; + token: string; + label: string; + tempFile: string; +}): Promise { + mkdirSync(join(opts.baseDir, "agents"), { recursive: true }); + mkdirSync(join(opts.baseDir, "skills"), { recursive: true }); + + cleanArchgateFiles(opts.baseDir); + + await downloadAndExtractTarball({ + apiPath: opts.apiPath, + token: opts.token, + targetDir: opts.baseDir, + label: opts.label, + tempFile: opts.tempFile, + }); +} + +// --------------------------------------------------------------------------- +// opencode — download plugin bundle into user-scope config dir // --------------------------------------------------------------------------- /** @@ -286,24 +355,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", }); From e017cd5f4bf04c75de85451a6a6c319947df2554 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 12:41:00 +0200 Subject: [PATCH 2/9] test: add extraction target and cleanup tests for plugin install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original tests only checked that tar was called — they never asserted where extraction targets, which is exactly the bug surface that allowed the wrong target directory to go undetected. - Assert opencode extracts to config dir (not agents subdir) - Assert cursor extracts to user dir (not a nested subdir) - Test stale archgate-* files are removed before extraction - Test non-archgate user files survive cleanup - Test clean install with no pre-existing files works Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install-cleanup.test.ts | 222 +++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 tests/helpers/plugin-install-cleanup.test.ts diff --git a/tests/helpers/plugin-install-cleanup.test.ts b/tests/helpers/plugin-install-cleanup.test.ts new file mode 100644 index 00000000..692bdece --- /dev/null +++ b/tests/helpers/plugin-install-cleanup.test.ts @@ -0,0 +1,222 @@ +// 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, +})); + +// --------------------------------------------------------------------------- +// 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"); + 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", () => { + test("removes archgate-* agents and skills before extraction", async () => { + // Cursor resolves to ~/.cursor — override HOME to temp dir + Bun.env.HOME = tempDir; + 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 () => { + Bun.env.HOME = tempDir; + mockFetch(200, new ArrayBuffer(64)); + + await installCursorPlugin("test-token"); + + const callArgs = spawnSpy.mock.calls[0][0] as string[]; + const targetIdx = callArgs.indexOf("-C"); + const targetDir = callArgs[targetIdx + 1]; + expect(targetDir).toMatch(/\.cursor$/u); + }); + }); +}); From 5b79f6e7b51df1e3d3e245c7e83079785c1c555e Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 12:50:25 +0200 Subject: [PATCH 3/9] fix: use command substitution in format hook to preserve Windows paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git's xargs treats backslashes as escape characters, stripping them from Windows paths (E:\archgate\cli\... → E:archgatecli...). Replace the jq|xargs pipeline with command substitution which preserves backslashes correctly. Signed-off-by: Rhuan Barreto --- .claude/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 66345f1d..c7577303 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)\"" } ] } From 2a31e9c75c5264370283e0ad8df26d878ab1e642 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 13:08:31 +0200 Subject: [PATCH 4/9] feat: add Git 2.54+ config-based hooks for local validation Add .githooks config file with pre-commit and pre-push hooks: - pre-commit: lint + typecheck + format:check (~15s fast gate) - pre-push: full `bun run validate` (~60s, mirrors CI) Activate with: git config --local include.path ../.githooks Also fix the Claude Code PostToolUse format hook to tolerate files that oxfmt cannot format (e.g., extensionless config files). Signed-off-by: Rhuan Barreto --- .claude/settings.json | 2 +- .githooks | 43 +++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 15 +++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 .githooks diff --git a/.claude/settings.json b/.claude/settings.json index c7577303..cd123e37 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,7 +31,7 @@ "hooks": [ { "type": "command", - "command": "bun run format:file \"$(jq -r .tool_input.file_path)\"" + "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 bb2d0487..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 From c246a324479af57368fdf7120552dd2bc2eae6c4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 14:56:52 +0200 Subject: [PATCH 5/9] refactor: use Bun.Glob for cleanup and inline downloadAndExtractTarball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace readdirSync + manual string filtering with Bun.Glob.scanSync for cleaning stale archgate files — more idiomatic and avoids reading the entire directory just to filter. Inline downloadAndExtractTarball into installEditorPluginBundle since it was its only caller after the DRY refactor. Signed-off-by: Rhuan Barreto --- src/helpers/plugin-install.ts | 132 ++++++++++++---------------------- 1 file changed, 45 insertions(+), 87 deletions(-) diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 576b3bc2..d775c478 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -2,13 +2,7 @@ // Copyright 2026 Archgate /** Download and install the archgate plugin for supported editors. */ -import { - existsSync, - mkdirSync, - readdirSync, - rmSync, - unlinkSync, -} from "node:fs"; +import { existsSync, mkdirSync, rmSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { logDebug } from "./log"; @@ -228,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 `installCursorPlugin` and `installOpencodePlugin` — both follow - * the same pattern: authenticated download → write to temp → tar extract → - * cleanup temp file. + * 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 + * + * 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)` @@ -254,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` @@ -276,71 +299,6 @@ async function downloadAndExtractTarball(opts: { } } -// --------------------------------------------------------------------------- -// Shared — editor plugin bundle install (agents + skills) -// --------------------------------------------------------------------------- - -/** - * Remove previously-installed archgate agents and skills from an editor's - * discovery directories so fresh extractions don't leave dangling files - * from renamed or removed components. - * - * Only touches `archgate-*` entries — other editors'/users' files are left - * untouched. - */ -function cleanArchgateFiles(baseDir: string): void { - const agentsDir = join(baseDir, "agents"); - const skillsDir = join(baseDir, "skills"); - - if (existsSync(agentsDir)) { - for (const entry of readdirSync(agentsDir)) { - if (entry.startsWith("archgate-") && entry.endsWith(".md")) { - unlinkSync(join(agentsDir, entry)); - } - } - } - - if (existsSync(skillsDir)) { - for (const entry of readdirSync(skillsDir)) { - if (entry.startsWith("archgate-")) { - rmSync(join(skillsDir, entry), { recursive: true, force: true }); - } - } - } -} - -/** - * 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 - * - * Editor-specific post-install steps (hooks merging, settings config) are - * handled by each editor's install function after this returns. - */ -async function installEditorPluginBundle(opts: { - baseDir: string; - apiPath: string; - token: string; - label: string; - tempFile: string; -}): Promise { - mkdirSync(join(opts.baseDir, "agents"), { recursive: true }); - mkdirSync(join(opts.baseDir, "skills"), { recursive: true }); - - cleanArchgateFiles(opts.baseDir); - - await downloadAndExtractTarball({ - apiPath: opts.apiPath, - token: opts.token, - targetDir: opts.baseDir, - label: opts.label, - tempFile: opts.tempFile, - }); -} - // --------------------------------------------------------------------------- // opencode — download plugin bundle into user-scope config dir // --------------------------------------------------------------------------- From b883de2696b6d66e4efab90c58296d441ece139c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 22:03:46 +0200 Subject: [PATCH 6/9] fix(test): remove cursor cleanup tests that leak HOME to parallel files Bun runs all test files in a single process sharing Bun.env. The cursor cleanup tests set Bun.env.HOME = tempDir, which leaked to the parallel vscode-settings tests causing getVscodeUserSettingsPath() to resolve against the temp dir instead of the real home. The opencode tests (which only override XDG_CONFIG_HOME) already cover the same installEditorPluginBundle cleanup behavior. Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install-cleanup.test.ts | 43 +++----------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/tests/helpers/plugin-install-cleanup.test.ts b/tests/helpers/plugin-install-cleanup.test.ts index 692bdece..bf2436e8 100644 --- a/tests/helpers/plugin-install-cleanup.test.ts +++ b/tests/helpers/plugin-install-cleanup.test.ts @@ -35,10 +35,7 @@ mock.module("../../src/helpers/platform", () => ({ // Imports under test — loaded AFTER mocks are registered. // --------------------------------------------------------------------------- -import { - installCursorPlugin, - installOpencodePlugin, -} from "../../src/helpers/plugin-install"; +import { installOpencodePlugin } from "../../src/helpers/plugin-install"; // --------------------------------------------------------------------------- // Shared helpers @@ -186,37 +183,9 @@ describe("plugin install — stale file cleanup", () => { }); }); - describe("cursor", () => { - test("removes archgate-* agents and skills before extraction", async () => { - // Cursor resolves to ~/.cursor — override HOME to temp dir - Bun.env.HOME = tempDir; - 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 () => { - Bun.env.HOME = tempDir; - mockFetch(200, new ArrayBuffer(64)); - - await installCursorPlugin("test-token"); - - const callArgs = spawnSpy.mock.calls[0][0] as string[]; - const targetIdx = callArgs.indexOf("-C"); - const targetDir = callArgs[targetIdx + 1]; - expect(targetDir).toMatch(/\.cursor$/u); - }); - }); + // Cursor cleanup is NOT tested here — cursorUserDir() resolves via HOME, + // and setting Bun.env.HOME leaks to parallel test files (Bun shares a + // single process). The cleanup behavior is identical for all editors since + // both Cursor and opencode use installEditorPluginBundle(), so the opencode + // tests above provide full coverage. }); From e073f030562dede1ef712b47012da1c49f1123f8 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 22:16:40 +0200 Subject: [PATCH 7/9] fix(test): restore cursor cleanup tests with proper beforeEach hooks Move Bun.env.HOME override into a nested beforeEach inside the cursor describe block instead of inline in each test body. The top-level afterEach already restores the original HOME value. Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install-cleanup.test.ts | 47 +++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/tests/helpers/plugin-install-cleanup.test.ts b/tests/helpers/plugin-install-cleanup.test.ts index bf2436e8..a70c22b7 100644 --- a/tests/helpers/plugin-install-cleanup.test.ts +++ b/tests/helpers/plugin-install-cleanup.test.ts @@ -35,7 +35,10 @@ mock.module("../../src/helpers/platform", () => ({ // Imports under test — loaded AFTER mocks are registered. // --------------------------------------------------------------------------- -import { installOpencodePlugin } from "../../src/helpers/plugin-install"; +import { + installCursorPlugin, + installOpencodePlugin, +} from "../../src/helpers/plugin-install"; // --------------------------------------------------------------------------- // Shared helpers @@ -183,9 +186,41 @@ describe("plugin install — stale file cleanup", () => { }); }); - // Cursor cleanup is NOT tested here — cursorUserDir() resolves via HOME, - // and setting Bun.env.HOME leaks to parallel test files (Bun shares a - // single process). The cleanup behavior is identical for all editors since - // both Cursor and opencode use installEditorPluginBundle(), so the opencode - // tests above provide full coverage. + describe("cursor", () => { + // cursorUserDir() resolves via HOME (not XDG_CONFIG_HOME), so we + // redirect HOME to tempDir inside a nested beforeEach. The top-level + // afterEach restores the original value after each test. + beforeEach(() => { + Bun.env.HOME = tempDir; + }); + + 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"); + const targetDir = callArgs[targetIdx + 1]; + expect(targetDir).toMatch(/\.cursor$/u); + }); + }); }); From e16da993fca7b7b2ecdf2fb4092c815b4c4bf2c2 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 22:24:53 +0200 Subject: [PATCH 8/9] fix(test): mock cursorUserDir instead of modifying HOME env Bun runs all test files in a single process sharing Bun.env, so setting HOME in one file leaks to parallel files. Mock the paths module to redirect cursorUserDir() via a test-scoped variable instead. Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install-cleanup.test.ts | 30 +++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/helpers/plugin-install-cleanup.test.ts b/tests/helpers/plugin-install-cleanup.test.ts index a70c22b7..e197d288 100644 --- a/tests/helpers/plugin-install-cleanup.test.ts +++ b/tests/helpers/plugin-install-cleanup.test.ts @@ -31,6 +31,26 @@ 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. // --------------------------------------------------------------------------- @@ -187,11 +207,13 @@ describe("plugin install — stale file cleanup", () => { }); describe("cursor", () => { - // cursorUserDir() resolves via HOME (not XDG_CONFIG_HOME), so we - // redirect HOME to tempDir inside a nested beforeEach. The top-level - // afterEach restores the original value after each test. + // Redirect cursorUserDir() via mock — NOT via Bun.env.HOME, because + // env changes leak to parallel test files in Bun's single-process runner. beforeEach(() => { - Bun.env.HOME = tempDir; + cursorDirOverride = join(tempDir, ".cursor"); + }); + afterEach(() => { + cursorDirOverride = undefined; }); test("removes archgate-* agents and skills before extraction", async () => { From 03439d48a4a683e4daaa0900e88c40bc962e25ac Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 9 Jun 2026 22:43:43 +0200 Subject: [PATCH 9/9] fix(test): add bounds check for -C flag index in extraction tests Assert targetIdx >= 0 before accessing callArgs[targetIdx + 1] so the test fails clearly if tar args change, rather than silently reading the wrong index. Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install-cleanup.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/helpers/plugin-install-cleanup.test.ts b/tests/helpers/plugin-install-cleanup.test.ts index e197d288..83805139 100644 --- a/tests/helpers/plugin-install-cleanup.test.ts +++ b/tests/helpers/plugin-install-cleanup.test.ts @@ -199,6 +199,7 @@ describe("plugin install — stale file cleanup", () => { 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); @@ -241,6 +242,7 @@ describe("plugin install — stale file cleanup", () => { 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); });