diff --git a/CHANGELOG.md b/CHANGELOG.md index 96992b1..4053774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/FIXME.md b/FIXME.md index 3dbeecb..1cd897c 100644 --- a/FIXME.md +++ b/FIXME.md @@ -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. diff --git a/README.md b/README.md index a1d9c4a..9fcad98 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..08c7b49 --- /dev/null +++ b/REQUIREMENTS.md @@ -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. diff --git a/TODO.md b/TODO.md index 66a4a3d..d53ec7a 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 8919c63..f2670d0 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -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 diff --git a/index.js b/index.js new file mode 100644 index 0000000..208cde1 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +export { default, TOOLKIT_PLUGIN_ID } from "./dist/plugin.js" diff --git a/package.json b/package.json index 07cb3ef..f198d37 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "opencode-toolkit": "./dist/cli.js" }, "files": [ + "index.js", "dist/", "content/", "LICENSE", diff --git a/src/installer/config.ts b/src/installer/config.ts index 6468de4..6eb38f6 100644 --- a/src/installer/config.ts +++ b/src/installer/config.ts @@ -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) diff --git a/src/installer/project.ts b/src/installer/project.ts index c3e1eac..c4a0311 100644 --- a/src/installer/project.ts +++ b/src/installer/project.ts @@ -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 { 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() const written: string[] = [] const manifest: InstallManifest = { @@ -266,7 +278,7 @@ export async function installProject(projectDirectory: string): Promise !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 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 { const [vcs, changes] = await Promise.all([ctx.vcs.get(), ctx.vcs.status()]) return { diff --git a/test/installer-config.test.js b/test/installer-config.test.js index ec1abb6..2da450a 100644 --- a/test/installer-config.test.js +++ b/test/installer-config.test.js @@ -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) diff --git a/test/installer-project.test.js b/test/installer-project.test.js index b0a2775..4f83ddc 100644 --- a/test/installer-project.test.js +++ b/test/installer-project.test.js @@ -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") diff --git a/test/plugin.test.js b/test/plugin.test.js index 15ba9f9..58ce6af 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -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",