geneseed 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +938 -0
- package/DESIGN.md +1423 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +48 -0
- package/README.md +336 -0
- package/SETUP.md +1027 -0
- package/SHIPPED.md +70 -0
- package/adapters/bob/README.md +100 -0
- package/adapters/claude-code/README.md +101 -0
- package/adapters/claude-code/settings.json +47 -0
- package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
- package/adapters/copilot/README.md +76 -0
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
- package/adapters/opencode/README.md +582 -0
- package/adapters/opencode/agents/orchestrator.md +25 -0
- package/adapters/opencode/opencode.json +44 -0
- package/adapters/opencode/plugins/geneseed-activity.js +430 -0
- package/adapters/opencode/plugins/geneseed-context.js +876 -0
- package/adapters/opencode/plugins/geneseed-guard.js +290 -0
- package/adapters/opencode/plugins/geneseed-learn.js +396 -0
- package/adapters/opencode/plugins/geneseed-notify.js +142 -0
- package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
- package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
- package/adapters/opencode/workflows/_runtime.js +277 -0
- package/adapters/opencode/workflows/council.js +64 -0
- package/adapters/opencode/workflows/dispatch.js +89 -0
- package/adapters/opencode/workflows/research-plan-implement.js +43 -0
- package/adapters/opencode/workflows/review.js +57 -0
- package/bin/geneseed-cli.mjs +412 -0
- package/bin/geneseed-hook.mjs +129 -0
- package/bin/geneseed.mjs +1086 -0
- package/docs/declined.md +149 -0
- package/docs/limits.md +270 -0
- package/docs/opencode-plugin-setup.md +64 -0
- package/docs/token-footprint.md +68 -0
- package/docs/web/_groups.json +45 -0
- package/docs/web/about.md +6 -0
- package/docs/web/adapters-claude-code.md +8 -0
- package/docs/web/adapters-opencode-loads.md +8 -0
- package/docs/web/adapters-opencode-spec.md +8 -0
- package/docs/web/adapters-opencode.md +8 -0
- package/docs/web/agents.md +8 -0
- package/docs/web/authoring.md +11 -0
- package/docs/web/autostart.md +9 -0
- package/docs/web/claude-hooks.md +36 -0
- package/docs/web/cli.md +6 -0
- package/docs/web/collaboration.md +27 -0
- package/docs/web/configure-wiki.md +9 -0
- package/docs/web/design.md +7 -0
- package/docs/web/env-knobs.md +9 -0
- package/docs/web/exclusions.md +26 -0
- package/docs/web/first-session.md +28 -0
- package/docs/web/footprint.md +31 -0
- package/docs/web/foreman-mode.md +33 -0
- package/docs/web/glossary.md +6 -0
- package/docs/web/harnesses.md +28 -0
- package/docs/web/headless.md +9 -0
- package/docs/web/ingest-docs.md +9 -0
- package/docs/web/install-paths.md +9 -0
- package/docs/web/install-quick.md +35 -0
- package/docs/web/lsp-overview.md +37 -0
- package/docs/web/mcp-claude-code.md +10 -0
- package/docs/web/mcp-filesystem.md +9 -0
- package/docs/web/mcp-gitlab.md +9 -0
- package/docs/web/mcp-markitdown.md +9 -0
- package/docs/web/mcp-overview.md +34 -0
- package/docs/web/mcp-trouble.md +9 -0
- package/docs/web/mcp-verify.md +9 -0
- package/docs/web/memory.md +7 -0
- package/docs/web/migrate.md +39 -0
- package/docs/web/model.md +20 -0
- package/docs/web/notebook.md +7 -0
- package/docs/web/pipelines.md +24 -0
- package/docs/web/plugin-activity.md +23 -0
- package/docs/web/plugin-context.md +26 -0
- package/docs/web/plugin-guard.md +24 -0
- package/docs/web/plugin-learn.md +27 -0
- package/docs/web/plugin-notify.md +24 -0
- package/docs/web/plugin-ponytail.md +23 -0
- package/docs/web/plugin-setup.md +7 -0
- package/docs/web/plugin-workflow.md +24 -0
- package/docs/web/plugins.md +17 -0
- package/docs/web/project-context.md +9 -0
- package/docs/web/rules.md +8 -0
- package/docs/web/run-anywhere.md +9 -0
- package/docs/web/self-improve.md +8 -0
- package/docs/web/setup-choices.md +39 -0
- package/docs/web/skills.md +8 -0
- package/docs/web/themes-author.md +8 -0
- package/docs/web/themes.md +9 -0
- package/docs/web/token-footprint.md +7 -0
- package/docs/web/trouble.md +38 -0
- package/docs/web/upgrade.md +9 -0
- package/docs/web/verify.md +30 -0
- package/docs/web/wiki.md +7 -0
- package/docs/web/worktree.md +10 -0
- package/docs/web-ui.md +144 -0
- package/docs/wiki.md +43 -0
- package/geneseed +39 -0
- package/geneseed.cmd +34 -0
- package/harness.config.json +6 -0
- package/js/anim.mjs +326 -0
- package/js/catalog.mjs +112 -0
- package/js/checkout.mjs +88 -0
- package/js/cli-table.json +1034 -0
- package/js/cli.mjs +498 -0
- package/js/diff.mjs +384 -0
- package/js/doctor.mjs +1364 -0
- package/js/emit.mjs +1373 -0
- package/js/excludes.mjs +271 -0
- package/js/generate.mjs +379 -0
- package/js/hooks.mjs +1026 -0
- package/js/hosts.mjs +253 -0
- package/js/installs.mjs +378 -0
- package/js/inventory.mjs +175 -0
- package/js/lib/fs.mjs +913 -0
- package/js/lib/proc.mjs +28 -0
- package/js/lib/udiff.mjs +275 -0
- package/js/link.mjs +286 -0
- package/js/mcp.mjs +335 -0
- package/js/memory.mjs +108 -0
- package/js/menu.mjs +118 -0
- package/js/migrate.mjs +251 -0
- package/js/native.mjs +426 -0
- package/js/opencode.mjs +325 -0
- package/js/registry.mjs +110 -0
- package/js/render.mjs +282 -0
- package/js/settings.mjs +924 -0
- package/js/setup.mjs +492 -0
- package/js/status.mjs +281 -0
- package/js/themes.mjs +195 -0
- package/js/tui.mjs +444 -0
- package/js/uninstall.mjs +1137 -0
- package/js/update.mjs +910 -0
- package/js/web/actions.mjs +868 -0
- package/js/web/activity.mjs +284 -0
- package/js/web/api.mjs +873 -0
- package/js/web/docs.mjs +531 -0
- package/js/web/graph.mjs +0 -0
- package/js/web/jobs.mjs +480 -0
- package/js/web/server.mjs +1135 -0
- package/package.json +54 -0
- package/registry.json +474 -0
- package/src/AGENT.md.tmpl +390 -0
- package/src/agents/_template.md +76 -0
- package/src/agents/advocate.md +36 -0
- package/src/agents/architect.md +55 -0
- package/src/agents/developer.md +66 -0
- package/src/agents/docs.md +54 -0
- package/src/agents/empiricist.md +39 -0
- package/src/agents/explorer.md +54 -0
- package/src/agents/framer.md +36 -0
- package/src/agents/historian.md +51 -0
- package/src/agents/operator.md +37 -0
- package/src/agents/pragmatist.md +37 -0
- package/src/agents/reviewer.md +52 -0
- package/src/agents/security.md +57 -0
- package/src/agents/skeptic.md +48 -0
- package/src/agents/steward.md +36 -0
- package/src/agents/tester.md +54 -0
- package/src/agents/user-advocate.md +36 -0
- package/src/agents/visionary.md +36 -0
- package/src/laws/universal.md +387 -0
- package/src/memory/README.md +109 -0
- package/src/memory/gitignore +6 -0
- package/src/modes/README.md +26 -0
- package/src/modes/direct.md +2 -0
- package/src/modes/foreman.md +19 -0
- package/src/notebook/README.md +39 -0
- package/src/notebook/gitignore +7 -0
- package/src/postures/README.md +31 -0
- package/src/postures/artisan.md +14 -0
- package/src/postures/assistant.md +12 -0
- package/src/postures/expert.md +11 -0
- package/src/postures/mentor.md +11 -0
- package/src/postures/peer.md +12 -0
- package/src/skills/_template.md +47 -0
- package/src/skills/brainstorm.md +28 -0
- package/src/skills/clarify.md +28 -0
- package/src/skills/codebase-design.md +44 -0
- package/src/skills/commit.md +48 -0
- package/src/skills/council.md +34 -0
- package/src/skills/crash-course.md +28 -0
- package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
- package/src/skills/daydream/SKILL.md +74 -0
- package/src/skills/daydream/VENDOR.md +25 -0
- package/src/skills/daydream/critic-prompt.md +38 -0
- package/src/skills/daydream/instructions.md +422 -0
- package/src/skills/daydream/synthesizer-prompt.md +41 -0
- package/src/skills/debug.md +37 -0
- package/src/skills/decode.md +28 -0
- package/src/skills/develop.md +29 -0
- package/src/skills/document-project.md +79 -0
- package/src/skills/domain-modeling.md +38 -0
- package/src/skills/drill.md +28 -0
- package/src/skills/feynman.md +26 -0
- package/src/skills/forge-mcp.md +48 -0
- package/src/skills/fresh-eyes.md +29 -0
- package/src/skills/frontend-design.md +58 -0
- package/src/skills/gap-detector.md +27 -0
- package/src/skills/geneseed-code-review.md +33 -0
- package/src/skills/geneseed.md +29 -0
- package/src/skills/git-archaeology.md +47 -0
- package/src/skills/git-rescue.md +68 -0
- package/src/skills/handoff.md +29 -0
- package/src/skills/herdr.md +164 -0
- package/src/skills/ingest.md +49 -0
- package/src/skills/learning-path.md +26 -0
- package/src/skills/migrate.md +45 -0
- package/src/skills/opencode-theme.md +52 -0
- package/src/skills/parallel-agents.md +28 -0
- package/src/skills/pipeline.md +52 -0
- package/src/skills/plan.md +45 -0
- package/src/skills/ponytail.md +29 -0
- package/src/skills/profile.md +58 -0
- package/src/skills/prose.md +42 -0
- package/src/skills/prototype.md +44 -0
- package/src/skills/react-view-transitions/AGENTS.md +955 -0
- package/src/skills/react-view-transitions/README.md +42 -0
- package/src/skills/react-view-transitions/SKILL.md +320 -0
- package/src/skills/react-view-transitions/VENDOR.md +16 -0
- package/src/skills/react-view-transitions/metadata.json +12 -0
- package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
- package/src/skills/react-view-transitions/references/implementation.md +182 -0
- package/src/skills/react-view-transitions/references/nextjs.md +176 -0
- package/src/skills/react-view-transitions/references/patterns.md +262 -0
- package/src/skills/refactor.md +32 -0
- package/src/skills/release.md +43 -0
- package/src/skills/repo-map.md +36 -0
- package/src/skills/research.md +42 -0
- package/src/skills/review-response.md +36 -0
- package/src/skills/roast-me.md +28 -0
- package/src/skills/rule.md +100 -0
- package/src/skills/ship.md +49 -0
- package/src/skills/tdd.md +31 -0
- package/src/skills/tickets.md +44 -0
- package/src/skills/token-report/SKILL.md +76 -0
- package/src/skills/token-report/VENDOR.md +23 -0
- package/src/skills/token-report/scripts/token_report.mjs +1301 -0
- package/src/skills/wayfinder.md +49 -0
- package/src/skills/wiki.md +68 -0
- package/src/skills/workflow.md +38 -0
- package/themes/README.md +69 -0
- package/themes/_TEMPLATE.json +147 -0
- package/themes/biker.json +147 -0
- package/themes/commentator.json +147 -0
- package/themes/cyberpunk.json +147 -0
- package/themes/gamer.json +147 -0
- package/themes/imperial.json +147 -0
- package/themes/joker.json +147 -0
- package/themes/marvin.json +147 -0
- package/themes/mean.json +147 -0
- package/themes/military.json +147 -0
- package/themes/neutral.json +147 -0
- package/themes/opencode/README.md +58 -0
- package/themes/opencode/catppuccin.json +27 -0
- package/themes/opencode/dracula.json +27 -0
- package/themes/opencode/everforest.json +27 -0
- package/themes/opencode/gruvbox.json +27 -0
- package/themes/opencode/kanagawa.json +27 -0
- package/themes/opencode/neon.json +27 -0
- package/themes/opencode/nord.json +27 -0
- package/themes/opencode/rosepine.json +27 -0
- package/themes/opencode/tokyonight.json +27 -0
- package/themes/pirate.json +147 -0
- package/themes/sports.json +147 -0
- package/themes/verstappen.json +147 -0
- package/themes/wizard.json +147 -0
- package/web/dist/assets/About-DX1-VTBP.js +1 -0
- package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
- package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
- package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
- package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
- package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
- package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
- package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
- package/web/dist/assets/Library-BEZscyaQ.js +1 -0
- package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
- package/web/dist/assets/Profile-DydukTxP.js +1 -0
- package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
- package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
- package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
- package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
- package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
- package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
- package/web/dist/assets/index-B1PIx7RM.js +1 -0
- package/web/dist/assets/index-BnTQUSUz.js +12 -0
- package/web/dist/assets/index-Ck2iCdaC.js +2 -0
- package/web/dist/assets/index-DUCsDv7z.css +1 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
- package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
- package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
- package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
- package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
- package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
- package/web/dist/assets/react-DOpiTn5v.js +32 -0
- package/web/dist/assets/roman-DkYN0LX9.js +1 -0
- package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
- package/web/dist/icon-maskable.svg +10 -0
- package/web/dist/icon.svg +8 -0
- package/web/dist/index.html +21 -0
- package/web/dist/manifest.webmanifest +14 -0
- package/web/dist/sw.js +40 -0
- package/web/src/pages/Laws.jsx +230 -0
package/js/uninstall.mjs
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `harness uninstall` — the first verb in this port that DELETES.
|
|
3
|
+
*
|
|
4
|
+
* Thirteen subcommands crossed before this one and every one of them either writes or
|
|
5
|
+
* reports. `uninstall` removes a deployed install: the manifest's owned files, the empty
|
|
6
|
+
* directories they leave behind, the settings-file wiring, the managed block in the
|
|
7
|
+
* instruction carrier, the markers, and the registry row that the markers' absence prunes.
|
|
8
|
+
* That changes what the GATE has to prove, in three ways, and all three were settled before
|
|
9
|
+
* a line of this file was written.
|
|
10
|
+
*
|
|
11
|
+
* 1. THE GATE HAS TO PROVE WHAT SURVIVED, not only what went. `golden._snapshot` walks
|
|
12
|
+
* FILES, so a cell that seeds an install and uninstalls it reports the deletions
|
|
13
|
+
* perfectly — and says nothing at all about the user's own file in the same directory.
|
|
14
|
+
* P5c's rule applies directly: an ownership gate needs a positive control beside it. Every
|
|
15
|
+
* cell here that names a deleted path also seeds an UNOWNED neighbour and names it in
|
|
16
|
+
* `expect_files`, so a port that deleted by glob instead of by manifest fails.
|
|
17
|
+
* 2. AN EMPTY DIRECTORY WAS INVISIBLE TO EVERY CELL. The ancestor-climb prune is a quarter of
|
|
18
|
+
* this verb and `_snapshot` could not see it: a port that unlinked the files and left
|
|
19
|
+
* `skills/<name>/` behind was byte-identical everywhere. Closed by a `<dirs>` column in
|
|
20
|
+
* `tests/harness_golden.py`'s snapshot rather than a per-cell expectation, because one
|
|
21
|
+
* entry closes it for all 219 cells written before it existed — `cmdTheme`'s statement
|
|
22
|
+
* order sat in the same hole. The ABSOLUTE half is a different axis and is the sixth
|
|
23
|
+
* expectation kind, `expect_absent_files`.
|
|
24
|
+
* 3. `--archive-memory` TOUCHES THE MEMORY STORE, the one thing here a user cannot rebuild.
|
|
25
|
+
* It is why `_archive_store` MOVES and never deletes, and why the Python's `memory=delete`
|
|
26
|
+
* disposition has no flag reaching it from the CLI. This port keeps both properties.
|
|
27
|
+
*
|
|
28
|
+
* AND SINCE P6i, THE REVERSIBLE SIBLINGS ARE HERE TOO. `_install_deactivate` /
|
|
29
|
+
* `_install_reactivate` (and their Claude-style twins) were left out with a due date: "an
|
|
30
|
+
* unported deactivate has no partition to be part of until P6 brings its caller". P6f found
|
|
31
|
+
* the caller — `api_install_toggle` — and P6i ports it, so the pair arrives in the module
|
|
32
|
+
* whose thirteen private helpers it shares (`pruneAncestors`, `settingsFile`, `claudeMdPath`,
|
|
33
|
+
* `managedOf`, `ownedOf`, `installAgentEntry`, `unmergeOpencodeJson`, `rmtreeQuiet`, …),
|
|
34
|
+
* exactly as `_harness_mcp.py` keeps them in one file. Deactivate is `_uninstall_global` with
|
|
35
|
+
* `move` where it has `unlink`, plus an inverse; splitting them would have duplicated the
|
|
36
|
+
* owned-file walk and the ancestor prune a fourth time.
|
|
37
|
+
*
|
|
38
|
+
* NO SPAWN. `bin/geneseed-cli.mjs`'s `child_process` allow-list names `js/doctor.mjs` and one
|
|
39
|
+
* argv, and this module does not extend it: every operation here is a filesystem call. That
|
|
40
|
+
* matters more than usual for this verb — shelling the uninstall out to a Python interpreter
|
|
41
|
+
* would be byte-identical in every cell of the matrix, and the allow-list plus
|
|
42
|
+
* `test_uninstall_removes_an_install_with_no_python_on_path` are what refute it.
|
|
43
|
+
*/
|
|
44
|
+
import {
|
|
45
|
+
existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync,
|
|
46
|
+
unlinkSync,
|
|
47
|
+
} from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
|
|
50
|
+
import { hookRunnerEntry } from '../bin/geneseed.mjs';
|
|
51
|
+
import { confirm } from './setup.mjs';
|
|
52
|
+
import {
|
|
53
|
+
claudeCfg, claudeReadManifest, emitHostScopeOf, installKind, installState,
|
|
54
|
+
registeredTargets, readMaybe, DISABLED_STASH,
|
|
55
|
+
} from './installs.mjs';
|
|
56
|
+
import {
|
|
57
|
+
GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, pyResolve,
|
|
58
|
+
} from './hosts.mjs';
|
|
59
|
+
import { mcpCommented } from './mcp.mjs';
|
|
60
|
+
import {
|
|
61
|
+
managedBlockRead, managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
|
|
62
|
+
opencodeTarget, readJsonc, settingsIntegrityCheck, wireClaudeExcludes,
|
|
63
|
+
unwireClaudeExcludes, unwireClaudeSettings,
|
|
64
|
+
} from './settings.mjs';
|
|
65
|
+
import {
|
|
66
|
+
comparePaths, indexOfEq, jsonDumps, jsonDumpsIndent, pyEq, pyIsAbsolute, pyPrint,
|
|
67
|
+
pyPrintErr, readText, within, writeText,
|
|
68
|
+
} from './lib/fs.mjs';
|
|
69
|
+
|
|
70
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
71
|
+
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
72
|
+
const hostSpec = (host) => HOSTS.find((h) => h.host === host);
|
|
73
|
+
|
|
74
|
+
/** The Claude-STYLE hosts — one manifest shape, one reversal. Spelled once. */
|
|
75
|
+
const CLAUDE_STYLE = ['claude', 'bob', 'copilot'];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `shutil.rmtree(p, ignore_errors=True)`.
|
|
79
|
+
*
|
|
80
|
+
* `force: true` already swallows ENOENT; the try/catch is for everything else, which is what
|
|
81
|
+
* `ignore_errors` swallows and `rmSync` throws. The behaviour this preserves is the one
|
|
82
|
+
* `_install_uninstall` step 3 was written around: an ignored error can leave the directory
|
|
83
|
+
* standing while the call reports success, which is why the survivors sweep exists at all.
|
|
84
|
+
*
|
|
85
|
+
* THE DIRECTORY TEST IS THE PORT, NOT A PRECAUTION, and it was missing until P3's unit tier
|
|
86
|
+
* asked for it. `shutil.rmtree` REFUSES anything that is not a real directory: handed a file it
|
|
87
|
+
* raises `NotADirectoryError`, handed a symlink it raises outright ("Cannot call rmtree on a
|
|
88
|
+
* symbolic link"), and `ignore_errors=True` turns both into a no-op that leaves the thing on
|
|
89
|
+
* disk. `rmSync(recursive, force)` deletes all three without complaint. The gap is destructive
|
|
90
|
+
* in exactly one direction — the port removes what the reference preserves — and it is
|
|
91
|
+
* REACHABLE: `installDeactivate`'s rollback calls this on `root/.geneseed-disabled`, so a user
|
|
92
|
+
* with a plain FILE of that name at their install root loses it on the port and keeps it on the
|
|
93
|
+
* reference. No cell can see this; nothing plants a file where a stash directory belongs.
|
|
94
|
+
*
|
|
95
|
+
* `lstatSync`, not `statSync`, so a symlink to a directory is refused as the reference refuses
|
|
96
|
+
* it rather than followed.
|
|
97
|
+
*/
|
|
98
|
+
function rmtreeQuiet(p) {
|
|
99
|
+
try {
|
|
100
|
+
if (!lstatSync(p).isDirectory()) return;
|
|
101
|
+
rmSync(p, { recursive: true, force: true });
|
|
102
|
+
} catch { /* ignore_errors=True, and the missing-path case lands here too */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `Path.unlink()` inside a `try: ... except OSError: pass`. */
|
|
106
|
+
function unlinkQuiet(p) {
|
|
107
|
+
try { unlinkSync(p); } catch { /* as the Python's bare except OSError */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `Path.rmdir()` — only ever called on a directory this code has just found empty.
|
|
112
|
+
*
|
|
113
|
+
* `rmdirSync`, not `rmSync(p, { recursive: false })`: the latter throws EISDIR on a
|
|
114
|
+
* DIRECTORY, which is the only thing either caller ever hands it. Caught by the first
|
|
115
|
+
* cross-implementation run of the group — every prune reported the directory as an owned
|
|
116
|
+
* file that could not be removed, and the whole matrix came back INCOMPLETE.
|
|
117
|
+
*/
|
|
118
|
+
function rmdirQuiet(p) {
|
|
119
|
+
try { rmdirSync(p); } catch { /* as the Python's except OSError */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `not any(d.iterdir())` — and False for a path that is not a readable directory. */
|
|
123
|
+
function isEmptyDir(p) {
|
|
124
|
+
try { return readdirSync(p).length === 0; } catch { return false; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The ancestor climb `_uninstall_global`, `_claude_uninstall` and
|
|
129
|
+
* `_opencode_project_uninstall` each write out inline, in the same four lines.
|
|
130
|
+
*
|
|
131
|
+
* Walking UP rather than clearing the immediate parent is the behaviour: it clears the nested
|
|
132
|
+
* layout of a vendored skill folder (`skills/<name>/references/…`, `…/.claude-plugin/…`) as
|
|
133
|
+
* well as a flat native one. Written once here where the Python has three copies — the three
|
|
134
|
+
* are identical, and this is the half of the verb no cell could see before the `<dirs>`
|
|
135
|
+
* column existed.
|
|
136
|
+
*
|
|
137
|
+
* The Python's loop has NO try/except: an `OSError` from `rmdir` propagates to the caller's
|
|
138
|
+
* per-file handler and lands the file in `failed`. Reproduced exactly, so `rmdirQuiet` is
|
|
139
|
+
* deliberately NOT what this calls.
|
|
140
|
+
*/
|
|
141
|
+
function pruneAncestors(start, stop) {
|
|
142
|
+
let d = start;
|
|
143
|
+
while (d !== stop && isDir(d) && isEmptyDir(d)) {
|
|
144
|
+
rmdirSync(d);
|
|
145
|
+
d = path.dirname(d);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The manifest's `owned` list, unlinked one by one with the ancestor prune, returning
|
|
151
|
+
* `[removed, failed]`.
|
|
152
|
+
*
|
|
153
|
+
* The third copy of this loop in `_harness_mcp.py` and the reason it is one function here:
|
|
154
|
+
* `_uninstall_global` (rooted at the target), `_claude_uninstall` (rooted at the cfg dir) and
|
|
155
|
+
* `_opencode_project_uninstall` (rooted at `.opencode/`, and the only one that PREFIXES its
|
|
156
|
+
* failure strings) differ in the root and in the label, which are the two arguments. The
|
|
157
|
+
* `victim.is_file()` guard is load-bearing rather than defensive: a manifest entry naming a
|
|
158
|
+
* directory is SKIPPED, not recursed into, and `removed` counts only what actually went.
|
|
159
|
+
*/
|
|
160
|
+
function unlinkOwned(base, owned, label = '') {
|
|
161
|
+
let removed = 0;
|
|
162
|
+
const failed = [];
|
|
163
|
+
for (const rel of owned) {
|
|
164
|
+
const victim = path.join(base, rel);
|
|
165
|
+
try {
|
|
166
|
+
if (isFile(victim)) {
|
|
167
|
+
unlinkSync(victim);
|
|
168
|
+
removed += 1;
|
|
169
|
+
pruneAncestors(path.dirname(victim), base);
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
// `f"{rel} ({e})"`. `str(OSError)` and an `Error.message` word the same fault
|
|
173
|
+
// differently, and no cell can reach this: it needs a file that exists, is a file, and
|
|
174
|
+
// cannot be unlinked. Recorded rather than papered over — the same standing item
|
|
175
|
+
// `js/excludes.mjs` carries for its `could not remove <stub>` branch.
|
|
176
|
+
failed.push(`${label}${rel} (${e && e.message ? e.message : e})`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [removed, failed];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
|
|
183
|
+
function warnSurvivors(failed) {
|
|
184
|
+
pyPrintErr('[uninstall] WARN: could not remove '
|
|
185
|
+
+ `${failed.length} owned file(s): ${failed.join(', ')}\n`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function warnMarkersKept() {
|
|
189
|
+
pyPrintErr('[uninstall] WARN: the manifest and markers were KEPT so '
|
|
190
|
+
+ '`harness uninstall` can be retried once the file(s) are '
|
|
191
|
+
+ 'unlocked/removable.\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The markers a completed reversal drops, in the Python's order. */
|
|
195
|
+
const REVERSAL_MARKERS = [GLOBAL_MANIFEST, '.geneseed-theme', '.geneseed-emit',
|
|
196
|
+
'.geneseed-footprint', VERSION_MARKER];
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `_harness_mcp._unmerge_opencode_json` — drop one `instructions` entry, leave every other
|
|
200
|
+
* key intact.
|
|
201
|
+
*
|
|
202
|
+
* A COMMENTED `.jsonc` is not rewritten and the user is told to do it by hand: rewriting it
|
|
203
|
+
* would drop the comments. That branch returns False, so the caller's `unmerged` reports
|
|
204
|
+
* REALITY rather than intent.
|
|
205
|
+
*/
|
|
206
|
+
export function unmergeOpencodeJson(p, entry) {
|
|
207
|
+
const target = opencodeTarget(p);
|
|
208
|
+
if (!existsSync(target)) return false;
|
|
209
|
+
let cfg;
|
|
210
|
+
let hadComments;
|
|
211
|
+
const text = readMaybe(target);
|
|
212
|
+
if (text === null) return false; // the Python's `except OSError: return False`
|
|
213
|
+
[cfg, hadComments] = readJsonc(text);
|
|
214
|
+
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) return false;
|
|
215
|
+
const instr = cfg.instructions;
|
|
216
|
+
if (!Array.isArray(instr) || !instr.includes(entry)) return false;
|
|
217
|
+
if (path.extname(target) === '.jsonc' && hadComments) {
|
|
218
|
+
pyPrint(`[uninstall] ${path.basename(target)} has comments — not rewriting it. Remove `
|
|
219
|
+
+ `this from its "instructions" by hand: ${jsonDumps(entry)}\n`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
cfg.instructions = instr.filter((i) => i !== entry);
|
|
223
|
+
writeText(target, `${jsonDumpsIndent(cfg)}\n`);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `_harness_mcp._archive_store` — move a runtime store aside to a sibling
|
|
229
|
+
* `archived-<name>/<timestamp>/`.
|
|
230
|
+
*
|
|
231
|
+
* NEVER a delete. Memory is the one thing in this system a user cannot rebuild, so the
|
|
232
|
+
* destructive verb's most destructive option is still a move; `_archive_memory` is the same
|
|
233
|
+
* function with `memory` hardcoded and is not ported separately because the only caller that
|
|
234
|
+
* reaches it (`_uninstall_global(archive_memory=True)`) is called with `False` by
|
|
235
|
+
* `_install_uninstall`, which is the CLI's only path in.
|
|
236
|
+
*
|
|
237
|
+
* `renameSync` rather than a copy-then-delete: the destination is a SIBLING of the source, so
|
|
238
|
+
* it is the same device by construction and `shutil.move`'s cross-device fallback has nothing
|
|
239
|
+
* to do. ponytail: rename, and the day a store can live on another volume this needs the
|
|
240
|
+
* copy path back.
|
|
241
|
+
*/
|
|
242
|
+
export function archiveStore(store) {
|
|
243
|
+
const now = new Date();
|
|
244
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
245
|
+
const stamp = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}`
|
|
246
|
+
+ `-${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`;
|
|
247
|
+
const dest = path.join(path.dirname(store), `archived-${path.basename(store)}`, stamp);
|
|
248
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
249
|
+
renameSync(store, dest);
|
|
250
|
+
return dest;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `_harness_mcp._settings_file` — the file this install's hooks were actually wired into.
|
|
255
|
+
*
|
|
256
|
+
* `settings.local.json` for a Claude PROJECT install (personal, untracked), `settings.json`
|
|
257
|
+
* everywhere else, and the manifest is the authority. Every lifecycle path must target the
|
|
258
|
+
* file the EMIT wrote, or the hooks linger in one file while the claims chase another.
|
|
259
|
+
*/
|
|
260
|
+
function settingsFile(cfg, managed) {
|
|
261
|
+
return path.join(cfg, (managed && managed.settings_file) || 'settings.json');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `_harness_mcp._claude_md_path` — where the manifest says the managed block lives. */
|
|
265
|
+
function claudeMdPath(cfg, managed) {
|
|
266
|
+
const rel = ((managed && managed.claude_md) || {}).rel || 'CLAUDE.md';
|
|
267
|
+
return pyResolve(path.join(cfg, rel));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A manifest's `managed` map, or `{}` — the Python's `isinstance(..., dict)` guard. */
|
|
271
|
+
function managedOf(man) {
|
|
272
|
+
const mg = man.managed;
|
|
273
|
+
return typeof mg === 'object' && mg !== null && !Array.isArray(mg) ? mg : {};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A manifest's `owned` list, or `[]`. */
|
|
277
|
+
function ownedOf(man) {
|
|
278
|
+
return Array.isArray(man.owned) ? man.owned : [];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `_harness_mcp._claude_uninstall` — reverse a Claude-style install at `cfg`.
|
|
283
|
+
*
|
|
284
|
+
* The survivors gate is the part worth reading twice. A locked owned file must NOT take the
|
|
285
|
+
* manifest and markers with it: the manifest is this install's only qualifying signal
|
|
286
|
+
* (`claudeState` keys on it), so deleting it while a file survives would report the install
|
|
287
|
+
* 'absent' and the retry the WARN promises would bounce off `cmdUninstall`'s own gate,
|
|
288
|
+
* stranding the leftovers forever.
|
|
289
|
+
*/
|
|
290
|
+
export function claudeUninstall(cfg, archiveMemory) {
|
|
291
|
+
const man = claudeReadManifest(cfg);
|
|
292
|
+
const managed = managedOf(man);
|
|
293
|
+
const [removed, failed] = unlinkOwned(cfg, ownedOf(man));
|
|
294
|
+
if (failed.length) warnSurvivors(failed);
|
|
295
|
+
const hooks = managed.settings_hooks || [];
|
|
296
|
+
const unwired = unwireClaudeSettings(settingsFile(cfg, managed), hooks);
|
|
297
|
+
unwireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
|
|
298
|
+
// The unwire is VERIFIED, not assumed: a commented settings file is never rewritten, so a
|
|
299
|
+
// supposedly-uninstalled repo could keep firing Geneseed's hooks. Loud, never fatal.
|
|
300
|
+
settingsIntegrityCheck(settingsFile(cfg, managed), managed, 'absent');
|
|
301
|
+
// Always EXCISE, never whole-file delete: even where Geneseed created CLAUDE.md the user
|
|
302
|
+
// may have added prose since. The file goes only if the excision leaves it empty.
|
|
303
|
+
managedBlockRemove(claudeMdPath(cfg, managed));
|
|
304
|
+
if (failed.length) warnMarkersKept();
|
|
305
|
+
else for (const m of REVERSAL_MARKERS) unlinkQuiet(path.join(cfg, m));
|
|
306
|
+
let archived = null;
|
|
307
|
+
if (archiveMemory && isDir(path.join(cfg, 'memory'))) {
|
|
308
|
+
archived = archiveStore(path.join(cfg, 'memory'));
|
|
309
|
+
}
|
|
310
|
+
// `unmerged` reports REALITY: hooks were recorded AND the unwire actually rewrote the file.
|
|
311
|
+
return { removed, unmerged: Boolean(hooks.length) && unwired, archived, failed };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* `_harness_mcp._uninstall_global` — reverse a global install at `target` via its manifest.
|
|
316
|
+
*
|
|
317
|
+
* Host-aware at the top, and that dispatch is the whole reason a Claude/Bob/Copilot global
|
|
318
|
+
* install and an OpenCode one can share one entry point: the Claude family has no
|
|
319
|
+
* opencode.json to unmerge and a settings.json plus a managed block instead.
|
|
320
|
+
*/
|
|
321
|
+
export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
|
|
322
|
+
if (CLAUDE_STYLE.includes(host)) return claudeUninstall(target, archiveMemory);
|
|
323
|
+
const man = claudeReadManifest(target);
|
|
324
|
+
const [removed, failed] = unlinkOwned(target, ownedOf(man));
|
|
325
|
+
if (failed.length) warnSurvivors(failed);
|
|
326
|
+
for (const d of ['agents', 'skills', 'plugins']) {
|
|
327
|
+
const p = path.join(target, d);
|
|
328
|
+
if (isDir(p) && isEmptyDir(p)) rmdirQuiet(p);
|
|
329
|
+
}
|
|
330
|
+
const unmerged = unmergeOpencodeJson(path.join(target, 'opencode.json'),
|
|
331
|
+
path.join(target, 'AGENT.md').split(path.sep).join('/'));
|
|
332
|
+
if (failed.length) warnMarkersKept();
|
|
333
|
+
else for (const m of REVERSAL_MARKERS) unlinkQuiet(path.join(target, m));
|
|
334
|
+
let archived = null;
|
|
335
|
+
if (archiveMemory && isDir(path.join(target, 'memory'))) {
|
|
336
|
+
archived = archiveStore(path.join(target, 'memory'));
|
|
337
|
+
}
|
|
338
|
+
return { removed, unmerged, archived, failed };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* `_harness_mcp._mcp_load`, reduced to the OpenCode arm.
|
|
343
|
+
*
|
|
344
|
+
* The Claude arm (`.mcp.json`, `~/.claude.json`, parsed with STRICT `json.loads` because the
|
|
345
|
+
* comment stripper's trailing-comma pass is not string-aware) belongs to the MCP catalog and
|
|
346
|
+
* the TUI, and neither has crossed. `installAgentEntry` is this port's only caller and passes
|
|
347
|
+
* no host, so porting the branch would ship an unreachable arm with no cell and no partition
|
|
348
|
+
* to be part of — the criterion this port decides keep-vs-delete by.
|
|
349
|
+
*/
|
|
350
|
+
function mcpLoadOpencode(p) {
|
|
351
|
+
if (!existsSync(p)) return {};
|
|
352
|
+
const text = readMaybe(p);
|
|
353
|
+
if (text === null) return {};
|
|
354
|
+
const [data] = readJsonc(text);
|
|
355
|
+
return typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* `_harness_mcp._install_agent_entry` — the `instructions` entry to drop.
|
|
360
|
+
*
|
|
361
|
+
* A global install wires the ABSOLUTE posix path; a project install wires the relative
|
|
362
|
+
* `…/AGENT.md` the emit recorded, read back off the live config so a bundle sub-dir layout
|
|
363
|
+
* round-trips. `pyIsAbsolute` and not `path.isAbsolute`: they disagree on Windows for a
|
|
364
|
+
* ROOTLESS `/repo/AGENT.md`, which Python keeps and `path.isAbsolute` would have skipped.
|
|
365
|
+
*/
|
|
366
|
+
export function installAgentEntry(root, kind) {
|
|
367
|
+
if (kind === 'global') {
|
|
368
|
+
return path.join(root, 'AGENT.md').split(path.sep).join('/');
|
|
369
|
+
}
|
|
370
|
+
const cfg = mcpLoadOpencode(opencodeTarget(path.join(root, 'opencode.json')));
|
|
371
|
+
return installAgentEntryOf(cfg.instructions);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The DECISION half of `installAgentEntry`, split out so the corpus can drive it.
|
|
376
|
+
*
|
|
377
|
+
* A cell can only ever observe the entries a seeded `opencode.json` holds, and the shape
|
|
378
|
+
* that separates the two `is_absolute` rules is a Windows ROOTLESS path — which a cell CAN
|
|
379
|
+
* seed but whose answer then only differs on one platform, in one branch, with no other
|
|
380
|
+
* observable effect. `tests/test_pure_function_parity.py` drives the list directly.
|
|
381
|
+
*/
|
|
382
|
+
export function installAgentEntryOf(instr) {
|
|
383
|
+
if (Array.isArray(instr)) {
|
|
384
|
+
for (const e of instr) {
|
|
385
|
+
if (typeof e === 'string' && !pyIsAbsolute(e) && path.basename(e) === 'AGENT.md') return e;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return 'AGENT.md';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* `_harness_mcp._opencode_project_uninstall` — reverse a per-repo OpenCode emit.
|
|
393
|
+
*
|
|
394
|
+
* Two shapes, and which one runs is decided by the manifest's PRESENCE. With one,
|
|
395
|
+
* `.opencode/` is unlinked file by file so a hand-added agent or plugin under it survives;
|
|
396
|
+
* without one — a pre-manifest legacy install — the directory goes whole. The portable bundle
|
|
397
|
+
* dirs beside it (`laws`/`agents`/`skills`) are deleted whole EITHER WAY, because the plain
|
|
398
|
+
* `build()` step wipes and rewrites them every run regardless of what the manifest says.
|
|
399
|
+
*
|
|
400
|
+
* The entry is read BEFORE AGENT.md is deleted. Reading it after would find the wire
|
|
401
|
+
* describing a file that no longer exists, which the fallback would then paper over with the
|
|
402
|
+
* canonical spelling — and a bundle sub-dir layout would be left wired.
|
|
403
|
+
*/
|
|
404
|
+
export function opencodeProjectUninstall(root) {
|
|
405
|
+
const entry = installAgentEntry(root, 'project');
|
|
406
|
+
let removed = 0;
|
|
407
|
+
let failed = [];
|
|
408
|
+
const oc = path.join(root, '.opencode');
|
|
409
|
+
const manifestPath = path.join(oc, GLOBAL_MANIFEST);
|
|
410
|
+
if (isFile(manifestPath)) {
|
|
411
|
+
const man = claudeReadManifest(oc);
|
|
412
|
+
[removed, failed] = unlinkOwned(oc, ownedOf(man), '.opencode/');
|
|
413
|
+
if (failed.length) warnSurvivors(failed);
|
|
414
|
+
// Survivors gate, mirroring the other two reversals: `_project_qualifies` keys off the
|
|
415
|
+
// manifest, so deleting it while an owned file survives makes the install unfindable.
|
|
416
|
+
if (!failed.length) {
|
|
417
|
+
unlinkQuiet(manifestPath);
|
|
418
|
+
if (isDir(oc) && isEmptyDir(oc)) rmdirQuiet(oc);
|
|
419
|
+
} else {
|
|
420
|
+
warnMarkersKept();
|
|
421
|
+
}
|
|
422
|
+
} else if (isDir(oc)) {
|
|
423
|
+
rmtreeQuiet(oc);
|
|
424
|
+
removed += 1;
|
|
425
|
+
}
|
|
426
|
+
for (const d of ['laws', 'agents', 'skills']) {
|
|
427
|
+
const p = path.join(root, d);
|
|
428
|
+
if (isDir(p)) { rmtreeQuiet(p); removed += 1; }
|
|
429
|
+
}
|
|
430
|
+
const am = path.join(root, 'AGENT.md');
|
|
431
|
+
if (isFile(am)) { unlinkQuiet(am); removed += 1; }
|
|
432
|
+
const unmerged = unmergeOpencodeJson(path.join(root, 'opencode.json'), entry);
|
|
433
|
+
const result = { removed, unmerged, archived: null };
|
|
434
|
+
if (failed.length) result.failed = failed;
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** `_harness_mcp._install_data_dir` — where the manifest and the runtime stores live. */
|
|
439
|
+
export function installDataDir(root, host = 'opencode', scope = 'global') {
|
|
440
|
+
if (scope === 'project' && CLAUDE_STYLE.includes(host)) {
|
|
441
|
+
return path.join(root, hostSpec(host).projectMarker);
|
|
442
|
+
}
|
|
443
|
+
return root;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* `_harness_mcp._owned_dirs_for` — the dirs the reversal was supposed to have emptied.
|
|
448
|
+
*
|
|
449
|
+
* EXISTENCE is the retry signal, not emptiness: the reversals only `rmdir` a directory they
|
|
450
|
+
* found empty, so a survivor here means either a locked file inside it or the `rmdir` itself
|
|
451
|
+
* failing, and both are retry-worthy.
|
|
452
|
+
*/
|
|
453
|
+
export function ownedDirsFor(root, host, scope, data) {
|
|
454
|
+
const dirs = [path.join(data, 'agents'), path.join(data, 'skills')];
|
|
455
|
+
if (host === 'opencode') {
|
|
456
|
+
if (scope === 'project') {
|
|
457
|
+
dirs.push(path.join(root, '.opencode'), path.join(root, 'laws'));
|
|
458
|
+
} else {
|
|
459
|
+
dirs.push(path.join(data, 'plugins'));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return dirs;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* `_harness_mcp._install_uninstall` — permanently remove the install at `root`, then de-list
|
|
467
|
+
* it. Every op is best-effort and idempotent, so a partly-removed install can be retried.
|
|
468
|
+
*
|
|
469
|
+
* STEP 3 IS THE ONE WITH THE ARGUMENT IN IT. The ROOT markers are what the registry
|
|
470
|
+
* self-prunes a row by, and dropping them while anything survived would make the install
|
|
471
|
+
* unfindable and therefore unretriable — an OpenCode PROJECT install in particular carries no
|
|
472
|
+
* manifest at the root, so `.geneseed-emit` is its only qualifying signal. The directory
|
|
473
|
+
* sweep alone is not enough either: `ownedDirsFor` watches agents/skills (+ .opencode/laws/
|
|
474
|
+
* plugins), so a locked owned file OUTSIDE those (Bob's `rules/geneseed.md`) would slip the
|
|
475
|
+
* gate while the reversal kept its manifest, and deleting the root markers here would then
|
|
476
|
+
* contradict that "KEPT" promise. The reversal's own `failed` list closes that hole.
|
|
477
|
+
*/
|
|
478
|
+
export function installUninstall(root, host = 'opencode', scope = 'global', memory = 'keep') {
|
|
479
|
+
if (!['keep', 'archive', 'delete'].includes(memory)) memory = 'keep';
|
|
480
|
+
if (installState(root, host, scope) === 'absent') {
|
|
481
|
+
return { ok: false, error: 'nothing installed here' };
|
|
482
|
+
}
|
|
483
|
+
const data = installDataDir(root, host, scope);
|
|
484
|
+
// 1. The matching per-host reversal, then the disabled-state stash — its bytes are this
|
|
485
|
+
// install's, and they go with it.
|
|
486
|
+
let summary;
|
|
487
|
+
if (CLAUDE_STYLE.includes(host)) {
|
|
488
|
+
summary = claudeUninstall(data, false);
|
|
489
|
+
rmtreeQuiet(path.join(data, DISABLED_STASH, host));
|
|
490
|
+
} else if (installKind(root) === 'global') {
|
|
491
|
+
summary = uninstallGlobal(root, false, 'opencode');
|
|
492
|
+
rmtreeQuiet(path.join(root, DISABLED_STASH));
|
|
493
|
+
} else {
|
|
494
|
+
summary = opencodeProjectUninstall(root);
|
|
495
|
+
rmtreeQuiet(path.join(root, DISABLED_STASH));
|
|
496
|
+
}
|
|
497
|
+
// 2. Memory + notebook, independent of the owned-file removal above.
|
|
498
|
+
const archived = [];
|
|
499
|
+
for (const name of ['memory', 'notebook']) {
|
|
500
|
+
const store = path.join(data, name);
|
|
501
|
+
if (!isDir(store)) continue;
|
|
502
|
+
if (memory === 'archive') archived.push(archiveStore(store));
|
|
503
|
+
else if (memory === 'delete') rmtreeQuiet(store);
|
|
504
|
+
}
|
|
505
|
+
// 3. The root markers, but only once the reversal's dirs are actually gone.
|
|
506
|
+
const failed = summary.failed || [];
|
|
507
|
+
const survivors = ownedDirsFor(root, host, scope, data).filter((d) => existsSync(d));
|
|
508
|
+
if (survivors.length || failed.length) {
|
|
509
|
+
if (survivors.length && !failed.length) {
|
|
510
|
+
// With `failed` set the reversal already warned twice — no third overlapping WARN.
|
|
511
|
+
pyPrintErr('[uninstall] WARN: could not fully remove the install — still present: '
|
|
512
|
+
+ `${survivors.join(', ')}. The install marker was KEPT so you can retry `
|
|
513
|
+
+ '`harness uninstall` once the file(s) are unlocked/removable.\n');
|
|
514
|
+
}
|
|
515
|
+
const out = {
|
|
516
|
+
ok: true,
|
|
517
|
+
removed: summary.removed ?? 0,
|
|
518
|
+
memory,
|
|
519
|
+
incomplete: survivors.concat(failed.filter((f) => !survivors.includes(f))),
|
|
520
|
+
};
|
|
521
|
+
if (archived.length) out.archived = archived;
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
for (const m of ['.geneseed-emit', '.geneseed-theme', VERSION_MARKER]) {
|
|
525
|
+
unlinkQuiet(path.join(root, m));
|
|
526
|
+
}
|
|
527
|
+
// 4. Tidy an emptied marker dir (.claude/.bob) so no husk lingers in the repo.
|
|
528
|
+
if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
|
|
529
|
+
const out = { ok: true, removed: summary.removed ?? 0, memory };
|
|
530
|
+
if (archived.length) out.archived = archived;
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* `_harness_mcp._project_qualifies` — does `root` carry a REAL Geneseed project install?
|
|
536
|
+
*
|
|
537
|
+
* The marker dir exists, is not the host's global config dir seen from its parent (the
|
|
538
|
+
* `installTargets` aliasing guard), and shows Geneseed's own tracks: the manifest, or the
|
|
539
|
+
* root `.geneseed-emit` naming this host's project emit for a pre-manifest legacy install.
|
|
540
|
+
* A bare non-Geneseed `.claude/` is very common and must never hijack the resolve.
|
|
541
|
+
*/
|
|
542
|
+
export function projectQualifies(root, host) {
|
|
543
|
+
const spec = hostSpec(host);
|
|
544
|
+
const cfg = path.join(root, spec.projectMarker);
|
|
545
|
+
if (!isDir(cfg)) return false;
|
|
546
|
+
try {
|
|
547
|
+
if (pyResolve(cfg) === pyResolve(spec.configDir())) return false;
|
|
548
|
+
} catch { /* as the Python's bare `except Exception: pass` */ }
|
|
549
|
+
if (isFile(path.join(cfg, GLOBAL_MANIFEST))) return true;
|
|
550
|
+
const hs = emitHostScopeOf(root);
|
|
551
|
+
return Boolean(hs) && hs[0] === host && hs[1] === 'project';
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* `_harness_mcp._uninstall_resolve` — (host, scope, root), or null.
|
|
556
|
+
*
|
|
557
|
+
* Precedence, most-specific first, and the ORDER of the first two is the subtle one: `~/.claude`
|
|
558
|
+
* is NAMED like a project marker and is the global install, never `claude:project` rooted at
|
|
559
|
+
* `$HOME`, so the global-config-dir case is checked before the marker-name case.
|
|
560
|
+
*
|
|
561
|
+
* 1. `--target` IS a host's global config dir.
|
|
562
|
+
* 2. `--target` IS a project marker dir itself (…/.claude) — root is its parent.
|
|
563
|
+
* 3. `--target` (as a root) carries a Geneseed project install.
|
|
564
|
+
* 4. `--target` given and unrecognised — null, and the caller reports it.
|
|
565
|
+
* 5. No `--target`: the cwd, then the OpenCode global config dir.
|
|
566
|
+
*/
|
|
567
|
+
export function uninstallResolve(targetArg) {
|
|
568
|
+
const globalHit = (p) => {
|
|
569
|
+
for (const spec of HOSTS) {
|
|
570
|
+
try {
|
|
571
|
+
if (p === pyResolve(spec.configDir())) return [spec.host, 'global', p];
|
|
572
|
+
} catch { continue; }
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
};
|
|
576
|
+
const projectHit = (root) => {
|
|
577
|
+
for (const spec of HOSTS) {
|
|
578
|
+
if (projectQualifies(root, spec.host)) return [spec.host, 'project', root];
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
};
|
|
582
|
+
if (targetArg) {
|
|
583
|
+
const p = pyResolve(expanduser(targetArg));
|
|
584
|
+
const hit = globalHit(p);
|
|
585
|
+
if (hit) return hit;
|
|
586
|
+
for (const spec of HOSTS) {
|
|
587
|
+
if (path.basename(p) === spec.projectMarker && isDir(p)
|
|
588
|
+
&& projectQualifies(path.dirname(p), spec.host)) {
|
|
589
|
+
return [spec.host, 'project', path.dirname(p)];
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return projectHit(p);
|
|
593
|
+
}
|
|
594
|
+
// `.resolve()` so the cwd fallback matches every other branch and the registry, which
|
|
595
|
+
// stores resolved paths — a short-form (8.3) cwd, as Windows CI hands back for %TEMP%,
|
|
596
|
+
// would otherwise return a root that LOOKS different from the identical directory.
|
|
597
|
+
const hit = projectHit(pyResolve(process.cwd()));
|
|
598
|
+
if (hit) return hit;
|
|
599
|
+
return ['opencode', 'global', opencodeConfigDir()];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* `_harness_mcp._surviving_project_installs` — the PROJECT installs still on record.
|
|
604
|
+
*
|
|
605
|
+
* A global uninstall never cascades: each project install is self-contained, its hook
|
|
606
|
+
* commands invoke the shared checkout by absolute path rather than anything under the global
|
|
607
|
+
* config dir. The registry is the only place a project install outside the cwd can be
|
|
608
|
+
* rediscovered from, and the just-removed root is excluded.
|
|
609
|
+
*/
|
|
610
|
+
export function survivingProjectInstalls(removedRoot) {
|
|
611
|
+
const rroot = pyResolve(removedRoot);
|
|
612
|
+
const out = [];
|
|
613
|
+
for (const [host, scope, root] of registeredTargets()) {
|
|
614
|
+
if (scope !== 'project') continue;
|
|
615
|
+
try { if (pyResolve(root) === rroot) continue; } catch { /* as the Python's except OSError */ }
|
|
616
|
+
out.push([host, scope, root]);
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** `_harness_mcp._print_surviving_project_inventory` — informational, never a cascade. */
|
|
622
|
+
export function printSurvivingProjectInventory(removedRoot) {
|
|
623
|
+
const survivors = survivingProjectInstalls(removedRoot);
|
|
624
|
+
if (!survivors.length) return;
|
|
625
|
+
pyPrint(`[uninstall] ${survivors.length} project install(s) remain — the global removal `
|
|
626
|
+
+ 'does not affect them (each is self-contained):\n');
|
|
627
|
+
for (const [host, scope, root] of survivors) {
|
|
628
|
+
pyPrint(` - ${root} (${host}:${scope}) — remove with: `
|
|
629
|
+
+ `harness uninstall --target "${root}" --yes\n`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* `_harness_mcp._print_other_host_hits` — one message per ADDITIONAL host at the same root.
|
|
635
|
+
*
|
|
636
|
+
* A repo can carry `.opencode/`, `.claude/` and `.bob/` side by side and `uninstall` only ever
|
|
637
|
+
* removes the one it resolved to, so a repeat run needs to know there is more to do.
|
|
638
|
+
*/
|
|
639
|
+
export function printOtherHostHits(root, removedHost) {
|
|
640
|
+
for (const spec of HOSTS) {
|
|
641
|
+
if (spec.host !== removedHost && projectQualifies(root, spec.host)) {
|
|
642
|
+
pyPrint(`[uninstall] also found ${spec.host}:project here — run \`harness `
|
|
643
|
+
+ 'uninstall\` again to remove it.\n');
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* `_harness_setup._confirm`, and the only interactive read this verb makes.
|
|
650
|
+
*
|
|
651
|
+
* MOVED TO `js/setup.mjs` IN P5i, where the Python's own owner is: `_confirm` lives in
|
|
652
|
+
* `_harness_setup` beside the `_ask` it is built on, and `setup` is its second caller. The
|
|
653
|
+
* body stayed here for one phase because a helper with one caller is not shared yet; the
|
|
654
|
+
* rule this port uses is that the SECOND owner is what moves it, and a byte gate is what
|
|
655
|
+
* licenses the move.
|
|
656
|
+
*
|
|
657
|
+
* UNREACHABLE FROM EVERY CELL, restated because moving it does not change that: `cmdUninstall`
|
|
658
|
+
* only reaches it when stdin is a TTY, and the harness gives every cell a pipe. What the
|
|
659
|
+
* cells DO gate is the branch beside it — the non-interactive refusal — which is why that one
|
|
660
|
+
* has an `expect` naming its wording. It is now gated positively too, by the stdin-seeded
|
|
661
|
+
* corpus P5i added for the wizard.
|
|
662
|
+
*/
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* `_harness_mcp.cmd_uninstall`.
|
|
666
|
+
*
|
|
667
|
+
* The printing is the loud half and the deletions are the quiet one; the cells gate both. The
|
|
668
|
+
* inventory at the end is informational in the strict sense — a global uninstall never
|
|
669
|
+
* touches a project install, and saying so is the only thing those two branches do.
|
|
670
|
+
*/
|
|
671
|
+
export function cmdUninstall(args) {
|
|
672
|
+
const hit = uninstallResolve(args.target);
|
|
673
|
+
if (hit === null) {
|
|
674
|
+
// `uninstallResolve(null)` never returns null — it falls back to the OpenCode global
|
|
675
|
+
// default — so reaching here means `--target` was given and was not recognised.
|
|
676
|
+
const targetDesc = pyResolve(expanduser(args.target));
|
|
677
|
+
pyPrintErr(`[uninstall] no Geneseed install detected at ${targetDesc}.\n`
|
|
678
|
+
+ '[uninstall] pass --target <repo> for a project install (.opencode/.claude/'
|
|
679
|
+
+ '.bob/.github) or --target <config dir> for a global one.\n');
|
|
680
|
+
return 1;
|
|
681
|
+
}
|
|
682
|
+
const [host, scope, root] = hit;
|
|
683
|
+
if (installState(root, host, scope) === 'absent') {
|
|
684
|
+
const where = scope === 'project' && host !== 'opencode'
|
|
685
|
+
? ` under ${hostSpec(host).projectMarker}/` : '';
|
|
686
|
+
pyPrintErr(`[uninstall] no ${host}:${scope} Geneseed install at ${root} `
|
|
687
|
+
+ `(no ${GLOBAL_MANIFEST}${where}).\n`);
|
|
688
|
+
return 1;
|
|
689
|
+
}
|
|
690
|
+
const data = installDataDir(root, host, scope);
|
|
691
|
+
const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
|
|
692
|
+
pyPrint(`[uninstall] target: ${root} (${host}:${scope})\n`);
|
|
693
|
+
if (host === 'copilot') {
|
|
694
|
+
pyPrint('[uninstall] removes: agents/, skills/, markers, and the '
|
|
695
|
+
+ `${hostSpec(host).agentFile} managed block (Copilot has no `
|
|
696
|
+
+ 'settings.json/hooks to unwire; your own .github files are kept).\n');
|
|
697
|
+
} else if (host === 'claude' || host === 'bob') {
|
|
698
|
+
pyPrint('[uninstall] removes: agents/, skills/, markers, the '
|
|
699
|
+
+ `${hostSpec(host).agentFile} managed block, and Geneseed's `
|
|
700
|
+
+ 'settings.json hooks/excludes (your own keys/hooks are kept).\n');
|
|
701
|
+
} else if (scope === 'global') {
|
|
702
|
+
pyPrint('[uninstall] removes: AGENT.md, agents/, skills/, plugins/, markers, and the '
|
|
703
|
+
+ 'opencode.json instructions entry.\n');
|
|
704
|
+
} else {
|
|
705
|
+
pyPrint('[uninstall] removes: AGENT.md, .opencode/, laws/, agents/, skills/, and the '
|
|
706
|
+
+ 'opencode.json instructions entry.\n');
|
|
707
|
+
}
|
|
708
|
+
pyPrint('[uninstall] memory/ and notebook/ are kept in place (never deleted here)'
|
|
709
|
+
+ (stores.length ? ' — --archive-memory sets both aside.' : '.') + '\n');
|
|
710
|
+
if (stores.length && args.archiveMemory) {
|
|
711
|
+
pyPrint(`[uninstall] ${stores.join(' + ')}: will be ARCHIVED to a sibling `
|
|
712
|
+
+ 'archived-<name>/<timestamp>/ (never deleted)\n');
|
|
713
|
+
}
|
|
714
|
+
if (!args.yes) {
|
|
715
|
+
if (!process.stdin.isTTY) {
|
|
716
|
+
pyPrintErr('[uninstall] refusing to proceed without --yes (non-interactive).\n');
|
|
717
|
+
return 1;
|
|
718
|
+
}
|
|
719
|
+
if (!confirm('Proceed with uninstall?', false)) {
|
|
720
|
+
pyPrint('[uninstall] cancelled — nothing removed.\n');
|
|
721
|
+
return 0;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const memory = args.archiveMemory ? 'archive' : 'keep';
|
|
725
|
+
const s = installUninstall(root, host, scope, memory);
|
|
726
|
+
if (!(s.ok ?? true)) {
|
|
727
|
+
pyPrintErr(`[uninstall] failed: ${s.error || 'unknown error'}\n`);
|
|
728
|
+
return 1;
|
|
729
|
+
}
|
|
730
|
+
const archived = s.archived || [];
|
|
731
|
+
const mem = archived.length ? `archived -> ${archived.join(', ')}` : 'kept in place';
|
|
732
|
+
const cfgfile = host === 'opencode' ? 'opencode.json' : 'settings.json';
|
|
733
|
+
if (s.incomplete && s.incomplete.length) {
|
|
734
|
+
pyPrint(`[uninstall] INCOMPLETE — removed ${s.removed} file(s), but `
|
|
735
|
+
+ `${s.incomplete.length} item(s) survived (see the WARN above); `
|
|
736
|
+
+ 'the install marker was kept — retry `harness uninstall` once they\'re '
|
|
737
|
+
+ `removable. ${cfgfile} updated where needed; memory/notebook ${mem}.\n`);
|
|
738
|
+
} else {
|
|
739
|
+
pyPrint(`[uninstall] done — removed ${s.removed} file(s); ${cfgfile} updated where `
|
|
740
|
+
+ `needed; memory/notebook ${mem}. Start a new session to apply.\n`);
|
|
741
|
+
}
|
|
742
|
+
if (scope === 'global') printSurvivingProjectInventory(root);
|
|
743
|
+
else if (scope === 'project') printOtherHostHits(root, host);
|
|
744
|
+
return 0;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---- P6i: ACTIVATION — the reversible half ---------------------------------------------
|
|
748
|
+
//
|
|
749
|
+
// Deactivate turns an install OFF without deleting a byte: every owned artifact MOVES into a
|
|
750
|
+
// sibling stash and the wiring is dropped. Reactivate moves the same bytes back. The stash
|
|
751
|
+
// dir's PRESENCE is the disabled flag and its CONTENTS are the restore source — there is no
|
|
752
|
+
// recorded JSON state that could drift from the filesystem, which is the design decision the
|
|
753
|
+
// whole port has to preserve rather than improve on.
|
|
754
|
+
//
|
|
755
|
+
// ALL-OR-NOTHING IS THE PROPERTY, and it is the one a port breaks silently. A move that fails
|
|
756
|
+
// puts every earlier move back and reports, leaving the install fully `active`; a port that
|
|
757
|
+
// merely stopped on the failure would leave a half-gutted config dir that neither state
|
|
758
|
+
// answers for. `tests/web_golden.py`'s `install/a-deactivate-whose-second-move-collides…`
|
|
759
|
+
// drives it: a manifest naming a file AND its parent directory is the only shape a seeded
|
|
760
|
+
// world can use to make the SECOND move collide.
|
|
761
|
+
|
|
762
|
+
/** `e` is an OSError rather than a programming fault — Node tags fs errors with `code`. */
|
|
763
|
+
const isOsError = (e) => Boolean(e) && typeof e.code === 'string';
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* `_harness_mcp._move_tree` — move a file or dir, refusing an existing destination.
|
|
767
|
+
*
|
|
768
|
+
* THE REFUSAL IS THE CONTRACT, not a safety net: the stash is what a reactivate restores
|
|
769
|
+
* FROM, so silently overwriting a destination would destroy the only copy of whatever was
|
|
770
|
+
* already there. The caller catches and rolls back.
|
|
771
|
+
*
|
|
772
|
+
* `raise FileExistsError(dst)` is a SINGLE-ARGUMENT OSError, whose `str()` is just the path,
|
|
773
|
+
* and the caller interpolates it into the `failed` list a cell compares byte for byte — so
|
|
774
|
+
* the message is reproduced exactly here. Node's own fs errors word themselves completely
|
|
775
|
+
* differently (`EEXIST: file already exists, rename 'a' -> 'b'` against Python's
|
|
776
|
+
* `[Errno 17] File exists: 'b'`); that divergence is real, declared, and unreachable from
|
|
777
|
+
* any cell, exactly as `unlinkOwned`'s is — the only arm a seeded world can reach is this
|
|
778
|
+
* one, which is why this one is exact.
|
|
779
|
+
*
|
|
780
|
+
* `renameSync`, not a copy-then-delete: `shutil.move` falls back to a copy across
|
|
781
|
+
* filesystems, and the stash is a SIBLING inside the same root, so there is no device to
|
|
782
|
+
* cross by construction.
|
|
783
|
+
*/
|
|
784
|
+
function moveTree(src, dst) {
|
|
785
|
+
if (existsSync(dst)) {
|
|
786
|
+
const e = new Error(String(dst));
|
|
787
|
+
e.code = 'EEXIST';
|
|
788
|
+
throw e;
|
|
789
|
+
}
|
|
790
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
791
|
+
renameSync(src, dst);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* `_harness_mcp._stashed_kind` — which kind of install a stash holds, from its CONTENTS.
|
|
796
|
+
*
|
|
797
|
+
* There is no recorded tag, on purpose. A project stash carries `.opencode/`; a global one
|
|
798
|
+
* carries the moved owned files. `null` for an empty or missing stash.
|
|
799
|
+
*/
|
|
800
|
+
function stashedKind(root) {
|
|
801
|
+
const stash = path.join(root, DISABLED_STASH);
|
|
802
|
+
if (isDir(path.join(stash, '.opencode'))) return 'project';
|
|
803
|
+
if (isDir(stash) && !isEmptyDir(stash)) return 'global';
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* `_harness_mcp._install_relive` — the disabled install's CONTENT was re-created live.
|
|
809
|
+
*
|
|
810
|
+
* The user ran `build`/`upgrade` while disabled. The live signal is KIND-SPECIFIC and the
|
|
811
|
+
* stashed kind is what picks it: a global deactivate moves AGENT.md aside (leaving the
|
|
812
|
+
* manifest marker, so `installKind` still answers 'global' and would false-positive on its
|
|
813
|
+
* own), a project deactivate leaves AGENT.md alone and moves `.opencode/`.
|
|
814
|
+
*/
|
|
815
|
+
function installRelive(root) {
|
|
816
|
+
if (stashedKind(root) === 'project') return isDir(path.join(root, '.opencode'));
|
|
817
|
+
return isFile(path.join(root, 'AGENT.md'));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* `_harness_mcp._install_move_list` — the rels to move aside, `_within`-guarded.
|
|
822
|
+
*
|
|
823
|
+
* `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
|
|
824
|
+
* boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
|
|
825
|
+
* `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
|
|
826
|
+
* `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
|
|
827
|
+
* concatenated it into `<root>/C:/evil.md`, which passes containment and would then be MOVED.
|
|
828
|
+
* `path.resolve` has pathlib's semantics for exactly this case, and collapses `..` besides,
|
|
829
|
+
* which is why the guard runs on the resolved path rather than the lexical one.
|
|
830
|
+
*/
|
|
831
|
+
function installMoveList(root, kind) {
|
|
832
|
+
let rels;
|
|
833
|
+
if (kind === 'project') {
|
|
834
|
+
rels = ['.opencode'];
|
|
835
|
+
} else {
|
|
836
|
+
rels = ownedOf(claudeReadManifest(root)).filter((r) => r !== VERSION_MARKER);
|
|
837
|
+
}
|
|
838
|
+
const rroot = pyResolve(root);
|
|
839
|
+
return rels.filter((r) => r && within(pyResolve(path.resolve(rroot, r)), rroot));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* `_harness_mcp._install_readd_entry` — put back JUST the AGENT.md `instructions` entry.
|
|
844
|
+
*
|
|
845
|
+
* Deliberately a minimal inline re-add rather than the emit's own merge, whose side effects
|
|
846
|
+
* (adding `permission` and `lsp: true` when absent) would clobber values the user may have
|
|
847
|
+
* set since the install was disabled. A commented `.jsonc` is not rewritten — the same
|
|
848
|
+
* refusal `unmergeOpencodeJson` makes — but here the files have ALREADY been restored, so it
|
|
849
|
+
* tells the operator what to add rather than failing the whole reactivate. That asymmetry is
|
|
850
|
+
* the reference's and is gated per side.
|
|
851
|
+
*/
|
|
852
|
+
export function installReaddEntry(target, entry) {
|
|
853
|
+
if (!existsSync(target)) {
|
|
854
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
855
|
+
writeText(target, `${jsonDumpsIndent({
|
|
856
|
+
$schema: 'https://opencode.ai/config.json', instructions: [entry],
|
|
857
|
+
})}\n`);
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
let raw;
|
|
861
|
+
try { raw = readText(target); } catch { return false; } // except OSError
|
|
862
|
+
const [cfg, hadComments] = readJsonc(raw);
|
|
863
|
+
if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) return false;
|
|
864
|
+
const instr = Array.isArray(cfg.instructions) ? cfg.instructions : [];
|
|
865
|
+
if (indexOfEq(instr, entry) >= 0) return false;
|
|
866
|
+
if (path.extname(target) === '.jsonc' && hadComments) {
|
|
867
|
+
pyPrint(`[activate] ${path.basename(target)} has comments — not rewriting it. Add this `
|
|
868
|
+
+ `to its "instructions" by hand: ${jsonDumps(entry)}\n`);
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
cfg.instructions = [...instr, entry];
|
|
872
|
+
writeText(target, `${jsonDumpsIndent(cfg)}\n`);
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* `sorted(stash.rglob('*'))` with the directories dropped.
|
|
878
|
+
*
|
|
879
|
+
* `comparePaths`, not a bare sort: Windows compares paths case-folded and POSIX does not, and
|
|
880
|
+
* the order here decides which collision is reported first in `failed`. Sorting the files
|
|
881
|
+
* alone is the same order as sorting everything and skipping dirs afterwards, which is what
|
|
882
|
+
* the Python does. `isDir` rather than the dirent flag, so a symlinked directory is recursed
|
|
883
|
+
* into exactly as `is_dir()` follows it.
|
|
884
|
+
*/
|
|
885
|
+
function stashFiles(stash) {
|
|
886
|
+
const out = [];
|
|
887
|
+
const walk = (d) => {
|
|
888
|
+
let ents;
|
|
889
|
+
try { ents = readdirSync(d); } catch { return; }
|
|
890
|
+
for (const name of ents) {
|
|
891
|
+
const full = path.join(d, name);
|
|
892
|
+
if (isDir(full)) walk(full);
|
|
893
|
+
else out.push(full);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
walk(stash);
|
|
897
|
+
return out.sort(comparePaths);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** `rel.as_posix()` for a path already known to be relative. */
|
|
901
|
+
const asPosix = (rel) => rel.split(path.sep).join('/');
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* `_harness_mcp._install_deactivate` — turn an OpenCode install off without deleting a byte.
|
|
905
|
+
*
|
|
906
|
+
* The config edit is the LAST step and the only non-move mutation, so a move failure rolls
|
|
907
|
+
* back cleanly with the `instructions` entry still intact. The prune climbs from each moved
|
|
908
|
+
* file's PARENT so a `skills/<name>/` husk goes too — the half of this function no file
|
|
909
|
+
* snapshot can see, and the reason `tests/web_golden.py` grew a `<dirs>` column in P6i.
|
|
910
|
+
*/
|
|
911
|
+
export function installDeactivate(root, host = 'opencode', scope = 'global') {
|
|
912
|
+
if (CLAUDE_STYLE.includes(host)) return claudeDeactivate(root, scope, host);
|
|
913
|
+
if (installState(root) !== 'active') {
|
|
914
|
+
return { ok: false, error: `install is not active (${installState(root)})` };
|
|
915
|
+
}
|
|
916
|
+
const kind = installKind(root);
|
|
917
|
+
const target = opencodeTarget(path.join(root, 'opencode.json'));
|
|
918
|
+
// Caught UP FRONT, before any file moves: a commented `.jsonc` cannot be rewritten without
|
|
919
|
+
// dropping the user's comments, so refuse and move NOTHING.
|
|
920
|
+
if (mcpCommented(target)) {
|
|
921
|
+
return { ok: false,
|
|
922
|
+
error: `${path.basename(target)} has comments — refusing to rewrite it. `
|
|
923
|
+
+ 'Disable by hand or convert it to plain .json first.' };
|
|
924
|
+
}
|
|
925
|
+
const stash = path.join(root, DISABLED_STASH);
|
|
926
|
+
const done = []; // rels already moved, for rollback
|
|
927
|
+
for (const rel of installMoveList(root, kind)) {
|
|
928
|
+
const src = path.join(root, rel);
|
|
929
|
+
if (!existsSync(src)) continue; // a manifest entry already gone — nothing to move
|
|
930
|
+
try {
|
|
931
|
+
moveTree(src, path.join(stash, rel));
|
|
932
|
+
done.push(rel);
|
|
933
|
+
} catch (e) {
|
|
934
|
+
if (!isOsError(e)) throw e;
|
|
935
|
+
for (const r of [...done].reverse()) {
|
|
936
|
+
try { renameSync(path.join(stash, r), path.join(root, r)); } catch { /* OSError */ }
|
|
937
|
+
}
|
|
938
|
+
rmtreeQuiet(stash);
|
|
939
|
+
return { ok: false, failed: [`${rel} (${e.message})`], rolled_back: done.length };
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
unmergeOpencodeJson(path.join(root, 'opencode.json'), installAgentEntry(root, kind));
|
|
943
|
+
for (const rel of done) pruneAncestors(path.dirname(path.join(root, rel)), root);
|
|
944
|
+
return { ok: true, kind, moved: done.length };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** `_harness_mcp._install_reactivate` — the inverse. */
|
|
948
|
+
export function installReactivate(root, host = 'opencode', scope = 'global') {
|
|
949
|
+
if (CLAUDE_STYLE.includes(host)) return claudeReactivate(root, scope, host);
|
|
950
|
+
if (installState(root) !== 'disabled') {
|
|
951
|
+
return { ok: false, error: `install is not disabled (${installState(root)})` };
|
|
952
|
+
}
|
|
953
|
+
const stash = path.join(root, DISABLED_STASH);
|
|
954
|
+
const target = opencodeTarget(path.join(root, 'opencode.json'));
|
|
955
|
+
// The re-emit-while-disabled guard: discard the now-stale snapshot rather than clobber the
|
|
956
|
+
// fresh files, ensure the entry is present, and SAY SO. `installRelive`, not `installKind`
|
|
957
|
+
// — a global deactivate leaves the manifest marker behind, so the kind test would lie here.
|
|
958
|
+
if (installRelive(root)) {
|
|
959
|
+
const kind = installKind(root) || 'global';
|
|
960
|
+
rmtreeQuiet(stash);
|
|
961
|
+
installReaddEntry(target, installAgentEntry(root, kind));
|
|
962
|
+
return { ok: true,
|
|
963
|
+
note: 'install was re-created while disabled; discarded the stashed snapshot' };
|
|
964
|
+
}
|
|
965
|
+
const leftovers = [];
|
|
966
|
+
let moved = 0;
|
|
967
|
+
for (const src of stashFiles(stash)) {
|
|
968
|
+
const rel = path.relative(stash, src);
|
|
969
|
+
const dst = path.join(root, rel);
|
|
970
|
+
if (existsSync(dst)) {
|
|
971
|
+
// NEVER delete the stash while anything is unrestored — skip, keep, report.
|
|
972
|
+
leftovers.push(asPosix(rel));
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
976
|
+
renameSync(src, dst);
|
|
977
|
+
moved += 1;
|
|
978
|
+
}
|
|
979
|
+
if (leftovers.length) return { ok: false, failed: leftovers, moved };
|
|
980
|
+
const kind = installKind(root) || 'global';
|
|
981
|
+
installReaddEntry(target, installAgentEntry(root, kind));
|
|
982
|
+
rmtreeQuiet(stash);
|
|
983
|
+
return { ok: true, kind, moved };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ---- the Claude-style fork (Claude · IBM Bob · GitHub Copilot) ---------------------------
|
|
987
|
+
//
|
|
988
|
+
// A Claude-style install has NO `instructions` array — the harness reaches the host through
|
|
989
|
+
// the CLAUDE.md managed block and the settings.json hooks. So the reversal is three moving
|
|
990
|
+
// parts instead of one: stash the owned agents/skills, EXCISE the managed block (stashing its
|
|
991
|
+
// content for an exact restore), and unwire the recorded hook groups. The stash lives under a
|
|
992
|
+
// host-TAGGED subdir so one root carrying both an OpenCode and a Claude install can disable
|
|
993
|
+
// each independently.
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* `_harness_mcp._clean_host_stash` — drop this host's stash and the parent if it empties.
|
|
997
|
+
*
|
|
998
|
+
* So 'disabled' never lingers as an empty marker, and a same-root other-host stash is left
|
|
999
|
+
* standing.
|
|
1000
|
+
*/
|
|
1001
|
+
function cleanHostStash(cfg, host = 'claude') {
|
|
1002
|
+
rmtreeQuiet(path.join(cfg, DISABLED_STASH, host));
|
|
1003
|
+
const parent = path.join(cfg, DISABLED_STASH);
|
|
1004
|
+
if (isDir(parent) && isEmptyDir(parent)) rmdirQuiet(parent);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* `_harness_mcp._remerge_claude_hooks` — re-merge the canonical hooks and RECORD the claims.
|
|
1009
|
+
*
|
|
1010
|
+
* Threading the manifest's recorded claims through is what prunes stale groups; writing the
|
|
1011
|
+
* resulting set back is what keeps a group re-added after an interpreter or checkout move
|
|
1012
|
+
* from being orphaned at uninstall. The manifest rewrite is atomic through a sibling temp
|
|
1013
|
+
* file and swallows its own OSError, exactly as the reference does — a failed bookkeeping
|
|
1014
|
+
* write must not fail the reactivate that has already moved every file back.
|
|
1015
|
+
*/
|
|
1016
|
+
function remergeClaudeHooks(cfg) {
|
|
1017
|
+
const data = claudeReadManifest(cfg);
|
|
1018
|
+
const managed = managedOf(data);
|
|
1019
|
+
// `hookOpts` is the one argument `mergeClaudeSettings` will not default, and it is right
|
|
1020
|
+
// not to: there is no computable fallback for the runner/entry the shim bakes. The
|
|
1021
|
+
// emitter's own originator is imported rather than restated — a reactivate that wired a
|
|
1022
|
+
// different shim path would point every re-added hook at a file the emitter never writes.
|
|
1023
|
+
const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
|
|
1024
|
+
managed.settings_hooks ?? null, hookRunnerEntry());
|
|
1025
|
+
// `and data` — a manifest that did not parse is not one to write back.
|
|
1026
|
+
if (!pyEq(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
|
|
1027
|
+
managed.settings_hooks = claims;
|
|
1028
|
+
data.managed = managed;
|
|
1029
|
+
const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
|
|
1030
|
+
try {
|
|
1031
|
+
writeText(tmp, `${jsonDumpsIndent(data)}\n`);
|
|
1032
|
+
renameSync(tmp, path.join(cfg, GLOBAL_MANIFEST));
|
|
1033
|
+
} catch { /* except OSError: pass */ }
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** `_harness_mcp._claude_deactivate`. */
|
|
1038
|
+
function claudeDeactivate(root, scope = 'global', host = 'claude') {
|
|
1039
|
+
const cfg = claudeCfg(root, scope, host);
|
|
1040
|
+
// `installState` dispatches Claude-style hosts to `claudeState(root, scope, host)`, which
|
|
1041
|
+
// is the call the Python makes directly. Same answer, one exported name.
|
|
1042
|
+
if (installState(root, host, scope) !== 'active') {
|
|
1043
|
+
return { ok: false,
|
|
1044
|
+
error: `install is not active (${installState(root, host, scope)})` };
|
|
1045
|
+
}
|
|
1046
|
+
const man = claudeReadManifest(cfg);
|
|
1047
|
+
const managed = managedOf(man);
|
|
1048
|
+
const stash = path.join(cfg, DISABLED_STASH, host);
|
|
1049
|
+
const rroot = pyResolve(cfg);
|
|
1050
|
+
const rels = ownedOf(man).filter((r) => r && r !== VERSION_MARKER
|
|
1051
|
+
&& within(pyResolve(path.resolve(rroot, r)), rroot));
|
|
1052
|
+
const done = [];
|
|
1053
|
+
for (const rel of rels) {
|
|
1054
|
+
const src = path.join(cfg, rel);
|
|
1055
|
+
if (!existsSync(src)) continue;
|
|
1056
|
+
try {
|
|
1057
|
+
moveTree(src, path.join(stash, rel));
|
|
1058
|
+
done.push(rel);
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
if (!isOsError(e)) throw e;
|
|
1061
|
+
for (const r of [...done].reverse()) {
|
|
1062
|
+
try { renameSync(path.join(stash, r), path.join(cfg, r)); } catch { /* OSError */ }
|
|
1063
|
+
}
|
|
1064
|
+
cleanHostStash(cfg, host);
|
|
1065
|
+
return { ok: false, failed: [`${rel} (${e.message})`], rolled_back: done.length };
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
unwireClaudeSettings(settingsFile(cfg, managed), managed.settings_hooks || []);
|
|
1069
|
+
unwireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
|
|
1070
|
+
// The same integrity check the uninstall path runs: a deactivate that silently failed to
|
|
1071
|
+
// unwire would leave hooks firing in a repo the user believes is off.
|
|
1072
|
+
settingsIntegrityCheck(settingsFile(cfg, managed), managed, 'absent');
|
|
1073
|
+
const cm = claudeMdPath(cfg, managed);
|
|
1074
|
+
const block = managedBlockRead(cm);
|
|
1075
|
+
mkdirSync(stash, { recursive: true }); // presence == disabled, even if nothing moved
|
|
1076
|
+
if (block !== null) {
|
|
1077
|
+
writeText(path.join(stash, '_claude_md_block.txt'), block);
|
|
1078
|
+
// ALWAYS EXCISE, never whole-file delete: prose the user added around the block must
|
|
1079
|
+
// survive a disable. The file goes only if nothing else remains.
|
|
1080
|
+
managedBlockRemove(cm);
|
|
1081
|
+
}
|
|
1082
|
+
for (const rel of done) pruneAncestors(path.dirname(path.join(cfg, rel)), cfg);
|
|
1083
|
+
return { ok: true, kind: host, moved: done.length };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** `_harness_mcp._claude_reactivate`. */
|
|
1087
|
+
function claudeReactivate(root, scope = 'global', host = 'claude') {
|
|
1088
|
+
const cfg = claudeCfg(root, scope, host);
|
|
1089
|
+
if (installState(root, host, scope) !== 'disabled') {
|
|
1090
|
+
return { ok: false,
|
|
1091
|
+
error: `install is not disabled (${installState(root, host, scope)})` };
|
|
1092
|
+
}
|
|
1093
|
+
const stash = path.join(cfg, DISABLED_STASH, host);
|
|
1094
|
+
const blockFile = path.join(stash, '_claude_md_block.txt');
|
|
1095
|
+
const reliveManifest = claudeReadManifest(cfg);
|
|
1096
|
+
// `.get("managed") or {}` — an `or`, not an isinstance guard, and the difference is
|
|
1097
|
+
// observable on a manifest whose `managed` is `null`.
|
|
1098
|
+
const reliveManaged = reliveManifest.managed || {};
|
|
1099
|
+
let relive = managedBlockRead(claudeMdPath(cfg, reliveManaged)) !== null;
|
|
1100
|
+
// Bob GLOBAL writes no managed AGENTS.md block — its preamble carrier is the owned
|
|
1101
|
+
// `rules/geneseed.md`, so a live copy of THAT is the relive signal there. Without this arm
|
|
1102
|
+
// the guard never fires for bob-global and every stashed file collides with the fresh emit,
|
|
1103
|
+
// stranding the install 'disabled' until the stash is deleted by hand.
|
|
1104
|
+
if (!relive && !reliveManaged.claude_md) {
|
|
1105
|
+
relive = Object.keys(reliveManifest).length > 0
|
|
1106
|
+
&& isFile(path.join(cfg, 'rules', 'geneseed.md'));
|
|
1107
|
+
}
|
|
1108
|
+
if (relive) {
|
|
1109
|
+
remergeClaudeHooks(cfg); // ensure the hooks are present (and the claims exact)
|
|
1110
|
+
cleanHostStash(cfg, host);
|
|
1111
|
+
return { ok: true,
|
|
1112
|
+
note: 'install was re-created while disabled; discarded the stashed snapshot' };
|
|
1113
|
+
}
|
|
1114
|
+
const leftovers = [];
|
|
1115
|
+
let moved = 0;
|
|
1116
|
+
for (const src of stashFiles(stash)) {
|
|
1117
|
+
const rel = path.relative(stash, src);
|
|
1118
|
+
if (asPosix(rel) === '_claude_md_block.txt') continue; // restored separately, below
|
|
1119
|
+
const dst = path.join(cfg, rel);
|
|
1120
|
+
if (existsSync(dst)) {
|
|
1121
|
+
leftovers.push(asPosix(rel));
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
1125
|
+
renameSync(src, dst);
|
|
1126
|
+
moved += 1;
|
|
1127
|
+
}
|
|
1128
|
+
if (leftovers.length) return { ok: false, failed: leftovers, moved };
|
|
1129
|
+
remergeClaudeHooks(cfg);
|
|
1130
|
+
const managed = claudeReadManifest(cfg).managed || {};
|
|
1131
|
+
wireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
|
|
1132
|
+
if (existsSync(blockFile)) {
|
|
1133
|
+
managedBlockWrite(claudeMdPath(cfg, managed), readText(blockFile));
|
|
1134
|
+
}
|
|
1135
|
+
cleanHostStash(cfg, host);
|
|
1136
|
+
return { ok: true, kind: host, moved };
|
|
1137
|
+
}
|