Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand Down
43 changes: 43 additions & 0 deletions .githooks
Original file line number Diff line number Diff line change
@@ -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.<name>.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
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.enabled false`. Skip all hooks for a single commit: `git commit --no-verify`.

## Architecture

### Commands
Expand Down Expand Up @@ -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 `is<Editor>CliAvailable()` and any install/download helper
2. `src/helpers/plugin-install.ts` — add `is<Editor>CliAvailable()` 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`
Expand Down
10 changes: 5 additions & 5 deletions src/commands/plugin/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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": {
Expand Down
88 changes: 57 additions & 31 deletions src/helpers/plugin-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -142,13 +142,11 @@ export async function installClaudePlugin(): Promise<void> {
*/
export async function installCursorPlugin(token: string): Promise<void> {
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",
});
Expand Down Expand Up @@ -224,40 +222,69 @@ 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<void> {
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)`
);
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`
Expand All @@ -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
// ---------------------------------------------------------------------------

/**
Expand All @@ -286,24 +313,23 @@ export async function isOpencodeCliAvailable(): Promise<boolean> {
}

/**
* 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<void> {
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",
});
Expand Down
Loading
Loading