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
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web console's WRITES — `rituals/_web_actions.py`'s mutating endpoints plus the two
|
|
3
|
+
* GETs that had to cross with them.
|
|
4
|
+
*
|
|
5
|
+
* WHAT THE PLAN'S TABLE GOT WRONG, measured by reading `js/` rather than by trusting it.
|
|
6
|
+
* Three of its five "already ported" claims were false and one endpoint was out by an order
|
|
7
|
+
* of magnitude:
|
|
8
|
+
*
|
|
9
|
+
* * `_memory_drop_index` — "check whether P5's `learn` ported it". It did not; nothing in
|
|
10
|
+
* `js/` referenced it. It lands in `js/hooks.mjs`, beside the other memory-store
|
|
11
|
+
* primitives, because that is where its Python siblings' twins already live.
|
|
12
|
+
* * `_mcp_load` — "already in js/ (P6c)". What crossed in P6c is `readJsonc` and a
|
|
13
|
+
* five-line no-host reader inside `js/web/api.mjs` for `wiki.jsonc`. The host fork and
|
|
14
|
+
* the other twelve MCP functions are 168 lines of new `js/mcp.mjs`.
|
|
15
|
+
* * `settings.mjs`'s atomic write — a DIFFERENT Python function from `_mcp_save`, with a
|
|
16
|
+
* different temp suffix and no parent-directory creation. See `js/mcp.mjs`.
|
|
17
|
+
* * `api_install_toggle` — "~25 new; `_move_tree`, `_prune_empty_ancestors` and the stash
|
|
18
|
+
* ARE in `js/uninstall.mjs` (P5h)". `DISABLED_STASH`, `installKind` and `installState`
|
|
19
|
+
* are. `_move_tree`, `_prune_empty_ancestors`, `_install_move_list`, `_install_relive`,
|
|
20
|
+
* `_install_readd_entry`, `_install_deactivate`, `_install_reactivate`,
|
|
21
|
+
* `_claude_deactivate` and `_claude_reactivate` are not — 293 lines of unported
|
|
22
|
+
* move-and-roll-back engine, scored at zero. It is deferred; see below.
|
|
23
|
+
*
|
|
24
|
+
* ---------------------------------------------------------------------------------------
|
|
25
|
+
* THE ARGV-IN-A-RESPONSE-BODY TRAP, SETTLED — AND ITS PREMISE IS FALSE.
|
|
26
|
+
*
|
|
27
|
+
* The plan says `api_install_cmd` and `api_deploy_cmd` "return the interpreter and the
|
|
28
|
+
* generator's path — a Python argv, as DATA, in a response body", and offers two
|
|
29
|
+
* options: (a) the Node twin returns its own argv head and the harness normalises it, or
|
|
30
|
+
* (b) the endpoint returns the ACTION and its arguments and the job runner resolves the
|
|
31
|
+
* command — "the better design", at the cost of changing the reference and rebuilding the
|
|
32
|
+
* tracked `web/dist`.
|
|
33
|
+
*
|
|
34
|
+
* MEASURED FIRST, as the phase's own rule says. Two measurements decide it:
|
|
35
|
+
*
|
|
36
|
+
* 1. `grep` over `web/src/` finds NO consumer of either response. The React client's only
|
|
37
|
+
* calls into that area are `pickFolder`; nothing reads a `cmd` field.
|
|
38
|
+
* 2. `rituals/_web_server.py` explains why. `_post_routes` calls `api_install_cmd`, and
|
|
39
|
+
* the ONLY thing it does with the result is `if "error" in plan: send_json(plan, 409)`
|
|
40
|
+
* / `jm.start("install", plan["cmd"], …)`. The argv is handed to the job runner in the
|
|
41
|
+
* SAME PROCESS. `{"cmd": [...]}` never reaches the wire; the client sees a `job_id` at
|
|
42
|
+
* 202 or an `error`.
|
|
43
|
+
*
|
|
44
|
+
* So there is no argv in a response body. `{"cmd": [...]}` is a function's return TYPE, not
|
|
45
|
+
* an endpoint's response, and the byte gate was never going to see it. That collapses the
|
|
46
|
+
* choice:
|
|
47
|
+
*
|
|
48
|
+
* * Option (b) buys nothing. Its whole value was making two implementations return the
|
|
49
|
+
* same wire bytes; they already do, because neither returns these bytes at all. The
|
|
50
|
+
* resolver and the runner are already in one process, so "let the runner resolve it" is
|
|
51
|
+
* a rename. It would change the reference and force a `web/dist` rebuild for zero
|
|
52
|
+
* observable difference — the definition of work this port refuses.
|
|
53
|
+
* * Option (a) is what remains, minus the harness change: the Node twin returns
|
|
54
|
+
* `[process.execPath, bin/geneseed-cli.mjs, …]`, which is the honest command for the
|
|
55
|
+
* runtime it will run on, and naming a Python interpreter there would put Python back into
|
|
56
|
+
* a "no Python needed" install for the one operation the console exists to run.
|
|
57
|
+
*
|
|
58
|
+
* `web/dist` therefore does NOT need rebuilding, and it was not rebuilt.
|
|
59
|
+
*
|
|
60
|
+
* WHAT GATES IT, since no cell can. The value never reaches a response, so
|
|
61
|
+
* `tests/web_golden.py` is structurally blind to it — which is exactly the shape a corpus
|
|
62
|
+
* answers and a cell cannot (the phase rule: a cell runs the PROGRAM, a corpus runs the
|
|
63
|
+
* FUNCTION). The gate is a unit test that calls BOTH implementations and asserts three
|
|
64
|
+
* things: the reference's argv head is its own interpreter and `build.py`, absolutely; the
|
|
65
|
+
* twin's head is `process.execPath` and `bin/geneseed-cli.mjs`, absolutely; and the TAIL —
|
|
66
|
+
* every argument after the head, which is where a real port bug would live — is identical.
|
|
67
|
+
* That is the absolute-assertion-per-side discipline a tolerant comparison owes, applied to
|
|
68
|
+
* a value the byte gate cannot reach.
|
|
69
|
+
*
|
|
70
|
+
* THE CODE IS P6g's, THE DECISION IS THIS PHASE's. `api_install_cmd`, `api_deploy_cmd` and
|
|
71
|
+
* `api_restore` all sit behind `/api/actions/<x>`, which needs the `JobManager` to route at
|
|
72
|
+
* all — so porting them here would produce three functions with no call site, no cell and
|
|
73
|
+
* no route, which is the "declaration that is not a dispatch" this project keeps being
|
|
74
|
+
* bitten by. P6g wires the prefix and brings them across with the gate described above.
|
|
75
|
+
*
|
|
76
|
+
* ---------------------------------------------------------------------------------------
|
|
77
|
+
* `api_pick_folder` DOES NOT CROSS, and that is permanent.
|
|
78
|
+
*
|
|
79
|
+
* It opens an OS-NATIVE folder chooser on the daemon host: `osascript` on macOS, a one-shot
|
|
80
|
+
* `tkinter` subprocess elsewhere. There is no Node twin that is not a new GUI dependency —
|
|
81
|
+
* and the `child_process` ban would make it a sixth `_ALLOWED_SPAWNS` row for a modal
|
|
82
|
+
* dialog. So it is DECLINED rather than deferred, and `js/web/server.mjs` declares it in
|
|
83
|
+
* `DECLINED_POST` — a set separate from `NOT_PORTED_POST`, because the two mean different
|
|
84
|
+
* things and a phase that empties the second must not silently empty the first. The UI
|
|
85
|
+
* already falls back to its editable path field when this endpoint errors, which is the
|
|
86
|
+
* behaviour a 501 produces.
|
|
87
|
+
*
|
|
88
|
+
* ---------------------------------------------------------------------------------------
|
|
89
|
+
* `api_install_toggle` IS DEFERRED, with its measurement.
|
|
90
|
+
*
|
|
91
|
+
* The endpoint is 27 lines over three engine calls. One of the three (`_install_uninstall`)
|
|
92
|
+
* crossed in P5h. The other two pull in 293 lines of all-or-nothing tree moves with
|
|
93
|
+
* rollback, a stash layout, an `instructions`-entry unmerge, a re-emit-while-disabled guard
|
|
94
|
+
* and a host fork into `_claude_deactivate`/`_claude_reactivate`. None of it is web code and
|
|
95
|
+
* none of it has a `harness` VERB — which is why P5 never reached it — and its gate is a
|
|
96
|
+
* file-move fixture of the kind `harness_golden` is built for, not a request script. Porting
|
|
97
|
+
* it inside a web phase would put the largest unported engine block behind the weakest
|
|
98
|
+
* available gate. `/api/install` stays in `NOT_PORTED_POST`.
|
|
99
|
+
*/
|
|
100
|
+
import {
|
|
101
|
+
accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync, statSync, unlinkSync,
|
|
102
|
+
} from 'node:fs';
|
|
103
|
+
import os from 'node:os';
|
|
104
|
+
import path from 'node:path';
|
|
105
|
+
|
|
106
|
+
import { emitGlobalInto } from '../../bin/geneseed.mjs';
|
|
107
|
+
import { ROOT, discoverNames } from '../checkout.mjs';
|
|
108
|
+
import { withStdoutSwallowed } from '../diff.mjs';
|
|
109
|
+
import { excludeAdd, excludeRemove } from '../excludes.mjs';
|
|
110
|
+
import { setupBuildArgs } from '../generate.mjs';
|
|
111
|
+
import { frontmatter, memoryDropIndex } from '../hooks.mjs';
|
|
112
|
+
import {
|
|
113
|
+
HOSTS, bobConfigDir, claudeConfigDir, copilotConfigDir, expanduser, opencodeConfigDir,
|
|
114
|
+
pyResolve,
|
|
115
|
+
} from '../hosts.mjs';
|
|
116
|
+
import {
|
|
117
|
+
EMIT_HOST_SCOPE, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
|
|
118
|
+
readMaybe,
|
|
119
|
+
} from '../installs.mjs';
|
|
120
|
+
import {
|
|
121
|
+
MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
|
|
122
|
+
mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
|
|
123
|
+
} from '../mcp.mjs';
|
|
124
|
+
import {
|
|
125
|
+
PY_SPACE, parseJson, pyLen, pyRepr, pyStr, pyStripSpace, pyTruthy, readText, writeText,
|
|
126
|
+
} from '../lib/fs.mjs';
|
|
127
|
+
import { installDeactivate, installReactivate, installUninstall } from '../uninstall.mjs';
|
|
128
|
+
import { pySplitLines } from '../lib/udiff.mjs';
|
|
129
|
+
import {
|
|
130
|
+
NotFound, deployed, emitChoices, fingerprint, themeChoices, viewCfg, within,
|
|
131
|
+
} from './api.mjs';
|
|
132
|
+
|
|
133
|
+
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
134
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
135
|
+
|
|
136
|
+
/** `dict.get(key, default)` over a `json.loads` result. */
|
|
137
|
+
const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, key)
|
|
138
|
+
? body[key] : dflt);
|
|
139
|
+
|
|
140
|
+
/** `str(x or "")` — Python's truthiness, then Python's `str`. */
|
|
141
|
+
const strOr = (v) => (pyTruthy(v) ? pyStr(v) : '');
|
|
142
|
+
|
|
143
|
+
/** `str.split()` with no argument — runs of PYTHON whitespace, no empties. */
|
|
144
|
+
const pyWords = (s) => s.split(new RegExp(`[${PY_SPACE}]+`)).filter(Boolean);
|
|
145
|
+
|
|
146
|
+
/** `datetime.date.today().isoformat()` — LOCAL, as the reference's naive `date.today()` is. */
|
|
147
|
+
export function todayIso(d = new Date()) {
|
|
148
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
149
|
+
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `today` and `today + timedelta(days=n)` from ONE sample of the clock.
|
|
154
|
+
*
|
|
155
|
+
* The reference binds `today = datetime.date.today()` once and derives both dates from it.
|
|
156
|
+
* Two `new Date()` calls would put the provenance line and the expiry on either side of a
|
|
157
|
+
* midnight that fell between them — a one-run-in-86400 disagreement, which is the worst kind
|
|
158
|
+
* to debug.
|
|
159
|
+
*/
|
|
160
|
+
function promoteDates(days) {
|
|
161
|
+
const d = new Date();
|
|
162
|
+
const today = todayIso(d);
|
|
163
|
+
d.setDate(d.getDate() + days);
|
|
164
|
+
return [today, todayIso(d)];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `body.get(k, "") != <a str>` — Python's `!=`, where a non-string NEVER equals a string.
|
|
169
|
+
*
|
|
170
|
+
* `str(...)` would be wrong here and measurably so: the reference compares the RAW body
|
|
171
|
+
* value, so a client sending `"fingerprint": 0` against an ABSENT file (whose fingerprint is
|
|
172
|
+
* `""`) gets a conflict there, where a coerced comparison would find `"" == ""` and write.
|
|
173
|
+
*/
|
|
174
|
+
const fpMatches = (got, want) => typeof got === 'string' && got === want;
|
|
175
|
+
|
|
176
|
+
// ---- user rules (user-rules.md) ----------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** `build.RULES_FILE`. */
|
|
179
|
+
export const RULES_FILE = 'user-rules.md';
|
|
180
|
+
const rulesPath = (state) => path.join(state.target, RULES_FILE);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `_web_catalog.RULE_HEAD_RE` — `## R<n> — Title`, anchored at column 0 so a rule body's
|
|
184
|
+
* fenced code and the stub's indented format example never parse as rules.
|
|
185
|
+
*
|
|
186
|
+
* The whitespace classes are `PY_SPACE`, not `\s`: P6d measured the two apart and this
|
|
187
|
+
* pattern runs over a HAND-EDITED file, which is where a non-breaking space actually turns
|
|
188
|
+
* up. `\S` is its complement for the same reason.
|
|
189
|
+
*
|
|
190
|
+
* `\d` IS LEFT ASCII, and that is a declared divergence rather than an oversight. Python's
|
|
191
|
+
* `\d` on a `str` pattern is `\p{Nd}`, so `## R١ — …` parses there and not here. Matching it
|
|
192
|
+
* would only move the problem one line down: the reference then calls `int()` on the captured
|
|
193
|
+
* text, and `int()` accepts every Unicode decimal digit where `Number()` accepts none — so
|
|
194
|
+
* closing the gap needs `int`'s digit table, for a heading no theme seeds, no build writes
|
|
195
|
+
* and the web editor cannot produce (it writes `R<n>` itself from `max(ids) + 1`).
|
|
196
|
+
*/
|
|
197
|
+
export const RULE_HEAD_RE = new RegExp(
|
|
198
|
+
`^##[${PY_SPACE}]+R(\\d+)[${PY_SPACE}]*[—–-]+[${PY_SPACE}]*`
|
|
199
|
+
+ `([^${PY_SPACE}].*?)[${PY_SPACE}]*$`,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
/** `_web_catalog.RULES_BUDGET` — advisory; nothing blocks past it. */
|
|
203
|
+
export const RULES_BUDGET = { max_rules: 15, max_tokens: 1500 };
|
|
204
|
+
|
|
205
|
+
const META_RE = new RegExp(`^\\((.+)\\)[${PY_SPACE}]*$`);
|
|
206
|
+
|
|
207
|
+
/** `_web_catalog._parse_rule_meta` — `{}` when the line is body rather than metadata. */
|
|
208
|
+
function parseRuleMeta(line) {
|
|
209
|
+
const m = META_RE.exec(pyStripSpace(line));
|
|
210
|
+
if (!m) return {};
|
|
211
|
+
const meta = {};
|
|
212
|
+
for (const part of m[1].split('|')) {
|
|
213
|
+
const at = part.indexOf(':');
|
|
214
|
+
if (at < 0) continue; // `partition` with no separator — not a pair
|
|
215
|
+
const key = pyStripSpace(part.slice(0, at)).toLowerCase().replaceAll(' ', '_');
|
|
216
|
+
if (['scope', 'source', 'trial_until'].includes(key)) {
|
|
217
|
+
meta[key] = pyStripSpace(part.slice(at + 1));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return meta;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `_web_catalog.parse_rules` — user-rules.md to `[rules, warnings]`.
|
|
225
|
+
*
|
|
226
|
+
* Every rule carries its `start`/`end` LINE INDICES, which is what lets `apiRulesMutate`
|
|
227
|
+
* splice exactly one block and leave every other byte of the user's file — prose,
|
|
228
|
+
* formatting, hand-written sections — untouched.
|
|
229
|
+
*/
|
|
230
|
+
export function parseRules(text) {
|
|
231
|
+
const lines = pySplitLines(text);
|
|
232
|
+
const rules = [];
|
|
233
|
+
const warnings = [];
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
236
|
+
const m = RULE_HEAD_RE.exec(lines[idx]);
|
|
237
|
+
if (!m) continue;
|
|
238
|
+
let end = lines.length;
|
|
239
|
+
for (let j = idx + 1; j < lines.length; j += 1) {
|
|
240
|
+
if (lines[j].startsWith('## ')) { end = j; break; }
|
|
241
|
+
}
|
|
242
|
+
const rid = Number(m[1]);
|
|
243
|
+
if (seen.has(rid)) warnings.push(`duplicate rule id R${rid}`);
|
|
244
|
+
seen.add(rid);
|
|
245
|
+
let k = idx + 1;
|
|
246
|
+
while (k < end && !pyStripSpace(lines[k])) k += 1;
|
|
247
|
+
const meta = k < end ? parseRuleMeta(lines[k]) : {};
|
|
248
|
+
if (Object.keys(meta).length) k += 1;
|
|
249
|
+
rules.push({
|
|
250
|
+
id: rid,
|
|
251
|
+
title: m[2],
|
|
252
|
+
scope: Object.hasOwn(meta, 'scope') ? meta.scope : 'project',
|
|
253
|
+
source: Object.hasOwn(meta, 'source') ? meta.source : '',
|
|
254
|
+
trial_until: Object.hasOwn(meta, 'trial_until') ? meta.trial_until : '',
|
|
255
|
+
body: pyStripSpace(lines.slice(k, end).join('\n')),
|
|
256
|
+
start: idx,
|
|
257
|
+
end,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return [rules, warnings];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `_web_catalog.api_rules` — the Rules page payload. */
|
|
264
|
+
export function apiRules(state) {
|
|
265
|
+
const p = rulesPath(state);
|
|
266
|
+
if (!isFile(p)) {
|
|
267
|
+
return { exists: false, path: p, rules: [], warnings: [], fingerprint: '',
|
|
268
|
+
stats: { rules: 0, lines: 0, tokens: 0, ...RULES_BUDGET } };
|
|
269
|
+
}
|
|
270
|
+
const text = readMaybe(p) ?? '';
|
|
271
|
+
const [rules, warnings] = parseRules(text);
|
|
272
|
+
const today = todayIso();
|
|
273
|
+
const out = rules.map((r) => ({
|
|
274
|
+
id: r.id, title: r.title, scope: r.scope, source: r.source,
|
|
275
|
+
trial_until: r.trial_until,
|
|
276
|
+
status: r.trial_until ? 'trial' : 'active',
|
|
277
|
+
overdue: Boolean(r.trial_until) && r.trial_until < today,
|
|
278
|
+
body: r.body,
|
|
279
|
+
}));
|
|
280
|
+
return { exists: true, path: p, rules: out, warnings, fingerprint: fingerprint(text),
|
|
281
|
+
// `len(text) // 4` — CODE POINTS. `String.length` counts UTF-16 units, so an emoji in a
|
|
282
|
+
// rule body would put the two token estimates two apart.
|
|
283
|
+
stats: { rules: rules.length, lines: pySplitLines(text).length,
|
|
284
|
+
tokens: Math.floor(pyLen(text) / 4), ...RULES_BUDGET } };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `_web_actions._rules_read`. */
|
|
288
|
+
function rulesRead(state) {
|
|
289
|
+
const p = rulesPath(state);
|
|
290
|
+
return [p, isFile(p) ? (readMaybe(p) ?? '') : ''];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** `_web_actions._rule_block`. */
|
|
294
|
+
function ruleBlock(rid, title, scope, source, trialUntil, body) {
|
|
295
|
+
const meta = [`scope: ${scope}`];
|
|
296
|
+
if (source) meta.push(`source: ${source}`);
|
|
297
|
+
if (trialUntil) meta.push(`trial until: ${trialUntil}`);
|
|
298
|
+
return `## R${rid} — ${title}\n(${meta.join(' | ')})\n${pyStripSpace(body)}\n`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `_web_actions._rule_fields` — validate and normalise a rule's writable fields.
|
|
303
|
+
*
|
|
304
|
+
* Throws (→ the shell's JSON 500 carrying the message) on an unusable rule. `\p{Nd}` here and
|
|
305
|
+
* not `\d`, unlike `RULE_HEAD_RE`: this one is a pure predicate over a string that is stored
|
|
306
|
+
* as a string, so matching Python's Unicode-aware `\d` exactly costs nothing and needs no
|
|
307
|
+
* `int()`.
|
|
308
|
+
*/
|
|
309
|
+
function ruleFields(body) {
|
|
310
|
+
const title = pyWords(strOr(bget(body, 'title'))).join(' ');
|
|
311
|
+
const text = pyStripSpace(strOr(bget(body, 'body')));
|
|
312
|
+
if (!title) throw new Error('a rule needs a title');
|
|
313
|
+
if (!text) throw new Error('a rule needs body text');
|
|
314
|
+
let scope = bget(body, 'scope');
|
|
315
|
+
if (scope !== 'user' && scope !== 'project') scope = 'project';
|
|
316
|
+
const source = pyWords(strOr(bget(body, 'source'))).join(' ');
|
|
317
|
+
const trial = pyStripSpace(strOr(bget(body, 'trial_until')));
|
|
318
|
+
if (trial && !/^\p{Nd}{4}-\p{Nd}{2}-\p{Nd}{2}$/u.test(trial)) {
|
|
319
|
+
throw new Error('trial until must be YYYY-MM-DD');
|
|
320
|
+
}
|
|
321
|
+
return [title, text, scope, source, trial];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `int(body.get("id"))`, with both of its failures folded into `NotFound("rule id")`.
|
|
326
|
+
*
|
|
327
|
+
* Python's `int()` takes an int, a bool (which IS an int there), a float (truncating) and a
|
|
328
|
+
* decimal string with surrounding whitespace; everything else is a TypeError or ValueError,
|
|
329
|
+
* and the reference catches both.
|
|
330
|
+
*/
|
|
331
|
+
function ruleId(v) {
|
|
332
|
+
if (typeof v === 'string') {
|
|
333
|
+
const s = pyStripSpace(v);
|
|
334
|
+
if (!/^[+-]?\d+$/.test(s)) throw new NotFound('rule id');
|
|
335
|
+
return Number(s);
|
|
336
|
+
}
|
|
337
|
+
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
338
|
+
if (typeof v === 'number') return Math.trunc(v);
|
|
339
|
+
if (v !== null && v !== undefined && typeof v.valueOf === 'function'
|
|
340
|
+
&& typeof v.valueOf() === 'number') {
|
|
341
|
+
return Math.trunc(v.valueOf());
|
|
342
|
+
}
|
|
343
|
+
throw new NotFound('rule id');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* `_web_actions.api_rules_mutate` — add / update / delete on user-rules.md.
|
|
348
|
+
*
|
|
349
|
+
* EVERY MUTATION REQUIRES THE FINGERPRINT OF THE CONTENT THE CLIENT LAST READ. An agent
|
|
350
|
+
* session may be editing the same file mid-flight, so a stale write returns `ok: false`
|
|
351
|
+
* (which the shell maps to 409) and the client re-fetches — it never clobbers. The fresh
|
|
352
|
+
* fingerprint comes back in the response so a client can chain edits without a re-fetch.
|
|
353
|
+
*/
|
|
354
|
+
export function apiRulesMutate(state, body) {
|
|
355
|
+
const op = bget(body, 'op');
|
|
356
|
+
if (op !== 'add' && op !== 'update' && op !== 'delete') {
|
|
357
|
+
throw new NotFound(`rules op ${pyRepr(op)}`);
|
|
358
|
+
}
|
|
359
|
+
const [p, original] = rulesRead(state);
|
|
360
|
+
let text = original;
|
|
361
|
+
if (!fpMatches(bget(body, 'fingerprint', ''), fingerprint(text))) {
|
|
362
|
+
return { ok: false, error: 'conflict',
|
|
363
|
+
detail: 'user-rules.md changed since you loaded it — reloading' };
|
|
364
|
+
}
|
|
365
|
+
const [rules] = parseRules(text);
|
|
366
|
+
let rid;
|
|
367
|
+
let next;
|
|
368
|
+
if (op === 'add') {
|
|
369
|
+
const [title, rtext, scope, source, trial] = ruleFields(body);
|
|
370
|
+
rid = rules.reduce((mx, r) => (r.id > mx ? r.id : mx), 0) + 1;
|
|
371
|
+
if (!text) text = '# User rules\n';
|
|
372
|
+
next = `${text.replace(/\n+$/, '')}\n\n${ruleBlock(rid, title, scope, source, trial, rtext)}`;
|
|
373
|
+
} else {
|
|
374
|
+
rid = ruleId(bget(body, 'id'));
|
|
375
|
+
const target = rules.find((r) => r.id === rid);
|
|
376
|
+
if (target === undefined) throw new NotFound(`rule R${rid}`);
|
|
377
|
+
const lines = pySplitLines(text);
|
|
378
|
+
if (op === 'update') {
|
|
379
|
+
const [title, rtext, scope, source, trial] = ruleFields(body);
|
|
380
|
+
const block = ruleBlock(rid, title, scope, source, trial, rtext);
|
|
381
|
+
lines.splice(target.start, target.end - target.start,
|
|
382
|
+
...pySplitLines(block.replace(/\n+$/, '')));
|
|
383
|
+
} else {
|
|
384
|
+
// delete — also swallow ONE preceding blank separator line, or repeated deletes leave
|
|
385
|
+
// a growing run of blank lines in the user's file.
|
|
386
|
+
let start = target.start;
|
|
387
|
+
if (start > 0 && !pyStripSpace(lines[start - 1])) start -= 1;
|
|
388
|
+
lines.splice(start, target.end - start);
|
|
389
|
+
}
|
|
390
|
+
next = `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
|
391
|
+
}
|
|
392
|
+
writeText(p, next);
|
|
393
|
+
return { ok: true, op, id: rid, fingerprint: fingerprint(next) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `_web_actions.api_rules_promote` — one memory fact into a trial rule.
|
|
398
|
+
*
|
|
399
|
+
* The provenance line and the month of probation are the rule this endpoint owns, and both
|
|
400
|
+
* are DATES read from the clock. `tests/web_golden.py` gates them from its own shared
|
|
401
|
+
* per-cell clock rather than destamping them — a destamp would erase exactly the thing.
|
|
402
|
+
*/
|
|
403
|
+
export function apiRulesPromote(state, body) {
|
|
404
|
+
const name = strOr(bget(body, 'name'));
|
|
405
|
+
const d = memoryDir(state);
|
|
406
|
+
if (!isDir(d)) throw new NotFound('memory store');
|
|
407
|
+
if (!name || name.includes('/') || name.includes('\\')
|
|
408
|
+
|| name === 'MEMORY' || name === 'README') {
|
|
409
|
+
throw new NotFound(name);
|
|
410
|
+
}
|
|
411
|
+
const src = path.join(d, `${name}.md`);
|
|
412
|
+
if (!isFile(src)) throw new NotFound(name);
|
|
413
|
+
const [fm, memBody] = frontmatter(readMaybe(src) ?? '');
|
|
414
|
+
const fmName = fm.has('name') ? fm.get('name') : name;
|
|
415
|
+
const fmDesc = fm.has('description') ? fm.get('description') : '';
|
|
416
|
+
const [today, trial] = promoteDates(30);
|
|
417
|
+
const res = apiRulesMutate(state, {
|
|
418
|
+
op: 'add',
|
|
419
|
+
fingerprint: bget(body, 'fingerprint', ''),
|
|
420
|
+
title: pyTruthy(bget(body, 'title')) ? bget(body, 'title') : fmName,
|
|
421
|
+
body: pyTruthy(bget(body, 'body')) ? bget(body, 'body')
|
|
422
|
+
: (pyStripSpace(memBody) || fmDesc),
|
|
423
|
+
scope: bget(body, 'scope'),
|
|
424
|
+
source: `memory ${name}, promoted ${today}`,
|
|
425
|
+
trial_until: trial,
|
|
426
|
+
});
|
|
427
|
+
if (pyTruthy(res.ok) && pyTruthy(bget(body, 'delete_memory'))) {
|
|
428
|
+
apiMemoryDelete(state, name);
|
|
429
|
+
res.deleted_memory = name;
|
|
430
|
+
}
|
|
431
|
+
return res;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---- PROFILE.md --------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/** `_web_actions.api_profile_save` — the whole file, fingerprint-guarded like the rules. */
|
|
437
|
+
export function apiProfileSave(state, body) {
|
|
438
|
+
const p = path.join(state.target, 'PROFILE.md');
|
|
439
|
+
const cur = isFile(p) ? (readMaybe(p) ?? '') : '';
|
|
440
|
+
if (!fpMatches(bget(body, 'fingerprint', ''), fingerprint(cur))) {
|
|
441
|
+
return { ok: false, error: 'conflict',
|
|
442
|
+
detail: 'PROFILE.md changed since you loaded it — reloading' };
|
|
443
|
+
}
|
|
444
|
+
let next = strOr(bget(body, 'text'));
|
|
445
|
+
if (next && !next.endsWith('\n')) next += '\n';
|
|
446
|
+
writeText(p, next);
|
|
447
|
+
// The fingerprint of what is ON DISK, which is not the fingerprint of what was SENT once
|
|
448
|
+
// the newline has been appended — and the client chains its next save off this value.
|
|
449
|
+
return { ok: true, path: p, fingerprint: fingerprint(next) };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---- memory ------------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/** `_web_catalog._memory_dir` — always `<target>/memory`, never the CWD-scanning resolver. */
|
|
455
|
+
const memoryDir = (state) => path.join(state.target, 'memory');
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* `_web_actions.api_memory_delete` — one fact file, and its line in the MEMORY.md index.
|
|
459
|
+
*
|
|
460
|
+
* `name` is a BARE SLUG: a path separator, or one of the two reserved names, is refused, so
|
|
461
|
+
* this can only ever remove a fact inside the resolved memory dir. MEMORY.md is the index the
|
|
462
|
+
* agent reads at session start and README.md documents the store — deleting either through
|
|
463
|
+
* this endpoint would be silent damage, which is why the guard is a name check and not a
|
|
464
|
+
* containment check.
|
|
465
|
+
*/
|
|
466
|
+
export function apiMemoryDelete(state, name) {
|
|
467
|
+
const d = memoryDir(state);
|
|
468
|
+
if (!isDir(d)) throw new NotFound('memory store');
|
|
469
|
+
if (!name || name.includes('/') || name.includes('\\')
|
|
470
|
+
|| name === 'MEMORY' || name === 'README') {
|
|
471
|
+
throw new NotFound(name);
|
|
472
|
+
}
|
|
473
|
+
const p = path.join(d, `${name}.md`);
|
|
474
|
+
if (!isFile(p)) throw new NotFound(name);
|
|
475
|
+
unlinkSync(p);
|
|
476
|
+
memoryDropIndex(d, name);
|
|
477
|
+
state.refresh();
|
|
478
|
+
return { deleted: name };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---- sovereign-repo exclusions -------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* `_web_actions.api_excludes_mutate` — this endpoint owns no exclusion logic of its own; it
|
|
485
|
+
* validates the body and hands off to the same engine `harness exclude add|remove` calls.
|
|
486
|
+
*
|
|
487
|
+
* A malformed body is rejected with `ok: false` (→ 409) rather than reaching `excludeAdd`,
|
|
488
|
+
* which assumes a real path string. That arm is also where a body that never parsed lands:
|
|
489
|
+
* `readJsonBody` hands the endpoint `{}`, and `{}` has no action.
|
|
490
|
+
*/
|
|
491
|
+
export function apiExcludesMutate(state, body) {
|
|
492
|
+
const action = bget(body, 'action');
|
|
493
|
+
const p = pyStripSpace(strOr(bget(body, 'path')));
|
|
494
|
+
if ((action !== 'add' && action !== 'remove') || !p) {
|
|
495
|
+
return { ok: false, path: p,
|
|
496
|
+
messages: ['body must be {action: add|remove, path: <folder>}'] };
|
|
497
|
+
}
|
|
498
|
+
return action === 'add' ? excludeAdd(p) : excludeRemove(p);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---- MCP ----------------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
/** `_web_actions.api_mcp` — MCP servers per ACTIVE install, host-aware. */
|
|
504
|
+
export function apiMcp() {
|
|
505
|
+
const out = [];
|
|
506
|
+
for (const [label, p, host, scope, root] of mcpInstallTargets()) {
|
|
507
|
+
if (installState(root, host, scope) !== 'active') continue;
|
|
508
|
+
const cfg = mcpLoad(p, host);
|
|
509
|
+
const servers = [];
|
|
510
|
+
for (const name of mcpKnownNames(cfg, host)) {
|
|
511
|
+
const [lbl, desc] = mcpMeta(name);
|
|
512
|
+
servers.push({ name, label: lbl, desc,
|
|
513
|
+
preset: Object.hasOwn(MCP_PRESETS, name),
|
|
514
|
+
state: mcpState(cfg, name, host) });
|
|
515
|
+
}
|
|
516
|
+
out.push({ label, path: p, host, root, exists: isFile(p),
|
|
517
|
+
commented: mcpCommented(p), servers });
|
|
518
|
+
}
|
|
519
|
+
// `default` is kept for response-shape stability; the table nests MCP per install.
|
|
520
|
+
return { targets: out, default: 0 };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* `_web_actions.api_mcp_toggle` — enable / disable / first-add one server.
|
|
525
|
+
*
|
|
526
|
+
* THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
|
|
527
|
+
* endpoint: `known` is built from the detected install targets and an unlisted path is a 404
|
|
528
|
+
* before anything is read or written. Without it this is "write arbitrary JSON to an
|
|
529
|
+
* arbitrary file", behind a CSRF token and nothing else.
|
|
530
|
+
*
|
|
531
|
+
* The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
|
|
532
|
+
* rewritten — so the safety check and the value saved come from the same read, with no
|
|
533
|
+
* time-of-check/time-of-use gap and no comment-stripper touching a string that contains
|
|
534
|
+
* `,]`. A file that will not parse is refused rather than clobbered: it may be
|
|
535
|
+
* `~/.claude.json`, which holds projects and history far beyond MCP wiring.
|
|
536
|
+
*/
|
|
537
|
+
export function apiMcpToggle(state, body) {
|
|
538
|
+
const name = strOr(bget(body, 'name'));
|
|
539
|
+
const want = pyTruthy(bget(body, 'enabled'));
|
|
540
|
+
const pathArg = strOr(bget(body, 'path'));
|
|
541
|
+
const known = new Map();
|
|
542
|
+
for (const [, cfgPath, host] of mcpInstallTargets()) {
|
|
543
|
+
known.set(String(cfgPath), [cfgPath, host]);
|
|
544
|
+
}
|
|
545
|
+
const hit = known.get(pathArg);
|
|
546
|
+
if (hit === undefined || !name) {
|
|
547
|
+
throw new NotFound(`mcp target ${pathArg || '(none)'}`);
|
|
548
|
+
}
|
|
549
|
+
const [p, host] = hit;
|
|
550
|
+
if (mcpCommented(p)) {
|
|
551
|
+
return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
|
|
552
|
+
}
|
|
553
|
+
let cfg;
|
|
554
|
+
if (['claude', 'bob', 'copilot'].includes(host)) {
|
|
555
|
+
if (isFile(p)) {
|
|
556
|
+
let parsed;
|
|
557
|
+
try {
|
|
558
|
+
parsed = parseJson(readText(p));
|
|
559
|
+
} catch {
|
|
560
|
+
parsed = null;
|
|
561
|
+
}
|
|
562
|
+
if (!isDict(parsed)) {
|
|
563
|
+
return { ok: false,
|
|
564
|
+
error: "couldn't parse this config — edit it by hand to avoid data loss" };
|
|
565
|
+
}
|
|
566
|
+
cfg = parsed;
|
|
567
|
+
} else {
|
|
568
|
+
cfg = {};
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
cfg = mcpLoad(p);
|
|
572
|
+
}
|
|
573
|
+
if (mcpState(cfg, name, host) === 'absent') {
|
|
574
|
+
if (!Object.hasOwn(MCP_PRESETS, name)) {
|
|
575
|
+
return { ok: false, error: `unknown server '${name}'` };
|
|
576
|
+
}
|
|
577
|
+
if (!want) return { ok: false, error: `'${name}' is not configured` };
|
|
578
|
+
cfg = mcpApply(cfg, name, mcpPresetBlock(name, host), host);
|
|
579
|
+
cfg = mcpSetEnabled(cfg, name, true, host);
|
|
580
|
+
} else {
|
|
581
|
+
cfg = mcpSetEnabled(cfg, name, want, host);
|
|
582
|
+
}
|
|
583
|
+
mcpSave(p, cfg);
|
|
584
|
+
return { ok: true, name, state: mcpState(cfg, name, host) };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---- the harness selector ------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* `_web_actions.api_select_view` — re-point the whole console at a detected install.
|
|
591
|
+
*
|
|
592
|
+
* The (host, path) PAIR must be one of the detected targets, and the ROOT is threaded through
|
|
593
|
+
* beside the data dir: markers and sigils live at the install root, not in the data dir, and
|
|
594
|
+
* without it a claude/bob PROJECT view mis-detects as opencode/neutral.
|
|
595
|
+
*
|
|
596
|
+
* The pair is SERIALISED into the key rather than concatenated with a separator: the path
|
|
597
|
+
* comes straight out of a request body, and any separator it could contain would let one
|
|
598
|
+
* pair impersonate another in a lookup whose whole job is to be an allowlist.
|
|
599
|
+
*/
|
|
600
|
+
export function apiSelectView(state, body) {
|
|
601
|
+
const known = new Map();
|
|
602
|
+
for (const [host, scope, root] of installTargets()) {
|
|
603
|
+
known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
|
|
604
|
+
}
|
|
605
|
+
const hit = known.get(JSON.stringify(
|
|
606
|
+
[strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
|
|
607
|
+
));
|
|
608
|
+
if (hit === undefined) throw new NotFound('unknown install (host, path)');
|
|
609
|
+
const [host, scope, root] = hit;
|
|
610
|
+
state.selectView(viewCfg(host, scope, root), root);
|
|
611
|
+
return { ok: true, target: state.target, theme: state.theme, emit: state.emit };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---- P6g — restore, and the two build-command resolvers -------------------------------------
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* `_web_actions._build_override` — (theme, emit) for a Build POST.
|
|
618
|
+
*
|
|
619
|
+
* A valid override in the body wins; anything missing or unrecognised falls back to the
|
|
620
|
+
* DETECTED install, so a bogus body value can never reach the build argv. That is the same
|
|
621
|
+
* allowlist shape `api_install_cmd` uses, and it is the reason neither endpoint needs to
|
|
622
|
+
* sanitise a string: an unknown one is simply not used.
|
|
623
|
+
*/
|
|
624
|
+
export function buildOverride(state, body) {
|
|
625
|
+
const themes = new Set(themeChoices().map((c) => c.name));
|
|
626
|
+
const emits = new Set(emitChoices().map((c) => c.name));
|
|
627
|
+
const t = bget(body, 'theme');
|
|
628
|
+
const e = bget(body, 'emit');
|
|
629
|
+
return [themes.has(t) ? t : state.theme, emits.has(e) ? e : state.emit];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* `_web_actions._global_emitter_for` — the global emit matching a deployed install's
|
|
634
|
+
* `.geneseed-emit`, so the EXPECTED render uses the install's own host dialect.
|
|
635
|
+
*
|
|
636
|
+
* Returns the HOST rather than a function, because `emitGlobalInto(host, …)` is how the twin
|
|
637
|
+
* of `build.HOSTS[host]["emit_global"]` is already spelt (`js/diff.mjs` calls it that way).
|
|
638
|
+
* An unknown or missing marker falls back to OpenCode, exactly as the reference's two
|
|
639
|
+
* `.get(..., default)` calls do.
|
|
640
|
+
*/
|
|
641
|
+
export function globalEmitHostFor(emit) {
|
|
642
|
+
const host = (EMIT_HOST_SCOPE.get(emit || '') ?? ['opencode', 'global'])[0];
|
|
643
|
+
return HOSTS.some((h) => h.host === host) ? host : 'opencode';
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* `_web_actions.api_restore` — restore selected drifted files from the SOURCE render.
|
|
648
|
+
*
|
|
649
|
+
* Source wins and local edits are discarded (the inverse, keeping them, is Export
|
|
650
|
+
* improvements). Renders the expected copy exactly as `_diff_collect` does, then per rel:
|
|
651
|
+
* expected present -> overwrite/create the deployed copy; expected absent but deployed present
|
|
652
|
+
* (an 'added' file) -> delete it; neither -> an error and nothing touched.
|
|
653
|
+
*
|
|
654
|
+
* SYNCHRONOUS, AND NOT A JOB. One render, the same cost as a diff GET, and it returns a
|
|
655
|
+
* structured result rather than a job id — which is why `_post_routes` answers it before it
|
|
656
|
+
* ever consults the action table.
|
|
657
|
+
*
|
|
658
|
+
* THE EMIT AND THE FOOTPRINT ARE READ OFF THE DEPLOYMENT, and the reference's comment says
|
|
659
|
+
* what each one costs: a silently-OpenCode `expected` would overwrite a Claude install's
|
|
660
|
+
* agents with the wrong frontmatter, and a full-footprint `expected` on a lean install
|
|
661
|
+
* rewrites AGENT.md with the inlined laws and DELETES `laws/universal.md`, which only the lean
|
|
662
|
+
* emit writes.
|
|
663
|
+
*
|
|
664
|
+
* THE POSTURE AND THE MODE cost the most of the four, because this verb WRITES. Rendering
|
|
665
|
+
* `expected` at `peer`/`direct` made restoring AGENT.md silently revert the user's chosen
|
|
666
|
+
* register — and `diffCollect`'s matching hole is what put the file in the restore list in the
|
|
667
|
+
* first place, so the panel offered the revert and then performed it. Both sides read the
|
|
668
|
+
* deployment now; `state` already carries the two values (`installs.mjs` detects them from
|
|
669
|
+
* the rendered `## Posture` / `## Mode` lead).
|
|
670
|
+
*/
|
|
671
|
+
export function apiRestore(state, files) {
|
|
672
|
+
if (!deployed(state)) {
|
|
673
|
+
return { restored: [], deleted: [], errors: ['no deployed harness'] };
|
|
674
|
+
}
|
|
675
|
+
const restored = [];
|
|
676
|
+
const deleted = [];
|
|
677
|
+
const errors = [];
|
|
678
|
+
const target = pyResolve(state.target);
|
|
679
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-restore-'));
|
|
680
|
+
try {
|
|
681
|
+
const expected = pyResolve(path.join(tmp, 'expected'));
|
|
682
|
+
withStdoutSwallowed(() => emitGlobalInto(globalEmitHostFor(state.emit), {
|
|
683
|
+
theme: state.theme,
|
|
684
|
+
out: path.join(tmp, 'bundle'),
|
|
685
|
+
cfgDir: expected,
|
|
686
|
+
footprint: state.footprint,
|
|
687
|
+
posture: state.posture,
|
|
688
|
+
mode: state.mode,
|
|
689
|
+
}));
|
|
690
|
+
for (const raw of (pyTruthy(files) ? files : [])) {
|
|
691
|
+
const rel = pyStripSpace(pyStr(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
|
|
692
|
+
const dst = pyResolve(path.join(target, rel));
|
|
693
|
+
const src = pyResolve(path.join(expected, rel));
|
|
694
|
+
if (!rel || !within(dst, target) || !within(src, expected)) {
|
|
695
|
+
errors.push(`${rel}: outside the deployed tree`);
|
|
696
|
+
} else if (isFile(src)) {
|
|
697
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
698
|
+
// `shutil.copyfile` — a BYTE copy, so the restored file keeps the render's own line
|
|
699
|
+
// endings rather than the platform's (the `writeText`/`copy2` rule, one file over).
|
|
700
|
+
copyFileSync(src, dst);
|
|
701
|
+
restored.push(rel);
|
|
702
|
+
} else if (isFile(dst)) {
|
|
703
|
+
unlinkSync(dst);
|
|
704
|
+
deleted.push(rel);
|
|
705
|
+
} else {
|
|
706
|
+
errors.push(`${rel}: not in the source render nor deployed`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
} finally {
|
|
710
|
+
// `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
|
|
711
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
712
|
+
}
|
|
713
|
+
state.refresh();
|
|
714
|
+
return { restored, deleted, errors };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** `_web_actions._EMIT_FOR` — (host, scope) -> the emit name that installs it. */
|
|
718
|
+
const EMIT_FOR = new Map([
|
|
719
|
+
['opencode global', 'opencode-global'], ['opencode project', 'opencode'],
|
|
720
|
+
['claude global', 'claude-global'], ['claude project', 'claude'],
|
|
721
|
+
['bob global', 'bob-global'], ['bob project', 'bob'],
|
|
722
|
+
['copilot global', 'copilot-global'], ['copilot project', 'copilot'],
|
|
723
|
+
]);
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* `_web_actions.api_install_cmd` — the build command that installs Geneseed into a DETECTED
|
|
727
|
+
* location, or re-themes an already-active one (an in-place re-emit, the same command either
|
|
728
|
+
* way).
|
|
729
|
+
*
|
|
730
|
+
* The (host, path) pair MUST be one of the detected targets — the same allowlist
|
|
731
|
+
* `api_select_view` uses, and the reason the target is never built from raw body input — and
|
|
732
|
+
* it must not be `disabled` (reactivate first). Every other field follows the same rule: a
|
|
733
|
+
* VALID picked value wins, else the install's own, so a re-theme never silently flips the
|
|
734
|
+
* footprint, the register or the mode, and a bogus body value cannot reach the argv.
|
|
735
|
+
*
|
|
736
|
+
* `{cmd: [...]}` IS A RETURN TYPE, NOT A RESPONSE. `_post_routes` hands it straight to the job
|
|
737
|
+
* runner in the same process; it never reaches the wire. That is what collapsed P6f's
|
|
738
|
+
* "argv-in-a-response-body" question — see this file's header — and why the twin's head is
|
|
739
|
+
* `process.execPath` + `bin/geneseed.mjs` rather than a Python argv it would then have to
|
|
740
|
+
* pretend to run.
|
|
741
|
+
*/
|
|
742
|
+
export function apiInstallCmd(state, body) {
|
|
743
|
+
const known = new Map();
|
|
744
|
+
for (const [host, scope, root] of installTargets()) {
|
|
745
|
+
known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
|
|
746
|
+
}
|
|
747
|
+
const hit = known.get(JSON.stringify(
|
|
748
|
+
[strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
|
|
749
|
+
));
|
|
750
|
+
if (hit === undefined) throw new NotFound('unknown install (host, path)');
|
|
751
|
+
const [host, scope, root] = hit;
|
|
752
|
+
if (installState(root, host, scope) === 'disabled') {
|
|
753
|
+
return { error: 'install is disabled — reactivate it before (re)building' };
|
|
754
|
+
}
|
|
755
|
+
const emit = EMIT_FOR.get(`${host} ${scope}`);
|
|
756
|
+
if (emit === undefined) return { error: `no install mode for ${host}:${scope}` };
|
|
757
|
+
const themes = new Set(themeChoices().map((c) => c.name));
|
|
758
|
+
const bt = bget(body, 'theme');
|
|
759
|
+
const theme = themes.has(bt) ? bt : state.theme;
|
|
760
|
+
const bfp = bget(body, 'footprint');
|
|
761
|
+
const fp = (bfp === 'lean' || bfp === 'full') ? bfp : footprintOfDir(root);
|
|
762
|
+
const bpos = bget(body, 'posture');
|
|
763
|
+
const pos = discoverNames('postures', 'peer').includes(bpos)
|
|
764
|
+
? bpos : (postureOfDir(root) || 'peer');
|
|
765
|
+
const bmode = bget(body, 'mode');
|
|
766
|
+
const mode = discoverNames('modes', 'direct').includes(bmode)
|
|
767
|
+
? bmode : (modeOfDir(root) || 'direct');
|
|
768
|
+
const out = scope === 'global' ? null : String(root);
|
|
769
|
+
const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode);
|
|
770
|
+
return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* `_web_actions.api_deploy_cmd` — the build command that deploys a FRESH per-repo harness into
|
|
775
|
+
* an arbitrary folder the user chose.
|
|
776
|
+
*
|
|
777
|
+
* The open-ended sibling of `api_install_cmd`, which only rebuilds a pre-detected target from a
|
|
778
|
+
* tight allowlist. Scope is always `project`: a global lands in the host's config dir, never a
|
|
779
|
+
* chosen folder. THIS ENDPOINT TAKES A RAW PATH, so it is the trust boundary — the path is
|
|
780
|
+
* validated here as an existing, writable directory that is not a host's own global config dir
|
|
781
|
+
* (deploying a `project` emit there would mislabel as the global row and collide on dedup).
|
|
782
|
+
*/
|
|
783
|
+
/**
|
|
784
|
+
* `_web_actions.api_install_toggle` — deactivate, reactivate, or REMOVE one install.
|
|
785
|
+
*
|
|
786
|
+
* KEYED ON THE (host, path) PAIR, and the pair must be one of the DETECTED installs. A cwd can
|
|
787
|
+
* carry both an OpenCode and a Claude install at the same path, so a path alone is ambiguous;
|
|
788
|
+
* and this endpoint moves and deletes whole trees, so the root is never built from raw body
|
|
789
|
+
* input. An unknown pair is a `NotFound`, which the shell answers 404.
|
|
790
|
+
*
|
|
791
|
+
* THE ENGINE IS `js/uninstall.mjs`'s, and P6i is what brought it. `installDeactivate` /
|
|
792
|
+
* `installReactivate` are the reversible siblings of `installUninstall` — the same owned-file
|
|
793
|
+
* walk and ancestor prune with `move` where the reversal has `unlink` — which is why they live
|
|
794
|
+
* beside it rather than in the web tree. `remove` is `installUninstall`, which crossed in P5h
|
|
795
|
+
* and reaches the wire for the first time here.
|
|
796
|
+
*/
|
|
797
|
+
export function apiInstallToggle(state, body) {
|
|
798
|
+
const known = new Map();
|
|
799
|
+
for (const [host, scope, root] of installTargets()) {
|
|
800
|
+
known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
|
|
801
|
+
}
|
|
802
|
+
const hit = known.get(JSON.stringify(
|
|
803
|
+
[strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
|
|
804
|
+
));
|
|
805
|
+
if (hit === undefined) throw new NotFound('unknown install (host, path)');
|
|
806
|
+
const [host, scope, root] = hit;
|
|
807
|
+
const action = bget(body, 'action');
|
|
808
|
+
let res;
|
|
809
|
+
if (action === 'deactivate') {
|
|
810
|
+
res = installDeactivate(root, host, scope);
|
|
811
|
+
} else if (action === 'activate') {
|
|
812
|
+
res = installReactivate(root, host, scope);
|
|
813
|
+
} else if (action === 'remove') {
|
|
814
|
+
// Destructive. `memory` ∈ {keep, archive, delete} governs the memory/notebook stores and
|
|
815
|
+
// is validated IN THE ENGINE — an unknown value falls back to `keep`, never a surprise
|
|
816
|
+
// delete, which is why a bogus body value is passed through rather than rejected here.
|
|
817
|
+
const mem = bget(body, 'memory');
|
|
818
|
+
res = installUninstall(root, host, scope, pyTruthy(mem) ? mem : 'keep');
|
|
819
|
+
} else {
|
|
820
|
+
// `{action!r}` — Python's repr, SINGLE-quoted. `JSON.stringify` would double-quote it
|
|
821
|
+
// inside a JSON string body and the two sides would differ on every typo.
|
|
822
|
+
res = { ok: false, error: `unknown action ${pyRepr(action)}` };
|
|
823
|
+
}
|
|
824
|
+
state.refresh();
|
|
825
|
+
return res;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function apiDeployCmd(state, body) {
|
|
829
|
+
const host = pyStripSpace(strOr(bget(body, 'host')));
|
|
830
|
+
if (!HOSTS.some((h) => h.host === host)) {
|
|
831
|
+
return { error: `unknown host: ${host || '(none)'}` };
|
|
832
|
+
}
|
|
833
|
+
const raw = pyStripSpace(strOr(bget(body, 'path')));
|
|
834
|
+
if (!raw) return { error: 'no folder given' };
|
|
835
|
+
let root;
|
|
836
|
+
try {
|
|
837
|
+
root = pyResolve(expanduser(raw));
|
|
838
|
+
} catch {
|
|
839
|
+
return { error: `bad path: ${raw}` };
|
|
840
|
+
}
|
|
841
|
+
if (!isDir(root)) return { error: `not a folder: ${root}` };
|
|
842
|
+
try {
|
|
843
|
+
accessSync(root, constants.W_OK); // `os.access(root, os.W_OK)`
|
|
844
|
+
} catch {
|
|
845
|
+
return { error: `folder not writable: ${root}` };
|
|
846
|
+
}
|
|
847
|
+
const cfgdirs = new Set();
|
|
848
|
+
for (const fn of [opencodeConfigDir, claudeConfigDir, bobConfigDir, copilotConfigDir]) {
|
|
849
|
+
try {
|
|
850
|
+
cfgdirs.add(pyResolve(fn()));
|
|
851
|
+
} catch { /* the reference's bare `except: pass` */ }
|
|
852
|
+
}
|
|
853
|
+
if (cfgdirs.has(root)) {
|
|
854
|
+
return { error: "that's a host global config dir — use its existing row to build a global install" };
|
|
855
|
+
}
|
|
856
|
+
const themes = new Set(themeChoices().map((c) => c.name));
|
|
857
|
+
const bt = bget(body, 'theme');
|
|
858
|
+
const theme = themes.has(bt) ? bt : state.theme;
|
|
859
|
+
const bfp = bget(body, 'footprint');
|
|
860
|
+
const fp = (bfp === 'lean' || bfp === 'full') ? bfp : 'full'; // a fresh deploy is full
|
|
861
|
+
const bpos = bget(body, 'posture');
|
|
862
|
+
const pos = discoverNames('postures', 'peer').includes(bpos) ? bpos : 'peer';
|
|
863
|
+
const bmode = bget(body, 'mode');
|
|
864
|
+
const mode = discoverNames('modes', 'direct').includes(bmode) ? bmode : 'direct';
|
|
865
|
+
// project-scope emit name == host name (opencode / claude / bob / copilot)
|
|
866
|
+
const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode);
|
|
867
|
+
return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
|
|
868
|
+
}
|