diff --git a/.mise/locks/trunk/1.3.4/aube-lock.yaml b/.mise/locks/trunk/1.3.4/aube-lock.yaml new file mode 100644 index 0000000..d742e72 --- /dev/null +++ b/.mise/locks/trunk/1.3.4/aube-lock.yaml @@ -0,0 +1,105 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@trunkio/launcher': + specifier: 1.3.4 + version: 1.3.4 + +packages: + + '@trunkio/launcher@1.3.4': + resolution: {integrity: sha512-4LCsFVvZtKht7EkbOq5gDsRLIBOH05ycNxm1Vrv+YzY+uOK2HueLBcLU8oejV9v01LTtWjfLJxonIgTSo7lwng==} + engines: {node: '>=18.0.0'} + hasBin: true + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@trunkio/launcher@1.3.4': + dependencies: + semver: 7.8.5 + tar: 6.2.1 + yaml: 2.9.1 + + chownr@2.0.0: {} + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + semver@7.8.5: {} + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + yallist@4.0.0: {} + + yaml@2.9.1: {} diff --git a/.mise/locks/trunk/1.3.4/package.json b/.mise/locks/trunk/1.3.4/package.json new file mode 100644 index 0000000..ee9d50e --- /dev/null +++ b/.mise/locks/trunk/1.3.4/package.json @@ -0,0 +1,7 @@ +{ + "name": "mise-npm-install", + "private": true, + "dependencies": { + "@trunkio/launcher": "1.3.4" + } +} diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 405e212..9642020 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -24,6 +24,7 @@ lint: - linters: [markdownlint, prettier] paths: - CHANGELOG.md + - .mise/**/*.yaml enabled: - gofmt@1.20.4 - osv-scanner@2.6.0 diff --git a/AGENTS.md b/AGENTS.md index 3fb9b01..805bb86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ binary and copied to agent directories by `thts init agents`. | -------- | ------------ | -------------------------------- | -------------------- | --------------- | ---------------- | | Claude | `.claude/` | `skills/*.md` | `commands/` | `hooks/` | `settings.json` | | Codex | `.codex/` | `skills/*/SKILL.md` | `prompts/` (global) | None | `config.toml` | -| OpenCode | `.opencode/` | `skills/*/SKILL.md` | `commands/` | `plugins/` | `opencode.json` | +| OpenCode | `.opencode/` | `skills/*/SKILL.md` | `commands/` | `plugins/` | Not thts-managed | | Gemini | `.gemini/` | `skills/*/SKILL.md` | `commands/*.toml` | `hooks/` | `settings.json` | | Pi | `.pi/` | `skills/thts-integrate/SKILL.md` | `prompts/` | `extensions/` | Not thts-managed | | Droid | `.factory/` | `skills/*/SKILL.md` | `commands/` | `hooks/` | Not thts-managed | @@ -94,14 +94,14 @@ standalone `hooks.json`; do not create or modify its user-owned `settings.json`. ### Hook Support -| Agent | Hook Support | Hook Events | Settings File | -| -------- | --------------- | ------------------------------------ | --------------------- | -| Claude | Yes | `SessionStart`, `UserPromptSubmit` | `settings.local.json` | -| Gemini | Yes | `SessionStart`, `BeforeAgent` | `settings.local.json` | -| OpenCode | Yes (plugin) | `experimental.chat.system.transform` | N/A (plugin) | -| Pi | Yes (extension) | `before_agent_start` | N/A (extension) | -| Droid | Yes | `SessionStart`, `UserPromptSubmit` | `hooks.json` | -| Codex | No | N/A | N/A | +| Agent | Hook Support | Hook Events | Settings File | +| -------- | --------------- | ----------------------------------------------- | --------------------- | +| Claude | Yes | `SessionStart`, `UserPromptSubmit` | `settings.local.json` | +| Gemini | Yes | `SessionStart`, `BeforeAgent` | `settings.local.json` | +| OpenCode | Yes (plugin) | v1 system transform; v2 `context`, `compaction` | N/A (plugin) | +| Pi | Yes (extension) | `before_agent_start` | N/A (extension) | +| Droid | Yes | `SessionStart`, `UserPromptSubmit` | `hooks.json` | +| Codex | No | N/A | N/A | Pi's project `.pi/` resources require Pi project trust. Non-interactive Pi runs do not prompt, so use `pi --approve` to load them for one run or @@ -111,6 +111,13 @@ Droid snapshots hooks at session startup. Restart it or review/reload through `/hooks` after changes. `hooksDisabled` or enterprise `allowManagedHooksOnly` can prevent project or user thts hooks from running. +OpenCode targets v2, retaining best-effort legacy compatibility with v1.18.29+ +through its existing API adapter. Its single dependency-free plugin exports +`id`, `server`, and `setup`. V2 policy follows +`session.location.directory`, including shared servers; do not append `subpath`. +Keep settings user-owned. Only an exact, manifest-owned legacy settings template +may be removed during migration. Customized files must survive refresh/uninit. + ### Embedded Files | File | Purpose | Agent Support | @@ -183,6 +190,10 @@ go test -tags=integration ./internal/cmd/... # Integration tests go test -tags=integration ./... # All tests ``` +OpenCode adapter behavior is covered by unit tests; installation lifecycle is +covered by `scripts/verify-opencode-integration.sh`. Smoke-test v2 manually when +changing the integration or upgrading across a significant API change. + **Coverage targets:** config/git >70%, fs/thts >60% (all met) #### Verification Scripts diff --git a/README.md b/README.md index cb57ce4..eb30f9c 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,10 @@ thts sync -m "Added architecture notes" CLI, Pi, and Droid CLI to give them awareness of your thoughts directory. +OpenCode integration targets v2, retaining best-effort legacy compatibility with +v1.18.29+. The same plugin handles project and global installs. See the +[OpenCode upgrade instructions](docs/guide.md#upgrading-opencode-integration). + ```bash thts init agents # Install for detected agents thts init agents -i # Interactive mode diff --git a/docs/contributing-agents.md b/docs/contributing-agents.md index 62fa1cb..1e5c026 100644 --- a/docs/contributing-agents.md +++ b/docs/contributing-agents.md @@ -274,7 +274,7 @@ func getPluginsFS(agentType agents.AgentType) fs.FS { Create a settings template only when thts should manage a default settings file. The template filename must match `SettingsTemplate`; `SettingsFile` remains the destination name. Leave `SettingsTemplate` empty when the agent's settings are -user-owned, as for Pi. +user-owned, as for OpenCode, Pi, and Droid. For testbot with `SettingsFile: "config.json"`: diff --git a/docs/guide.md b/docs/guide.md index 425e25e..c768c14 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -50,6 +50,7 @@ - [Global vs Project Configuration](#global-vs-project-configuration) - [Prerequisites](#prerequisites) - [Integration Levels](#integration-levels) + - [Upgrading OpenCode Integration](#upgrading-opencode-integration) - [Pi Project Trust](#pi-project-trust) - [Customizing Hook Keywords](#customizing-hook-keywords) - [Claude Plan Directive](#claude-plan-directive) @@ -795,6 +796,10 @@ your thoughts directory and enable session continuity. invoked as `/prompts:` (e.g., `/prompts:thts-handoff`). - **OpenCode XDG**: OpenCode uses XDG for global config (`~/.config/opencode/`) rather than a dot-folder in home. + - The plugin targets v2, retaining best-effort legacy compatibility with + v1.18.29+ through its existing API adapter. + - OpenCode settings are user-owned, including + when `--with-settings` is used. - **Gemini commands**: Gemini stores commands as TOML and does not support native sub-agents. - **Pi resources**: Pi uses skills, Markdown prompt templates, and TypeScript @@ -892,10 +897,16 @@ Claude and Gemini: 2. Loading full instructions (~200 lines) only when keywords are detected 3. Keywords include: research, plan, decision, thoughts, handoff, notes, etc. -OpenCode uses an idempotent plugin that adds the generated instructions to each -model request without adding another copy when another plugin instance already -supplied it. OpenCode local-only mode installs the same project plugin, which is -covered by the managed thts patterns in `.gitignore`. +OpenCode uses an idempotent plugin that adds generated instructions to normal +agent requests and compaction without adding another copy when policy is already +present in the system instructions. V2 title and transient generation requests +do not receive the policy. On shared servers, the plugin uses the session's +working directory. `thts` must be available on the server's PATH. + +OpenCode local-only mode uses the same plugin, covered by the managed thts +patterns in `.gitignore`. With global hooks configured it uses the global plugin. +With hooks disabled, automatic injection is unavailable; enable hooks or select +shared/on-demand integration. OpenCode does not use an `AGENTS.local.md` fallback. Pi uses an idempotent TypeScript extension at `.pi/extensions/thts-integration.ts`. It adds the generated policy at @@ -920,8 +931,40 @@ integration, or review and reload hooks through `/hooks`. Factory's `hooksDisabled` setting or an enterprise `allowManagedHooksOnly` policy can prevent project or user thts hooks from running. -The OpenCode plugin also caches generated instructions for its session. Restart -OpenCode after changing thts categories or instruction configuration. +The OpenCode plugin caches generated instructions per working directory for the +plugin's lifetime, while checking project initialization before each injection. +If a check fails, cached instructions and any pending render for that directory +are discarded. +Restart OpenCode after changing thts categories or instruction configuration. +For v2, restart the server as well as attached clients when a fresh plugin cache +is needed. + +#### Upgrading OpenCode Integration + +After upgrading thts, refresh existing project resources: + +```bash +thts init agents --agents opencode --refresh --dry-run # Preview only +thts init agents --agents opencode --refresh +``` + +For a global installation, reinstall the global components you use: + +```bash +thts init agents --agents opencode --global=all +``` + +Refresh any existing project installation afterward to remove its owned local +plugin when global hooks are selected. Refresh replaces thts-generated plugin, +skill, command, and subagent files; put custom resources beside them. Restart +OpenCode to load the update. V2 watches plugin files, but a server restart also +clears cached policy. + +OpenCode settings are no longer created or owned by thts. Upgrade and uninstall +remove an old manifest-owned `opencode.json` only if it exactly matches the +obsolete thts template. Customized settings are preserved and released from +thts ownership. If they retain the old `permissions.allow` object, follow the +[permissions migration guidance](troubleshooting.md#opencode-settings-errors). **Note:** Codex does not support hooks and will automatically fall back to always-on mode with a warning. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c4fc533..a3fa9e0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,6 +2,39 @@ ## Common Issues +### OpenCode Plugin Not Loading + +OpenCode v2 is the actively supported version. Legacy v1.18.29+ compatibility is +best-effort; upgrade to v2 if you encounter a v1-specific issue. +Refresh installed resources after upgrading thts: + +```bash +thts init agents --agents opencode --refresh # Project installation +thts init agents --agents opencode --global=all # Global installation +``` + +Restart OpenCode afterward; for v2 shared servers, restart the server too. +Check `thts init --check` and `thts agent-instructions` from the session's project +directory on the server. The plugin quietly skips injection if either fails or +`thts` is missing from the server's PATH. In v2's active plugin list, its ID is +`thts.integration`. + +### OpenCode Settings Errors + +Older `thts init agents --with-settings` generated an `opencode.json` containing +`"permissions": { "allow": [] }`. That is not a valid current permissions shape. +If the file is customized, thts preserves it rather than rewriting your settings. + +Remove that obsolete empty permissions object, retaining your other settings. +For intentional permission rules, use the schema for your OpenCode version: + +- v1 uses `permission`, for example `"permission": { "edit": "ask" }`. +- v2 uses a rule array, for example + `"permissions": [{ "action": "edit", "resource": "*", "effect": "ask" }]`. + +See the [v2 permissions guide](https://opencode.ai/v2/docs/permissions). The thts +plugin itself does not require a settings file or a plugin config entry. + ### "Run `thts setup` first" You need to configure thts before using it in any project. diff --git a/embed.go b/embed.go index 4e608dc..ce3041a 100644 --- a/embed.go +++ b/embed.go @@ -28,7 +28,7 @@ var Instructions embed.FS var Templates embed.FS // Settings contains embedded default settings files for agents. -// Files are named by agent type: codex.toml, opencode.json, etc. +// Files are named by agent type: codex.toml, gemini.json, etc. // Claude settings are built dynamically and not embedded. // //go:embed embedded/settings/* diff --git a/embed_test.go b/embed_test.go index 02dedaa..a9a459b 100644 --- a/embed_test.go +++ b/embed_test.go @@ -317,7 +317,6 @@ func TestGetDefaultSettingsByTemplateIdentity(t *testing.T) { }{ {template: "codex.toml", want: "# Codex CLI configuration"}, {template: "gemini.json", want: `"contextFileName": "AGENTS.md"`}, - {template: "opencode.json", want: `"model": "anthropic/claude-sonnet-4-20250514"`}, } for _, tt := range tests { diff --git a/embedded/plugins/opencode/thts-integration.test.ts b/embedded/plugins/opencode/thts-integration.test.ts index bfba3da..2d063b3 100644 --- a/embedded/plugins/opencode/thts-integration.test.ts +++ b/embedded/plugins/opencode/thts-integration.test.ts @@ -1,168 +1,303 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + mkdtemp, + mkdir, + readFile, + rm, + watch, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -import { ThtsIntegration } from "./thts-integration"; - -type Response = { - exitCode: number; - stdout?: string; -}; - -function createShell(responses: Response[]) { - const calls: string[] = []; - const directories: string[] = []; - const shell = (strings: TemplateStringsArray) => { - const command = strings.join(""); - calls.push(command); - const response = responses.shift(); - if (!response) { - throw new Error(`unexpected command: ${command}`); - } - - const output = { - exitCode: response.exitCode, - text: () => response.stdout ?? "", - }; - const result = Object.assign(Promise.resolve(output), { - cwd: (directory: string) => { - directories.push(directory); - return result; - }, - quiet: () => result, - nothrow: () => result, - }); - return result; - }; - - return { calls, directories, shell }; -} - -async function createPlugin(responses: Response[]) { - const fake = createShell(responses); - const hooks = await ThtsIntegration({ - directory: "/repo", - $: fake.shell, - } as never); - const transform = hooks["experimental.chat.system.transform"]; - if (!transform) { - throw new Error("missing system transform hook"); - } - return { ...fake, hooks, transform }; -} +import plugin from "./thts-integration"; +type Response = { exitCode: number; stdout?: string; waitForRelease?: boolean }; +type Event = { sessionID: string; system: { type: "text"; text: string }[] }; const success = { exitCode: 0 }; const instructions = { exitCode: 0, stdout: "# thts Integration Instructions\n\nPolicy", }; -describe("OpenCode thts integration", () => { - test("injects once across duplicate plugin instances", async () => { - const first = await createPlugin([success, instructions]); - const second = await createPlugin([]); - const output = { system: [] as string[] }; - - await first.transform({} as never, output); - await second.transform({} as never, output); - - expect(output.system).toHaveLength(1); - expect(output.system[0]).toContain(""); - expect(first.calls).toEqual([ - "thts init --check", - "thts agent-instructions", - ]); - expect(first.directories).toEqual(["/repo", "/repo"]); - expect(second.calls).toEqual([]); - - const legacyOutput = { - system: ["# thts Integration Instructions\n\nLegacy policy"], - }; - await second.transform({} as never, legacyOutput); - expect(legacyOutput.system).toHaveLength(1); - }); - - test("rechecks eligibility but reuses instructions for later requests", async () => { - const plugin = await createPlugin([success, instructions, success]); +let root: string; +let oldPath: string | undefined; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "thts-opencode-")); + oldPath = process.env.PATH; + await writeFile( + join(root, "thts"), + `#!${process.execPath} +const { readFileSync, writeFileSync, appendFileSync, existsSync } = require("node:fs"); +const responses = JSON.parse(readFileSync("responses.json", "utf8")); +const response = responses.shift(); +appendFileSync("calls.txt", process.argv.slice(2).join(" ") + "\\n"); +writeFileSync("responses.json", JSON.stringify(responses)); +if (!response) process.exit(99); +if (response.waitForRelease) { + writeFileSync("render-started", ""); + while (!existsSync("release-render")) await Bun.sleep(5); +} +process.stdout.write(response.stdout ?? ""); +process.exit(response.exitCode); +`, + { mode: 0o755 }, + ); + process.env.PATH = `${root}:${oldPath}`; +}); +afterEach(async () => { + process.env.PATH = oldPath; + await rm(root, { recursive: true, force: true }); +}); - const first = { system: [] as string[] }; - await plugin.transform({} as never, first); - const afterCompaction = { system: [] as string[] }; - await plugin.transform({} as never, afterCompaction); +async function repo(name: string, responses: Response[]) { + const directory = join(root, name); + await mkdir(directory); + await writeFile(join(directory, "responses.json"), JSON.stringify(responses)); + await writeFile(join(directory, "calls.txt"), ""); + return directory; +} +async function calls(directory: string) { + return (await readFile(join(directory, "calls.txt"), "utf8")) + .split("\n") + .filter(Boolean); +} - expect(first.system).toHaveLength(1); - expect(afterCompaction.system).toHaveLength(1); - expect(plugin.calls).toEqual([ - "thts init --check", - "thts agent-instructions", - "thts init --check", - ]); - expect(plugin.hooks["experimental.session.compacting"]).toBeUndefined(); - }); +async function v1(directory: string) { + const hooks = await plugin.server({ directory } as never); + const transform = hooks["experimental.chat.system.transform"]!; + return { + hooks, + async inject(system: string[]) { + await transform({} as never, { system }); + }, + }; +} +async function v2(directory: () => string | Promise) { + const hooks = new Map Promise>(); + const sessions: string[] = []; + await plugin.setup({ + // Deliberately different from the session's directory. + location: { directory: "/wrong-plugin-location" }, + session: { + async get({ sessionID }: { sessionID: string }) { + sessions.push(sessionID); + return { + location: { directory: await directory() }, + subpath: "ignored", + }; + }, + async hook(name: string, callback: (event: Event) => Promise) { + hooks.set(name, callback); + }, + }, + } as never); + return { hooks, sessions }; +} - test("recovers after initialization or instruction failures", async () => { - const afterInit = await createPlugin([ - { exitCode: 1 }, - success, - instructions, - ]); - const disabled = { system: [] as string[] }; - await afterInit.transform({} as never, disabled); - const enabled = { system: [] as string[] }; - await afterInit.transform({} as never, enabled); +for (const version of [1, 2]) { + describe(`OpenCode v${version}`, () => { + async function adapter(directory: string) { + if (version === 1) return v1(directory); + const instance = await v2(() => directory); + return { + async inject(system: string[]) { + const event: Event = { + sessionID: "session-a", + system: system.map((text) => ({ type: "text", text })), + }; + await instance.hooks.get("context")!(event); + system.splice( + 0, + system.length, + ...event.system.map((part) => part.text), + ); + }, + }; + } - expect(disabled.system).toEqual([]); - expect(enabled.system).toHaveLength(1); + test("injects once across plugin instances and respects existing policy", async () => { + const directory = await repo("repo", [success, instructions]); + const first = await adapter(directory); + const second = await adapter(directory); + const system: string[] = ["Other instructions"]; + await first.inject(system); + await second.inject(system); + expect(system).toHaveLength(2); + expect(system[1]).toContain(""); + const legacy = ["# thts Integration Instructions\nLegacy policy"]; + await second.inject(legacy); + expect(legacy).toHaveLength(1); + expect(await calls(directory)).toEqual([ + "init --check", + "agent-instructions", + ]); + }); - const afterCommand = await createPlugin([ - success, - { exitCode: 1 }, - success, - instructions, - ]); - const failed = { system: [] as string[] }; - await afterCommand.transform({} as never, failed); - const recovered = { system: [] as string[] }; - await afterCommand.transform({} as never, recovered); + test("rechecks eligibility, caches policy, and invalidates on uninit", async () => { + const directory = await repo("repo", [ + success, + instructions, + success, + { exitCode: 1 }, + success, + { exitCode: 0, stdout: "Changed policy" }, + ]); + const instance = await adapter(directory); + for (const expected of ["Policy", "Policy", null, "Changed policy"]) { + const system: string[] = []; + await instance.inject(system); + if (expected) expect(system[0]).toContain(expected); + else expect(system).toEqual([]); + } + expect(await calls(directory)).toEqual([ + "init --check", + "agent-instructions", + "init --check", + "init --check", + "init --check", + "agent-instructions", + ]); + }); - expect(failed.system).toEqual([]); - expect(recovered.system).toHaveLength(1); + for (const replace of [false, true]) { + test(`discards an in-flight policy after cache ${replace ? "replacement" : "deletion"}`, async () => { + const directory = await repo("repo", [ + success, + { ...instructions, waitForRelease: true }, + { exitCode: 1 }, + success, + { exitCode: 0, stdout: "Fresh policy" }, + success, + ]); + const instance = await adapter(directory); + const events = watch(directory, { signal: AbortSignal.timeout(4000) }); + const stale: string[] = []; + const pending = instance.inject(stale); + const fresh: string[] = []; + try { + for await (const event of events) { + if (event.filename === "render-started") break; + } + const ineligible: string[] = []; + await instance.inject(ineligible); + expect(ineligible).toEqual([]); + if (replace) await instance.inject(fresh); + } finally { + await writeFile(join(directory, "release-render"), ""); + await pending; + } + expect(stale).toEqual([]); + if (!replace) await instance.inject(fresh); + expect(fresh[0]).toContain("Fresh policy"); + const cached: string[] = []; + await instance.inject(cached); + expect(cached).toEqual(fresh); + expect(await calls(directory)).toEqual([ + "init --check", + "agent-instructions", + "init --check", + "init --check", + "agent-instructions", + "init --check", + ]); + }); + } - const afterBlank = await createPlugin([ - success, - { exitCode: 0, stdout: " " }, - success, - instructions, - ]); - const blank = { system: [] as string[] }; - await afterBlank.transform({} as never, blank); - const afterBlankRetry = { system: [] as string[] }; - await afterBlank.transform({} as never, afterBlankRetry); + test("recovers from check, render, and empty-output failures", async () => { + const directory = await repo("repo", [ + { exitCode: 1 }, + success, + { exitCode: 1 }, + success, + { exitCode: 0, stdout: " " }, + success, + instructions, + ]); + const instance = await adapter(directory); + for (let i = 0; i < 4; i++) { + const system: string[] = []; + await instance.inject(system); + expect(system).toHaveLength(i === 3 ? 1 : 0); + } + }); - expect(blank.system).toEqual([]); - expect(afterBlankRetry.system).toHaveLength(1); + test("missing executable and missing cwd are quiet no-ops", async () => { + const directory = await repo("repo", [success, instructions]); + const instance = await adapter(directory); + process.env.PATH = ""; + const system: string[] = []; + await instance.inject(system); + expect(system).toEqual([]); + process.env.PATH = `${root}:${oldPath}`; + await instance.inject(system); + expect(system).toHaveLength(1); + await (await adapter(join(root, "missing"))).inject([]); + }); }); +} - test("stops injecting after the project is uninitialized", async () => { - const changedInstructions = { - exitCode: 0, - stdout: "# thts Integration Instructions\n\nChanged policy", - }; - const plugin = await createPlugin([ - success, - instructions, - { exitCode: 1 }, - success, - changedInstructions, +test("v2 registers work and compaction only; compaction shares policy cache", async () => { + const directory = await repo("repo", [success, instructions, success]); + const instance = await v2(() => directory); + expect([...instance.hooks.keys()]).toEqual(["context", "compaction"]); + for (const hook of instance.hooks.values()) { + const event: Event = { sessionID: "session-a", system: [] }; + await hook(event); + expect(event.system).toEqual([ + { + type: "text", + text: `\n${instructions.stdout}`, + }, ]); + } + expect(await calls(directory)).toEqual([ + "init --check", + "agent-instructions", + "init --check", + ]); +}); - const initialized = { system: [] as string[] }; - await plugin.transform({} as never, initialized); - const uninitialized = { system: [] as string[] }; - await plugin.transform({} as never, uninitialized); - const reinitialized = { system: [] as string[] }; - await plugin.transform({} as never, reinitialized); +test("v2 follows session moves and isolates directory caches", async () => { + const first = await repo("first", [success, instructions, success]); + const second = await repo("second", [ + success, + { exitCode: 0, stdout: "Other policy" }, + ]); + let directory = first; + const instance = await v2(() => directory); + for (const [cwd, expected] of [ + [first, "Policy"], + [second, "Other policy"], + [first, "Policy"], + ]) { + directory = cwd; + const event: Event = { sessionID: "moving-session", system: [] }; + await instance.hooks.get("context")!(event); + expect(event.system[0].text).toContain(expected); + } + expect(instance.sessions).toEqual(Array(3).fill("moving-session")); + expect(await calls(first)).toEqual([ + "init --check", + "agent-instructions", + "init --check", + ]); + expect(await calls(second)).toEqual(["init --check", "agent-instructions"]); +}); - expect(initialized.system).toHaveLength(1); - expect(uninitialized.system).toEqual([]); - expect(reinitialized.system[0]).toContain("Changed policy"); +test("v2 lookup failures are retryable and existing policy avoids lookup", async () => { + const directory = await repo("repo", [success, instructions]); + let fail = true; + const instance = await v2(() => { + if (fail) throw new Error("session unavailable"); + return directory; }); + const event: Event = { sessionID: "session-a", system: [] }; + await instance.hooks.get("context")!(event); + expect(event.system).toEqual([]); + fail = false; + await instance.hooks.get("context")!(event); + await instance.hooks.get("context")!(event); + expect(event.system).toHaveLength(1); + expect(instance.sessions).toHaveLength(2); }); diff --git a/embedded/plugins/opencode/thts-integration.ts b/embedded/plugins/opencode/thts-integration.ts index 26dc4a0..ae4e6e6 100644 --- a/embedded/plugins/opencode/thts-integration.ts +++ b/embedded/plugins/opencode/thts-integration.ts @@ -1,57 +1,91 @@ -// thts integration plugin for OpenCode -// Makes thoughts/ instructions available once per model request. +// thts integration for OpenCode v1.18.29+ and v2. +// Keep this file self-contained: thts installs it without an npm package. -import type { Plugin } from "@opencode-ai/plugin"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { Plugin as V1Plugin } from "@opencode-ai/plugin"; +import type { Plugin } from "@opencode/plugin"; +import type { SessionContext } from "@opencode/plugin/promise/session"; +const exec = promisify(execFile); const THTS_MARKER = ""; const THTS_HEADING = "# thts Integration Instructions"; -export const ThtsIntegration: Plugin = async ({ directory, $ }) => { - let instructions: Promise | undefined; +function hasPolicy(content: string) { + return content.includes(THTS_MARKER) || content.includes(THTS_HEADING); +} - const getInstructions = async () => { - instructions ??= (async () => { - const result = await $`thts agent-instructions` - .cwd(directory) - .quiet() - .nothrow(); - if (result.exitCode !== 0) { - return null; - } +function createPolicyLoader() { + const policies = new Map>(); - return result.text().trim() || null; - })(); + const run = async (directory: string, args: string[]) => { + try { + const { stdout } = await exec("thts", args, { + cwd: directory, + timeout: 10_000, + encoding: "utf8", + }); + return stdout.trim(); + } catch { + return null; + } + }; - const content = await instructions; + return async (directory: string) => { + if ((await run(directory, ["init", "--check"])) === null) { + policies.delete(directory); + return null; + } + + let pending = policies.get(directory); + if (!pending) { + pending = run(directory, ["agent-instructions"]); + policies.set(directory, pending); + } + const content = await pending; + if (policies.get(directory) !== pending) return null; if (!content) { - instructions = undefined; + policies.delete(directory); + return null; } - return content; + return `${THTS_MARKER}\n${content}`; }; +} +const server: V1Plugin = async ({ directory }) => { + const loadPolicy = createPolicyLoader(); return { "experimental.chat.system.transform": async (_input, output) => { - const alreadyPresent = output.system.some( - (content) => - content.includes(THTS_MARKER) || content.includes(THTS_HEADING), - ); - if (alreadyPresent) { - return; - } - - const enabled = await $`thts init --check` - .cwd(directory) - .quiet() - .nothrow(); - if (enabled.exitCode !== 0) { - instructions = undefined; - return; - } - - const content = await getInstructions(); - if (content) { - output.system.push(`${THTS_MARKER}\n${content}`); - } + if (output.system.some(hasPolicy)) return; + const content = await loadPolicy(directory); + if (content && !output.system.some(hasPolicy)) + output.system.push(content); }, }; }; + +async function setup(ctx: Plugin.Context) { + const loadPolicy = createPolicyLoader(); + const inject = async ( + event: Pick, + ) => { + if (event.system.some((part) => hasPolicy(part.text))) return; + let directory: string; + try { + const session = await ctx.session.get({ sessionID: event.sessionID }); + directory = session.location.directory; + } catch { + return; + } + const content = await loadPolicy(directory); + if (content && !event.system.some((part) => hasPolicy(part.text))) { + event.system.push({ type: "text", text: content }); + } + }; + await ctx.session.hook("context", inject); + await ctx.session.hook("compaction", inject); +} + +// V1 calls server(); V2 calls setup(). Plugin.define is an identity helper, +// so the structural definition avoids a runtime dependency on either SDK. +export default { id: "thts.integration", server, setup }; diff --git a/embedded/settings/opencode.json b/embedded/settings/opencode.json deleted file mode 100644 index c6ca3fe..0000000 --- a/embedded/settings/opencode.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "model": "anthropic/claude-sonnet-4-20250514", - "permissions": { - "allow": [] - } -} diff --git a/internal/agents/types.go b/internal/agents/types.go index 397aedd..1cf7c12 100644 --- a/internal/agents/types.go +++ b/internal/agents/types.go @@ -172,7 +172,7 @@ var AgentConfigs = map[AgentType]*AgentConfig{ CommandsGlobalOnly: false, GlobalUsesXDG: true, // OpenCode uses ~/.config/opencode/ for global SettingsFile: "opencode.json", - SettingsTemplate: "opencode.json", + SettingsTemplate: "", // OpenCode settings are user-owned SettingsFormat: "json", SupportsHooks: true, HooksDir: "", diff --git a/internal/cmd/agents/init.go b/internal/cmd/agents/init.go index 19cb8dc..c781e51 100644 --- a/internal/cmd/agents/init.go +++ b/internal/cmd/agents/init.go @@ -251,7 +251,7 @@ func runAgentsInit(cmd *cobra.Command, args []string) error { existingAgents := detectExistingAgentManifests(targetDir) // Handle --refresh flag or prompt for action if agents exist - if len(existingAgents) > 0 && !initForce { + if len(existingAgents) > 0 && (!initForce || initRefresh) { if initRefresh { return refreshAgentSetup(targetDir, existingAgents) } @@ -546,7 +546,7 @@ func buildInstallationPlan(projectDir string, agentType agents.AgentType, level if agentConfig.PluginsDir != "" && hooksMode == config.ComponentModeLocal { relPath := filepath.Join(agentConfig.PluginsDir, "thts-integration.ts") plan.pluginFiles = append(plan.pluginFiles, relPath) - } else if !usesGlobalPiRuntimeAdapter(agentType, agentConfig, hooksMode) { + } else if agentType != agents.AgentOpenCode && !usesGlobalPiRuntimeAdapter(agentType, agentConfig, hooksMode) { if agentConfig.Type == agents.AgentClaude { plan.instructionsFile = "CLAUDE.local.md" } else if agentConfig.Type == agents.AgentDroid { @@ -740,7 +740,7 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL level = normalizeIntegrationLevel(level) if existing, err := loadManifest(agentDir); err == nil { previousManifest = existing - } else if agentType == agents.AgentDroid && !os.IsNotExist(err) { + } else if (agentType == agents.AgentDroid || agentType == agents.AgentOpenCode) && !os.IsNotExist(err) { return fmt.Errorf("read previous manifest: %w", err) } if agentType == agents.AgentDroid { @@ -761,6 +761,10 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL } return reconcileErr } + } else if agentType == agents.AgentOpenCode && previousManifest != nil { + if err := reconcileOpenCodeIntegration(projectDir, agentDir, previousManifest, level, hooksMode); err != nil { + return err + } } else if previousManifest != nil && normalizeIntegrationLevel(previousManifest.IntegrationLevel) == IntegrationHook && (level != IntegrationHook || hooksMode != config.ComponentModeLocal) { if err := removeProjectHookIntegration(projectDir, agentDir, agentConfig, previousManifest); err != nil { return fmt.Errorf("remove previous hook integration: %w", err) @@ -772,6 +776,10 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL IntegrationLevel: level, Files: []string{}, } + if agentType == agents.AgentOpenCode && previousManifest != nil { + manifest.Files = slices.Clone(previousManifest.Files) + manifest.Modifications = previousManifest.Modifications + } if previousManifest != nil && previousManifest.Modifications.Hooks != nil && level == IntegrationHook && hooksMode == config.ComponentModeLocal { manifest.Modifications.Hooks = previousManifest.Modifications.Hooks manifest.Modifications.Gitignore = previousManifest.Modifications.Gitignore @@ -880,16 +888,21 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL fmt.Printf("%s\n", ui.InfoF(" %s: using global installation", runtimeAdapterDisplayName(agentType))) case config.ComponentModeLocal: if err := setupHookIntegration(projectDir, agentDir, agentType, agentConfig, manifest); err != nil { - if agentType == agents.AgentDroid { - return fmt.Errorf("setup Droid hook integration: %w", err) + if agentType == agents.AgentDroid || agentType == agents.AgentOpenCode { + return fmt.Errorf("setup %s hook integration: %w", agentType, err) } fmt.Println(ui.WarningF(" Could not setup hook integration: %v", err)) } } } else if level == IntegrationAgentsContentLocal && agentConfig.PluginsDir != "" && hooksMode == config.ComponentModeLocal { if err := setupHookIntegration(projectDir, agentDir, agentType, agentConfig, manifest); err != nil { + if agentType == agents.AgentOpenCode { + return fmt.Errorf("setup OpenCode local plugin: %w", err) + } fmt.Println(ui.WarningF(" Could not setup local plugin integration: %v", err)) } + } else if agentType == agents.AgentOpenCode && level == IntegrationAgentsContentLocal { + reportOpenCodeRuntimeMode(hooksMode) } else if !usesGlobalPiRuntimeAdapter(agentType, agentConfig, hooksMode) { // Traditional integration (markers or config) instMod, gitignorePatterns, err := setupIntegrationLevel(projectDir, agentDir, agentConfig, level) @@ -907,7 +920,7 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL fmt.Println(ui.WarningF(" Could not setup integration: %v", err)) } else { if instMod != nil { - if agentType == agents.AgentDroid && previousManifest != nil && + if (agentType == agents.AgentDroid || agentType == agents.AgentOpenCode) && previousManifest != nil && previousManifest.Modifications.InstructionsMD != nil && previousManifest.Modifications.InstructionsMD.Action == "created" && filepath.Clean(previousManifest.Modifications.InstructionsMD.Path) == filepath.Clean(instMod.Path) { @@ -937,11 +950,14 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL fmt.Println(ui.SuccessF(" Created %s", agentConfig.SettingsFile)) } } + if agentType == agents.AgentOpenCode && level == IntegrationHook && hooksMode == config.ComponentModeDisabled { + reportOpenCodeRuntimeMode(hooksMode) + } // Write manifest if err := writeManifest(agentDir, manifest); err != nil { - if agentType == agents.AgentDroid { - return fmt.Errorf("write Droid manifest: %w", err) + if agentType == agents.AgentDroid || agentType == agents.AgentOpenCode { + return fmt.Errorf("write %s manifest: %w", agentType, err) } fmt.Println(ui.WarningF(" Could not write manifest: %v", err)) } @@ -2502,6 +2518,23 @@ func detectExistingAgentManifests(projectDir string) []agents.AgentType { // This re-copies skills, commands, agents, and updates instructions without // prompting for integration level (preserves existing level from manifest). func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { + if initAgents != "" { + selected, err := agents.ParseAgentTypes(initAgents) + if err != nil { + return err + } + agentTypes = slices.DeleteFunc(slices.Clone(agentTypes), func(agentType agents.AgentType) bool { + return !slices.Contains(selected, agentType) + }) + if len(agentTypes) == 0 { + return fmt.Errorf("no initialized agents match --agents %s", initAgents) + } + } + if initDryRun { + fmt.Println(ui.InfoF("Would refresh: %s", strings.Join(agents.AgentTypesToStrings(agentTypes), ", "))) + fmt.Println(ui.Info("Dry run complete. No files were changed.")) + return nil + } fmt.Println(ui.Header("Refreshing Agent Integration")) fmt.Println() @@ -2515,8 +2548,8 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { // Load existing manifest to get integration level manifest, err := loadManifest(agentDir) if err != nil { - if agentType == agents.AgentDroid { - return fmt.Errorf("read Droid manifest: %w", err) + if agentType == agents.AgentDroid || agentType == agents.AgentOpenCode { + return fmt.Errorf("read %s manifest: %w", agentType, err) } fmt.Println(ui.WarningF("Could not read manifest for %s: %v", agentType, err)) continue @@ -2527,6 +2560,14 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { var filesUpdated int level := normalizeIntegrationLevel(manifest.IntegrationLevel) hooksMode := resolveAgentComponentMode(cfg, globalManifest, agentType, "hooks") + if agentType == agents.AgentOpenCode { + if err := reconcileOpenCodeIntegration(projectDir, agentDir, manifest, level, hooksMode); err != nil { + return err + } + if level == IntegrationHook || level == IntegrationAgentsContentLocal { + reportOpenCodeRuntimeMode(hooksMode) + } + } if agentType == agents.AgentDroid { if err := validateDroidProjectResourceCollisions(agentDir, agentConfig, cfg, globalManifest, manifest, level); err != nil { return err @@ -2662,6 +2703,13 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { } } case IntegrationAgentsContent: + if agentType == agents.AgentOpenCode && manifest.Modifications.InstructionsMD == nil { + mod, _, err := setupIntegrationLevel(projectDir, agentDir, agentConfig, level) + if err != nil { + return err + } + manifest.Modifications.InstructionsMD = mod + } // Refresh marker block content if err := refreshIntegration(projectDir, agentDir, agentConfig, cfg); err != nil { if agentType == agents.AgentDroid { @@ -2694,6 +2742,9 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { } else if agentConfig.PluginsDir != "" && (level == IntegrationHook || level == IntegrationAgentsContentLocal) && hooksMode == config.ComponentModeLocal { copied, err := copyPluginsToManifest(agentDir, agentType, agentConfig, manifest) if err != nil { + if agentType == agents.AgentOpenCode { + return fmt.Errorf("refresh OpenCode plugin: %w", err) + } fmt.Println(ui.WarningF(" Could not update plugins: %v", err)) } else if copied > 0 { filesUpdated += copied @@ -2709,8 +2760,8 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { // Update manifest timestamp manifest.CreatedAt = time.Now().UTC().Format(time.RFC3339) if err := writeManifest(agentDir, manifest); err != nil { - if agentType == agents.AgentDroid { - return fmt.Errorf("update Droid manifest: %w", err) + if agentType == agents.AgentDroid || agentType == agents.AgentOpenCode { + return fmt.Errorf("update %s manifest: %w", agentType, err) } fmt.Println(ui.WarningF(" Could not update manifest: %v", err)) } diff --git a/internal/cmd/agents/init_test.go b/internal/cmd/agents/init_test.go index 9756d80..4fb36d6 100644 --- a/internal/cmd/agents/init_test.go +++ b/internal/cmd/agents/init_test.go @@ -21,7 +21,6 @@ func TestWriteAgentSettingsUsesEmbeddedTemplateAtAgentDestination(t *testing.T) }{ {agentType: internalagents.AgentCodex, destination: "config.toml"}, {agentType: internalagents.AgentGemini, destination: "settings.json"}, - {agentType: internalagents.AgentOpenCode, destination: "opencode.json"}, } { t.Run(string(tt.agentType), func(t *testing.T) { cfg := internalagents.GetConfig(tt.agentType) @@ -357,8 +356,8 @@ func TestLocalContentPlanMatchesPluginHookMode(t *testing.T) { wantInstructions bool }{ {name: "local hooks install plugin", hooksMode: config.ComponentModeLocal, wantPlugin: true}, - {name: "global hooks use local instructions", hooksMode: config.ComponentModeGlobal, wantInstructions: true}, - {name: "disabled hooks use local instructions", hooksMode: config.ComponentModeDisabled, wantInstructions: true}, + {name: "global hooks use global plugin", hooksMode: config.ComponentModeGlobal}, + {name: "disabled hooks have no fallback", hooksMode: config.ComponentModeDisabled}, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("THTS_CONFIG_PATH", filepath.Join(t.TempDir(), "config.yaml")) diff --git a/internal/cmd/agents/opencode.go b/internal/cmd/agents/opencode.go new file mode 100644 index 0000000..396e148 --- /dev/null +++ b/internal/cmd/agents/opencode.go @@ -0,0 +1,117 @@ +package agents + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + + "github.com/scottames/thts/internal/agents" + "github.com/scottames/thts/internal/config" + "github.com/scottames/thts/internal/ui" +) + +// Only the exact old generated file is safe to delete. Customized settings, +// JSONC, and symlinks remain user-owned, even if an old manifest claims them. +const legacyOpenCodeSettings = `{ + "model": "anthropic/claude-sonnet-4-20250514", + "permissions": { + "allow": [] + } +} +` + +func isLegacyOpenCodeSettings(agentDir string, manifest *Manifest) (bool, error) { + if !manifest.SettingsCreated && !slices.Contains(manifest.Files, "opencode.json") { + return false, nil + } + path := filepath.Join(agentDir, "opencode.json") + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 { + return false, nil + } + if !info.Mode().IsRegular() { + return false, fmt.Errorf("expected regular OpenCode settings file: %s", path) + } + content, err := os.ReadFile(path) + return string(content) == legacyOpenCodeSettings, err +} + +func migrateOpenCodeSettings(agentDir string, manifest *Manifest) error { + owned := manifest.SettingsCreated || slices.Contains(manifest.Files, "opencode.json") + if !owned { + return nil + } + remove, err := isLegacyOpenCodeSettings(agentDir, manifest) + if err != nil { + return err + } + path := filepath.Join(agentDir, "opencode.json") + if remove { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + fmt.Println(ui.Info("Removed obsolete thts-generated OpenCode settings")) + } else if _, err := os.Lstat(path); err == nil { + fmt.Println(ui.InfoF("Preserved user-owned settings: %s", path)) + content, _ := os.ReadFile(path) + var settings struct { + Permissions map[string]json.RawMessage `json:"permissions"` + } + if json.Unmarshal(content, &settings) == nil && settings.Permissions["allow"] != nil { + fmt.Println(ui.Warning("Preserved obsolete permissions.allow object; migrate it using the OpenCode permissions guide.")) + } + } + manifest.SettingsCreated = false + manifest.Files = removeStringValue(manifest.Files, "opencode.json") + return nil +} + +func reportOpenCodeRuntimeMode(mode config.ComponentMode) { + switch mode { + case config.ComponentModeGlobal: + fmt.Println(ui.Info("OpenCode: using global plugin")) + case config.ComponentModeDisabled: + fmt.Println(ui.Info("OpenCode plugin disabled: automatic injection is unavailable. Enable hooks or select shared/on-demand integration.")) + } +} + +func reconcileOpenCodeIntegration(projectDir, agentDir string, manifest *Manifest, level IntegrationLevel, mode config.ComponentMode) error { + // Save after reconciliation, including partial progress on failure, so failed + // cleanup never loses the remaining ownership record. + err := reconcileOpenCodeFiles(projectDir, agentDir, manifest, level, mode) + if saveErr := writeManifest(agentDir, manifest); saveErr != nil { + return errors.Join(err, fmt.Errorf("save OpenCode migration: %w", saveErr)) + } + return err +} + +func reconcileOpenCodeFiles(projectDir, agentDir string, manifest *Manifest, level IntegrationLevel, mode config.ComponentMode) error { + if err := migrateOpenCodeSettings(agentDir, manifest); err != nil { + return err + } + keepPlugin := (level == IntegrationHook || level == IntegrationAgentsContentLocal) && mode == config.ComponentModeLocal + const plugin = "plugins/thts-integration.ts" + if !keepPlugin && slices.Contains(manifest.Files, filepath.FromSlash(plugin)) { + if err := os.Remove(filepath.Join(agentDir, plugin)); err != nil && !os.IsNotExist(err) { + return err + } + manifest.Files = removeStringValue(manifest.Files, filepath.FromSlash(plugin)) + cleanEmptyDirs(filepath.Join(agentDir, "plugins")) + } + if mod := manifest.Modifications.InstructionsMD; mod != nil && (level != IntegrationAgentsContent || mod.IntegrationType == "config") { + if err := removeThtsIntegration(mod, agents.AgentOpenCode, projectDir, nil); err != nil && !os.IsNotExist(err) { + return err + } + manifest.Modifications.InstructionsMD = nil + } + return migrateLegacyLocalInstructions(projectDir, agentDir, agents.GetConfig(agents.AgentOpenCode), manifest) +} diff --git a/internal/cmd/agents/opencode_test.go b/internal/cmd/agents/opencode_test.go new file mode 100644 index 0000000..ee235e8 --- /dev/null +++ b/internal/cmd/agents/opencode_test.go @@ -0,0 +1,350 @@ +package agents + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + thtsfiles "github.com/scottames/thts" + internalagents "github.com/scottames/thts/internal/agents" + "github.com/scottames/thts/internal/config" +) + +func setupOpenCodeTest(t *testing.T) { + t.Helper() + t.Setenv("THTS_CONFIG_PATH", filepath.Join(t.TempDir(), "config.yaml")) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + oldAgents, oldDryRun, oldSettings, oldRefresh, oldForce := initAgents, initDryRun, initWithSettings, initRefresh, initForce + initAgents, initDryRun, initWithSettings, initRefresh, initForce = "", false, false, false, false + t.Cleanup(func() { + initAgents, initDryRun, initWithSettings, initRefresh, initForce = oldAgents, oldDryRun, oldSettings, oldRefresh, oldForce + }) +} + +func TestOpenCodeSettingsAreUserOwned(t *testing.T) { + setupOpenCodeTest(t) + initWithSettings = true + for _, content := range []string{"", `{"model":"user/model"}`, "// user config\n{}"} { + t.Run(content, func(t *testing.T) { + project := t.TempDir() + agentDir := filepath.Join(project, ".opencode") + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatal(err) + } + path := filepath.Join(agentDir, "opencode.json") + if strings.HasPrefix(content, "//") { + path += "c" + } + if content != "" { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationHook); err != nil { + t.Fatal(err) + } + manifest, err := loadManifest(agentDir) + if err != nil || manifest.SettingsCreated || slices.Contains(manifest.Files, "opencode.json") { + t.Fatalf("unexpected settings ownership: %+v, %v", manifest, err) + } + if err := refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + if err := Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if content == "" { + if !os.IsNotExist(err) { + t.Fatalf("settings unexpectedly created: %v", err) + } + } else if err != nil || string(got) != content { + t.Fatalf("user settings changed: %q, %v", got, err) + } + }) + } +} + +func TestOpenCodeLegacySettingsMigration(t *testing.T) { + for _, operation := range []string{"refresh", "reinit", "uninit"} { + for _, customized := range []bool{false, true} { + t.Run(operation+map[bool]string{false: "/exact", true: "/custom"}[customized], func(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationHook); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(project, ".opencode") + manifest, err := loadManifest(agentDir) + if err != nil { + t.Fatal(err) + } + manifest.SettingsCreated = true + manifest.Files = append(manifest.Files, "opencode.json") + if err := writeManifest(agentDir, manifest); err != nil { + t.Fatal(err) + } + content := legacyOpenCodeSettings + if customized { + content = strings.Replace(content, "claude-sonnet-4-20250514", "custom-model", 1) + } + path := filepath.Join(agentDir, "opencode.json") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + switch operation { + case "refresh": + err = refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}) + case "reinit": + err = initAgent(project, internalagents.AgentOpenCode, IntegrationHook) + case "uninit": + err = Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}) + } + if err != nil { + t.Fatal(err) + } + if operation != "uninit" { + updated, err := loadManifest(agentDir) + if err != nil || updated.SettingsCreated || slices.Contains(updated.Files, "opencode.json") { + t.Fatalf("settings ownership retained: %+v, %v", updated, err) + } + if err := Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + } + got, err := os.ReadFile(path) + if customized { + if err != nil || string(got) != content { + t.Fatalf("custom settings changed: %q, %v", got, err) + } + } else if !os.IsNotExist(err) { + t.Fatalf("legacy settings retained: %v", err) + } + }) + } + } +} + +func TestOpenCodeSettingsSymlinkAndCleanupFailure(t *testing.T) { + setupOpenCodeTest(t) + agentDir := t.TempDir() + target := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(target, []byte(legacyOpenCodeSettings), 0644); err != nil { + t.Fatal(err) + } + path := filepath.Join(agentDir, "opencode.json") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + manifest := &Manifest{SettingsCreated: true, Files: []string{"opencode.json"}} + if err := migrateOpenCodeSettings(agentDir, manifest); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(path); err != nil { + t.Fatalf("settings symlink removed: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0755); err != nil { + t.Fatal(err) + } + manifest.SettingsCreated = true + manifest.Files = []string{"opencode.json"} + if err := migrateOpenCodeSettings(agentDir, manifest); err == nil { + t.Fatal("expected cleanup error for non-file") + } + if !manifest.SettingsCreated || !slices.Contains(manifest.Files, "opencode.json") { + t.Fatal("failed settings cleanup lost ownership") + } +} + +func TestOpenCodePluginTransitions(t *testing.T) { + for _, previous := range []IntegrationLevel{IntegrationHook, IntegrationAgentsContentLocal} { + for _, target := range []IntegrationLevel{IntegrationHook, IntegrationAgentsContentLocal, IntegrationAgentsContent, IntegrationOnDemand} { + for _, mode := range []config.ComponentMode{config.ComponentModeLocal, config.ComponentModeGlobal, config.ComponentModeDisabled} { + t.Run(string(previous)+"/"+string(target)+"/"+string(mode), func(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + if err := initAgent(project, internalagents.AgentOpenCode, previous); err != nil { + t.Fatal(err) + } + if err := config.Save(&config.Config{Agents: &config.AgentsConfig{PerAgent: map[string]*config.AgentComponentModes{ + "opencode": {Hooks: mode}, + }}}); err != nil { + t.Fatal(err) + } + if err := initAgent(project, internalagents.AgentOpenCode, target); err != nil { + t.Fatal(err) + } + if err := refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(project, ".opencode") + _, err := os.Stat(filepath.Join(agentDir, "plugins", "thts-integration.ts")) + want := mode == config.ComponentModeLocal && (target == IntegrationHook || target == IntegrationAgentsContentLocal) + if (err == nil) != want { + t.Fatalf("plugin exists = %t, want %t (%v)", err == nil, want, err) + } + if _, err := os.Stat(filepath.Join(agentDir, "AGENTS.local.md")); !os.IsNotExist(err) { + t.Fatalf("unsupported local fallback exists: %v", err) + } + }) + } + } + } +} + +func TestOpenCodeRefreshRemovesOwnedPluginOnly(t *testing.T) { + for _, owned := range []bool{false, true} { + t.Run(map[bool]string{false: "unowned", true: "owned"}[owned], func(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationAgentsContentLocal); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(project, ".opencode") + manifest, err := loadManifest(agentDir) + if err != nil { + t.Fatal(err) + } + if !owned { + manifest.Files = removeStringValue(manifest.Files, "plugins/thts-integration.ts") + if err := writeManifest(agentDir, manifest); err != nil { + t.Fatal(err) + } + } + if err := config.Save(&config.Config{Agents: &config.AgentsConfig{PerAgent: map[string]*config.AgentComponentModes{ + "opencode": {Hooks: config.ComponentModeGlobal}, + }}}); err != nil { + t.Fatal(err) + } + if err := refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + _, err = os.Stat(filepath.Join(agentDir, "plugins", "thts-integration.ts")) + if (err == nil) == owned { + t.Fatalf("plugin removal did not honor ownership: %v", err) + } + }) + } +} + +func TestRefreshCLISelectionAndDryRun(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + t.Chdir(project) + for _, agent := range []internalagents.AgentType{internalagents.AgentOpenCode, internalagents.AgentPi} { + if err := initAgent(project, agent, IntegrationHook); err != nil { + t.Fatal(err) + } + } + plugin := filepath.Join(project, ".opencode", "plugins", "thts-integration.ts") + pi := filepath.Join(project, ".pi", "extensions", "thts-integration.ts") + for _, path := range []string{plugin, pi} { + if err := os.WriteFile(path, []byte("stale"), 0644); err != nil { + t.Fatal(err) + } + } + initAgents, initRefresh, initDryRun = "opencode", true, true + if err := runAgentsInit(InitCmd, nil); err != nil { + t.Fatal(err) + } + for _, path := range []string{plugin, pi} { + if got, err := os.ReadFile(path); err != nil || string(got) != "stale" { + t.Fatalf("dry-run wrote %s: %q, %v", path, got, err) + } + } + initDryRun = false + if err := runAgentsInit(InitCmd, nil); err != nil { + t.Fatal(err) + } + want, err := thtsfiles.OpenCodePlugins.ReadFile("embedded/plugins/opencode/thts-integration.ts") + if err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(plugin); err != nil || string(got) != string(want) { + t.Fatalf("plugin not refreshed: %v", err) + } + if got, err := os.ReadFile(pi); err != nil || string(got) != "stale" { + t.Fatalf("refresh touched unselected Pi: %q, %v", got, err) + } +} + +func TestOpenCodeRemovalRetainsFailedCleanup(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationHook); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(project, ".opencode") + plugin := filepath.Join(agentDir, "plugins", "thts-integration.ts") + if err := os.Remove(plugin); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(plugin, 0755); err != nil { + t.Fatal(err) + } + blocker := filepath.Join(plugin, "blocker") + if err := os.WriteFile(blocker, []byte("preserve"), 0644); err != nil { + t.Fatal(err) + } + if err := Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}); err == nil { + t.Fatal("expected failed cleanup") + } + manifest, err := loadManifest(agentDir) + if err != nil || !slices.Equal(manifest.Files, []string{"plugins/thts-integration.ts"}) { + t.Fatalf("failed cleanup lost ownership or retained removed paths: %+v, %v", manifest, err) + } + if err := os.Remove(blocker); err != nil { + t.Fatal(err) + } + if err := Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } +} + +func TestOpenCodeRefreshMigratesLegacyConfigInstructions(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + agentDir := filepath.Join(project, ".opencode") + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatal(err) + } + settings := filepath.Join(project, "opencode.json") + if err := os.WriteFile(settings, []byte(`{"model":"user/model","instructions":[".opencode/thts-instructions.md","keep.md"]}`), 0644); err != nil { + t.Fatal(err) + } + manifest := &Manifest{ + Agent: "opencode", IntegrationLevel: IntegrationAgentsContent, + Modifications: ManifestModifications{InstructionsMD: &InstructionsMDModification{ + Path: settings, Action: "modified", IntegrationType: "config", ConfigKey: "instructions", + }}, + } + if err := writeManifest(agentDir, manifest); err != nil { + t.Fatal(err) + } + for range 2 { + if err := refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + } + got, err := os.ReadFile(settings) + if err != nil || strings.Contains(string(got), "thts-instructions.md") || !strings.Contains(string(got), "keep.md") || !strings.Contains(string(got), "user/model") { + t.Fatalf("config migration changed user values or retained obsolete entry: %s, %v", got, err) + } + policy, err := os.ReadFile(filepath.Join(project, "AGENTS.md")) + if err != nil || strings.Count(string(policy), ThtsMarkerStart) != 1 { + t.Fatalf("shared policy not migrated: %s, %v", policy, err) + } + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationOnDemand); err != nil { + t.Fatal(err) + } + policy, err = os.ReadFile(filepath.Join(project, "AGENTS.md")) + if err == nil && strings.Contains(string(policy), ThtsMarkerStart) { + t.Fatal("on-demand transition retained shared policy") + } +} diff --git a/internal/cmd/agents/uninit.go b/internal/cmd/agents/uninit.go index 2084915..ece821e 100644 --- a/internal/cmd/agents/uninit.go +++ b/internal/cmd/agents/uninit.go @@ -252,6 +252,9 @@ func buildRemovalPlan(projectDir string, agentType agents.AgentType) (*removalPl // Try to load manifest manifest, err := loadManifest(agentDir) if err != nil { + if agentType == agents.AgentOpenCode && !os.IsNotExist(err) { + return nil, err + } // Droid support has no legacy manifestless installations. Inferring // ownership from native Factory filenames could delete user resources. if agentType == agents.AgentDroid { @@ -269,6 +272,16 @@ func buildRemovalPlan(projectDir string, agentType agents.AgentType) (*removalPl } plan.manifest = manifest + if agentType == agents.AgentOpenCode { + remove, err := isLegacyOpenCodeSettings(agentDir, manifest) + if err != nil { + return nil, err + } + if !remove { + manifest.Files = removeStringValue(manifest.Files, "opencode.json") + manifest.SettingsCreated = false + } + } plan.filesToRemove = slices.Clone(manifest.Files) plan.modifications = manifest.Modifications @@ -572,6 +585,13 @@ func isPathSafeForRemoval(relativePath, baseDir string) bool { // performRemoval removes all thts integration files and reverts modifications. func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) error { cfg := agents.GetConfig(plan.agentType) + retainProgress := plan.manifest != nil && (plan.agentType == agents.AgentDroid || plan.agentType == agents.AgentOpenCode) + if plan.agentType == agents.AgentOpenCode && plan.manifest != nil { + if err := migrateOpenCodeSettings(plan.agentDir, plan.manifest); err != nil { + return err + } + plan.filesToRemove = removeStringValue(plan.filesToRemove, "opencode.json") + } var warnings []string droidHooksRemoved := false if plan.agentType == agents.AgentDroid && plan.modifications.Hooks != nil { @@ -590,7 +610,7 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) } // Validate path is safe to remove if !isPathSafeForRemoval(f, plan.agentDir) { - if plan.agentType == agents.AgentDroid { + if retainProgress { warnings = append(warnings, fmt.Sprintf("refused to remove unsafe manifest path %s", f)) } continue @@ -602,7 +622,7 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) } else if err == nil { fmt.Println(ui.SuccessF("Removed %s", f)) } - if plan.agentType == agents.AgentDroid && (err == nil || os.IsNotExist(err)) { + if retainProgress && (err == nil || os.IsNotExist(err)) { plan.manifest.Files = removeStringValue(plan.manifest.Files, f) } } @@ -664,7 +684,7 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) if err := removeThtsIntegration(plan.modifications.InstructionsMD, plan.agentType, plan.projectDir, removingAgents); err != nil { warnings = append(warnings, fmt.Sprintf("failed to clean instruction file: %v", err)) } else { - if plan.agentType == agents.AgentDroid { + if retainProgress { plan.manifest.Modifications.InstructionsMD = nil } fmt.Println(ui.Success("Removed thts integration from instruction file")) @@ -680,11 +700,11 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) } else if removed { fmt.Println(ui.SuccessF("Removed %s from .gitignore", pattern)) } - if plan.agentType == agents.AgentDroid && err == nil { + if retainProgress && err == nil { plan.manifest.Modifications.Gitignore.Patterns = removeStringValue(plan.manifest.Modifications.Gitignore.Patterns, pattern) } } - if plan.agentType == agents.AgentDroid && len(plan.manifest.Modifications.Gitignore.Patterns) == 0 { + if retainProgress && len(plan.manifest.Modifications.Gitignore.Patterns) == 0 { plan.manifest.Modifications.Gitignore = nil } } @@ -694,7 +714,7 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) for _, w := range warnings { fmt.Printf(" %s\n", w) } - if plan.agentType == agents.AgentDroid { + if retainProgress { if err := writeManifest(plan.agentDir, plan.manifest); err != nil { warnings = append(warnings, fmt.Sprintf("failed to retain manifest: %v", err)) } diff --git a/mise.lock b/mise.lock index 589aa5f..1892802 100644 --- a/mise.lock +++ b/mise.lock @@ -1,6 +1,6 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html -lockfile_version = 1 +lockfile_version = 2 [[tools."aqua:goreleaser/goreleaser"]] version = "2.18.1" @@ -25,12 +25,24 @@ url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/gorele url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377081" provenance = "github-attestations" +[tools."aqua:goreleaser/goreleaser"."platforms.linux-x64-baseline"] +checksum = "sha256:0c6122af0ad8fd65638889bf7d3757148b2f80eeff9f079682f0655df66ec8e8" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Linux_x86_64.tar.gz" +url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377081" +provenance = "github-attestations" + [tools."aqua:goreleaser/goreleaser"."platforms.linux-x64-musl"] checksum = "sha256:0c6122af0ad8fd65638889bf7d3757148b2f80eeff9f079682f0655df66ec8e8" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Linux_x86_64.tar.gz" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377081" provenance = "github-attestations" +[tools."aqua:goreleaser/goreleaser"."platforms.linux-x64-musl-baseline"] +checksum = "sha256:0c6122af0ad8fd65638889bf7d3757148b2f80eeff9f079682f0655df66ec8e8" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Linux_x86_64.tar.gz" +url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377081" +provenance = "github-attestations" + [tools."aqua:goreleaser/goreleaser"."platforms.macos-arm64"] checksum = "sha256:d0af1f86c439a59ab06c54c505f66fea1a166093dc91a7c0c8aebe3da1cc13b7" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Darwin_all.tar.gz" @@ -43,12 +55,24 @@ url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/gorele url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377101" provenance = "github-attestations" +[tools."aqua:goreleaser/goreleaser"."platforms.macos-x64-baseline"] +checksum = "sha256:d0af1f86c439a59ab06c54c505f66fea1a166093dc91a7c0c8aebe3da1cc13b7" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Darwin_all.tar.gz" +url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377101" +provenance = "github-attestations" + [tools."aqua:goreleaser/goreleaser"."platforms.windows-x64"] checksum = "sha256:dbb3112f619c4827311b726b3c98047bce69af1ecee99984d55d450c04c471fc" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Windows_x86_64.zip" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377158" provenance = "github-attestations" +[tools."aqua:goreleaser/goreleaser"."platforms.windows-x64-baseline"] +checksum = "sha256:dbb3112f619c4827311b726b3c98047bce69af1ecee99984d55d450c04c471fc" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Windows_x86_64.zip" +url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377158" +provenance = "github-attestations" + [[tools."aqua:int128/ghcp"]] version = "1.15.3" backend = "aqua:int128/ghcp" @@ -69,11 +93,21 @@ checksum = "sha256:537b71c1382fc95c73702d4ba0fc1a2465842a1c20f9987d75b5c7581ba11 url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_linux_amd64.zip" url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091706" +[tools."aqua:int128/ghcp"."platforms.linux-x64-baseline"] +checksum = "sha256:537b71c1382fc95c73702d4ba0fc1a2465842a1c20f9987d75b5c7581ba115ea" +url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_linux_amd64.zip" +url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091706" + [tools."aqua:int128/ghcp"."platforms.linux-x64-musl"] checksum = "sha256:537b71c1382fc95c73702d4ba0fc1a2465842a1c20f9987d75b5c7581ba115ea" url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_linux_amd64.zip" url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091706" +[tools."aqua:int128/ghcp"."platforms.linux-x64-musl-baseline"] +checksum = "sha256:537b71c1382fc95c73702d4ba0fc1a2465842a1c20f9987d75b5c7581ba115ea" +url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_linux_amd64.zip" +url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091706" + [tools."aqua:int128/ghcp"."platforms.macos-arm64"] checksum = "sha256:83c8fb1e167adbfbe767288e9679d1a34b8a59241e56f84379ff9b9e1ae709f2" url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_darwin_arm64.zip" @@ -84,11 +118,21 @@ checksum = "sha256:5553fba0e479ec6ae55a479399e64fe78a1b0242e85b8be2c65e2ba6a1d4a url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_darwin_amd64.zip" url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091693" +[tools."aqua:int128/ghcp"."platforms.macos-x64-baseline"] +checksum = "sha256:5553fba0e479ec6ae55a479399e64fe78a1b0242e85b8be2c65e2ba6a1d4aef9" +url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_darwin_amd64.zip" +url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091693" + [tools."aqua:int128/ghcp"."platforms.windows-x64"] checksum = "sha256:42b101dfaa7e7a66585ecbf262c2c2111bbd00e7cdb063b554bc7df467744cb1" url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_windows_amd64.zip" url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091732" +[tools."aqua:int128/ghcp"."platforms.windows-x64-baseline"] +checksum = "sha256:42b101dfaa7e7a66585ecbf262c2c2111bbd00e7cdb063b554bc7df467744cb1" +url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_windows_amd64.zip" +url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091732" + [[tools.bun]] version = "1.4.2" backend = "core:bun" @@ -155,10 +199,18 @@ url = "https://dl.google.com/go/go1.27.1.linux-arm64.tar.gz" checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" +[tools.go."platforms.linux-x64-baseline"] +checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" +url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" + [tools.go."platforms.linux-x64-musl"] checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" +[tools.go."platforms.linux-x64-musl-baseline"] +checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" +url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" + [tools.go."platforms.macos-arm64"] checksum = "sha256:ee215d57e0ec269c60cc9ceca68e6bda321ba9ee5afe24f4b0988703c2d87d12" url = "https://dl.google.com/go/go1.27.1.darwin-arm64.tar.gz" @@ -167,11 +219,20 @@ url = "https://dl.google.com/go/go1.27.1.darwin-arm64.tar.gz" checksum = "sha256:8f8f52c6649542cf027bbc9b9c68d1ec042f9f34808a40413f0b8b3f66f3caa4" url = "https://dl.google.com/go/go1.27.1.darwin-amd64.tar.gz" +[tools.go."platforms.macos-x64-baseline"] +checksum = "sha256:8f8f52c6649542cf027bbc9b9c68d1ec042f9f34808a40413f0b8b3f66f3caa4" +url = "https://dl.google.com/go/go1.27.1.darwin-amd64.tar.gz" + [tools.go."platforms.windows-x64"] checksum = "sha256:a3911b5e0e1b1053f25ed0675f4c1c6aad1e2bfcf253df2b9be4caabd2edd95d" url = "https://dl.google.com/go/go1.27.1.windows-amd64.zip" +[tools.go."platforms.windows-x64-baseline"] +checksum = "sha256:a3911b5e0e1b1053f25ed0675f4c1c6aad1e2bfcf253df2b9be4caabd2edd95d" +url = "https://dl.google.com/go/go1.27.1.windows-amd64.zip" + [[tools.trunk]] version = "1.3.4" +aube = { path = ".mise/locks/trunk/1.3.4", digest = "sha256:af09c5952cbbdf895bb558cb7ac8f0c2d09885d497419c257dbe0a6efab81f8f" } backend = "npm:@trunkio/launcher" specifiers = ["1.3.4"] diff --git a/mise.toml b/mise.toml index c0628a5..e0419b8 100644 --- a/mise.toml +++ b/mise.toml @@ -25,6 +25,7 @@ run = """ go test -tags=integration ./internal/cmd/... scripts/verify-pi-integration.sh scripts/verify-droid-integration.sh +scripts/verify-opencode-integration.sh """ [tasks.test-all] @@ -33,6 +34,7 @@ run = """ go test -tags=integration ./internal/cmd/... scripts/verify-pi-integration.sh scripts/verify-droid-integration.sh +scripts/verify-opencode-integration.sh """ [tasks.test-coverage] diff --git a/scripts/verify-opencode-integration.sh b/scripts/verify-opencode-integration.sh new file mode 100755 index 0000000..01b17de --- /dev/null +++ b/scripts/verify-opencode-integration.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +pass() { printf 'PASS: %s\n' "$1"; } +fail() { printf 'FAIL: %s\n' "$1"; exit 1; } +require_file() { [[ -f "$1" ]] || fail "expected file: $1"; } +require_absent() { [[ ! -e "$1" ]] || fail "expected absent path: $1"; } + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(git -C "$script_dir/.." rev-parse --show-toplevel) +temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/thts-opencode-verify.XXXXXX") +trap 'rm -rf "$temp_dir"' EXIT +binary="$temp_dir/thts" +go -C "$repo_root" build -o "$binary" ./cmd/thts + +export HOME="$temp_dir/home" +export XDG_CONFIG_HOME="$temp_dir/config" +export XDG_STATE_HOME="$temp_dir/state" +export THTS_CONFIG_PATH="$XDG_CONFIG_HOME/thts/config.yaml" +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0 +mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" "$temp_dir/project" +cd "$temp_dir/project" + +output=$("$binary" init agents --agents opencode --with-settings --dry-run) +[[ "$output" == *"has no thts-managed settings"* ]] || fail "dry-run omitted settings ownership" +require_absent .opencode +pass "fresh dry-run creates nothing" + +mkdir -p .opencode +printf '{"model":"user/model"}\n' >.opencode/opencode.json +cp .opencode/opencode.json "$temp_dir/user-settings" +"$binary" init agents --agents opencode --with-settings >/dev/null +for path in plugins/thts-integration.ts skills/thts-integrate/SKILL.md commands/thts-handoff.md commands/thts-resume.md agents/thoughts-locator.md agents/thoughts-analyzer.md; do + require_file ".opencode/$path" +done +cmp .opencode/opencode.json "$temp_dir/user-settings" +require_absent .opencode/AGENTS.local.md +pass "project install preserves settings and installs all resources" + +"$binary" init agents --agents pi --force >/dev/null +printf 'stale opencode\n' >.opencode/plugins/thts-integration.ts +printf 'stale pi\n' >.pi/extensions/thts-integration.ts +cp .opencode/thts-manifest.json "$temp_dir/manifest" +output=$("$binary" init agents --agents opencode --refresh --dry-run) +[[ "$output" == *"Would refresh: opencode"* ]] || fail "refresh ignored agent selection" +cmp .opencode/thts-manifest.json "$temp_dir/manifest" +[[ $(<.opencode/plugins/thts-integration.ts) == "stale opencode" ]] || fail "dry-run changed plugin" +"$binary" init agents --agents opencode --refresh >/dev/null +cmp .opencode/plugins/thts-integration.ts "$repo_root/embedded/plugins/opencode/thts-integration.ts" +[[ $(<.pi/extensions/thts-integration.ts) == "stale pi" ]] || fail "refresh changed another agent" +pass "targeted refresh and refresh dry-run honor selection" + +"$binary" init agents --agents pi --global=all >/dev/null +"$binary" init agents --agents opencode --global=all >/dev/null +global="$XDG_CONFIG_HOME/opencode" +require_file "$global/plugins/thts-integration.ts" +require_absent "$global/opencode.json" +"$binary" init agents --agents opencode --refresh >/dev/null +require_absent .opencode/plugins/thts-integration.ts +require_file "$global/plugins/thts-integration.ts" +pass "global transition removes the owned project plugin" + +printf 'unrelated plugin\n' >"$global/plugins/user.ts" +"$binary" uninit agents --agents opencode --global --force >/dev/null +require_absent "$global/plugins/thts-integration.ts" +require_file "$global/plugins/user.ts" +require_file "$HOME/.pi/agent/extensions/thts-integration.ts" +pass "global removal preserves unrelated files and other agents" + +"$binary" uninit agents --agents opencode --force >/dev/null +require_absent .opencode/thts-manifest.json +require_absent .opencode/skills/thts-integrate/SKILL.md +cmp .opencode/opencode.json "$temp_dir/user-settings" +require_file .pi/extensions/thts-integration.ts +pass "project removal preserves user settings and other agents"