toolfactory 0.1.1

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.
Files changed (228) hide show
  1. package/README.md +166 -0
  2. package/dist/bindings/index.d.ts +23 -0
  3. package/dist/bindings/index.js +29 -0
  4. package/dist/bindings/python.d.ts +33 -0
  5. package/dist/bindings/python.js +805 -0
  6. package/dist/bindings/python.test.d.ts +1 -0
  7. package/dist/bindings/python.test.js +316 -0
  8. package/dist/bindings/typescript.d.ts +39 -0
  9. package/dist/bindings/typescript.js +758 -0
  10. package/dist/bindings/typescript.test.d.ts +1 -0
  11. package/dist/bindings/typescript.test.js +264 -0
  12. package/dist/commands.d.ts +188 -0
  13. package/dist/commands.js +662 -0
  14. package/dist/commands.test.d.ts +1 -0
  15. package/dist/commands.test.js +215 -0
  16. package/dist/e2e.test.d.ts +1 -0
  17. package/dist/e2e.test.js +124 -0
  18. package/dist/hosts/browser.d.ts +10 -0
  19. package/dist/hosts/browser.js +94 -0
  20. package/dist/hosts/github.d.ts +66 -0
  21. package/dist/hosts/github.js +247 -0
  22. package/dist/hosts/openclaw.d.ts +4 -0
  23. package/dist/hosts/openclaw.js +94 -0
  24. package/dist/hosts/template.d.ts +15 -0
  25. package/dist/hosts/template.js +142 -0
  26. package/dist/hosts/web.d.ts +4 -0
  27. package/dist/hosts/web.js +89 -0
  28. package/dist/identity/name.d.ts +20 -0
  29. package/dist/identity/name.js +33 -0
  30. package/dist/identity/name.test.d.ts +1 -0
  31. package/dist/identity/name.test.js +37 -0
  32. package/dist/identity/read.d.ts +9 -0
  33. package/dist/identity/read.js +106 -0
  34. package/dist/identity/read.test.d.ts +1 -0
  35. package/dist/identity/read.test.js +76 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.js +6 -0
  38. package/dist/introspect/index.d.ts +15 -0
  39. package/dist/introspect/index.js +63 -0
  40. package/dist/model.d.ts +236 -0
  41. package/dist/model.js +214 -0
  42. package/dist/ops.d.ts +255 -0
  43. package/dist/ops.js +297 -0
  44. package/dist/project/apply.d.ts +35 -0
  45. package/dist/project/apply.js +360 -0
  46. package/dist/project/apply.test.d.ts +1 -0
  47. package/dist/project/apply.test.js +285 -0
  48. package/dist/project/gate.d.ts +129 -0
  49. package/dist/project/gate.js +597 -0
  50. package/dist/project/history.d.ts +62 -0
  51. package/dist/project/history.js +41 -0
  52. package/dist/project/load.d.ts +21 -0
  53. package/dist/project/load.js +82 -0
  54. package/dist/project/lock.d.ts +28 -0
  55. package/dist/project/lock.js +42 -0
  56. package/dist/project/plan.d.ts +5 -0
  57. package/dist/project/plan.js +96 -0
  58. package/dist/report/coverage.d.ts +22 -0
  59. package/dist/report/coverage.js +72 -0
  60. package/dist/report/coverage.test.d.ts +1 -0
  61. package/dist/report/coverage.test.js +93 -0
  62. package/dist/surfaces/agent-plugins.d.ts +10 -0
  63. package/dist/surfaces/agent-plugins.js +36 -0
  64. package/dist/surfaces/agents.d.ts +35 -0
  65. package/dist/surfaces/agents.js +609 -0
  66. package/dist/surfaces/agents.template.d.ts +8 -0
  67. package/dist/surfaces/agents.template.js +21 -0
  68. package/dist/surfaces/agents.test.d.ts +1 -0
  69. package/dist/surfaces/agents.test.js +181 -0
  70. package/dist/surfaces/browser-extension.d.ts +101 -0
  71. package/dist/surfaces/browser-extension.js +1034 -0
  72. package/dist/surfaces/browser-extension.test.d.ts +1 -0
  73. package/dist/surfaces/browser-extension.test.js +145 -0
  74. package/dist/surfaces/claude.d.ts +2 -0
  75. package/dist/surfaces/claude.js +81 -0
  76. package/dist/surfaces/codex.d.ts +13 -0
  77. package/dist/surfaces/codex.js +101 -0
  78. package/dist/surfaces/codex.test.d.ts +1 -0
  79. package/dist/surfaces/codex.test.js +67 -0
  80. package/dist/surfaces/cursor.d.ts +2 -0
  81. package/dist/surfaces/cursor.js +50 -0
  82. package/dist/surfaces/distribution.test.d.ts +1 -0
  83. package/dist/surfaces/distribution.test.js +146 -0
  84. package/dist/surfaces/dsh.d.ts +25 -0
  85. package/dist/surfaces/dsh.js +147 -0
  86. package/dist/surfaces/dsh.test.d.ts +1 -0
  87. package/dist/surfaces/dsh.test.js +100 -0
  88. package/dist/surfaces/external.d.ts +9 -0
  89. package/dist/surfaces/external.js +4 -0
  90. package/dist/surfaces/gemini.d.ts +29 -0
  91. package/dist/surfaces/gemini.js +68 -0
  92. package/dist/surfaces/gemini.test.d.ts +1 -0
  93. package/dist/surfaces/gemini.test.js +74 -0
  94. package/dist/surfaces/hermes-native.d.ts +53 -0
  95. package/dist/surfaces/hermes-native.js +507 -0
  96. package/dist/surfaces/hermes-native.test.d.ts +1 -0
  97. package/dist/surfaces/hermes-native.test.js +156 -0
  98. package/dist/surfaces/kernel.d.ts +5 -0
  99. package/dist/surfaces/kernel.js +63 -0
  100. package/dist/surfaces/mcp-registry.d.ts +19 -0
  101. package/dist/surfaces/mcp-registry.js +160 -0
  102. package/dist/surfaces/mcp-registry.test.d.ts +1 -0
  103. package/dist/surfaces/mcp-registry.test.js +79 -0
  104. package/dist/surfaces/mcpb.d.ts +11 -0
  105. package/dist/surfaces/mcpb.js +118 -0
  106. package/dist/surfaces/mcpb.test.d.ts +1 -0
  107. package/dist/surfaces/mcpb.test.js +104 -0
  108. package/dist/surfaces/npm.d.ts +6 -0
  109. package/dist/surfaces/npm.js +72 -0
  110. package/dist/surfaces/openclaw-native.d.ts +93 -0
  111. package/dist/surfaces/openclaw-native.js +956 -0
  112. package/dist/surfaces/openclaw-native.test.d.ts +1 -0
  113. package/dist/surfaces/openclaw-native.test.js +246 -0
  114. package/dist/surfaces/pypi.d.ts +5 -0
  115. package/dist/surfaces/pypi.js +82 -0
  116. package/dist/surfaces/readme.d.ts +7 -0
  117. package/dist/surfaces/readme.js +181 -0
  118. package/dist/surfaces/registry.d.ts +6 -0
  119. package/dist/surfaces/registry.js +73 -0
  120. package/dist/surfaces/shared.d.ts +49 -0
  121. package/dist/surfaces/shared.js +112 -0
  122. package/dist/surfaces/skill.d.ts +7 -0
  123. package/dist/surfaces/skill.js +118 -0
  124. package/dist/surfaces/skill.test.d.ts +1 -0
  125. package/dist/surfaces/skill.test.js +87 -0
  126. package/dist/surfaces/web.d.ts +97 -0
  127. package/dist/surfaces/web.js +1285 -0
  128. package/dist/surfaces/web.test.d.ts +1 -0
  129. package/dist/surfaces/web.test.js +255 -0
  130. package/dist/surfaces/workflows.d.ts +2 -0
  131. package/dist/surfaces/workflows.js +809 -0
  132. package/dist/surfaces/workflows.test.d.ts +1 -0
  133. package/dist/surfaces/workflows.test.js +563 -0
  134. package/dist/toolfactory/cli.d.ts +2 -0
  135. package/dist/toolfactory/cli.js +65 -0
  136. package/dist/toolfactory/config.d.ts +3 -0
  137. package/dist/toolfactory/config.js +27 -0
  138. package/dist/toolfactory/mcp.d.ts +37 -0
  139. package/dist/toolfactory/mcp.js +269 -0
  140. package/dist/toolfactory/types.d.ts +31 -0
  141. package/dist/toolfactory/types.js +10 -0
  142. package/dist/toolfactory/web.d.ts +4 -0
  143. package/dist/toolfactory/web.js +40 -0
  144. package/dist/validate/agent-plugins.d.ts +5 -0
  145. package/dist/validate/agent-plugins.js +46 -0
  146. package/package.json +68 -0
  147. package/schemas/agent-plugins/mcp.schema.json +120 -0
  148. package/schemas/agent-plugins/plugin.schema.json +65 -0
  149. package/src/bindings/index.ts +47 -0
  150. package/src/bindings/python.test.ts +374 -0
  151. package/src/bindings/python.ts +862 -0
  152. package/src/bindings/typescript.test.ts +329 -0
  153. package/src/bindings/typescript.ts +818 -0
  154. package/src/commands.test.ts +253 -0
  155. package/src/commands.ts +886 -0
  156. package/src/e2e.test.ts +169 -0
  157. package/src/hosts/browser.ts +106 -0
  158. package/src/hosts/github.ts +321 -0
  159. package/src/hosts/openclaw.ts +110 -0
  160. package/src/hosts/template.ts +149 -0
  161. package/src/hosts/web.ts +111 -0
  162. package/src/identity/name.test.ts +43 -0
  163. package/src/identity/name.ts +39 -0
  164. package/src/identity/read.test.ts +92 -0
  165. package/src/identity/read.ts +119 -0
  166. package/src/index.ts +11 -0
  167. package/src/introspect/index.ts +72 -0
  168. package/src/model.ts +386 -0
  169. package/src/ops.ts +333 -0
  170. package/src/project/apply.test.ts +348 -0
  171. package/src/project/apply.ts +417 -0
  172. package/src/project/gate.ts +717 -0
  173. package/src/project/history.ts +46 -0
  174. package/src/project/load.ts +104 -0
  175. package/src/project/lock.ts +54 -0
  176. package/src/project/plan.ts +101 -0
  177. package/src/report/coverage.test.ts +108 -0
  178. package/src/report/coverage.ts +113 -0
  179. package/src/surfaces/agent-plugins.ts +47 -0
  180. package/src/surfaces/agents.template.ts +32 -0
  181. package/src/surfaces/agents.test.ts +206 -0
  182. package/src/surfaces/agents.ts +729 -0
  183. package/src/surfaces/browser-extension.test.ts +175 -0
  184. package/src/surfaces/browser-extension.ts +1095 -0
  185. package/src/surfaces/claude.ts +99 -0
  186. package/src/surfaces/codex.test.ts +71 -0
  187. package/src/surfaces/codex.ts +105 -0
  188. package/src/surfaces/cursor.ts +62 -0
  189. package/src/surfaces/distribution.test.ts +166 -0
  190. package/src/surfaces/dsh.test.ts +110 -0
  191. package/src/surfaces/dsh.ts +159 -0
  192. package/src/surfaces/external.ts +13 -0
  193. package/src/surfaces/gemini.test.ts +80 -0
  194. package/src/surfaces/gemini.ts +84 -0
  195. package/src/surfaces/hermes-native.test.ts +179 -0
  196. package/src/surfaces/hermes-native.ts +584 -0
  197. package/src/surfaces/kernel.ts +68 -0
  198. package/src/surfaces/mcp-registry.test.ts +99 -0
  199. package/src/surfaces/mcp-registry.ts +190 -0
  200. package/src/surfaces/mcpb.test.ts +112 -0
  201. package/src/surfaces/mcpb.ts +130 -0
  202. package/src/surfaces/npm.ts +78 -0
  203. package/src/surfaces/openclaw-native.test.ts +283 -0
  204. package/src/surfaces/openclaw-native.ts +1070 -0
  205. package/src/surfaces/pypi.ts +84 -0
  206. package/src/surfaces/readme.ts +218 -0
  207. package/src/surfaces/registry.ts +79 -0
  208. package/src/surfaces/shared.ts +140 -0
  209. package/src/surfaces/skill.test.ts +103 -0
  210. package/src/surfaces/skill.ts +136 -0
  211. package/src/surfaces/web.test.ts +303 -0
  212. package/src/surfaces/web.ts +1310 -0
  213. package/src/surfaces/workflows.test.ts +677 -0
  214. package/src/surfaces/workflows.ts +936 -0
  215. package/src/toolfactory/cli.ts +74 -0
  216. package/src/toolfactory/config.ts +33 -0
  217. package/src/toolfactory/mcp.ts +311 -0
  218. package/src/toolfactory/types.ts +52 -0
  219. package/src/toolfactory/web.ts +45 -0
  220. package/src/validate/agent-plugins.ts +53 -0
  221. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  222. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  223. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  224. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  225. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  226. package/web/dist/assets/index-CCXhQKK3.css +2 -0
  227. package/web/dist/assets/index-D1PwZ3qS.js +10 -0
  228. package/web/dist/index.html +14 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Vendored from https://github.com/GoatInAHat/template at d63bc8cede8c432fc2d9345e2e3057bbe3b3f4d2.
3
+ *
4
+ * Do not edit: written by `pnpm vendor:template`, proved by `src/hosts/template.ts`'s
5
+ * scaffoldDrift, which the `agents` surface's validate() runs against the real repository.
6
+ */
7
+ export const TEMPLATE_COMMIT = "d63bc8cede8c432fc2d9345e2e3057bbe3b3f4d2";
8
+ export const TEMPLATE_FILES = {
9
+ ".agents/README.md": "# `.agents/`\n\nCanonical agent configuration, following [AGENTS.md](https://agents.md) and\nthe [Agent Skills](https://agentskills.io) standard. Two tiers, nothing else:\n\n| Tier | Contents |\n|---|---|\n| **Canonical**, committed | `AGENTS.md`, `skills/`, `mcp/servers.json`, `sync.py`, `setup`, and the environment hooks below |\n| **Generated**, gitignored | Everything else a harness reads — MCP configs, skill symlinks, the one-line `CLAUDE.md`/`GEMINI.md` imports of `AGENTS.md` — rendered per checkout by `sync.py` for the harnesses detected on the machine |\n\nA harness appears in this system only if something must be done for it — an\nMCP config dialect, an instructions pointer, or skill symlinks (`sync.py\nlist` shows who gets what). Every other harness reads `AGENTS.md` and\n`.agents/skills/` natively, needs no adapter, and is deliberately listed\nnowhere: a new harness that follows the standards is supported implicitly,\nwith no template change.\n\nThe only committed harness-specific files are the hook carriers\n(`.claude/settings.json`, `.cursor/environment.json`,\n`copilot-setup-steps.yml`, the devcontainer): git deliberately executes\nnothing from a fresh clone — a repo that could would be an attack vector —\nso each harness's own committed hook format is the one place automation can\nstart from. Everything derivable is generated.\n\n## Two-way sync\n\n`python3 .agents/sync.py` converges both directions in one run:\n\n- **Canon → harnesses**: renders MCP configs (`.mcp.json`, `.cursor/mcp.json`,\n `.codex/config.toml`, …) and skill symlinks (`.claude/skills/`,\n `.codebuddy/skills/`) from `.agents/`.\n- **Harnesses → canon**: a skill directory dropped in a harness skills dir, or\n an MCP server added to any rendered config (`claude mcp add`, a hand edit),\n is adopted into `.agents/` and rendered back out to every other harness.\n\n`setup` installs native git hooks so none of this is ever run by hand:\n`post-checkout` and `post-merge` re-sync quietly after every pull or branch\nswitch, and `pre-commit` absorbs, re-renders, and stages the canonical result\nitself — a commit that only needs converging just works, and it fails only\nwhen a human must decide (conflicting skill contents, broken frontmatter, a\ngenerated file force-added to git). CI runs the same `check`.\n\nThird-party skills come through `npx skills add <repo>@<skill> -y`, and their\nbookkeeping stays that tool's: it maintains the root `skills-lock.json`,\nwhich the template doesn't ship — it appears with the first install and is\ncommitted from then on (the pre-commit hook stages it automatically).\n`sync.py` never touches the lock; it only fans the skills themselves out.\n\n## Local overlay\n\n`.agents/local/` is the personal tier: the same shape as `.agents/` —\n`skills/`, `mcp/servers.json`, and an optional `AGENTS.md` of extra\ninstructions — but gitignored, so nothing in it reaches teammates or GitHub.\nIt overlays the canon on this machine: local skills and servers render into\nevery harness here, a local entry wins a name collision with the canon (so\nyou can point a server somewhere else just for yourself), and absorption\nnever moves local material into the canon. Personal instructions reach\nClaude and Gemini through the generated `CLAUDE.md`/`GEMINI.md` imports;\nharnesses that read `AGENTS.md` directly have no local-instructions hook, and\ntheir native per-user files (`.claude/settings.local.json`,\n`CLAUDE.local.md`) keep working alongside this.\n\nTo keep something you installed through a harness personal instead of shared,\nmove it into `.agents/local/` before committing — absorption's default is the\nshared canon.\n\n## Environments\n\nEvery environment reaches the same `.agents/setup` through its native,\ncommitted hook — that one script is where project setup (dependencies,\ncodegen) goes.\n\nSetup is isolation-aware: outside an isolated environment it touches nothing\nbeyond the repository, because a repo's setup must never rewrite personal\nagent configs on someone's own machine. Only where `$HOME` belongs to the\nproject — a container or CI sandbox, detected by environment markers or the\n`--isolated` flag a sandbox-only hook passes — does it also install\n[rtk](https://github.com/rtk-ai/rtk) and hook it into every agent rtk\nsupports, with the target list read from rtk's own CLI so new rtk targets\nneed no template change. The devcontainer (the\n[Dev Containers](https://containers.dev) standard: Codespaces, VS Code,\nCursor, DevPod, Ona, JetBrains) is the sanctioned way to get that isolation\non your own machine. Agents whose rtk integration is a project instruction\nfile, and agents rtk doesn't know, are covered by the rtk note in\n`AGENTS.md`, which they all read natively.\n\nrtk's version is rtk's own business: setup installs the latest release,\nbest-effort, and every rtk step degrades gracefully when it is unavailable\nor changes shape. `AGENTS.md` ships with\na one-time bootstrap notice for environments with no hook; the first\nsuccessful run outside CI deletes it (the template repository itself,\nmatched by the `keep=` slug in the notice's marker, keeps it).\n\n| Environment | Committed hook |\n|---|---|\n| Claude Code (web and local) | `SessionStart` hook in `.claude/settings.json`. The web UI's \"Run setup script\" step shows as skipped — that's the platform's own per-environment field, which a repo can't fill; setup runs a moment later, inside \"Started Claude Code\". Optionally paste `bash .agents/setup` into that field so a brand-new container's very first session also loads freshly generated MCP config at startup; later sessions have it either way (the container is cached after the hook). |\n| Devcontainers: Codespaces, Ona, DevPod | `postCreateCommand` in `.devcontainer/devcontainer.json` |\n| Cursor cloud agents | `install` in `.cursor/environment.json` |\n| Copilot coding agent | `.github/workflows/copilot-setup-steps.yml` |\n| Amp orbs | runs `.agents/setup` by convention |\n| CI | `.github/workflows/agent-config.yml` |\n| Codex cloud, Jules, Devin, … | no in-repo hook exists; paste `bash .agents/setup` into the environment's setup-script field. They read `AGENTS.md` and `.agents/skills/` natively, so this only matters once project setup does something. |\n\n## Rules\n\n- Edit skills in `.agents/skills/` and MCP servers in `mcp/servers.json`; the\n per-harness copies are generated. `mcp/servers.json` uses the Claude\n `mcpServers` dialect plus an optional `tools` read-only allowlist (rendered\n where supported: Codex `enabled_tools`, Gemini/Qwen `includeTools`).\n- Credentials never go in configs — reference environment variables.\n- To support a new harness, add one `HARNESSES` entry (and renderer) in\n `sync.py` and mirror its outputs in `.gitignore`'s generated block;\n `sync.py check` fails until both agree.\n- Codex trusts a project `.codex/config.toml` only after you trust the\n project; `sync.py install-codex` writes the user-level config instead.\n",
10
+ ".agents/setup": '#!/usr/bin/env bash\n# The one setup entry point. Every environment calls this same script through\n# its native, committed hook — see the environments table in .agents/README.md\n# — so setup stays standardized instead of per-harness. Idempotent and\n# non-interactive. Outside an isolated environment it touches nothing beyond\n# this repository; only where $HOME belongs to the project — a container or\n# CI sandbox, which the devcontainer provides locally — does it also install\n# rtk and hook it into agent configs.\nset -euo pipefail\ncd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"\n\n# --isolated (passed by hooks that only ever run in project-owned sandboxes)\n# forces isolation; otherwise detect it. Remaining arguments go to sync.py.\nisolated=false\nfor arg in "$@"; do\n shift\n if [ "$arg" = "--isolated" ]; then isolated=true; else set -- "$@" "$arg"; fi\ndone\nif [ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || [ -f /.dockerenv ] ||\n [ -n "${CODESPACES:-}${DEVCONTAINER:-}${REMOTE_CONTAINERS:-}${GITPOD_WORKSPACE_ID:-}${CI:-}${container:-}" ]; then\n isolated=true\nfi\n\n# The template repository itself — origin matching the keep= slug in\n# AGENTS.md\'s bootstrap marker — keeps shipping that notice; repos generated\n# from it shed it on first setup.\nis_template=false\nkeep_slug="$(sed -n \'s/.*<!-- setup keep=\\([^ >]*\\) -->.*/\\1/p\' AGENTS.md 2>/dev/null | head -n 1)"\norigin_url="$(git remote get-url origin 2>/dev/null | tr \'[:upper:]\' \'[:lower:]\' || true)"\nif [ -n "$keep_slug" ] && [ -n "$origin_url" ]; then\n case "$origin_url" in\n *"$(printf \'%s\' "$keep_slug" | tr \'[:upper:]\' \'[:lower:]\')"*) is_template=true ;;\n esac\nfi\n\n# Adopt harness-local agent config into .agents/ and render adapters back out.\npython3 .agents/sync.py "$@"\n\n# Native git hooks, since git runs nothing from a fresh clone by itself:\n# after this first contact, every pull and branch switch re-syncs on its own,\n# and pre-commit converges and stages agent config so commits just work.\n# Never clobbers a hook this repo\'s users wrote themselves.\nhooks_dir="$(git rev-parse --git-path hooks)"\nmkdir -p "$hooks_dir"\nown_hook() {\n [ ! -e "$hooks_dir/$1" ] || grep -q \'\\.agents/sync\\.py\' "$hooks_dir/$1" 2>/dev/null\n}\nif own_hook pre-commit; then\n cat > "$hooks_dir/pre-commit" <<\'HOOK\'\n#!/usr/bin/env bash\n# Installed by .agents/setup. Absorbs harness-local agent config into\n# .agents/, re-renders, and stages the canonical result, so the commit just\n# works; it fails only when a human must decide (the message says why).\nout="$(python3 .agents/sync.py)" || { printf \'%s\\n\' "$out" >&2; exit 1; }\nprintf \'%s\\n\' "$out" | grep \'^Adopted\' >&2 || true\ngit add -A -- .agents\ngit add -A -- skills-lock.json 2>/dev/null || true\nexec python3 .agents/sync.py check >/dev/null\nHOOK\n chmod +x "$hooks_dir/pre-commit"\nfi\nfor lifecycle_hook in post-checkout post-merge; do\n if own_hook "$lifecycle_hook"; then\n cat > "$hooks_dir/$lifecycle_hook" <<\'HOOK\'\n#!/usr/bin/env bash\n# Installed by .agents/setup.\npython3 .agents/sync.py >/dev/null 2>&1 || true\nHOOK\n chmod +x "$hooks_dir/$lifecycle_hook"\n fi\ndone\n\n# The bootstrap notice in AGENTS.md is for agents in environments where\n# nothing ran this script; once it has run, the hooks above make everything\n# automatic, so the notice deletes itself. CI keeps the file untouched (a\n# self-edit would dirty the tree), as does the template repository.\nif [ "${GITHUB_ACTIONS:-}" != "true" ] && [ "$is_template" != "true" ]; then\n python3 - <<\'PY\'\nimport re\nfrom pathlib import Path\n\npath = Path("AGENTS.md")\ntext = path.read_text(encoding="utf-8") if path.is_file() else ""\nmatch = re.search(r"<!-- setup[^>]* -->\\n.*?<!-- /setup -->\\n*", text, re.S)\nif match:\n path.write_text(text[:match.start()] + text[match.end():], encoding="utf-8")\n print("Bootstrap complete; removed the one-time notice from AGENTS.md.")\nPY\nfi\n\n# rtk (github.com/rtk-ai/rtk) compresses command output before agents read\n# it. Isolated environments only: a repo\'s setup must not rewrite personal\n# agent configs on someone\'s own machine.\nexport PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"\ninstall_rtk() {\n curl -fsSL "https://raw.githubusercontent.com/rtk-ai/rtk/${1:-refs/heads/master}/install.sh" |\n RTK_VERSION="${1:-}" sh >/dev/null 2>&1\n}\nif [ "$isolated" = "true" ]; then\n if ! command -v rtk >/dev/null 2>&1; then\n # Latest release — versioning is rtk\'s own business. Where a sandbox\n # blocks api.github.com, the newest tag still comes over the git\n # protocol, so name it explicitly and retry.\n install_rtk || install_rtk "$(git ls-remote --tags --sort=-v:refname \\\n https://github.com/rtk-ai/rtk \'v[0-9]*\' 2>/dev/null |\n grep -v \'\\^{}\' | sed -n \'1s|.*refs/tags/||p\')" || true\n fi\n # Hook rtk into every agent it supports, enumerated from rtk\'s own help\n # so a new rtk target needs no template change. Runs in a throwaway\n # directory: global (-g) config lands in $HOME, and anything an init\n # drops into its working directory — the project-scoped,\n # instruction-file modes — is discarded, since those agents read\n # AGENTS.md and its rtk note already covers them.\n if command -v rtk >/dev/null 2>&1; then\n mkdir -p "$HOME/.claude" "$HOME/.cursor" # rtk 0.46 assumes these exist\n rtk_tmp="$(mktemp -d)"\n (\n cd "$rtk_tmp"\n for rtk_agent in $(rtk init --help 2>/dev/null |\n sed -n \'s/^ *- \\([a-z0-9-]*\\): .*/\\1/p\'); do\n rtk init -g --auto-patch --agent "$rtk_agent" >/dev/null 2>&1 || true\n done\n for rtk_flag in --gemini --copilot --opencode; do\n rtk init -g --auto-patch "$rtk_flag" >/dev/null 2>&1 || true\n done\n rtk init -g --codex >/dev/null 2>&1 || true # rejects --auto-patch\n )\n rm -rf "$rtk_tmp"\n fi\nelse\n echo "Personal environment: \\$HOME left alone (no rtk install or agent" \\\n "hooks). The devcontainer provides the fully wired, isolated setup."\nfi\n\n# Project setup goes here (dependency install, codegen, migrations). Keep it\n# idempotent and fast when there is nothing to do.\n',
11
+ ".agents/skills/.gitkeep": "",
12
+ ".agents/sync.py": '#!/usr/bin/env python3\n"""Two-way sync between the canonical agent config in .agents/ and harnesses.\n\nCanonical, committed sources:\n .agents/skills/ shared skills (Agent Skills standard)\n .agents/mcp/servers.json MCP servers, Claude `mcpServers` dialect plus an\n optional `tools` read-only allowlist per server\n\nPersonal overlay, gitignored:\n .agents/local/ same shape (skills/, mcp/servers.json, and an\n optional AGENTS.md of extra instructions).\n Overlays the canon on this machine only — wins\n name collisions, is never absorbed into the\n canon, and never reaches the repository.\n\nForward: everything a harness reads is rendered from the canon per checkout\nand gitignored — never hand-edited or committed; that includes the CLAUDE.md\nand GEMINI.md one-line imports of AGENTS.md. Rendering runs automatically\nthrough the committed environment hooks that call .agents/setup (table in\n.agents/README.md) and the git hooks setup installs. Reverse: additions made\nthrough one harness — a skill\ndirectory dropped in .claude/skills/, a server added with `claude mcp add` or\na hand edit to any rendered config — are adopted into the canon on the next\nsync and rendered back out to every other harness. A git pre-commit hook\ninstalled by .agents/setup runs `check`, so a commit fails until local state\nand canon have converged. Stdlib only, so all of this runs in any sandbox.\n\nUsage:\n sync.py [harness ...] absorb harness-local additions into the canon, then\n render for detected harnesses plus any named ones\n sync.py --all same, rendering every known harness (CI default)\n sync.py check read-only: fail on unabsorbed local additions,\n unrendered/broken skill links, or adapters that are\n committed or unignored\n sync.py list show known harnesses, detection state, and outputs\n sync.py install-codex install this repo\'s MCP block into ~/.codex/config.toml\n\nOnly harnesses this repo must do something for — an MCP config dialect, an\ninstructions pointer, or skill symlinks — have an entry here. Every other\nharness reads AGENTS.md and .agents/skills/ natively, needs nothing, and is\ndeliberately listed nowhere: a new harness that follows the standards is\nsupported implicitly.\n"""\nimport hashlib\nimport json\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\n\ntry:\n import tomllib\nexcept ImportError: # Python < 3.11: .codex/config.toml is skipped on absorb\n tomllib = None\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\nSKILLS_ROOT = REPO_ROOT / ".agents" / "skills"\nSERVERS_PATH = REPO_ROOT / ".agents" / "mcp" / "servers.json"\nLOCAL_ROOT = REPO_ROOT / ".agents" / "local"\nLOCAL_SKILLS = LOCAL_ROOT / "skills"\nLOCAL_SERVERS = LOCAL_ROOT / "mcp" / "servers.json"\nHASH_IGNORED = {".git", "node_modules", "__pycache__"}\n\n# Each harness: how to detect it on this machine (env vars set, commands on\n# PATH, directories under $HOME), where its per-skill symlinks go (None = it\n# reads .agents/skills natively), and which renderer produces its config files\n# (None = nothing to generate). Detection never probes a path sync.py itself\n# creates — one run would make that harness sticky-detected.\nHARNESSES = {\n "claude": {"env": ["CLAUDECODE", "CLAUDE_CODE_REMOTE"], "cmd": ["claude"], "home": [".claude"], "skills": ".claude/skills", "render": "claude"},\n "codex": {"cmd": ["codex"], "home": [".codex"], "skills": None, "render": "codex"},\n "cursor": {"cmd": ["cursor-agent", "cursor"], "home": [".cursor"], "skills": None, "render": "cursor"},\n "gemini": {"cmd": ["gemini"], "home": [".gemini"], "skills": None, "render": "gemini"},\n "qwen": {"cmd": ["qwen"], "home": [".qwen"], "skills": None, "render": "qwen"},\n "opencode": {"cmd": ["opencode"], "home": [".config/opencode"], "skills": None, "render": "opencode"},\n "vscode": {"cmd": ["code"], "skills": None, "render": "vscode"},\n "kilo": {"cmd": ["kilo"], "home": [".config/kilo"], "skills": None, "render": "kilo"},\n "factory": {"cmd": ["droid"], "home": [".factory"], "skills": None, "render": "factory"},\n "amp": {"cmd": ["amp"], "home": [".amp"], "skills": None, "render": "amp"},\n "codebuddy": {"cmd": ["codebuddy"], "home": [".codebuddy"], "skills": ".codebuddy/skills", "render": None},\n}\n\ndef detected(spec):\n return (\n any(os.environ.get(var) for var in spec.get("env", ()))\n or any(shutil.which(cmd) for cmd in spec.get("cmd", ()))\n or any((Path.home() / rel).exists() for rel in spec.get("home", ()))\n )\n\n\ndef load_servers_file(path, required=True):\n if not required and not path.is_file():\n return {}\n try:\n servers = json.loads(path.read_text(encoding="utf-8"))\n except (OSError, json.JSONDecodeError) as exc:\n raise SystemExit(f"ERROR: cannot read {path}: {exc}")\n if not isinstance(servers, dict):\n raise SystemExit(f"ERROR: {path} must contain a JSON object")\n for name, config in servers.items():\n if not isinstance(name, str) or not name:\n raise SystemExit(f"ERROR: every MCP server in {path} needs a non-empty string name")\n if not isinstance(config, dict):\n raise SystemExit(f"ERROR: MCP server {name!r} must contain an object")\n if not isinstance(config.get("url"), str) and not isinstance(config.get("command"), str):\n raise SystemExit(f"ERROR: MCP server {name!r} needs a `url` or a `command`")\n tools = config.get("tools")\n if tools is not None and not (\n isinstance(tools, list) and all(isinstance(tool, str) and tool for tool in tools)\n ):\n raise SystemExit(f"ERROR: MCP server {name!r} has a malformed tools allowlist")\n return servers\n\n\ndef load_canon():\n return load_servers_file(SERVERS_PATH)\n\n\ndef load_local():\n return load_servers_file(LOCAL_SERVERS, required=False)\n\n\ndef load_servers():\n """The effective server set: canon overlaid by the personal local tier."""\n return {**load_canon(), **load_local()}\n\n\ndef json_document(value):\n return json.dumps(value, indent=2, ensure_ascii=False) + "\\n"\n\n\ndef body_of(config):\n """The server config without the canonical-only `tools` allowlist."""\n return {key: value for key, value in config.items() if key != "tools"}\n\n\ndef is_remote(config):\n return "url" in config\n\n\n# ── Renderers: canon → harness ────────────────────────────────────────────────\n# Each takes the canonical servers dict and returns {relative path: content}.\n\n\ndef pointer_md(comment=False):\n """One-line import(s) of the instruction file(s) for CLAUDE.md/GEMINI.md."""\n text = "@AGENTS.md\\n"\n if (LOCAL_ROOT / "AGENTS.md").is_file():\n text += "@.agents/local/AGENTS.md\\n"\n if comment:\n text += ("\\n<!-- Generated by .agents/sync.py (Claude Code strips this "\n "comment); edit AGENTS.md or .agents/local/AGENTS.md instead. -->\\n")\n return text\n\n\ndef render_claude(servers):\n # The committed .claude/settings.json (the SessionStart hook carrier)\n # enables all project MCP servers, so no approval list is rendered here.\n rendered = {}\n for name, config in servers.items():\n body = body_of(config)\n if is_remote(body):\n body.setdefault("type", "http")\n rendered[name] = body\n return {\n "CLAUDE.md": pointer_md(comment=True),\n ".mcp.json": json_document({"mcpServers": rendered}),\n }\n\n\ndef render_cursor(servers):\n rendered = {}\n for name, config in servers.items():\n body = body_of(config)\n body.pop("type", None)\n rendered[name] = body\n return {".cursor/mcp.json": json_document({"mcpServers": rendered})}\n\n\ndef render_vscode(servers):\n # VS Code names the top-level key `servers` and wants an explicit type.\n rendered = {}\n for name, config in servers.items():\n body = body_of(config)\n body.setdefault("type", "http" if is_remote(body) else "stdio")\n rendered[name] = body\n return {".vscode/mcp.json": json_document({"servers": rendered})}\n\n\ndef gemini_servers(servers):\n # Gemini CLI names streamable HTTP `httpUrl`, keeps `url` for SSE, and\n # spells the allowlist `includeTools`.\n rendered = {}\n for name, config in servers.items():\n body = body_of(config)\n transport = body.pop("type", None)\n if "url" in body and transport != "sse":\n body["httpUrl"] = body.pop("url")\n if config.get("tools"):\n body["includeTools"] = list(config["tools"])\n rendered[name] = body\n return rendered\n\n\ndef render_gemini(servers):\n # GEMINI.md imports AGENTS.md, so context needs no settings entry.\n return {\n "GEMINI.md": pointer_md(),\n ".gemini/settings.json": json_document({"mcpServers": gemini_servers(servers)}),\n }\n\n\ndef render_qwen(servers):\n document = {\n "contextFileName": ["AGENTS.md", "QWEN.md"],\n "mcpServers": gemini_servers(servers),\n }\n return {".qwen/settings.json": json_document(document)}\n\n\ndef opencode_mcp(servers):\n rendered = {}\n for name, config in servers.items():\n if is_remote(config):\n entry = {"type": "remote", "url": config["url"]}\n if config.get("headers"):\n entry["headers"] = config["headers"]\n else:\n entry = {"type": "local", "command": [config["command"], *config.get("args", [])]}\n if config.get("env"):\n entry["environment"] = config["env"]\n rendered[name] = entry\n return rendered\n\n\ndef render_opencode(servers):\n return {"opencode.json": json_document({"mcp": opencode_mcp(servers)})}\n\n\ndef render_kilo(servers):\n return {"kilo.jsonc": json_document({"mcp": opencode_mcp(servers)})}\n\n\ndef render_factory(servers):\n rendered = {}\n for name, config in servers.items():\n body = body_of(config)\n body.setdefault("type", "http" if is_remote(body) else "stdio")\n rendered[name] = body\n return {".factory/mcp.json": json_document({"mcpServers": rendered})}\n\n\ndef render_amp(servers):\n rendered = {name: body_of(config) for name, config in servers.items()}\n return {".amp/settings.json": json_document({"amp.mcpServers": rendered})}\n\n\ndef toml_key(value):\n if re.fullmatch(r"[A-Za-z0-9_-]+", value):\n return value\n return json.dumps(value, ensure_ascii=False)\n\n\ndef toml_value(value):\n if isinstance(value, str):\n # json.dumps escapes U+0000-U+001F but leaves U+007F raw, which TOML forbids.\n return json.dumps(value, ensure_ascii=False).replace("\\x7f", "\\\\u007f")\n if isinstance(value, bool):\n return "true" if value else "false"\n if isinstance(value, (int, float)):\n return repr(value)\n if isinstance(value, list):\n return "[" + ", ".join(toml_value(item) for item in value) + "]"\n if isinstance(value, dict):\n pairs = (f"{toml_key(key)} = {toml_value(item)}" for key, item in value.items())\n return "{ " + ", ".join(pairs) + " }"\n raise SystemExit(f"ERROR: unsupported TOML value in MCP config: {value!r}")\n\n\ndef codex_toml(servers):\n lines = ["# Generated by .agents/sync.py; do not edit."]\n for name, config in servers.items():\n lines.extend(("", f"[mcp_servers.{toml_key(name)}]"))\n for key, value in config.items():\n if key in ("type", "tools"):\n continue\n lines.append(f"{toml_key(key)} = {toml_value(value)}")\n if config.get("tools"):\n lines.append(f"enabled_tools = {toml_value(list(config[\'tools\']))}")\n return "\\n".join(lines) + "\\n"\n\n\ndef render_codex(servers):\n # Codex loads a project .codex/config.toml only once the project is\n # trusted; `install-codex` covers the user-level config instead.\n return {".codex/config.toml": codex_toml(servers)}\n\n\nRENDERERS = {\n "claude": render_claude,\n "codex": render_codex,\n "cursor": render_cursor,\n "gemini": render_gemini,\n "qwen": render_qwen,\n "opencode": render_opencode,\n "vscode": render_vscode,\n "kilo": render_kilo,\n "factory": render_factory,\n "amp": render_amp,\n}\n\n\ndef output_paths():\n """Every repo-relative path any renderer can write, across all harnesses."""\n servers = load_servers()\n paths = set()\n for spec in HARNESSES.values():\n if spec["render"]:\n paths.update(RENDERERS[spec["render"]](servers))\n if spec["skills"]:\n # Trailing slash: `git check-ignore` only matches a directory-only\n # .gitignore pattern when the path is spelled as a directory.\n paths.add(spec["skills"] + "/")\n return sorted(paths)\n\n\n# ── Absorption: harness → canon ───────────────────────────────────────────────\n# Additions made through one harness are adopted into the canon, so the next\n# render propagates them to every other harness.\n\n\ndef strip_default_type(entry):\n config = dict(entry)\n if config.get("type") in ("http", "stdio"):\n del config["type"]\n return config\n\n\ndef from_gemini(entry):\n config = dict(entry)\n if "httpUrl" in config:\n config["url"] = config.pop("httpUrl")\n if "includeTools" in config:\n config["tools"] = config.pop("includeTools")\n return strip_default_type(config)\n\n\ndef from_opencode(entry):\n config = dict(entry)\n kind = config.pop("type", None)\n command = config.pop("command", None)\n if kind == "local" and isinstance(command, list) and command:\n config["command"] = command[0]\n if command[1:]:\n config["args"] = list(command[1:])\n elif command is not None:\n config["command"] = command\n if "environment" in config:\n config["env"] = config.pop("environment")\n return config\n\n\ndef from_codex(entry):\n config = dict(entry)\n if "enabled_tools" in config:\n config["tools"] = config.pop("enabled_tools")\n return config\n\n\n# Where each harness\'s config gains servers out-of-band (`claude mcp add`, a\n# hand edit, another agent\'s installer), and how to read entries back into the\n# canonical dialect.\nFOREIGN_SOURCES = (\n (".mcp.json", ("mcpServers",), strip_default_type),\n (".cursor/mcp.json", ("mcpServers",), strip_default_type),\n (".vscode/mcp.json", ("servers",), strip_default_type),\n (".factory/mcp.json", ("mcpServers",), strip_default_type),\n (".gemini/settings.json", ("mcpServers",), from_gemini),\n (".qwen/settings.json", ("mcpServers",), from_gemini),\n ("opencode.json", ("mcp",), from_opencode),\n ("kilo.jsonc", ("mcp",), from_opencode),\n (".amp/settings.json", ("amp.mcpServers",), strip_default_type),\n (".codex/config.toml", ("mcp_servers",), from_codex),\n)\n\n\ndef foreign_servers(servers):\n """Servers found in a harness config on disk but missing from the canon.\n\n Returns ({name: (canonical config, source path)}, [unreadable-file notes]).\n """\n found, problems = {}, []\n for rel, keys, normalize in FOREIGN_SOURCES:\n path = REPO_ROOT / rel\n if not path.is_file():\n continue\n try:\n if rel.endswith(".toml"):\n if tomllib is None:\n continue\n data = tomllib.loads(path.read_text(encoding="utf-8"))\n else:\n data = json.loads(path.read_text(encoding="utf-8"))\n except (OSError, ValueError) as exc:\n problems.append(f"{rel}: unreadable, rendering will replace it ({exc})")\n continue\n for key in keys:\n data = data.get(key) if isinstance(data, dict) else None\n if not isinstance(data, dict):\n continue\n for name, entry in data.items():\n if name in servers or name in found or not isinstance(entry, dict):\n continue\n config = normalize(entry)\n if isinstance(config.get("url"), str) or isinstance(config.get("command"), str):\n found[name] = (config, rel)\n return found, problems\n\n\ndef foreign_skills():\n """Real directories inside a harness skills dir; canon holds only symlinks."""\n found = []\n for spec in HARNESSES.values():\n if not spec["skills"]:\n continue\n target_dir = REPO_ROOT / spec["skills"]\n if not target_dir.is_dir():\n continue\n found.extend(\n (spec["skills"], entry)\n for entry in sorted(target_dir.iterdir())\n if entry.is_dir() and not entry.is_symlink()\n )\n return found\n\n\ndef absorb(canon, local):\n """Adopt harness-local additions into the canon before rendering.\n\n Entries from the personal .agents/local/ tier count as known and are\n never adopted; genuinely new material always lands in the shared canon —\n anyone who wants an addition to stay personal moves it to .agents/local/.\n """\n adopted_skills = []\n for rel, entry in foreign_skills():\n canonical = SKILLS_ROOT / entry.name\n if canonical.exists():\n if skill_hash(canonical) != skill_hash(entry):\n raise SystemExit(\n f"ERROR: {rel}/{entry.name} and .agents/skills/{entry.name} differ; "\n "merge them manually — .agents/skills is the canon."\n )\n shutil.rmtree(entry) # identical duplicate; the symlink replaces it\n else:\n SKILLS_ROOT.mkdir(parents=True, exist_ok=True)\n shutil.move(str(entry), str(canonical))\n adopted_skills.append(entry.name)\n print(f"Adopted skill {entry.name!r} from {rel}/ into .agents/skills/")\n\n adopted_servers, _ = foreign_servers({**canon, **local})\n for name, (config, rel) in adopted_servers.items():\n canon[name] = config\n print(f"Adopted MCP server {name!r} from {rel} into .agents/mcp/servers.json")\n if adopted_servers:\n atomic_write(SERVERS_PATH, json_document(canon))\n if adopted_skills or adopted_servers:\n print("Commit the updated files under .agents/.")\n\n\n# ── Skill symlinks ────────────────────────────────────────────────────────────\n\n\ndef symlinks_supported():\n """Whether this machine can create symlinks (Windows may need Developer\n Mode); when it can\'t, skill linking degrades to a warning, in sync and\n check alike, instead of breaking setup."""\n global _SYMLINKS_SUPPORTED\n if _SYMLINKS_SUPPORTED is None:\n try:\n with tempfile.TemporaryDirectory() as probe:\n os.symlink("probe-target", os.path.join(probe, "probe"))\n _SYMLINKS_SUPPORTED = True\n except OSError:\n _SYMLINKS_SUPPORTED = False\n return _SYMLINKS_SUPPORTED\n\n\n_SYMLINKS_SUPPORTED = None\n\n\ndef skill_names():\n """Canonical (committed, shared) skills only."""\n if not SKILLS_ROOT.is_dir():\n return []\n return sorted(path.name for path in SKILLS_ROOT.iterdir() if path.is_dir())\n\n\ndef skill_sources():\n """{name: source dir} across canon and local; local wins a name collision."""\n sources = {}\n for root in (SKILLS_ROOT, LOCAL_SKILLS):\n if root.is_dir():\n for path in sorted(root.iterdir()):\n if path.is_dir():\n sources[path.name] = path\n return sources\n\n\ndef sync_skills(target_rel):\n sources = skill_sources()\n target_dir = REPO_ROOT / target_rel\n if not sources and not target_dir.is_dir():\n return # nothing to link and nothing stale to prune\n if sources and not symlinks_supported():\n print(f"WARNING: symlinks unavailable; {target_rel} won\'t see shared "\n "skills on this machine.", file=sys.stderr)\n return\n target_dir.mkdir(parents=True, exist_ok=True)\n\n for name, source in sources.items():\n link_target = Path(os.path.relpath(source, target_dir))\n link_path = target_dir / name\n if link_path.is_symlink():\n if os.readlink(link_path) == str(link_target):\n continue\n link_path.unlink()\n elif link_path.exists():\n raise SystemExit(f"ERROR: {link_path} exists but is not a symlink.")\n link_path.symlink_to(link_target)\n\n for link_path in target_dir.iterdir():\n if not link_path.is_symlink():\n continue\n resolved = Path(os.path.normpath(target_dir / os.readlink(link_path)))\n if resolved.parent in (SKILLS_ROOT, LOCAL_SKILLS) and not resolved.exists():\n link_path.unlink()\n\n\ndef skill_link_failures(target_rel):\n """If this skills dir was rendered, its links must be complete and live."""\n sources = skill_sources()\n target_dir = REPO_ROOT / target_rel\n if not target_dir.is_dir() or not symlinks_supported():\n return []\n failures = [\n f"{target_rel}/{name}: missing or broken symlink (run .agents/sync.py)"\n for name in sources\n if not (target_dir / name).is_symlink() or not (target_dir / name).exists()\n ]\n failures.extend(\n f"{target_rel}/{link.name}: dangling symlink (run .agents/sync.py)"\n for link in target_dir.iterdir()\n if link.is_symlink() and not link.exists() and link.name not in sources\n )\n return failures\n\n\n# ── Skill hashing (for duplicate detection during absorption) ─────────────────\n\n\ndef skill_hash(skill_root):\n """Hash a skill directory: sorted relative path plus file bytes."""\n digest = hashlib.sha256()\n files = [\n path\n for path in skill_root.rglob("*")\n if path.is_file() and not HASH_IGNORED.intersection(path.relative_to(skill_root).parts)\n ]\n for path in sorted(files, key=lambda path: path.relative_to(skill_root).as_posix()):\n digest.update(path.relative_to(skill_root).as_posix().encode("utf-8"))\n digest.update(path.read_bytes())\n return digest.hexdigest()\n\n\n# ── Modes ─────────────────────────────────────────────────────────────────────\n\n\ndef atomic_write(path, content):\n path.parent.mkdir(parents=True, exist_ok=True)\n fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)\n try:\n with os.fdopen(fd, "w", encoding="utf-8", newline="\\n") as handle:\n handle.write(content)\n try:\n mode = path.stat().st_mode & 0o777\n except OSError:\n mode = 0o644\n os.chmod(temporary, mode)\n os.replace(temporary, path)\n finally:\n if os.path.exists(temporary):\n os.unlink(temporary)\n\n\ndef active_harnesses(arguments):\n """Detected harnesses plus any named ones; environment hooks name their own.\n\n A name with no entry needs nothing rendered — harnesses that follow the\n standards are supported implicitly — so it is noted and skipped, never\n refused.\n """\n named = [argument for argument in arguments if not argument.startswith("-")]\n for name in sorted(set(named) - set(HARNESSES)):\n print(f"{name}: nothing to render (reads AGENTS.md and .agents/skills natively)")\n if "--all" in arguments or os.environ.get("GITHUB_ACTIONS") == "true":\n return list(HARNESSES)\n return [name for name, spec in HARNESSES.items() if name in named or detected(spec)]\n\n\ndef sync(arguments):\n canon, local = load_canon(), load_local()\n absorb(canon, local)\n servers = {**canon, **local}\n active = active_harnesses(arguments)\n generated = []\n for name in active:\n spec = HARNESSES[name]\n files = RENDERERS[spec["render"]](servers) if spec["render"] else {}\n for rel, content in files.items():\n path = REPO_ROOT / rel\n try:\n if path.read_text(encoding="utf-8") == content:\n continue # unchanged; don\'t churn mtimes or clobber\n except OSError:\n pass\n atomic_write(path, content)\n if spec["skills"]:\n sync_skills(spec["skills"])\n files = {**files, spec["skills"] + "/": None}\n if files:\n generated.append(f"{name}: {\', \'.join(sorted(files))}")\n # Heal skill links a past render created for harnesses not active now.\n for name, spec in HARNESSES.items():\n if name not in active and spec["skills"] and (REPO_ROOT / spec["skills"]).is_dir():\n sync_skills(spec["skills"])\n for line in generated:\n print(f"Generated {line}")\n if not generated:\n print("No harnesses detected; run with --all or name one to force.")\n\n\ndef git(*arguments):\n return subprocess.run(\n ["git", "-C", str(REPO_ROOT), *arguments],\n capture_output=True, text=True, check=False,\n ).stdout.splitlines()\n\n\ndef check():\n """Read-only verification of every invariant; the pre-commit hook runs this.\n\n Generated adapters must be gitignored and out of the index, harness-local\n additions must be absorbed into the canon, rendered output must parse,\n and rendered skill symlinks must resolve.\n """\n failures = []\n\n # The personal overlay must stay ignored and uncommitted, like adapters.\n outputs = output_paths() + [".agents/local/"]\n tracked = git("ls-files", "--", *outputs)\n # --no-index: a tracked path would otherwise also be reported as unignored.\n ignored = set(git("check-ignore", "--no-index", "--", *outputs))\n failures.extend(f"{path}: committed generated adapter; remove it from git" for path in tracked)\n failures.extend(\n f"{path}: missing from .gitignore" for path in outputs if path not in ignored\n )\n\n servers = load_servers()\n for rel, entry in foreign_skills():\n failures.append(f"{rel}/{entry.name}: skill not in .agents/skills (run .agents/sync.py)")\n foreign, problems = foreign_servers(servers)\n failures.extend(\n f"{rel}: MCP server {name!r} not in .agents/mcp/servers.json (run .agents/sync.py)"\n for name, (_, rel) in foreign.items()\n )\n failures.extend(problems)\n\n for spec in HARNESSES.values():\n if spec["render"]:\n for rel, content in RENDERERS[spec["render"]](servers).items():\n try:\n if rel.endswith(".toml"):\n if tomllib:\n tomllib.loads(content)\n elif rel.endswith((".json", ".jsonc")):\n json.loads(content)\n except ValueError as exc:\n failures.append(f"{rel}: renderer produced invalid output: {exc}")\n if spec["skills"]:\n failures.extend(skill_link_failures(spec["skills"]))\n\n for rel in (".claude/settings.json", ".cursor/environment.json",\n ".devcontainer/devcontainer.json"):\n try:\n json.loads((REPO_ROOT / rel).read_text(encoding="utf-8"))\n except (OSError, ValueError) as exc:\n failures.append(f"{rel}: {exc}")\n\n if failures:\n print("Agent config problems:", file=sys.stderr)\n for failure in failures:\n print(f" {failure}", file=sys.stderr)\n raise SystemExit(1)\n print(f"OK: {len(outputs)} generated paths ignored, {len(skill_sources())} skill(s) linked.")\n\n\ndef list_harnesses():\n servers = load_servers()\n for name, spec in HARNESSES.items():\n adapters = []\n if spec["skills"]:\n adapters.append(f"skills → {spec[\'skills\']}")\n if spec["render"]:\n adapters.append("files → " + ", ".join(sorted(RENDERERS[spec["render"]](servers))))\n state = "detected" if detected(spec) else "not detected"\n print(f"{name:10} {state:13} {\'; \'.join(adapters)}")\n print("Any harness not listed reads AGENTS.md and .agents/skills natively; nothing to render.")\n\n\ndef install_codex():\n repo_name = re.sub(r"[^A-Za-z0-9_-]+", "-", REPO_ROOT.name).strip("-") or "repo"\n begin = f"# BEGIN {repo_name} managed MCP"\n end = f"# END {repo_name} managed MCP"\n block = begin + "\\n" + codex_toml(load_servers()) + end\n user_config = Path.home() / ".codex" / "config.toml"\n try:\n current = user_config.read_text(encoding="utf-8")\n except FileNotFoundError:\n current = ""\n pattern = re.compile(\n rf"(?ms)^[ \\t]*{re.escape(begin)}\\n.*?^[ \\t]*{re.escape(end)}[ \\t]*\\n?"\n )\n current = re.sub(r"\\n{3,}", "\\n\\n", pattern.sub("", current)).rstrip()\n merged = (current + "\\n\\n" if current else "") + block + "\\n"\n atomic_write(user_config, merged)\n print(f"Installed managed MCP block: {user_config}")\n\n\ndef main(arguments):\n modes = {"check": check, "list": list_harnesses, "install-codex": install_codex}\n if arguments and arguments[0] in modes:\n if arguments[1:]:\n raise SystemExit(f"ERROR: {arguments[0]} takes no further arguments")\n modes[arguments[0]]()\n elif all(argument == "--all" or not argument.startswith("-") for argument in arguments):\n sync(arguments)\n else:\n print(__doc__, file=sys.stderr)\n raise SystemExit(2)\n\n\nif __name__ == "__main__":\n try:\n main(sys.argv[1:])\n except BrokenPipeError:\n raise SystemExit(0)\n',
13
+ ".claude/settings.json": '{\n "enableAllProjectMcpServers": true,\n "hooks": {\n "SessionStart": [\n {\n "hooks": [\n {\n "type": "command",\n "command": "bash \\"$CLAUDE_PROJECT_DIR\\"/.agents/setup claude"\n }\n ]\n }\n ]\n }\n}\n',
14
+ ".cursor/environment.json": '{\n "install": "bash .agents/setup --isolated cursor"\n}\n',
15
+ ".devcontainer/Dockerfile": "# Base image kept here rather than in devcontainer.json so a project can swap\n# in the stack it needs and Dependabot can keep it current.\nFROM mcr.microsoft.com/devcontainers/base:ubuntu\n",
16
+ ".devcontainer/devcontainer.json": '{\n "name": "devcontainer",\n "build": {\n "dockerfile": "Dockerfile"\n },\n "features": {\n "ghcr.io/devcontainers/features/github-cli:1": {},\n "ghcr.io/devcontainers/features/python:1": {}\n },\n "containerEnv": {\n "DEVCONTAINER": "true"\n },\n "postCreateCommand": "bash .agents/setup"\n}\n',
17
+ ".gitattributes": "# Normalize line endings so scripts and symlinks survive a Windows checkout.\n* text=auto eol=lf\n",
18
+ ".github/workflows/agent-config.yml": 'name: Agent config\n\non:\n pull_request:\n push:\n branches: [main]\n workflow_dispatch:\n\npermissions:\n contents: read\n\njobs:\n check:\n name: Canon renders clean (${{ matrix.os }})\n strategy:\n fail-fast: false\n matrix:\n os: [ubuntu-latest, macos-latest, windows-latest]\n runs-on: ${{ matrix.os }}\n defaults:\n run:\n shell: bash\n steps:\n - uses: actions/checkout@v7\n\n - name: Ensure a python3 launcher exists (Windows)\n if: runner.os == \'Windows\'\n run: |\n if ! command -v python3 >/dev/null 2>&1; then\n mkdir -p "$HOME/bin"\n printf \'#!/bin/sh\\nexec python "$@"\\n\' > "$HOME/bin/python3"\n chmod +x "$HOME/bin/python3"\n echo "$HOME/bin" >> "$GITHUB_PATH"\n fi\n\n - name: Run the shared setup entry point\n run: bash .agents/setup --all\n\n - name: Prove system-bash 3.2 compatibility (macOS)\n # Setup is idempotent, so re-running under the oldest bash in the\n # wild is free and keeps personal Macs working.\n if: runner.os == \'macOS\'\n run: /bin/bash .agents/setup --all\n\n - name: Verify invariants\n run: python3 .agents/sync.py check\n\n - name: No untracked files left behind\n run: |\n if [ -n "$(git status --porcelain)" ]; then\n git status --porcelain\n echo "Setup left files that are neither tracked nor ignored."\n exit 1\n fi\n',
19
+ ".github/workflows/copilot-setup-steps.yml": "# Copilot's cloud coding agent runs the `copilot-setup-steps` job before the\n# agent starts. It reads AGENTS.md and .agents/skills/ natively; this hook\n# exists so project setup in .agents/setup reaches it too.\nname: Copilot setup steps\n\non: workflow_dispatch\n\njobs:\n copilot-setup-steps:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7\n - run: bash .agents/setup\n",
20
+ ".gitignore": "# ─── Agent local state ────────────────────────────────────\n# .agents/local/ is the personal overlay: same shape as .agents/ (skills/,\n# mcp/servers.json, AGENTS.md), rendered into your harnesses on this machine\n# only, never absorbed into the canon, never committed.\n/.agents/local/\nCLAUDE.local.md\n.claude/settings.local.json\n.claude/worktrees/\n\n# ─── Generated agent adapters ─────────────────────────────\n# Rendered per checkout from .agents/ by .agents/sync.py; never hand-edited\n# or committed. Keep in step with sync.py's renderers — `sync.py check` fails\n# if a renderer output is unignored or tracked.\n/CLAUDE.md\n/GEMINI.md\n/.mcp.json\n/.claude/skills/\n/.codebuddy/skills/\n/.gemini/settings.json\n/.qwen/settings.json\n/.cursor/mcp.json\n/.codex/config.toml\n/.vscode/mcp.json\n/.factory/mcp.json\n/.amp/settings.json\n/opencode.json\n/kilo.jsonc\n\n# ─── Secrets & env ────────────────────────────────────────\n.env\n.env.*\n!.env.example\n*.pem\n*.key\n*.p12\nid_rsa*\nid_ed25519*\nservice-account*.json\n.netrc\n\n# ─── Dependencies ─────────────────────────────────────────\nnode_modules/\n.venv/\nvenv/\n__pycache__/\n*.py[cod]\n\n# ─── Build output ─────────────────────────────────────────\ndist/\nbuild/\nout/\ntarget/\ncoverage/\n*.egg-info/\n\n# ─── Logs & caches ────────────────────────────────────────\n*.log\nlogs/\n.cache/\n.pytest_cache/\n.mypy_cache/\n.ruff_cache/\ntmp/\n\n# ─── Editors & OS ─────────────────────────────────────────\n.DS_Store\nThumbs.db\n.idea/\n*.swp\n*~\n\n# ─── Local data ───────────────────────────────────────────\n*.sqlite\n*.sqlite3\n*.db\n",
21
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,181 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AGENTS_BEGIN, AGENTS_END, IGNORE_PATH, reloadLine, SETUP_PATH, surface, } from "./agents.js";
3
+ import { TEMPLATE_FILES } from "./agents.template.js";
4
+ const echo = { name: "echo", inputSchema: { type: "object" }, requires: [] };
5
+ function project(surfaces, overrides = {}) {
6
+ return {
7
+ root: "/repo",
8
+ tool: {
9
+ schemaVersion: 1,
10
+ identity: "package.json",
11
+ binding: "typescript",
12
+ surfaces,
13
+ bundle: { runtime: "package" },
14
+ tests: { examples: {} },
15
+ config: {
16
+ properties: {
17
+ api_key: { type: "string", description: "key", "x-toolfactory": { sensitive: true } },
18
+ },
19
+ required: ["api_key"],
20
+ },
21
+ ...overrides,
22
+ },
23
+ identity: { name: "hello", version: "0.1.0", description: "Say hello" },
24
+ identityExtra: {},
25
+ operations: [echo],
26
+ toolfactoryVersion: "0.1.0",
27
+ packageManager: "npm",
28
+ };
29
+ }
30
+ function planned(target, path) {
31
+ const file = surface.plan(target).find((candidate) => candidate.path === path);
32
+ if (!file)
33
+ throw new Error(`the agents surface no longer plans ${path}`);
34
+ return file;
35
+ }
36
+ function region(target, path) {
37
+ const file = planned(target, path);
38
+ if (file.kind !== "region")
39
+ throw new Error(`${path} is not a region file`);
40
+ return file.regions[0]?.content ?? "";
41
+ }
42
+ describe("agents", () => {
43
+ it("plans AGENTS.md as one region under an author-owned bootstrap notice", () => {
44
+ const file = planned(project(["cli", "mcp"]), "AGENTS.md");
45
+ if (file.kind !== "region")
46
+ throw new Error("expected a region file");
47
+ expect(file.template).toContain("# hello\n");
48
+ expect(file.template).toContain(`${AGENTS_BEGIN}\n${AGENTS_END}`);
49
+ // `.agents/setup` deletes the notice on its first run, so it must sit outside the region a
50
+ // rebuild rewrites, and without the `keep=` slug that makes the template itself keep it.
51
+ const notice = file.template.slice(0, file.template.indexOf(AGENTS_BEGIN));
52
+ expect(notice).toContain("<!-- setup -->");
53
+ expect(notice).not.toContain("keep=");
54
+ const body = file.regions[0]?.content ?? "";
55
+ expect(body).not.toContain(AGENTS_BEGIN);
56
+ for (const verb of ["introspect", "build", "check", "validate", "coverage"]) {
57
+ expect(body).toContain(`npx toolfactory ${verb}`);
58
+ }
59
+ expect(body).toContain("npm run test:live");
60
+ // The reload table and the host worktree commands, both generated from one source each.
61
+ expect(body).toContain("| Claude Code | Reconnect from `/mcp`");
62
+ expect(body).toContain("claude --worktree <name>");
63
+ });
64
+ it("carries only the lines the selected surfaces and binding need", () => {
65
+ const bare = region(project(["cli"], { config: { properties: {}, required: [] } }), "AGENTS.md");
66
+ expect(bare).not.toContain("test:live");
67
+ expect(bare).not.toContain("plugins install");
68
+ expect(bare).toContain("No host-native surface");
69
+ const hosts = region(project(["openclaw-native", "hermes-native", "web", "mcp", "browser-extension"], {
70
+ binding: "python",
71
+ }), "AGENTS.md");
72
+ expect(hosts).toContain("openclaw plugins install --link hosts/openclaw --force");
73
+ expect(hosts).toContain("hermes plugins install file://");
74
+ // C7: `hermes gateway restart` is the messaging gateway, not the plugin loader.
75
+ expect(hosts).toContain("only for the\n messaging gateway daemon");
76
+ expect(hosts).toContain("uv run --with pytest pytest -q tests/test_live.py");
77
+ expect(hosts).toContain("src/hello/ops.py");
78
+ // The extension: how it loads, how it pairs with this checkout's own kernel, how it reloads,
79
+ // and whose job the content script's selectors are.
80
+ expect(hosts).toContain("Load unpacked → `hosts/browser/.output/chrome-mv3`");
81
+ expect(hosts).toContain("`uv run --quiet python -m hello.toolfactory.cli mcp --http --pair`");
82
+ expect(hosts).toContain("selectors are yours to maintain");
83
+ expect(hosts).toContain("wxt dev");
84
+ // Launch: one line per way of reaching the one listener. A Python plugin's OpenClaw shim is
85
+ // out of process, so it gets no Control UI tab — the CLI line is the honest answer there.
86
+ expect(hosts).toContain("## Launch the web app");
87
+ expect(hosts).toContain("`uv run --quiet python -m hello.toolfactory.cli mcp --http --open`");
88
+ expect(hosts).toContain("call the `web` operation");
89
+ expect(hosts).toContain("Open full page");
90
+ expect(hosts).not.toContain("tab in the Control UI");
91
+ expect(bare).not.toContain("## Launch the web app");
92
+ const native = region(project(["openclaw-native", "web", "mcp"]), "AGENTS.md");
93
+ expect(native).toContain("**Hello** tab in the Control UI");
94
+ expect(native).toContain("/plugins/hello/web");
95
+ });
96
+ it("vendors the template's carriers and keeps its head inside the setup region", () => {
97
+ const files = surface.plan(project(["cli"]));
98
+ for (const path of [".agents/sync.py", ".claude/settings.json", ".gitattributes"]) {
99
+ const file = files.find((candidate) => candidate.path === path);
100
+ if (file?.kind !== "file")
101
+ throw new Error(`${path} is not a whole file`);
102
+ expect(file.content).toBe(TEMPLATE_FILES[path]);
103
+ }
104
+ const setup = region(project(["cli"]), SETUP_PATH);
105
+ expect(setup).toContain('cd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"');
106
+ expect(setup).not.toContain("# Project setup goes here");
107
+ // Owner ask #11: a commit cannot leave a generated file stale, with or without CI.
108
+ expect(setup).toContain("npx toolfactory check >/dev/null");
109
+ expect(setup).toContain("npm install --no-audit --no-fund");
110
+ });
111
+ it("owns the agent adapters, the binding's ignores and the build outputs in .gitignore", () => {
112
+ const bare = region(project(["cli"]), IGNORE_PATH);
113
+ const agentBlocks = (TEMPLATE_FILES[IGNORE_PATH] ?? "").split("# ─── Secrets")[0] ?? "";
114
+ for (const line of agentBlocks.split("\n").filter((line) => line.startsWith("/"))) {
115
+ expect(bare).toContain(`\n${line}\n`);
116
+ }
117
+ expect(bare).toContain("\ndev.toolfactory/coverage.json\n");
118
+ expect(bare).not.toContain("web/src/ops.json");
119
+ expect(bare).not.toContain("hosts/*/package-lock.json");
120
+ const hosts = region(project(["openclaw-native", "web", "mcp"]), IGNORE_PATH);
121
+ expect(hosts).toContain("\nhosts/*/package-lock.json\n");
122
+ expect(hosts).toContain("\nweb/src/ops.json\n");
123
+ // The author's half of a fresh file keeps the template's other blocks and no duplicates.
124
+ const template = planned(project(["cli"]), IGNORE_PATH);
125
+ if (template.kind !== "region")
126
+ throw new Error("expected a region file");
127
+ const author = template.template.slice(template.template.indexOf("# /tf:ignore"));
128
+ expect(author).toContain("*.pem");
129
+ expect(author).not.toContain("node_modules/");
130
+ });
131
+ it("merges MCP servers by name, and never shadows toolfactory's own checkout", () => {
132
+ const file = planned(project(["mcp", "web"]), ".agents/mcp/servers.json");
133
+ if (file.kind !== "merge")
134
+ throw new Error("expected a merge file");
135
+ expect(file.patch.hello).toEqual({
136
+ command: "node",
137
+ args: ["--import", "tsx", "src/toolfactory/mcp.ts"],
138
+ });
139
+ expect(file.patch.toolfactory).toEqual({ command: "npx", args: ["toolfactory", "mcp"] });
140
+ expect(Object.keys(file.patch)).toEqual(file.owned);
141
+ expect(file.owned).toContain("playwright");
142
+ // Only one repository has the collision: toolfactory's own, where the published package would
143
+ // shadow the checkout entry under the same key.
144
+ const own = project(["mcp"]);
145
+ own.identity.name = "toolfactory";
146
+ const dogfood = planned(own, ".agents/mcp/servers.json");
147
+ if (dogfood.kind !== "merge")
148
+ throw new Error("expected a merge file");
149
+ expect(Object.keys(dogfood.patch)).toEqual(["toolfactory"]);
150
+ expect(dogfood.patch.toolfactory).toEqual({
151
+ command: "node",
152
+ args: ["--import", "tsx", "src/toolfactory/mcp.ts"],
153
+ });
154
+ // A tool with no server to run registers only the generator that maintains the repo.
155
+ const cliOnly = planned(project(["cli"]), ".agents/mcp/servers.json");
156
+ if (cliOnly.kind !== "merge")
157
+ throw new Error("expected a merge file");
158
+ expect(Object.keys(cliOnly.patch)).toEqual(["toolfactory"]);
159
+ });
160
+ it("lists the surfaces' submission portals and the Gemini host-install line", () => {
161
+ const body = region(project(["mcp", "claude", "agent-plugins", "codex", "gemini"]), "AGENTS.md");
162
+ expect(body).toContain("## Listing");
163
+ expect(body).toContain("docker/mcp-registry");
164
+ expect(body).toContain("cline/mcp-marketplace");
165
+ expect(body).toContain("chatmcp/mcpso");
166
+ expect(body).toContain("punkpeye/awesome-mcp-servers");
167
+ expect(body).toContain("claude.com/docs/plugins/submit");
168
+ expect(body).toContain("kiro.dev/powers/submit");
169
+ expect(body).toContain("developers.openai.com/plugins/deploy/submission");
170
+ expect(body).toContain("`gemini extensions link .`");
171
+ // No listing-eligible surface, no section; the bare `cli` project also gets no Gemini bullet.
172
+ const bare = region(project(["cli"]), "AGENTS.md");
173
+ expect(bare).not.toContain("## Listing");
174
+ expect(bare).not.toContain("gemini extensions link");
175
+ });
176
+ it("prints the reload line of the harness it is running inside", () => {
177
+ expect(reloadLine({ CLAUDECODE: "1" })).toContain("reconnect from `/mcp`");
178
+ expect(reloadLine({ HERMES_HOME: "/h" })).toContain("hermes skills trust");
179
+ expect(reloadLine({})).toContain("restart it or use its own reload command");
180
+ });
181
+ });
@@ -0,0 +1,101 @@
1
+ import type { Operation, Project, Surface, Verdict } from "../model.js";
2
+ export declare const HOST_DIR = "hosts/browser";
3
+ /**
4
+ * The WXT release the scaffold, the build and the drift probe all run. Exact, not a range: the
5
+ * constants below are what *this* version's `init` writes, and the drift host runs
6
+ * `npx wxt@<pin> init` to prove it. Renovate bumps it (`renovate.json` `customManagers`).
7
+ */
8
+ export declare const WXT_PIN = "0.21.4";
9
+ /** The engines one codebase is built for; `wxt build -b <target>` names each. */
10
+ export declare const TARGETS: readonly ["chrome", "edge", "firefox", "safari"];
11
+ export type Target = (typeof TARGETS)[number];
12
+ /** The three engines a verdict can differ on; Chrome and Edge are one Chromium package. */
13
+ export declare const ENGINES: readonly ["chromium", "firefox", "safari"];
14
+ export type Engine = (typeof ENGINES)[number];
15
+ /** Where `<tool> mcp --http` listens by default, and therefore what the extension pairs with. */
16
+ export declare const DEFAULT_ENDPOINT = "http://127.0.0.1:3000/mcp";
17
+ /** The generated regions: everything outside them is the author's and survives every build. */
18
+ export declare const CONFIG_BEGIN = "// tf:config";
19
+ export declare const CONFIG_END = "// /tf:config";
20
+ export declare const BG_BEGIN = "// tf:bg";
21
+ export declare const BG_END = "// /tf:bg";
22
+ export declare const CONTENT_BEGIN = "// tf:content";
23
+ export declare const CONTENT_END = "// /tf:content";
24
+ /**
25
+ * Everything `npx wxt@<WXT_PIN> init <dir> --template react --pm npm` wrote, transcribed once;
26
+ * `src/hosts/browser.ts` proves it current by running that command again.
27
+ */
28
+ export declare const BROWSER_SCAFFOLD: {
29
+ readonly template: "react";
30
+ readonly packageManager: "npm";
31
+ /** The placeholder icon set the template ships; WXT auto-wires `icon/<size>.png`. */
32
+ readonly icons: readonly ["16", "32", "48", "96", "128"];
33
+ readonly packageJson: {
34
+ readonly scripts: {
35
+ readonly dev: "wxt";
36
+ readonly "dev:firefox": "wxt -b firefox";
37
+ readonly build: "wxt build";
38
+ readonly "build:firefox": "wxt build -b firefox";
39
+ readonly zip: "wxt zip";
40
+ readonly "zip:firefox": "wxt zip -b firefox";
41
+ readonly compile: "tsc --noEmit";
42
+ readonly postinstall: "wxt prepare";
43
+ };
44
+ readonly dependencies: {
45
+ readonly react: "^19.2.4";
46
+ readonly "react-dom": "^19.2.4";
47
+ };
48
+ readonly devDependencies: {
49
+ readonly "@types/react": "^19.2.14";
50
+ readonly "@types/react-dom": "^19.2.3";
51
+ readonly "@wxt-dev/module-react": "^1.1.5";
52
+ readonly typescript: "^5.9.3";
53
+ readonly "web-ext": "^10.5.0";
54
+ readonly wxt: "^0.21.3";
55
+ };
56
+ };
57
+ readonly tsconfig: "{\n \"extends\": \"./.wxt/tsconfig.json\",\n \"compilerOptions\": {\n \"allowImportingTsExtensions\": true,\n \"jsx\": \"react-jsx\"\n }\n}\n";
58
+ readonly gitignore: "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.output\nstats.html\nstats-*.json\n.wxt\nweb-ext.config.ts\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n";
59
+ };
60
+ /** The private package `hosts/browser` is; its name is what `wxt zip` names the archives. */
61
+ export declare function browserPackage(project: Project): string;
62
+ /**
63
+ * The zip `wxt zip -b <browser>` writes, by name, inside the release assets. WXT names an archive
64
+ * after `zip.name`, which the generated config sets to the tool's own name, so the assets sit
65
+ * beside the tarball and the bundle under one identity instead of the package's local one.
66
+ */
67
+ export declare function zipName(project: Project, browser: "chrome" | "firefox" | "edge"): string;
68
+ /** The sources archive `wxt zip -b firefox` writes beside it; AMO asks for it when code is minified. */
69
+ export declare function sourcesZipName(project: Project): string;
70
+ export declare function endpoint(project: Project): string;
71
+ /**
72
+ * Firefox's add-on id. Required for a Firefox MV3 build and recommended for MV2, so it is
73
+ * emitted whenever it can be derived — an explicit `browserExtension.geckoId`, else the
74
+ * owner-scoped default from the identity file's repository. A tool with neither ships without
75
+ * the block, exactly as the README surface omits the lines that need a GitHub repository.
76
+ */
77
+ export declare function geckoId(project: Project): string | undefined;
78
+ /** Match patterns the extension may act in: loopback always, plus the declared session domains. */
79
+ export declare function hostPermissions(project: Project): string[];
80
+ /**
81
+ * The permission list is a capability projection, never authored: `storage` for the pairing
82
+ * store, `activeTab` when an operation declares `browser` (the store-friendly minimum for acting
83
+ * in the tab the user invoked the action on), and the two opt-ins. Anything wider — `debugger`
84
+ * and `tabGroups` for a CDP relay — is Chromium-only and belongs in the author's tail, where the
85
+ * generated per-target filter still drops it for the other engines.
86
+ */
87
+ export declare function permissions(project: Project, operations: Operation[]): string[];
88
+ /**
89
+ * The coalesced verdict, for `COVERAGE.md`'s single column: the browser is present, so a
90
+ * `browser` operation is native here (bridge 3, authored in this directory) and a `user-input`
91
+ * one is native too (the popup is the human). Nothing bridges a model or a channel.
92
+ */
93
+ export declare function verdict(operation: Operation): Verdict;
94
+ /**
95
+ * The same verdict per engine, which is the finer grain `TARGETS.md` carries. The vocabulary has
96
+ * one `browser` term, so the surface cannot tell a DOM operation from one that reaches for
97
+ * `chrome.debugger`; that stays prose in `TARGETS.md`. What it can derive is the session export:
98
+ * Safari Web Extensions cannot read httpOnly cookies, so an opted-in export degrades there.
99
+ */
100
+ export declare function browserVerdict(operation: Operation, project: Project, engine: Engine): Verdict;
101
+ export declare const surface: Surface;