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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.cache
*.tsbuildinfo

# rendered-package fixture (tests/templates.test.ts)
.rendered-fixture

# IntelliJ based IDEs
.idea

Expand Down
8 changes: 4 additions & 4 deletions templates/installer.template.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
| `COMMAND NAME` → "my-command.md" | Yes | Command file name in commands/ at the package root | — |
| `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; also the manifest file base | — |

**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no plugin array edits; the `plugin` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled content directories sit at the package root (`skills/`, `commands/`, `agents/` — no `assets/` intermediary) and resolve through `ASSET_LAYOUT_DIR`, which is `"."` by default; only when adapting a legacy package that wraps them in `assets/` is the constant overridden to `"assets"`, and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (`<package>`, `<package>@latest`, `<package>@<version>`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`<project>/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior.
**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no `plugins` array edits; the `plugins` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled content directories sit at the package root (`skills/`, `commands/`, `agents/` — no `assets/` intermediary) and resolve through `ASSET_LAYOUT_DIR`, which is `"."` by default; only when adapting a legacy package that wraps them in `assets/` is the constant overridden to `"assets"`, and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (`<package>`, `<package>@latest`, `<package>@<version>`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`<project>/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior.

---
import { exists, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
Expand Down Expand Up @@ -114,10 +114,10 @@ function getPackageDir(): string {
return join(import.meta.dirname, "..");
}

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

interface CacheOutcome {
Expand Down
22 changes: 18 additions & 4 deletions templates/manifest.template.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
|---|---|---|---|
| `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; the manifest file is `<configBase>/<package>.manifest.json` | — |

**Load-bearing — do not simplify:** idempotency is per-file sha256 (`Bun.CryptoHasher`), never a `.version` marker; a missing or malformed manifest reads as "not installed" (drift), never as a reason to write without an ensure path; a manifest without a `mode` field reads as `"copy"` so pre-generalization copy installs migrate cleanly; the manifest is one record per scope for BOTH install modes — `mode` and the registration fields record the deployment plan, while `files` records whatever payload the load-time hook has ensured (so a plugin-mode manifest gains payload hashes after the first load, and the CLI must preserve them when it rewrites registration); plugin-mode up-to-dateness includes the registration — the recorded entry must still be present in the recorded config file (semantic name match, `@latest`-aware) — plus the recorded payload hashes; consumer-modified files that were skipped during install are recorded with their **packaged** hash — never the consumer's on-disk content — so the modification stays detectable and `--force` can still take ownership of it.
**Load-bearing — do not simplify:** idempotency is per-file sha256 (`Bun.CryptoHasher`), never a `.version` marker; a missing or malformed manifest reads as "not installed" (drift), never as a reason to write without an ensure path; a manifest without a `mode` field reads as `"copy"` so pre-generalization copy installs migrate cleanly; the manifest is one record per scope for BOTH install modes — `mode` and the registration fields record the deployment plan, while `files` records whatever payload the load-time hook has ensured (so a plugin-mode manifest gains payload hashes after the first load, and the CLI must preserve them when it rewrites registration); plugin-mode up-to-dateness includes the registration — the recorded entry must still be present in the recorded config file (semantic name match, `@latest`-aware) — plus the recorded payload hashes; consumer-modified files that were skipped during install are recorded with their **packaged** hash — never the consumer's on-disk content — so the modification stays detectable and `--force` can still take ownership of it; registration presence is checked against the v2 `plugins` key first and the legacy v1 `plugin` key read-only, with entries matched by package name whether written as strings or `{ "package": ... }` objects.

---
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
Expand Down Expand Up @@ -133,14 +133,28 @@ export class InstallManifest {

function pluginEntries(text: string, configPath: string): string[] {
try {
const config = (configPath.endsWith(".jsonc") ? parseJsonc(text) : JSON.parse(text)) as { plugin?: unknown };
if (!Array.isArray(config.plugin)) return [];
return config.plugin.filter((entry): entry is string => typeof entry === "string");
const config = (configPath.endsWith(".jsonc") ? parseJsonc(text) : JSON.parse(text)) as Record<string, unknown>;
const v2 = entryNames(config.plugins);
if (v2.length > 0) return v2;
return entryNames(config.plugin);
} catch {
return [];
}
}

function entryNames(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => (typeof entry === "string" ? entry : entryPackage(entry)))
.filter((entry): entry is string => entry !== null);
}

function entryPackage(entry: unknown): string | null {
if (entry === null || typeof entry !== "object") return null;
const pkg = (entry as { package?: unknown }).package;
return typeof pkg === "string" ? pkg : null;
}

export async function listFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await readdir(rootDir, { withFileTypes: true, recursive: true });
return entries.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name));
Expand Down
44 changes: 36 additions & 8 deletions templates/package-basics.template.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
TEMPLATE INSTRUCTIONS
====================
=====================
Replace the following placeholders before use:

PACKAGE NAME
Expand All @@ -22,16 +22,28 @@ FILES
"plugins", or "tools" when the package
ships them

CONTENT DECLARATION
"assets" → keep when the package ships only skills and/or commands
"code" → replace when the package ships any agent, tool, hook, or
other plugin integration (code-backed packages may also
ship skills/commands)

BIN
"src/cli.ts" → the bunx entry point; keeps every
`bunx <package> ...` advisory working at
packager time, not only after publishing

CONTENT DECLARATION
"assets" → keep when the package ships only skills and/or commands
"code" → replace when the package ships any agent, tool, hook, or
other plugin integration (code-backed packages may also
ship skills/commands)
EXPORTS
"./server" → keep pointing at ./index.ts — OpenCode v2 resolves
the plugin entrypoint through the ./server export
on every runtime; never rely on the root-index
fallback (per opencode-v2-facts §14.6)

DEPENDENCIES
"@opencode/plugin": "latest" and "effect": "latest" must stay "latest" —
generated packages resolve current versions at
time of use; never pin a consumer version
(per opencode-v2-facts §15)

METADATA
"Publisher Name" → your name or organization
Expand All @@ -45,8 +57,24 @@ METADATA
"content": "assets",
"type": "module",
"module": "index.ts",
"exports": {
".": "./index.ts",
"./server": "./index.ts"
},
"bin": {
"opencode-myextension": "src/cli.ts"
},
"files": ["index.ts", "src", "skills", "commands"]
}
"files": ["index.ts", "src", "skills", "commands"],
"scripts": {
"check": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@opencode/plugin": "latest",
"effect": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.7.0"
}
}
16 changes: 16 additions & 0 deletions templates/package-full.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ CONTENT DECLARATION
other plugin integration (code-backed packages may also
ship skills/commands)

EXPORTS
"./server" → keep pointing at ./index.ts — OpenCode v2 resolves
the plugin entrypoint through the ./server export
on every runtime; never rely on the root-index
fallback (per opencode-v2-facts §14.6)

DEPENDENCIES
"@opencode/plugin": "latest" and "effect": "latest" must stay "latest" —
generated packages resolve current versions at
time of use; never pin a consumer version
(per opencode-v2-facts §15)

METADATA
"Publisher Name" → your name or organization
"Author Name <email@example.com>" → your name and email
Expand All @@ -40,6 +52,10 @@ METADATA
"content": "assets",
"type": "module",
"module": "index.ts",
"exports": {
".": "./index.ts",
"./server": "./index.ts"
},
"bin": {
"opencode-myextension": "src/cli.ts"
},
Expand Down
Loading
Loading