fix(plugin): load local toolkit and expose status tool
This commit is contained in:
1 parent
aac5627e98
commit
2fcfe2fbf7
14 files changed
+145
-34
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"opencode-toolkit": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"dist/",
|
||||
"content/",
|
||||
"LICENSE",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user