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]
### 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
- OpenCode V2 plugin exposing a namespaced, read-only project status tool.
- 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
`opencode.json` and `.opencode/opencode.json`. Decide whether safe support for
multi-layer config is worth implementing after studying V2 merge behavior.
- [ ] The plugin passes V2 contract tests and the package tarball installs, but
`opencode api --standalone get /api/plugin` returned an empty plugin list for
both the installed package and a minimal local smoke plugin. Diagnose whether
this reflects loading, API reporting, or configuration before claiming the
custom tool is active.
- [ ] `opencode debug agents` discovers the three installed toolkit agents from
the tarball, but this does not establish that plugin hooks/tools load.
- [x] The installer previously registered the unpublished npm package name,
which made OpenCode attempt a registry download and fail with 404. It now
registers the local package directory. The package root provides `index.js`
because configured local plugin paths must be directories with an entrypoint.
- [x] The namespaced tool now opts into Code Mode. OpenCode 2.0.24 loaded the
installed tarball and executed `toolkit_project_status` through a local mock
OpenAI-compatible endpoint without external model usage.
- [ ] Provider-backed workflows (agent delegation and actual skill invocation)
have not been tested; they require configured credentials and explicit
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.
- Subagents: planner, code reviewer, and security reviewer.
- 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
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.
The installer copies the selected skills and agents into `.opencode/`, adds the
package plugin to the applicable project config, and writes an ownership
manifest. It does not overwrite unmanaged files. If a managed file has been
edited since installation, updates stop rather than replacing it.
The installer copies the selected skills and agents into `.opencode/`, registers
the local package directory from `node_modules` in the applicable project
config, and writes an ownership manifest. This avoids asking OpenCode to fetch
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
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.
After installation, restart or reload OpenCode and select the installed
`toolkit-*` skills or subagents as needed. Configure and authenticate your
provider normally in OpenCode.
`toolkit-*` skills or subagents as needed. The plugin exposes its namespaced
tool through Code Mode. Configure and authenticate your provider normally in
OpenCode.
## Development
@@ -63,8 +66,10 @@ npm pack --dry-run
```
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
is not yet complete; see [`FIXME.md`](FIXME.md) and [`TODO.md`](TODO.md).
are deterministic and do not call an LLM. Plugin activation and tool execution
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
+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] Install the generated tarball in an isolated project and validate
idempotent CLI install/uninstall and native agent discovery.
- [ ] Resolve why the OpenCode 2.0.24 standalone plugin API reports no active
plugin for the installed package; verify the custom tool is exposed.
- [x] Diagnose and fix plugin loading: register the installed local plugin
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.
- [ ] Evaluate support for multi-layer project config only after verifying V2
array merge and precedence behavior.
+9 -7
View File
@@ -80,15 +80,16 @@ unverified behavior.
**Acceptance:** OpenCode discovers installed skills and agents; resources are
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.
- Avoid the V1 plugin callback, V1 event names, and module-global session state.
- 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
in a real OpenCode session without a provider call to exercise the tool itself.
**Acceptance:** installed package loads and the tool runs in a real OpenCode
session. Verified using a local mock OpenAI-compatible endpoint; no external
provider call was made.
### 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.
**Progress:** package tarball installs outside the checkout; the CLI is
idempotent and OpenCode discovers the three packaged agents. OpenCode 2.0.24's
standalone plugin API reports an empty active-plugin list, so the custom tool's
runtime activation still needs diagnosis.
idempotent and OpenCode discovers the three packaged agents. Runtime testing
showed that the old npm-name entry caused a registry 404 and that the namespaced
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
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"
},
"files": [
"index.js",
"dist/",
"content/",
"LICENSE",
+16
View File
@@ -49,6 +49,22 @@ export function addPlugin(source: string, packageName: string): PluginConfigResu
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 } {
const config = parseConfig(source)
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 path from "node:path"
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_ROOT = fileURLToPath(new URL("../../", import.meta.url))
@@ -41,6 +41,13 @@ function hash(content: Buffer | string): string {
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> {
const manifest = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8")) as { version?: string }
return manifest.version ?? "0.0.0"
@@ -255,7 +262,12 @@ export async function installProject(projectDirectory: string): Promise<InstallR
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 written: string[] = []
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 })),
],
configPath: configRelativePath,
pluginEntryAdded: previous?.pluginEntryAdded ?? configured.added,
pluginEntryAdded: previous?.pluginEntryAdded ?? configChanged,
}
try {
@@ -275,7 +287,7 @@ export async function installProject(projectDirectory: string): Promise<InstallR
await atomicWrite(item.absolutePath, item.content)
written.push(item.absolutePath)
}
if (configured.added) {
if (configChanged) {
oldBytes.set(configPath, config === "{}\n" && !(await fileExists(configPath)) ? undefined : Buffer.from(config))
await atomicWrite(configPath, configured.content)
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),
removed: 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,
}
}
@@ -336,13 +348,14 @@ export async function uninstallProject(projectDirectory: string): Promise<Uninst
let pluginRemoved = false
if (manifest.pluginEntryAdded) {
const configPath = path.resolve(projectRoot, manifest.configPath)
const pluginSpecifier = localPluginSpecifier(projectRoot, manifest.configPath)
try {
const configStat = await fs.lstat(configPath)
if (!configStat.isFile() || configStat.isSymbolicLink()) {
throw new Error(`Refusing to follow non-regular OpenCode config: ${configPath}`)
}
const source = await fs.readFile(configPath, "utf8")
const result = removePlugin(source, PACKAGE_NAME)
const result = removePlugin(source, pluginSpecifier)
if (result.removed) {
await atomicWrite(configPath, result.content)
pluginRemoved = true
+1 -1
View File
@@ -19,7 +19,7 @@ export default Plugin.define({
properties: {},
additionalProperties: false,
},
options: { namespace: "toolkit" },
options: { namespace: "toolkit", codemode: true },
execute: async (_input, _execution) => {
const [vcs, changes] = await Promise.all([ctx.vcs.get(), ctx.vcs.status()])
return {
+17 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
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"
@@ -36,6 +36,22 @@ test("removes only the requested package 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", () => {
assert.throws(() => addPlugin('{ "plugins": "not-an-array" }', pluginName), /plugins.*array/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-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/)
})
})
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 () => {
await withProject(async (project) => {
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[0].name, "project_status")
assert.deepEqual(registered[0].options, { namespace: "toolkit", codemode: true })
assert.equal(typeof registered[0].execute, "function")
assert.deepEqual(registered[0].input, {
type: "object",