Skip to content
Merged
2 changes: 2 additions & 0 deletions .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv
- **GitHub CodeQL "default setup" can silently skip PRs — use an explicit workflow** — The repository-level "default setup" for CodeQL does not guarantee analysis on every PR. PR #279 (Renovate deps update) had zero CodeQL analyses, dropping the Scorecard SAST score from 10 to 9. Fix: add an explicit `.github/workflows/codeql.yml` that runs on `push: [main]`, `pull_request: [main]`, and a weekly schedule. After merging, disable the "default setup" in repository Settings > Code security > Code scanning to avoid duplicate analyses. The explicit workflow gives Scorecard a detectable `github/codeql-action` reference and guarantees coverage.
- **`GITHUB_TOKEN`-authored pushes do NOT trigger downstream workflows — release.yml MUST use the GH App token** — When an Actions workflow pushes commits or opens PRs using `${{ github.token }}` / `secrets.GITHUB_TOKEN`, GitHub intentionally suppresses the resulting `push` / `pull_request` events to prevent recursion. Symptom on release PRs: the head SHA has no `pull_request`-event check runs, so `Validate Code` / `Lint, Test & Check` / `DCO Sign-off Check` are missing from the PR rollup and branch protection treats the PR as missing required checks. PR [#131](https://github.com/archgate/cli/pull/131) papered over this by manually `gh workflow run` + posting commit statuses, but `workflow_dispatch` runs land on `head_branch: release` with `pull_requests: []` — they are not associated with the PR ref, so `Lint, Test & Check` stayed orphaned and the bug recurred on PR [#251](https://github.com/archgate/cli/pull/251). Root-cause fix: in `release.yml` the `pull-request` job MUST generate a GitHub App installation token via `actions/create-github-app-token` (using `secrets.GH_APP_APP_ID` / `secrets.GH_APP_PRIVATE_KEY`) and pass it to BOTH `actions/checkout` and `simple-release-action`. App-token-authored pushes DO trigger `pull_request` events naturally. Apply the same pattern to any future workflow that pushes to a branch whose downstream CI must run.

- **Cross-command I/O sharing: export from the existing command file, don't create shared files** — When two commands need to share I/O functions (console.log with styleText), you CANNOT put them in `src/helpers/` (ARCH-002 forbids console.log in helpers) or create a new file under `src/commands/<parent>/` without a register function (ARCH-001 requires register\*Command export, ARCH-016 requires docs heading). The correct pattern: export the shared functions from the command file that already defines them (e.g., `plugin/install.ts` exports `installForEditor()` and `printManualInstructions()`) and import them in the other command. Applied in `upgrade.ts` importing from `./plugin/install`.

## Validation Pipeline

- `bun run validate` is the mandatory gate: lint → typecheck → format:check → test → ADR check → knip → build:check
Expand Down
91 changes: 62 additions & 29 deletions src/commands/plugin/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ const editorOption = new Option(
"target editor (omit to auto-detect and select)"
).choices(["claude", "cursor", "vscode", "copilot", "opencode"] as const);

async function installForEditor(
/**
* Install the archgate plugin for a single editor.
*
* Dispatches to the editor-specific install function, checks CLI availability,
* and surfaces manual instructions when the CLI is missing. Throws on failure
* so callers can collect errors and report them together.
*
* Exported for reuse by the `upgrade --plugins` flow.
*/
export async function installForEditor(
editor: EditorTarget,
label: string,
token: string
Expand Down Expand Up @@ -127,7 +136,12 @@ async function installForEditor(
}
}

function printManualInstructions(editor: EditorTarget): void {
/**
* Print manual installation instructions for a given editor.
*
* Exported for reuse by the `upgrade --plugins` flow.
*/
export function printManualInstructions(editor: EditorTarget): void {
switch (editor) {
case "claude": {
const url = buildMarketplaceUrl();
Expand Down Expand Up @@ -184,6 +198,47 @@ function printManualInstructions(editor: EditorTarget): void {
}
}

/**
* Run plugin installs for a list of editors, collecting failures.
*
* Returns the failure list so callers can decide how to handle them
* (e.g., exit 1 for `plugin install`, or just report for `upgrade`).
*
* Exported for reuse by the `upgrade --plugins` flow.
*/
export async function runPluginInstalls(
editors: EditorTarget[],
token: string,
verb: string = "install"
): Promise<{ editor: EditorTarget; label: string; error: string }[]> {
const failures: { editor: EditorTarget; label: string; error: string }[] = [];

for (const editor of editors) {
const label = EDITOR_LABELS[editor];
try {
// oxlint-disable-next-line no-await-in-loop -- sequential install with per-editor output
await installForEditor(editor, label, token);
} catch (err) {
failures.push({
editor,
label,
error: err instanceof Error ? err.message : String(err),
});
}
}

if (failures.length > 0) {
console.log();
for (const { editor, label, error } of failures) {
logError(`Failed to ${verb} plugin for ${label}.`, error);
printManualInstructions(editor);
console.log();
}
}

return failures;
}

export function registerPluginInstallCommand(plugin: Command) {
plugin
.command("install")
Expand Down Expand Up @@ -212,34 +267,12 @@ export function registerPluginInstallCommand(plugin: Command) {
editors = ["claude"];
}

const failures: {
editor: EditorTarget;
label: string;
error: string;
}[] = [];

for (const editor of editors) {
const label = EDITOR_LABELS[editor];
try {
// oxlint-disable-next-line no-await-in-loop -- sequential install with per-editor output
await installForEditor(editor, label, credentials.token);
} catch (err) {
failures.push({
editor,
label,
error: err instanceof Error ? err.message : String(err),
});
}
}

// Print all failures together at the end so they are easy to review
const failures = await runPluginInstalls(
editors,
credentials.token,
"install"
);
if (failures.length > 0) {
console.log();
for (const { editor, label, error } of failures) {
logError(`Failed to install plugin for ${label}.`, error);
printManualInstructions(editor);
console.log();
}
await exitWith(1);
}
} catch (err) {
Expand Down
69 changes: 67 additions & 2 deletions src/commands/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
replaceBinary,
} from "../helpers/binary-upgrade";
import { exitWith } from "../helpers/exit";
import { logDebug, logError } from "../helpers/log";
import type { EditorTarget } from "../helpers/init-project";
import { logDebug, logError, logInfo } from "../helpers/log";
import { internalPath } from "../helpers/paths";
import { getPlatformInfo, resolveCommand } from "../helpers/platform";
import { trackUpgradeResult } from "../helpers/telemetry";
Expand Down Expand Up @@ -305,11 +306,71 @@ async function runExternalUpgrade(
}
}

/**
* Offer to update editor plugins after a successful CLI upgrade.
* Plugin update failures are reported but do NOT change the exit code.
*/
async function maybeUpdatePlugins(pluginsFlag: boolean): Promise<void> {
const isTTY = process.stdin.isTTY === true;

if (!pluginsFlag && isTTY) {
const { default: inquirer } = await import("inquirer");
const { withPromptFix } = await import("../helpers/prompt");
const { updatePlugins } = await withPromptFix(() =>
inquirer.prompt([
{
type: "confirm",
name: "updatePlugins",
message: "Would you like to update your editor plugins too?",
default: true,
},
])
);
if (!updatePlugins) return;
}

const { loadCredentials } = await import("../helpers/credential-store");
const credentials = await loadCredentials();
if (!credentials) {
logInfo(
"Not logged in.",
"Run `archgate login` first, then `archgate plugin install` to update plugins."
);
return;
}

const { detectEditors, promptEditorSelection } =
await import("../helpers/editor-detect");
const detected = await detectEditors();
const available = detected.filter((e) => e.available);

if (available.length === 0) {
logInfo(
"No supported editors detected.",
"Run `archgate plugin install --editor <editor>` to install manually."
);
return;
}

let editors: EditorTarget[];
if (!pluginsFlag && isTTY) {
editors = await promptEditorSelection(detected);
} else {
editors = available.map((e) => e.id);
}

const { runPluginInstalls } = await import("./plugin/install");

console.log("Updating editor plugins...");
await runPluginInstalls(editors, credentials.token, "update");
}

export function registerUpgradeCommand(program: Command) {
program
.command("upgrade")
.description("Upgrade Archgate to the latest version")
.action(async () => {
.option("--plugins", "also update editor plugins after upgrading")
.action(async (opts) => {
try {
console.log("Checking for latest Archgate release...");

Expand Down Expand Up @@ -371,6 +432,9 @@ export function registerUpgradeCommand(program: Command) {
});

console.log(`Archgate upgraded to ${latestVersion} successfully.`);

// Offer plugin updates after a successful CLI upgrade
await maybeUpdatePlugins(opts.plugins === true);
} catch (err) {
if (err instanceof Error && err.name === "ExitPromptError") throw err;
trackUpgradeResult({
Expand All @@ -393,4 +457,5 @@ export {
detectInstallMethod as _detectInstallMethod,
formatBytes as _formatBytes,
createDownloadProgress as _createDownloadProgress,
maybeUpdatePlugins as _maybeUpdatePlugins,
};
3 changes: 2 additions & 1 deletion src/helpers/init-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ async function configureEditorSettings(
// Opencode agent files are user-scope and written by `tryInstallPlugin`
// after authenticating against the plugins service. Nothing lands in
// the project tree — return the resolved user-scope path so the init
// summary has something meaningful to print.
// summary has something meaningful to print. The opencode.json config
// (default_agent) is set inside installOpencodePlugin() itself.
return opencodeAgentsDir();
default:
return configureClaudeSettings(projectRoot);
Expand Down
88 changes: 88 additions & 0 deletions src/helpers/opencode-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Archgate
/**
* opencode-settings.ts — Configure opencode user-scope settings.
*
* Writes `opencode.json` to the XDG config directory
* (`~/.config/opencode/opencode.json`) with `default_agent` set to
* `archgate-developer`. Merges additively — existing user settings
* are preserved.
*
* opencode resolves its config via `xdg-basedir`, which falls back to
* `~/.config` on all platforms (including Windows). The path resolution
* uses `opencodeConfigDir()` from `paths.ts`.
*/

import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";

import { logDebug } from "./log";
import { opencodeConfigDir } from "./paths";

/** The agent name used in opencode's `default_agent` config field. */
const DEFAULT_AGENT = "archgate-developer";

type OpencodeConfig = Record<string, unknown>;

/**
* Pure, additive merge of archgate settings into existing opencode config.
*
* - `default_agent`: set only if absent (never overwrite user's choice)
* - All existing user settings are preserved (unknown keys pass through)
*/
export function mergeOpencodeSettings(
existing: OpencodeConfig
): OpencodeConfig {
const merged: OpencodeConfig = { ...existing };

if (!("default_agent" in merged)) {
merged.default_agent = DEFAULT_AGENT;
}

return merged;
}

/**
* Resolve the path to the opencode user-scope config file.
*
* The config lives in the same XDG config directory as the agents:
* `~/.config/opencode/opencode.json`. Uses the same resolution logic
* as `opencodeAgentsDir()` to stay consistent.
*/
export function opencodeConfigPath(): string {
return join(opencodeConfigDir(), "opencode.json");
}

/**
* Configure opencode settings for archgate integration.
*
* Reads existing `opencode.json` (if any), merges archgate settings
* additively, and writes the result. Creates parent directories if missing.
*
* @returns Absolute path to the config file.
*/
export async function configureOpencodeSettings(): Promise<string> {
const configPath = opencodeConfigPath();

let existing: OpencodeConfig = {};
if (existsSync(configPath)) {
try {
existing = (await Bun.file(configPath).json()) as OpencodeConfig;
} catch {
// Corrupted config file — start fresh
}
}

const merged = mergeOpencodeSettings(existing);

// Ensure parent directory exists
const dir = opencodeConfigDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}

logDebug("Writing opencode config:", configPath);
await Bun.write(configPath, JSON.stringify(merged, null, 2) + "\n");

return configPath;
}
18 changes: 12 additions & 6 deletions src/helpers/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,28 @@ function usableEnv(value: string | undefined): string | null {
}

/**
* Resolve the opencode user-scope agents directory.
* Resolve the opencode user-scope config directory (`~/.config/opencode/`).
*
* Opencode uses the `xdg-basedir` package to locate its config root. That
* package reads `$XDG_CONFIG_HOME` when set and otherwise falls back to
* `~/.config` on **all platforms** — including Windows, where the resolved
* path is `C:\Users\<user>\.config\opencode\agents` rather than anything
* under `%APPDATA%`. We mirror the same resolution here so the CLI writes
* to the exact directory opencode reads from.
* path is `C:\Users\<user>\.config\opencode\` rather than anything under
* `%APPDATA%`. We mirror the same resolution here so the CLI writes to
* the exact directory opencode reads from.
*
* The path is resolved at call time, not cached — tests override `HOME` /
* `XDG_CONFIG_HOME` per-test and expect the helper to pick up the override.
*
* Used by `opencodeAgentsDir()` and `opencodeConfigPath()`.
*/
export function opencodeAgentsDir(): string {
export function opencodeConfigDir(): string {
const xdg = usableEnv(Bun.env.XDG_CONFIG_HOME);
const base = xdg ?? join(archgateHomeDir(), ".config");
return join(base, "opencode", "agents");
return join(base, "opencode");
}

export function opencodeAgentsDir(): string {
return join(opencodeConfigDir(), "agents");
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/helpers/plugin-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ export async function installOpencodePlugin(token: string): Promise<void> {
// Ignore cleanup errors
}
}

// Configure opencode.json with default_agent (idempotent — only sets if absent)
const { configureOpencodeSettings } = await import("./opencode-settings");
await configureOpencodeSettings();
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading