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/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv
- **SLSA reusable workflow MUST be tag-pinned, not SHA-pinned** — `slsa-framework/slsa-github-generator/.github/workflows/*` looks like it should follow CI-001 (SHA pin), but the SLSA generator's `generate-builder.sh` reads the workflow ref to download the prebuilt builder from a GitHub release and rejects non-tag refs (`Invalid ref: ... Expected ref of the form refs/tags/vX.Y.Z`, exit 2). Pinning by SHA broke the v0.31.0 release ([run 25107195589](https://github.com/archgate/cli/actions/runs/25107195589)). The CI-001 rule allowlists this path so it does NOT block a SHA repin — meaning a future agent could "fix" the tag pin and the rule would be silent until the next release fails. ALWAYS keep `@v2.x.y` for `release-binaries.yml:165` and read the inline comment + CI-001 "Carved-out exceptions" before changing. Upstream issue: [slsa-framework/slsa-github-generator#150](https://github.com/slsa-framework/slsa-github-generator/issues/150).
- **Windows binary upgrade: never use detached child processes for `.old` cleanup** — On Windows, `replaceBinary()` renames the running exe to `.old` because the OS file-locks it. Cleaning up the `.old` via a detached `cmd /c ping -n 2 ... & del` process is unreliable (process may not spawn, `del` may fail silently, timing races). Instead, `cleanupStaleBinary()` runs at the next CLI startup as a fire-and-forget `unlink()` — the file is guaranteed unlocked by then. The cleanup is platform-agnostic (uses `getArtifactInfo()` to resolve the binary name), so it works on any supported platform even though only Windows currently creates `.old` files. The sync `unlinkSync` in `replaceBinary()` is kept as defense-in-depth for leftover `.old` files from previous upgrades. Do NOT reintroduce detached cleanup processes.
- **`bun:sqlite` file handles persist after `db.close()` on Windows — wrap test cleanup in try/catch** — Tests that create temp SQLite databases via `new Database(path)` will fail with `EBUSY: resource busy or locked` when `rmSync` tries to remove the temp directory in `afterEach`, even after calling `db.close()`. Windows holds the file handle briefly. Fix: (1) set `PRAGMA journal_mode = DELETE` in test DBs to avoid creating WAL/SHM files, and (2) wrap `rmSync` in `afterEach` with `try { rmSync(...) } catch { /* SQLite handles may persist */ }`. Each test must use a unique temp dir name so leftover files don't collide.
- **Inquirer prompts leave cursor at wrong column on Windows — always reset with `cursorTo`** — After an `inquirer.prompt()` call finishes (especially checkbox with long wrapped answer lines), the cursor is left at the end of the rendered answer text. On Windows terminals, `\n` moves down but does NOT reset to column 0, so all subsequent output starts at the wrong horizontal offset. Fix: call `cursorTo(process.stdout, 0)` from `node:readline` after each prompt returns, guarded by `if (process.stdout.isTTY)`. Applied in `src/helpers/editor-detect.ts` for `promptEditorSelection()` and `promptSingleEditorSelection()`. The `upgrade.ts` command already uses the same `clearLine`/`cursorTo` pattern for download progress cleanup.
- **Inquirer permanently breaks Windows console newline handling — use `withPromptFix()`** — When inquirer creates a readline interface on Windows, it enables VTP (Virtual Terminal Processing) which sets `DISABLE_NEWLINE_AUTO_RETURN`. This flag is NEVER restored after the prompt closes, so bare `\n` stops returning to column 0 for ALL subsequent output — not just during the prompt. The per-prompt `cursorTo(process.stdout, 0)` fix only addressed the cursor position after the answer line; it did NOT fix the console mode change. Root-cause fix: `src/helpers/prompt.ts` exports `withPromptFix()` which applies a permanent `process.stdout.write` patch (idempotent, applied once) that translates bare `\n` → `\r\n` via `(?<!\r)\n`, plus resets cursor to column 0 after each prompt. ALL inquirer prompt calls MUST be wrapped with `withPromptFix(() => inquirer.prompt([...]))`. The `upgrade.ts` command uses a separate `clearLine`/`cursorTo` pattern for download progress cleanup (not inquirer).
- **`inquirer` must be lazy-loaded via dynamic `import()` — never statically imported at module level** — `inquirer` costs ~200ms to parse. Static `import inquirer from "inquirer"` at module level forces every CLI invocation to pay this cost — even `--help`, `--version`, and non-interactive commands that never prompt. Always use `const { default: inquirer } = await import("inquirer")` at the call site, inside the action handler or function that actually needs it. Applied in `src/commands/adr/create.ts`, `src/commands/init.ts`, `src/helpers/editor-detect.ts`, `src/helpers/login-flow.ts`. Same principle applies to any heavy dependency used only in specific code paths (e.g., Sentry and PostHog SDKs are already lazy-loaded in `sentry.ts` and `telemetry.ts`).
- **Telemetry/Sentry init should be started eagerly but awaited lazily** — In `src/cli.ts`, `initSentry()` + `initTelemetry()` are started before command registration (so they run concurrently with setup) but only awaited in the `preAction` hook (right before the first telemetry event). This defers ~150ms of SDK parsing + git subprocess cost for `--help`/`--version` which never trigger `preAction`. The `preAction` hook is `async` to support this `await`.
- **Smoke test install-script steps must find a release with uploaded assets, not just the latest tag** — On release-commit pushes to main, the Validate and Release workflows trigger concurrently. The Release workflow creates the tag/release before `release-binaries.yml` uploads platform binaries. Smoke tests that naively use `gh release view --json tagName` get the just-created release and 404 on the binary download. Fix: iterate `gh release list --limit 5` and check each release's assets for the expected artifact (`archgate-win32-x64.zip` / `archgate-linux-x64.tar.gz`) before selecting the version. Applied in `smoke-test-windows.yml` and `smoke-test-linux.yml`.
Expand Down
4 changes: 2 additions & 2 deletions docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4020,8 +4020,8 @@ Installation behavior varies by editor:

- **Claude Code:** Auto-installs via `claude` CLI if available; prints manual commands otherwise.
- **Copilot CLI:** Auto-installs via `copilot` CLI if available; prints manual commands otherwise.
- **Cursor:** Installs the VS Code extension via `cursor` CLI if available and prints the team marketplace URL; prints manual instructions otherwise.
- **VS Code:** Adds the marketplace URL to VS Code user settings and installs the VS Code extension (`.vsix`) via `code` CLI if available; prints manual instructions otherwise.
- **Cursor:** Prints the [Team Private Plugin Marketplace](https://cursor.com/docs/plugins#team-marketplaces) URL with instructions to add it in Cursor Settings. Cursor does not support VSIX installation from the CLI.
- **VS Code:** Installs the VS Code extension (`.vsix`) via `code` CLI if available; prints manual instructions otherwise.
- **opencode:** Requires the `opencode` CLI to be on PATH — skips the install with a clear message otherwise. When present, downloads an authenticated tarball of agent files and extracts it into the user-scope opencode agents directory. `archgate plugin url --editor opencode` prints "N/A" — opencode has no marketplace URL. See the [opencode integration guide](/guides/opencode-integration/) for details.

## Examples
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/pt-br/reference/cli/plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ O comportamento de instalação varia por editor:

- **Claude Code:** Instala automaticamente via CLI `claude` se disponível; exibe comandos manuais caso contrário.
- **Copilot CLI:** Instala automaticamente via CLI `copilot` se disponível; exibe comandos manuais caso contrário.
- **Cursor:** Instala a extensão VS Code via CLI `cursor` se disponível e exibe a URL do team marketplace; exibe instruções manuais caso contrário.
- **VS Code:** Adiciona a URL do marketplace nas configurações de usuário do VS Code e instala a extensão VS Code (`.vsix`) via CLI `code` se disponível; exibe instruções manuais caso contrário.
- **Cursor:** Exibe a URL do [Team Private Plugin Marketplace](https://cursor.com/docs/plugins#team-marketplaces) com instruções para adicioná-la nas Configurações do Cursor. O Cursor não suporta instalação de VSIX via CLI.
- **VS Code:** Instala a extensão VS Code (`.vsix`) via CLI `code` se disponível; exibe instruções manuais caso contrário.
- **opencode:** Requer que a CLI `opencode` esteja no PATH -- pula a instalação com uma mensagem clara caso contrário. Quando presente, baixa um tarball autenticado de arquivos de agente e o extrai no diretório de agentes opencode do escopo do usuário. `archgate plugin url --editor opencode` exibe "N/A" -- opencode não tem URL de marketplace. Veja o [guia de integração com opencode](/guides/opencode-integration/) para detalhes.

## Exemplos
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/cli/plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ Installation behavior varies by editor:

- **Claude Code:** Auto-installs via `claude` CLI if available; prints manual commands otherwise.
- **Copilot CLI:** Auto-installs via `copilot` CLI if available; prints manual commands otherwise.
- **Cursor:** Installs the VS Code extension via `cursor` CLI if available and prints the team marketplace URL; prints manual instructions otherwise.
- **VS Code:** Adds the marketplace URL to VS Code user settings and installs the VS Code extension (`.vsix`) via `code` CLI if available; prints manual instructions otherwise.
- **Cursor:** Prints the [Team Private Plugin Marketplace](https://cursor.com/docs/plugins#team-marketplaces) URL with instructions to add it in Cursor Settings. Cursor does not support VSIX installation from the CLI.
- **VS Code:** Installs the VS Code extension (`.vsix`) via `code` CLI if available; prints manual instructions otherwise.
- **opencode:** Requires the `opencode` CLI to be on PATH — skips the install with a clear message otherwise. When present, downloads an authenticated tarball of agent files and extracts it into the user-scope opencode agents directory. `archgate plugin url --editor opencode` prints "N/A" — opencode has no marketplace URL. See the [opencode integration guide](/guides/opencode-integration/) for details.

## Examples
Expand Down
47 changes: 23 additions & 24 deletions src/commands/adr/create.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Archgate
import { cursorTo } from "node:readline";

import type { Command } from "@commander-js/extra-typings";

import type { AdrDomain } from "../../formats/adr";
Expand All @@ -15,6 +13,7 @@ import {
resolveDomainPrefix,
resolvedProjectPaths,
} from "../../helpers/project-config";
import { withPromptFix } from "../../helpers/prompt";

export function registerAdrCreateCommand(adr: Command) {
adr
Expand Down Expand Up @@ -63,28 +62,28 @@ export function registerAdrCreateCommand(adr: Command) {
// invocations or --help/--version.
const { default: inquirer } = await import("inquirer");
// Interactive mode
const answers = await inquirer.prompt([
{
type: "list",
name: "domain",
message: "Domain:",
choices: choices.map((d) => ({ name: d, value: d })),
},
{
type: "input",
name: "title",
message: "Title:",
validate: (input: string) =>
input.trim() !== "" || "Title is required",
},
{
type: "input",
name: "files",
message: "File patterns (comma-separated, optional):",
},
]);
// Windows cursor-reset — see editor-detect.ts for explanation.
if (process.stdout.isTTY) cursorTo(process.stdout, 0);
const answers = await withPromptFix(() =>
inquirer.prompt([
{
type: "list",
name: "domain",
message: "Domain:",
choices: choices.map((d) => ({ name: d, value: d })),
},
{
type: "input",
name: "title",
message: "Title:",
validate: (input: string) =>
input.trim() !== "" || "Title is required",
},
{
type: "input",
name: "files",
message: "File patterns (comma-separated, optional):",
},
])
);

domain = answers.domain as AdrDomain;
title = answers.title;
Expand Down
19 changes: 11 additions & 8 deletions src/commands/adr/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getMergedDomainPrefixes,
resolvedProjectPaths,
} from "../../helpers/project-config";
import { withPromptFix } from "../../helpers/prompt";
import {
resolveSource,
shallowClone,
Expand Down Expand Up @@ -372,14 +373,16 @@ export function registerAdrImportCommand(adr: Command) {

if (!opts.yes) {
const { default: inquirer } = await import("inquirer");
const { confirm } = await inquirer.prompt([
{
type: "confirm",
name: "confirm",
message: `Import ${adrsToImport.length} ADR(s)?`,
default: true,
},
]);
const { confirm } = await withPromptFix(() =>
inquirer.prompt([
{
type: "confirm",
name: "confirm",
message: `Import ${adrsToImport.length} ADR(s)?`,
default: true,
},
])
);
if (!confirm) {
console.log("Import cancelled.");
cleanup(tempDirs);
Expand Down
27 changes: 15 additions & 12 deletions src/commands/adr/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { logDebug, logError, logWarn } from "../../helpers/log";
import { formatJSON, isAgentContext } from "../../helpers/output";
import { findProjectRoot } from "../../helpers/paths";
import { resolvedProjectPaths } from "../../helpers/project-config";
import { withPromptFix } from "../../helpers/prompt";
import { resolveSource, shallowClone } from "../../helpers/registry";

// ---------- Types ----------
Expand Down Expand Up @@ -398,18 +399,20 @@ export function registerAdrSyncCommand(adr: Command) {
// oxlint-disable-next-line no-await-in-loop -- sequential interactive prompts
const { default: inquirer } = await import("inquirer");
// oxlint-disable-next-line no-await-in-loop -- sequential interactive prompts
const { choice } = await inquirer.prompt([
{
type: "list",
name: "choice",
message: `${diff.adrId}: What would you like to do?`,
choices: [
{ name: "Keep local", value: "keep" },
{ name: "Take upstream", value: "take" },
{ name: "Skip", value: "skip" },
],
},
]);
const { choice } = await withPromptFix(() =>
inquirer.prompt([
{
type: "list",
name: "choice",
message: `${diff.adrId}: What would you like to do?`,
choices: [
{ name: "Keep local", value: "keep" },
{ name: "Take upstream", value: "take" },
{ name: "Skip", value: "skip" },
],
},
])
);
action = choice;
}

Expand Down
80 changes: 40 additions & 40 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Copyright 2026 Archgate
import { existsSync } from "node:fs";
import { join } from "node:path";
import { cursorTo } from "node:readline";
import { styleText } from "node:util";

import type { Command } from "@commander-js/extra-typings";
Expand All @@ -15,6 +14,7 @@ import { EDITOR_LABELS, initProject } from "../helpers/init-project";
import type { EditorTarget } from "../helpers/init-project";
import { logError, logInfo, logWarn } from "../helpers/log";
import { runLoginFlow } from "../helpers/login-flow";
import { withPromptFix } from "../helpers/prompt";
import {
getRepoContext,
isPublicRepo,
Expand Down Expand Up @@ -93,17 +93,17 @@ export function registerInitCommand(program: Command) {
// Lazy-load inquirer — it costs ~200ms to parse and is only needed
// for interactive prompts, not for scripted or --help invocations.
const { default: inquirer } = await import("inquirer");
const { wantPlugin } = await inquirer.prompt([
{
type: "confirm",
name: "wantPlugin",
message:
"Would you like to install the Archgate editor plugin? (requires GitHub login)",
default: true,
},
]);
// Windows cursor-reset — see editor-detect.ts for explanation.
if (process.stdout.isTTY) cursorTo(process.stdout, 0);
const { wantPlugin } = await withPromptFix(() =>
inquirer.prompt([
{
type: "confirm",
name: "wantPlugin",
message:
"Would you like to install the Archgate editor plugin? (requires GitHub login)",
default: true,
},
])
);

if (wantPlugin) {
const result = await runLoginFlow({
Expand Down Expand Up @@ -216,20 +216,20 @@ async function runGreenfieldWizard(projectRoot: string): Promise<void> {
trackGreenfieldWizardShown();

console.log("");
const { wantPacks } = await inquirer.prompt([
{
type: "list",
name: "wantPacks",
message:
"No existing ADRs detected. Would you like to import starter packs?",
choices: [
{ name: "Yes, pick packs now (recommended)", value: true },
{ name: "No, start empty", value: false },
],
},
]);
// Windows cursor-reset — see editor-detect.ts for explanation.
if (process.stdout.isTTY) cursorTo(process.stdout, 0);
const { wantPacks } = await withPromptFix(() =>
inquirer.prompt([
{
type: "list",
name: "wantPacks",
message:
"No existing ADRs detected. Would you like to import starter packs?",
choices: [
{ name: "Yes, pick packs now (recommended)", value: true },
{ name: "No, start empty", value: false },
],
},
])
);

if (!wantPacks) {
trackWizardSkipped();
Expand Down Expand Up @@ -263,20 +263,20 @@ async function runGreenfieldWizard(projectRoot: string): Promise<void> {
return;
}

const { selectedPacks } = await inquirer.prompt([
{
type: "checkbox",
name: "selectedPacks",
message: "Select packs to import:",
choices: recommendations.map((rec) => ({
name: `${rec.packPath.padEnd(30)} ${String(rec.adrCount).padStart(2)} ADRs (${rec.matchedTags.join(", ")})`,
value: rec.packPath,
checked: rec.relevance === "high",
})),
},
]);
// Windows cursor-reset
if (process.stdout.isTTY) cursorTo(process.stdout, 0);
const { selectedPacks } = await withPromptFix(() =>
inquirer.prompt([
{
type: "checkbox",
name: "selectedPacks",
message: "Select packs to import:",
choices: recommendations.map((rec) => ({
name: `${rec.packPath.padEnd(30)} ${String(rec.adrCount).padStart(2)} ADRs (${rec.matchedTags.join(", ")})`,
value: rec.packPath,
checked: rec.relevance === "high",
})),
},
])
);

if (selectedPacks.length === 0) {
console.log("No packs selected.");
Expand Down
Loading
Loading