Skip to content
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@ opencode-architect is an [OpenCode](https://opencode.ai) plugin and CLI that shi

### Option 1 — Install as an OpenCode plugin

Add the package to the `plugin` array in your OpenCode config — `.opencode/opencode.json` in your project, or `~/.config/opencode/opencode.json` for all projects:
Add the package to the `plugins` array in your OpenCode config — `.opencode/opencode.json` in your project, or `~/.config/opencode/opencode.json` for all projects:

```json
{
"plugin": ["opencode-architect@latest"]
"plugins": ["opencode-architect@latest"]
}
```

The plugin registers the full agent suite at startup, with self-contained bundled references — no network sync.

### Option 2 — Install with the CLI (bunx or npx)

The CLI registers the package as a plugin: it adds `opencode-architect@latest` to the `plugin` array of your OpenCode config with surgical text editing (comments and formatting elsewhere in the file are preserved), then records the registration in an `opencode-architect.manifest.json` manifest at the scope base. Nothing is copied — the agents, references, and templates all load from the package at startup.
The CLI registers the package as a plugin: it adds `opencode-architect@latest` to the `plugins` array of your OpenCode config with surgical text editing (comments and formatting elsewhere in the file are preserved), then records the registration in an `opencode-architect.manifest.json` manifest at the scope base. Nothing is copied — the agents, references, and templates all load from the package at startup. A legacy v1 `plugin` entry is left untouched and reported with an upgrade advisory.

```bash
# Project scope (default): edits the ./.opencode/ or repo-root config
Expand All @@ -47,11 +47,11 @@ bunx opencode-architect --help # full usage

Re-running install when the manifest matches reality is a zero-write no-op. `--mode copy` is refused with an explanatory error: this package is code-backed (it ships agents), and copying cannot express plugin registration.

Every install — including a no-op — also clears this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@<installed version>`. Pinned copies like `opencode-architect@0.6.0` and other packages' cache dirs are left untouched. This makes OpenCode re-fetch the just-installed version on next start instead of reusing a stale or partial extraction. Removal is best-effort: a failure prints a warning but the install still succeeds.
Every install — including a no-op — also clears this package's stale cache keys from OpenCode's plugin cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@<installed version>` — each key holding every cached generation beneath it. Pinned keys like `opencode-architect@0.6.0` and other packages' cache keys are left untouched. This makes OpenCode re-fetch the just-installed version on next start instead of reusing a stale or partial extraction. Removal is best-effort: a failure prints a warning but the install still succeeds.

**Upgrading from a copy install (pre-0.8):** if a previous version copied agents into your scope base, install detects the old manifest, removes exactly the files it lists, prints a notice, and switches the scope to plugin registration in one step. Locally modified files are tracked by hash; uninstall and migration only remove what the manifest recorded.

`clear-cache` is a manual-only command (never invoked at load time) for removing cached copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). With no flags it removes `opencode-architect` and every `opencode-architect@*` copy. `--package <name>` removes `<name>` and every `<name>@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). `--dry-run` lists what any mode would remove without deleting anything, and previews broad modes without `--yes`. Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code.
`clear-cache` is a manual-only command (never invoked at load time) for removing cached package keys from OpenCode's plugin cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`), where each key directory holds every cached generation beneath it. With no flags it removes `opencode-architect` and every `opencode-architect@*` key. `--package <name>` removes `<name>` and every `<name>@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). `--dry-run` lists what any mode would remove without deleting anything, and previews broad modes without `--yes`. Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code.

## What you get: ten specialist OpenCode agents

Expand Down
20 changes: 6 additions & 14 deletions src/cache-cleaner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { exists, readdir, rm } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { ClearCacheUsageError } from "./clear-cache-usage-error";
import { NpmCache } from "./npm-cache";

export const PACKAGE_NAME = "opencode-architect";

Expand All @@ -19,15 +19,7 @@ export interface ClearCacheOutcome {
}

export class CacheCleaner {
public packagesCacheRoot(): string {
const xdgCacheHome = process.env.XDG_CACHE_HOME;
if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages");
return path.join(homedir(), ".cache", "opencode", "packages");
}

public opencodeCacheRoot(): string {
return path.dirname(this.packagesCacheRoot());
}
private readonly cache = new NpmCache();

public isUnsafePackageName(name: string): boolean {
return name.includes("/") || name.includes("\\") || name.includes("..") || name === ".";
Expand All @@ -54,7 +46,7 @@ export class CacheCleaner {
const warnings: string[] = [];

if (all) {
const target = this.opencodeCacheRoot();
const target = this.cache.opencodeRoot();
if (dryRun) {
if (await exists(target)) removed.push(target);
return dryRunOutcome(removed, warnings);
Expand All @@ -64,10 +56,10 @@ export class CacheCleaner {
}

const name = packageName ?? PACKAGE_NAME;
const packagesRoot = this.packagesCacheRoot();
const npmRoot = this.cache.root();
let entries: string[];
try {
entries = await readdir(packagesRoot);
entries = await readdir(npmRoot);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings, dryRun };
throw error;
Expand All @@ -77,7 +69,7 @@ export class CacheCleaner {
const targets = entries
.filter((entry) => entry === name || entry.startsWith(prefix))
.sort()
.map((entry) => path.join(packagesRoot, entry));
.map((entry) => path.join(npmRoot, entry));

if (dryRun) {
removed.push(...targets);
Expand Down
11 changes: 9 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export async function runCli(argv: string[]): Promise<number> {
if (outcome.configPath !== null) {
console.log(` Plugin entry: ${outcome.configPath}`);
}
if (outcome.configWarning !== null) {
console.warn(` Warning: ${outcome.configWarning}`);
}
console.log(` Manifest: ${outcome.manifestPath}`);
for (const cachePath of outcome.clearedCache) {
console.log(` Cleared cache: ${cachePath}`);
Expand All @@ -82,6 +85,9 @@ export async function runCli(argv: string[]): Promise<number> {
if (outcome.pluginRemoved && outcome.configPath !== null) {
console.log(` Removed the plugin entry from ${outcome.configPath}.`);
}
if (outcome.configWarning !== null) {
console.warn(` Warning: ${outcome.configWarning}`);
}
break;
}
case "status": {
Expand Down Expand Up @@ -141,9 +147,10 @@ function printHelp(): void {
console.log(`
opencode-architect v${VERSION}

Registers the opencode-architect plugin in a config file's plugin array so the
Registers the opencode-architect plugin in a config file's plugins array so the
agent suite loads from the package at startup. Nothing is copied: the plugin
registers the agents and resolves reference paths at load time.
registers the agents and resolves reference paths at load time. Legacy v1
"plugin" entries are detected and left read-only with an upgrade advisory.

Commands:
install Ensure the plugin entry and write the install manifest
Expand Down
32 changes: 21 additions & 11 deletions src/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { hashElement } from "folder-hash";
import { AGENT_FILENAMES } from "./agent-loader";
import { BundledAssetsMissingError } from "./bundled-assets-missing-error";
import { CopyModeUnsupportedError } from "./copy-mode-unsupported-error";
import { NpmCache } from "./npm-cache";
import { PluginConfigEditor } from "./plugin-config";

export type Scope = "local" | "global";
Expand Down Expand Up @@ -38,6 +39,7 @@ export interface InstallOutcome {
manifestPath: string;
configPath: string | null;
configAction: "noop" | "updated" | "created" | "blocked";
configWarning: string | null;
removedPayload: string[];
clearedCache: string[];
cacheWarnings: string[];
Expand All @@ -49,6 +51,7 @@ export interface UninstallOutcome {
removed: string[];
pluginRemoved: boolean;
configPath: string | null;
configWarning: string | null;
}

export interface StatusOutcome {
Expand All @@ -66,6 +69,7 @@ const LEGACY_MANIFEST_NAME = "opencode-architect.json";

export class Installer {
private readonly editor = new PluginConfigEditor();
private readonly cache = new NpmCache();
private readonly assetsDir: string;

constructor(assetsDir: string | null = null) {
Expand Down Expand Up @@ -140,6 +144,7 @@ export class Installer {
manifestPath,
configPath: registration.configPath,
configAction: registration.action,
configWarning: registration.warning,
removedPayload,
clearedCache: cache.removed,
cacheWarnings: cache.warnings,
Expand All @@ -149,9 +154,9 @@ export class Installer {
private async requireBundledAssets(version: string): Promise<void> {
for (const name of BUNDLED_ASSET_DIRS) {
const dir = path.join(this.assetsDir, name);
if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version);
if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.cache.root(), version);
const contents = await readdir(dir);
if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version);
if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.cache.root(), version);
}
}

Expand All @@ -162,7 +167,7 @@ export class Installer {
PACKAGE_NAME,
`${PACKAGE_NAME}@latest`,
`${PACKAGE_NAME}@${version}`,
].map((name) => path.join(this.packageCacheRoot(), name));
].map((name) => path.join(this.cache.root(), name));
for (const target of targets) {
if (!(await exists(target))) continue;
try {
Expand All @@ -176,12 +181,6 @@ export class Installer {
return { removed, warnings };
}

private packageCacheRoot(): string {
const xdgCacheHome = process.env.XDG_CACHE_HOME;
if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages");
return path.join(homedir(), ".cache", "opencode", "packages");
}

public async uninstall(scope: Scope, projectDir: string): Promise<UninstallOutcome> {
const base = this.scopeBase(scope, projectDir);
const record = await this.readManifestRecord(base);
Expand All @@ -208,8 +207,19 @@ export class Installer {
}

const mode: InstallMode =
manifest !== null ? manifest.mode : pluginRemoved ? "plugin" : "none";
return { scope, mode, removed, pluginRemoved, configPath: removal.configPath };
manifest !== null
? manifest.mode
: pluginRemoved || removal.configPath !== null
? "plugin"
: "none";
return {
scope,
mode,
removed,
pluginRemoved,
configPath: removal.configPath,
configWarning: removal.warning,
};
}

private async removeResidualPayload(base: string): Promise<string[]> {
Expand Down
14 changes: 14 additions & 0 deletions src/npm-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { homedir } from "node:os";
import path from "node:path";

export class NpmCache {
public root(): string {
const xdgCacheHome = process.env.XDG_CACHE_HOME;
if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "npm");
return path.join(homedir(), ".cache", "opencode", "npm");
}

public opencodeRoot(): string {
return path.dirname(this.root());
}
}
Loading
Loading