fix(plugin): load local toolkit and expose status tool

This commit is contained in:
sargatxet committed 2026-10-08 13:35:44 +02:00
1 parent aac5627e98
commit 2fcfe2fbf7
14 files changed
+145 -34

No files matched your search

+5
View File
@@ -4,6 +4,11 @@ All notable changes to this project are documented here.
## [Unreleased] ## [Unreleased]
### Fixed
- Register the project-local plugin directory instead of prompting OpenCode to
download the unpublished package from npm; enable the namespaced tool through
Code Mode.
### Added ### Added
- OpenCode V2 plugin exposing a namespaced, read-only project status tool. - OpenCode V2 plugin exposing a namespaced, read-only project status tool.
- Curated ECC-derived skills and subagents with per-file provenance records. - Curated ECC-derived skills and subagents with per-file provenance records.
+7 -7
View File
@@ -3,13 +3,13 @@
- [ ] The installer deliberately refuses projects with both - [ ] The installer deliberately refuses projects with both
`opencode.json` and `.opencode/opencode.json`. Decide whether safe support for `opencode.json` and `.opencode/opencode.json`. Decide whether safe support for
multi-layer config is worth implementing after studying V2 merge behavior. multi-layer config is worth implementing after studying V2 merge behavior.
- [ ] The plugin passes V2 contract tests and the package tarball installs, but - [x] The installer previously registered the unpublished npm package name,
`opencode api --standalone get /api/plugin` returned an empty plugin list for which made OpenCode attempt a registry download and fail with 404. It now
both the installed package and a minimal local smoke plugin. Diagnose whether registers the local package directory. The package root provides `index.js`
this reflects loading, API reporting, or configuration before claiming the because configured local plugin paths must be directories with an entrypoint.
custom tool is active. - [x] The namespaced tool now opts into Code Mode. OpenCode 2.0.24 loaded the
- [ ] `opencode debug agents` discovers the three installed toolkit agents from installed tarball and executed `toolkit_project_status` through a local mock
the tarball, but this does not establish that plugin hooks/tools load. OpenAI-compatible endpoint without external model usage.
- [ ] Provider-backed workflows (agent delegation and actual skill invocation) - [ ] Provider-backed workflows (agent delegation and actual skill invocation)
have not been tested; they require configured credentials and explicit have not been tested; they require configured credentials and explicit
approval of any model usage. approval of any model usage.
+14 -9
View File
@@ -12,7 +12,7 @@ and Node.js **20 or newer**. It is not yet published to npm.
- Skills: coding standards, security review, and test-driven development. - Skills: coding standards, security review, and test-driven development.
- Subagents: planner, code reviewer, and security reviewer. - Subagents: planner, code reviewer, and security reviewer.
- Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS - Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS
API. Live plugin activation is still under validation (see `FIXME.md`). API and exposed in the `toolkit` Code Mode namespace.
- Project installer with JSONC-preserving plugin registration, file ownership - Project installer with JSONC-preserving plugin registration, file ownership
hashes, collision checks, and protection for locally modified files. hashes, collision checks, and protection for locally modified files.
@@ -31,10 +31,12 @@ npx opencode-toolkit install .
Replace `/path/to/opencode-toolkit` with the checkout path on your machine. Replace `/path/to/opencode-toolkit` with the checkout path on your machine.
The installer copies the selected skills and agents into `.opencode/`, adds the The installer copies the selected skills and agents into `.opencode/`, registers
package plugin to the applicable project config, and writes an ownership the local package directory from `node_modules` in the applicable project
manifest. It does not overwrite unmanaged files. If a managed file has been config, and writes an ownership manifest. This avoids asking OpenCode to fetch
edited since installation, updates stop rather than replacing it. the provisional, unpublished package name from npm. It does not overwrite
unmanaged files. If a managed file has been edited since installation, updates
stop rather than replacing it.
If both `opencode.json` and `.opencode/opencode.json` exist, installation stops If both `opencode.json` and `.opencode/opencode.json` exist, installation stops
until the project consolidates them; this avoids ambiguous V2 config precedence. until the project consolidates them; this avoids ambiguous V2 config precedence.
@@ -49,8 +51,9 @@ remove the npm dependency from the consuming project's `package.json`; remove
that dependency separately if desired. that dependency separately if desired.
After installation, restart or reload OpenCode and select the installed After installation, restart or reload OpenCode and select the installed
`toolkit-*` skills or subagents as needed. Configure and authenticate your `toolkit-*` skills or subagents as needed. The plugin exposes its namespaced
provider normally in OpenCode. tool through Code Mode. Configure and authenticate your provider normally in
OpenCode.
## Development ## Development
@@ -63,8 +66,10 @@ npm pack --dry-run
``` ```
The package is tested against OpenCode `2.0.24`. The unit and installer tests The package is tested against OpenCode `2.0.24`. The unit and installer tests
are deterministic and do not call an LLM. Provider-backed workflow validation are deterministic and do not call an LLM. Plugin activation and tool execution
is not yet complete; see [`FIXME.md`](FIXME.md) and [`TODO.md`](TODO.md). were smoke-tested with a local mock OpenAI-compatible endpoint; provider-backed
agent and skill workflows are not yet complete. See [`FIXME.md`](FIXME.md) and
[`TODO.md`](TODO.md).
## Project documentation ## Project documentation
+28
View File
@@ -0,0 +1,28 @@
# Requirements
## Plugin loading and tools
- OpenCode V2 must load the plugin from the consuming project's installed local
package directory without trying to download the provisional package name
from npm.
- The package root must expose an OpenCode-loadable JavaScript entrypoint.
- Register `toolkit_project_status` in the `toolkit` Code Mode namespace. The
tool must report the current project and VCS status through OpenCode's V2 API
without mutating project state.
## Installation safety
- Register the plugin with a config-relative path that works with either
`opencode.json` or `.opencode/opencode.json`.
- Migrate an installer-owned legacy npm-name entry on update, and remove only
the installer-owned local path on uninstall.
- Preserve JSONC comments and unrelated plugin entries; do not overwrite
unmanaged project files.
## Verification
- Keep unit and installer tests deterministic and independent of external model
providers.
- Verify plugin activation and tool execution on OpenCode 2.0.24 using a local
mock OpenAI-compatible endpoint; do not make a paid or remote provider call as
part of deterministic validation.
+3 -2
View File
@@ -10,8 +10,9 @@
- [x] Add unit and installer regression tests and coverage threshold. - [x] Add unit and installer regression tests and coverage threshold.
- [x] Install the generated tarball in an isolated project and validate - [x] Install the generated tarball in an isolated project and validate
idempotent CLI install/uninstall and native agent discovery. idempotent CLI install/uninstall and native agent discovery.
- [ ] Resolve why the OpenCode 2.0.24 standalone plugin API reports no active - [x] Diagnose and fix plugin loading: register the installed local plugin
plugin for the installed package; verify the custom tool is exposed. directory, and expose its namespaced tool through Code Mode. Verify activation
and execution in OpenCode 2.0.24 against a local mock provider.
- [x] Detect and refuse ambiguous projects with both project config locations. - [x] Detect and refuse ambiguous projects with both project config locations.
- [ ] Evaluate support for multi-layer project config only after verifying V2 - [ ] Evaluate support for multi-layer project config only after verifying V2
array merge and precedence behavior. array merge and precedence behavior.
+9 -7
View File
@@ -80,15 +80,16 @@ unverified behavior.
**Acceptance:** OpenCode discovers installed skills and agents; resources are **Acceptance:** OpenCode discovers installed skills and agents; resources are
present; imported files have provenance and license attribution. present; imported files have provenance and license attribution.
### Phase 2 — Prove V2 runtime (unit contract complete; real-runtime smoke open) ### Phase 2 — Prove V2 runtime (complete)
- Use `Plugin.define({ id, setup(ctx) })`, V2 transforms and documented VCS APIs. - Use `Plugin.define({ id, setup(ctx) })`, V2 transforms and documented VCS APIs.
- Avoid the V1 plugin callback, V1 event names, and module-global session state. - Avoid the V1 plugin callback, V1 event names, and module-global session state.
- Verify namespaced tool registration and output through contract tests. - Verify namespaced tool registration and output through contract tests.
- Test loading and unloading under an actual OpenCode 2.0.24 runtime. - Test loading and tool execution under an actual OpenCode 2.0.24 runtime.
**Acceptance:** installed package appears as one active plugin and the tool runs **Acceptance:** installed package loads and the tool runs in a real OpenCode
in a real OpenCode session without a provider call to exercise the tool itself. session. Verified using a local mock OpenAI-compatible endpoint; no external
provider call was made.
### Phase 3 — Implement project installer (initial behavior complete) ### Phase 3 — Implement project installer (initial behavior complete)
@@ -117,9 +118,10 @@ uninstall preserves locally modified content.
- Verify behavior in a project with pre-existing config and skills. - Verify behavior in a project with pre-existing config and skills.
**Progress:** package tarball installs outside the checkout; the CLI is **Progress:** package tarball installs outside the checkout; the CLI is
idempotent and OpenCode discovers the three packaged agents. OpenCode 2.0.24's idempotent and OpenCode discovers the three packaged agents. Runtime testing
standalone plugin API reports an empty active-plugin list, so the custom tool's showed that the old npm-name entry caused a registry 404 and that the namespaced
runtime activation still needs diagnosis. tool needed Code Mode enabled. Both were corrected and the packaged plugin and
tool were verified in a local mock-provider session.
**Acceptance:** no checkout symlink or development dependency is needed at **Acceptance:** no checkout symlink or development dependency is needed at
runtime; config collisions are handled or reported before writes; the V2 plugin runtime; config collisions are handled or reported before writes; the V2 plugin
+1
View File
@@ -0,0 +1 @@
export { default, TOOLKIT_PLUGIN_ID } from "./dist/plugin.js"
+1
View File
@@ -23,6 +23,7 @@
"opencode-toolkit": "./dist/cli.js" "opencode-toolkit": "./dist/cli.js"
}, },
"files": [ "files": [
"index.js",
"dist/", "dist/",
"content/", "content/",
"LICENSE", "LICENSE",
+16
View File
@@ -49,6 +49,22 @@ export function addPlugin(source: string, packageName: string): PluginConfigResu
return { content: applyEdits(source, edits), added: true } return { content: applyEdits(source, edits), added: true }
} }
export function replacePlugin(
source: string,
current: string,
replacement: string,
): { content: string; replaced: boolean } {
const config = parseConfig(source)
const entries = pluginEntries(config)
const index = entries.findIndex((entry) => entry === current)
if (index < 0) return { content: source, replaced: false }
const edits = modify(source, ["plugins", index], replacement, {
formattingOptions: { insertSpaces: true, tabSize: 2 },
})
return { content: applyEdits(source, edits), replaced: true }
}
export function removePlugin(source: string, packageName: string): { content: string; removed: boolean } { export function removePlugin(source: string, packageName: string): { content: string; removed: boolean } {
const config = parseConfig(source) const config = parseConfig(source)
const entries = pluginEntries(config) const entries = pluginEntries(config)
+19 -6
View File
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto"
import { promises as fs } from "node:fs" import { promises as fs } from "node:fs"
import path from "node:path" import path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { addPlugin, removePlugin } from "./config.js" import { addPlugin, removePlugin, replacePlugin } from "./config.js"
const PACKAGE_NAME = "@sargatxet/opencode-toolkit" const PACKAGE_NAME = "@sargatxet/opencode-toolkit"
const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url)) const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url))
@@ -41,6 +41,13 @@ function hash(content: Buffer | string): string {
return createHash("sha256").update(content).digest("hex") return createHash("sha256").update(content).digest("hex")
} }
function localPluginSpecifier(projectRoot: string, configRelativePath: string): string {
const configDirectory = path.dirname(path.resolve(projectRoot, configRelativePath))
const packageDirectory = path.join(projectRoot, "node_modules", ...PACKAGE_NAME.split("/"))
const relative = path.relative(configDirectory, packageDirectory).split(path.sep).join("/")
return relative.startsWith(".") ? relative : `./${relative}`
}
async function packageVersion(): Promise<string> { async function packageVersion(): Promise<string> {
const manifest = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8")) as { version?: string } const manifest = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8")) as { version?: string }
return manifest.version ?? "0.0.0" return manifest.version ?? "0.0.0"
@@ -255,7 +262,12 @@ export async function installProject(projectDirectory: string): Promise<InstallR
config = "{}\n" config = "{}\n"
} }
const configured = addPlugin(config, PACKAGE_NAME) const pluginSpecifier = localPluginSpecifier(projectRoot, configRelativePath)
const migration = previous?.pluginEntryAdded
? replacePlugin(config, PACKAGE_NAME, pluginSpecifier)
: { content: config, replaced: false }
const configured = addPlugin(migration.content, pluginSpecifier)
const configChanged = migration.replaced || configured.added
const oldBytes = new Map<string, Buffer | undefined>() const oldBytes = new Map<string, Buffer | undefined>()
const written: string[] = [] const written: string[] = []
const manifest: InstallManifest = { const manifest: InstallManifest = {
@@ -266,7 +278,7 @@ export async function installProject(projectDirectory: string): Promise<InstallR
...staleManaged.filter((entry) => !entry.remove).map(({ relativePath, hash: contentHash }) => ({ path: relativePath, hash: contentHash })), ...staleManaged.filter((entry) => !entry.remove).map(({ relativePath, hash: contentHash }) => ({ path: relativePath, hash: contentHash })),
], ],
configPath: configRelativePath, configPath: configRelativePath,
pluginEntryAdded: previous?.pluginEntryAdded ?? configured.added, pluginEntryAdded: previous?.pluginEntryAdded ?? configChanged,
} }
try { try {
@@ -275,7 +287,7 @@ export async function installProject(projectDirectory: string): Promise<InstallR
await atomicWrite(item.absolutePath, item.content) await atomicWrite(item.absolutePath, item.content)
written.push(item.absolutePath) written.push(item.absolutePath)
} }
if (configured.added) { if (configChanged) {
oldBytes.set(configPath, config === "{}\n" && !(await fileExists(configPath)) ? undefined : Buffer.from(config)) oldBytes.set(configPath, config === "{}\n" && !(await fileExists(configPath)) ? undefined : Buffer.from(config))
await atomicWrite(configPath, configured.content) await atomicWrite(configPath, configured.content)
written.push(configPath) written.push(configPath)
@@ -302,7 +314,7 @@ export async function installProject(projectDirectory: string): Promise<InstallR
updated: planned.filter((item) => item.existed && previousFiles.get(item.relativePath) !== item.hash).map((item) => item.relativePath), updated: planned.filter((item) => item.existed && previousFiles.get(item.relativePath) !== item.hash).map((item) => item.relativePath),
removed: staleManaged.filter((item) => item.remove).map((item) => item.relativePath), removed: staleManaged.filter((item) => item.remove).map((item) => item.relativePath),
preserved: staleManaged.filter((item) => !item.remove).map((item) => item.relativePath), preserved: staleManaged.filter((item) => !item.remove).map((item) => item.relativePath),
pluginConfigured: configured.added || previous?.pluginEntryAdded === true, pluginConfigured: configChanged || previous?.pluginEntryAdded === true,
manifestPath, manifestPath,
} }
} }
@@ -336,13 +348,14 @@ export async function uninstallProject(projectDirectory: string): Promise<Uninst
let pluginRemoved = false let pluginRemoved = false
if (manifest.pluginEntryAdded) { if (manifest.pluginEntryAdded) {
const configPath = path.resolve(projectRoot, manifest.configPath) const configPath = path.resolve(projectRoot, manifest.configPath)
const pluginSpecifier = localPluginSpecifier(projectRoot, manifest.configPath)
try { try {
const configStat = await fs.lstat(configPath) const configStat = await fs.lstat(configPath)
if (!configStat.isFile() || configStat.isSymbolicLink()) { if (!configStat.isFile() || configStat.isSymbolicLink()) {
throw new Error(`Refusing to follow non-regular OpenCode config: ${configPath}`) throw new Error(`Refusing to follow non-regular OpenCode config: ${configPath}`)
} }
const source = await fs.readFile(configPath, "utf8") const source = await fs.readFile(configPath, "utf8")
const result = removePlugin(source, PACKAGE_NAME) const result = removePlugin(source, pluginSpecifier)
if (result.removed) { if (result.removed) {
await atomicWrite(configPath, result.content) await atomicWrite(configPath, result.content)
pluginRemoved = true pluginRemoved = true
+1 -1
View File
@@ -19,7 +19,7 @@ export default Plugin.define({
properties: {}, properties: {},
additionalProperties: false, additionalProperties: false,
}, },
options: { namespace: "toolkit" }, options: { namespace: "toolkit", codemode: true },
execute: async (_input, _execution) => { execute: async (_input, _execution) => {
const [vcs, changes] = await Promise.all([ctx.vcs.get(), ctx.vcs.status()]) const [vcs, changes] = await Promise.all([ctx.vcs.get(), ctx.vcs.status()])
return { return {
+17 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import test from "node:test" import test from "node:test"
import { addPlugin, removePlugin } from "../dist/installer/config.js" import { addPlugin, removePlugin, replacePlugin } from "../dist/installer/config.js"
const pluginName = "@sargatxet/opencode-toolkit" const pluginName = "@sargatxet/opencode-toolkit"
@@ -36,6 +36,22 @@ test("removes only the requested package plugin", () => {
assert.deepEqual(JSON.parse(result.content).plugins, ["other-plugin"]) assert.deepEqual(JSON.parse(result.content).plugins, ["other-plugin"])
}) })
test("replaces a managed plugin entry while preserving JSONC comments", () => {
const original = `{
"plugins": [
// Added by the previous installer version.
"${pluginName}"
]
}`
const replacement = "../node_modules/@sargatxet/opencode-toolkit"
const result = replacePlugin(original, pluginName, replacement)
assert.equal(result.replaced, true)
assert.deepEqual(JSON.parse(result.content.replace(" // Added by the previous installer version.\n", "")).plugins, [replacement])
assert.match(result.content, /Added by the previous installer version/)
})
test("rejects malformed or incompatible plugin configuration", () => { test("rejects malformed or incompatible plugin configuration", () => {
assert.throws(() => addPlugin('{ "plugins": "not-an-array" }', pluginName), /plugins.*array/i) assert.throws(() => addPlugin('{ "plugins": "not-an-array" }', pluginName), /plugins.*array/i)
assert.throws(() => addPlugin('{ broken', pluginName), /invalid JSONC/i) assert.throws(() => addPlugin('{ broken', pluginName), /invalid JSONC/i)
+23 -1
View File
@@ -35,11 +35,33 @@ test("installs curated content and package registration idempotently", async ()
"toolkit-security-review", "toolkit-security-review",
"toolkit-tdd-workflow", "toolkit-tdd-workflow",
]) ])
assert.match(config, new RegExp(PACKAGE_NAME.replaceAll("/", "\\/"))) assert.deepEqual(JSON.parse(config.replace(" // keep this comment\n", "")).plugins, [
"./node_modules/@sargatxet/opencode-toolkit",
])
assert.match(await readFile(path.join(project, "opencode.json"), "utf8"), /keep this comment/) assert.match(await readFile(path.join(project, "opencode.json"), "utf8"), /keep this comment/)
}) })
}) })
test("registers a config-relative package path and migrates the previously managed npm entry", async () => {
await withProject(async (project) => {
await mkdir(path.join(project, ".opencode"), { recursive: true })
const configPath = path.join(project, ".opencode", "opencode.json")
await writeFile(configPath, "{}\n")
await installProject(project)
await writeFile(configPath, `{
"plugins": ["${PACKAGE_NAME}"]
}\n`)
await installProject(project)
const config = JSON.parse(await readFile(configPath, "utf8"))
assert.deepEqual(config.plugins, ["../node_modules/@sargatxet/opencode-toolkit"])
const result = await uninstallProject(project)
assert.equal(result.pluginRemoved, true)
assert.deepEqual(JSON.parse(await readFile(configPath, "utf8")).plugins, [])
})
})
test("refuses to overwrite unowned files and leaves installation unchanged", async () => { test("refuses to overwrite unowned files and leaves installation unchanged", async () => {
await withProject(async (project) => { await withProject(async (project) => {
const target = path.join(project, ".opencode", "skills", "toolkit-tdd-workflow", "SKILL.md") const target = path.join(project, ".opencode", "skills", "toolkit-tdd-workflow", "SKILL.md")
+1
View File
@@ -45,6 +45,7 @@ test("registers namespaced read-only project status tool", async () => {
assert.equal(registered.length, 1) assert.equal(registered.length, 1)
assert.equal(registered[0].name, "project_status") assert.equal(registered[0].name, "project_status")
assert.deepEqual(registered[0].options, { namespace: "toolkit", codemode: true })
assert.equal(typeof registered[0].execute, "function") assert.equal(typeof registered[0].execute, "function")
assert.deepEqual(registered[0].input, { assert.deepEqual(registered[0].input, {
type: "object", type: "object",