thumbgate 1.30.0 → 1.31.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.
Files changed (77) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +66 -8
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +147 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +212 -206
  16. package/config/model-tiers.json +7 -2
  17. package/glama.json +6 -0
  18. package/package.json +60 -8
  19. package/public/assets/diagrams/before-after.svg +17 -16
  20. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  21. package/public/assets/diagrams/loop.svg +19 -13
  22. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  23. package/public/compare.html +1 -0
  24. package/public/dashboard.html +126 -28
  25. package/public/evaluations.html +1 -1
  26. package/public/index.html +142 -13
  27. package/public/numbers.html +3 -2
  28. package/public/pricing.html +143 -30
  29. package/scripts/a-plus-evidence-scorecard.js +303 -0
  30. package/scripts/async-eval-observability.js +36 -11
  31. package/scripts/audit-trail.js +37 -1
  32. package/scripts/auto-promote-gates.js +149 -34
  33. package/scripts/colbert-style-maxsim.js +236 -0
  34. package/scripts/cross-encoder-reranker.js +356 -126
  35. package/scripts/dashboard-chat.js +350 -17
  36. package/scripts/document-intake.js +283 -7
  37. package/scripts/eval-quality-suite.js +204 -0
  38. package/scripts/feedback-loop.js +115 -7
  39. package/scripts/feedback-paths.js +32 -13
  40. package/scripts/feedback-quality.js +53 -0
  41. package/scripts/filesystem-search.js +17 -7
  42. package/scripts/gates-engine.js +17 -0
  43. package/scripts/harness-tool-names.js +70 -0
  44. package/scripts/hook-runtime.js +10 -3
  45. package/scripts/lesson-db.js +16 -5
  46. package/scripts/lesson-embedding-index.js +67 -20
  47. package/scripts/lesson-embedding-maintenance.js +177 -0
  48. package/scripts/lesson-reranker.js +55 -9
  49. package/scripts/lesson-retrieval.js +305 -29
  50. package/scripts/lesson-search.js +22 -8
  51. package/scripts/llm-client.js +304 -15
  52. package/scripts/model-tier-router.js +593 -0
  53. package/scripts/pragmatic-hybrid-search.js +379 -0
  54. package/scripts/rag-document-pipeline.js +461 -0
  55. package/scripts/rag-structured-output.js +441 -0
  56. package/scripts/ragas-style-metrics.js +351 -0
  57. package/scripts/request-envelope.js +178 -0
  58. package/scripts/rerank-pipeline.js +370 -0
  59. package/scripts/rerank-quality-eval.js +155 -0
  60. package/scripts/retrieval-hybrid-ablation.js +120 -0
  61. package/scripts/retrieval-quality-tier.js +118 -0
  62. package/scripts/secret-scanner.js +395 -4
  63. package/scripts/self-distill-agent.js +7 -1
  64. package/scripts/self-healing-check.js +25 -0
  65. package/scripts/skill-packs.js +183 -0
  66. package/scripts/slow-loop.js +72 -0
  67. package/scripts/statusline-links.js +1 -1
  68. package/scripts/statusline.sh +8 -1
  69. package/scripts/telemetry-analytics.js +13 -1
  70. package/scripts/thumbgate-search.js +98 -6
  71. package/scripts/tier-budget-guard.js +186 -0
  72. package/scripts/tool-registry.js +46 -0
  73. package/scripts/vector-store.js +108 -4
  74. package/scripts/verify-marketing-pages-deployed.js +85 -3
  75. package/server.json +44 -0
  76. package/smithery.yaml +17 -0
  77. package/src/api/server.js +194 -13
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thumbgate",
3
3
  "description": "One 👎 becomes a hard rule the agent cannot bypass. Captures thumbs-down feedback, distills it into PreToolUse Pre-Action Checks, enforced across every future Claude Code session.",
4
- "version": "1.30.0",
4
+ "version": "1.31.0",
5
5
  "author": {
6
6
  "name": "Igor Ganapolsky",
7
7
  "email": "ig5973700@gmail.com",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "ThumbGate — 👍👎 feedback that teaches your AI agent. Thumbs down a mistake, it never happens again.",
5
5
  "homepage": "https://thumbgate.ai",
6
6
  "transport": "stdio",
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # ThumbGate
2
2
 
3
+ [![MCP Toplist](https://mcptoplist.com/badge/glama%2FIgorGanapolsky%2FThumbGate.svg)](https://mcptoplist.com/server/glama%2FIgorGanapolsky%2FThumbGate)
4
+
3
5
  <p align="center">
4
6
  <a href="https://thumbgate.ai">
5
7
  <img src="public/assets/brand/thumbgate-icon-512.png" alt="ThumbGate" width="128" height="128" />
@@ -28,6 +30,19 @@ Accepted feedback is stored as local lessons. Repeated concrete failures can bec
28
30
  npx thumbgate init # auto-detects the supported agent and wires its integration
29
31
  ```
30
32
 
33
+ ### MCP / Glama / registry install (stdio)
34
+
35
+ Directories and clients that install ThumbGate as an MCP server must start **stdio MCP**, not the HTTP API:
36
+
37
+ ```bash
38
+ npx -y thumbgate serve
39
+ ```
40
+
41
+ - Equivalent: `npx -y thumbgate mcp`
42
+ - Do **not** use `npm start` for MCP — that launches the hosted HTTP API (`src/api/server.js`), not the agent-facing stdio server.
43
+ - Canonical package metadata: `server.json` (`runtimeHint: npx` + `packageArguments: ["serve"]`), Smithery: `smithery.yaml`, maintainers: `glama.json`.
44
+ - Product name is **ThumbGate** only (npm: `thumbgate`). Retired legacy package aliases are not active product surfaces.
45
+
31
46
  Works with **Claude Code, Cursor, Codex, Gemini CLI, Amp, Cline, OpenCode** and MCP-compatible agents after their integration is configured. Free tier: 2 feedback captures/day (10 total) and up to 3 active auto-promoted prevention rules. [Pro: $19/mo or $149/yr](https://thumbgate.ai/checkout/pro?utm_source=github&utm_medium=readme) is the individual tier for unlimited rules, history-aware lessons, feedback sessions, a personal dashboard, and DPO export. Enterprise is custom and scoped after intake; hosted team sync and a hosted org dashboard are not in the current general-availability runtime.
32
47
 
33
48
  [![CI](https://github.com/IgorGanapolsky/ThumbGate/actions/workflows/ci.yml/badge.svg)](https://github.com/IgorGanapolsky/ThumbGate/actions/workflows/ci.yml)
@@ -256,6 +271,11 @@ evaluation/observability—is maintained in
256
271
  architecture exists, what can fail, how it is measured and secured, how it is
257
272
  deployed, and what evidence is required before claiming it works.
258
273
 
274
+ The full framework decision and one end-to-end RAG request—tenant authorization,
275
+ query transformation, parent-child retrieval, hybrid fusion, reranking,
276
+ structured generation, evaluation, and tracing—are documented in
277
+ [`docs/RAG_PRODUCTION_ARCHITECTURE.md`](./docs/RAG_PRODUCTION_ARCHITECTURE.md).
278
+
259
279
  ### Retrieval & latency: local-first, zero network hops
260
280
 
261
281
  ThumbGate's latency advantage is structural, not a tuned cloud cluster: there is no retrieval service and no model on the enforcement path, so the gate decision never leaves your machine.
@@ -302,22 +322,40 @@ Each recommendation ships with the benchmark commands to run next: feedback-deri
302
322
 
303
323
  ## Install for Your Agent
304
324
 
305
- | Agent | Command |
306
- |-------|---------|
307
- | **Claude Code** | `npx thumbgate init --agent claude-code` |
308
- | **Cursor** | `npx thumbgate init --agent cursor` |
309
- | **VS Code / Open VSX** | [plugins/vscode-extension/README.md](plugins/vscode-extension/README.md) |
310
- | **Antigravity-compatible** | [plugins/antigravity-extension/INSTALL.md](plugins/antigravity-extension/INSTALL.md) |
311
- | **JetBrains** | [plugins/jetbrains-plugin/README.md](plugins/jetbrains-plugin/README.md) |
312
- | **Codex** | `npx thumbgate init --agent codex` |
313
- | **Gemini CLI** | `npx thumbgate init --agent gemini` |
314
- | **Amp** | `npx thumbgate init --agent amp` |
315
- | **Cline** (Roo Code successor) | `npx thumbgate init --agent cline` |
316
- | **OpenCode** | `npx thumbgate init --agent opencode` |
317
- | **Claude Desktop** | [Download extension bundle](https://github.com/IgorGanapolsky/ThumbGate/releases/latest/download/thumbgate-claude-desktop.mcpb) |
318
- | **Any MCP agent** | `npx thumbgate serve` |
319
-
320
- Works with **Claude Code, Cursor, Codex, Gemini CLI, Amp, Cline, OpenCode**, and any MCP-compatible agent. Migrating from Roo Code (sunsetting 2026-05-15)? See [`adapters/cline/INSTALL.md`](./adapters/cline/INSTALL.md).
325
+ Enforcement depends on what the harness lets us intercept, so the table says which
326
+ you get. This distinction is real: with a pre-tool hook ThumbGate stops the action
327
+ before it runs; over MCP only, ThumbGate answers `gate_check` and the agent decides
328
+ whether to obey.
329
+
330
+ | Agent | Command | Enforcement |
331
+ |-------|---------|-------------|
332
+ | **Claude Code** | `npx thumbgate init --agent claude-code` | Hard — PreToolUse hook |
333
+ | **Codex** | `npx thumbgate init --agent codex` | Hard — `pre_tool_use` hook |
334
+ | **Gemini CLI** | `npx thumbgate init --agent gemini` | Hard — PreToolUse hook |
335
+ | **ForgeCode** | `npx thumbgate init --agent forge` | Hard — `pre_tool_use` trigger |
336
+ | **Cursor** | `npx thumbgate init --agent cursor` | Advisory — MCP `gate_check` |
337
+ | **Cline** (Roo Code successor) | `npx thumbgate init --agent cline` | Advisory — MCP `gate_check` + `.clinerules` |
338
+ | **OpenCode** | `npx thumbgate init --agent opencode` | Advisory — MCP `gate_check` |
339
+ | **Any MCP agent** | `npx thumbgate serve` | Advisory — MCP `gate_check` |
340
+ | **Amp** | `npx thumbgate init --agent amp` | Feedback capture only |
341
+ | **Claude Desktop** | [Download extension bundle](https://github.com/IgorGanapolsky/ThumbGate/releases/latest/download/thumbgate-claude-desktop.mcpb) | Advisory — MCP |
342
+ | **VS Code / Open VSX** | [plugins/vscode-extension/README.md](plugins/vscode-extension/README.md) | See plugin README |
343
+ | **Antigravity-compatible** | [plugins/antigravity-extension/INSTALL.md](plugins/antigravity-extension/INSTALL.md) | See plugin README |
344
+ | **JetBrains** | [plugins/jetbrains-plugin/README.md](plugins/jetbrains-plugin/README.md) | See plugin README |
345
+
346
+ **Advisory means the agent can ignore it.** Harnesses without a pre-tool hook expose no
347
+ interception point, so ThumbGate cannot stop the call itself — it returns a verdict the
348
+ agent is instructed to honor. Treat advisory coverage as a strong default, not a
349
+ guarantee, and prefer a hard-enforcement harness for anything irreversible.
350
+
351
+ The gate is **model-agnostic**: verdicts come from deterministic policy evaluation over
352
+ the proposed tool call (`scripts/gates-engine.js`), never from an LLM. Swapping the model
353
+ behind any harness does not change what is allowed.
354
+
355
+ > **Enforcement posture:** ThumbGate ships **warn-by-default** — a matched gate is logged
356
+ > and surfaced, not blocked. Set `THUMBGATE_STRICT_ENFORCEMENT=1` to hard-block. The
357
+ > `gate_check` tool reports `warn` (never `allow`) when a gate matched but posture
358
+ > downgraded it, so an agent is never told a flagged action is fine.
321
359
 
322
360
  ### Install scope: machine-wide vs per-project
323
361
 
@@ -2,13 +2,13 @@
2
2
  "mcpServers": {
3
3
  "thumbgate": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "thumbgate@1.30.0", "thumbgate", "serve"]
5
+ "args": ["--yes", "--package", "thumbgate@1.31.0", "thumbgate", "serve"]
6
6
  }
7
7
  },
8
8
  "hooks": {
9
9
  "preToolUse": {
10
10
  "command": "npx",
11
- "args": ["--yes", "--package", "thumbgate@1.30.0", "thumbgate", "gate-check"]
11
+ "args": ["--yes", "--package", "thumbgate@1.31.0", "thumbgate", "gate-check"]
12
12
  }
13
13
  }
14
14
  }
@@ -9,12 +9,12 @@ version: "1"
9
9
  skills:
10
10
  thumbgate-gate-check:
11
11
  description: "ThumbGate PreToolUse gate — blocks known-bad tool calls"
12
- command: "npx --yes --package thumbgate@1.30.0 thumbgate gate-check"
12
+ command: "npx --yes --package thumbgate@1.31.0 thumbgate gate-check"
13
13
  trigger: pre_tool_use
14
14
 
15
15
  thumbgate-feedback:
16
16
  description: "ThumbGate feedback capture — logs user prompt context"
17
- command: "npx --yes --package thumbgate@1.30.0 thumbgate hook-auto-capture"
17
+ command: "npx --yes --package thumbgate@1.31.0 thumbgate hook-auto-capture"
18
18
  trigger: user_prompt
19
19
 
20
20
  mcp:
@@ -23,6 +23,6 @@ mcp:
23
23
  args:
24
24
  - "--yes"
25
25
  - "--package"
26
- - "thumbgate@1.30.0"
26
+ - "thumbgate@1.31.0"
27
27
  - "thumbgate"
28
28
  - "serve"
@@ -133,9 +133,7 @@ const {
133
133
  throw error;
134
134
  },
135
135
  }));
136
- const {
137
- searchThumbgate,
138
- } = require('../../scripts/thumbgate-search');
136
+ const { searchThumbgateAsync } = require('../../scripts/thumbgate-search');
139
137
  const {
140
138
  buildMultimodalRetrievalPlan,
141
139
  } = require('../../scripts/multimodal-retrieval-plan');
@@ -317,7 +315,7 @@ const {
317
315
  finalizeSession: finalizeFeedbackSession,
318
316
  } = require('../../scripts/feedback-session');
319
317
 
320
- const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.30.0' };
318
+ const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.31.0' };
321
319
  const COMMERCE_CATEGORIES = [
322
320
  'product_recommendation',
323
321
  'brand_compliance',
@@ -971,14 +969,14 @@ async function callToolInner(name, args) {
971
969
  return buildSuggestFixResponse(args);
972
970
  case 'retrieve_lessons': {
973
971
  // Cross-encoder reranking: retrieve more candidates, then rerank for precision
974
- const { retrieveWithRerankingSync } = loadOptionalModule(path.join(__dirname, '../../scripts/cross-encoder-reranker'), () => ({
975
- retrieveWithRerankingSync: (toolName, actionContext, options = {}) => retrieveRelevantLessons(
972
+ const { retrieveWithReranking } = loadOptionalModule(path.join(__dirname, '../../scripts/cross-encoder-reranker'), () => ({
973
+ retrieveWithReranking: async (toolName, actionContext, options = {}) => retrieveRelevantLessons(
976
974
  toolName,
977
975
  actionContext,
978
976
  { maxResults: options.maxResults || 5 },
979
977
  ),
980
978
  }));
981
- return toTextResult(retrieveWithRerankingSync(
979
+ return toTextResult(await retrieveWithReranking(
982
980
  args.toolName,
983
981
  args.actionContext || '',
984
982
  {
@@ -987,6 +985,9 @@ async function callToolInner(name, args) {
987
985
  scope: args.scope,
988
986
  requireScope: args.requireScope === true,
989
987
  includeShared: args.includeShared !== false,
988
+ metadataFilters: args.filters,
989
+ queryRewrite: args.queryRewrite !== false,
990
+ includeRetrievalMeta: args.includeRetrievalMeta === true,
990
991
  },
991
992
  ));
992
993
  }
@@ -1009,11 +1010,13 @@ async function callToolInner(name, args) {
1009
1010
  }
1010
1011
  case 'search_thumbgate':
1011
1012
  enforceLimit('search_thumbgate');
1012
- return toTextResult(searchThumbgate({
1013
+ return toTextResult(await searchThumbgateAsync({
1013
1014
  query: args.query,
1014
1015
  limit: args.limit,
1015
1016
  source: args.source,
1016
1017
  signal: args.signal,
1018
+ metadataFilters: args.filters,
1019
+ queryRewrite: args.queryRewrite !== false,
1017
1020
  }));
1018
1021
  case 'import_document':
1019
1022
  return toTextResult(importDocument({
@@ -1038,6 +1041,61 @@ async function callToolInner(name, args) {
1038
1041
  }
1039
1042
  return toTextResult(document);
1040
1043
  }
1044
+ case 'gate_check': {
1045
+ // Same engine the PreToolUse hook uses, so an MCP client and a hook cannot
1046
+ // disagree about whether an action is allowed.
1047
+ const { runAsync } = require('../../scripts/gates-engine');
1048
+ const { canonicalizeToolCall } = require('../../scripts/harness-tool-names');
1049
+ const canonical = canonicalizeToolCall(args.tool_name, args.tool_input || {});
1050
+ const raw = await runAsync({
1051
+ tool_name: canonical.toolName,
1052
+ tool_input: canonical.toolInput,
1053
+ });
1054
+ let decision = 'allow';
1055
+ let reason = '';
1056
+ let flagged = false;
1057
+ try {
1058
+ const parsed = JSON.parse(raw);
1059
+ const hook = parsed.hookSpecificOutput || {};
1060
+ const verdict = hook.permissionDecision || parsed.decision || '';
1061
+ reason = hook.permissionDecisionReason || parsed.reason || hook.additionalContext || '';
1062
+ // The hook wire format says "deny"; the documented tool contract says "block".
1063
+ if (verdict === 'deny' || verdict === 'block') {
1064
+ decision = 'block';
1065
+ flagged = true;
1066
+ } else if (/\[GATE:/.test(reason)) {
1067
+ // A gate MATCHED but the warn-by-default posture downgraded it. Reporting
1068
+ // "allow" here is how this tool would become theater: .clinerules tells the
1069
+ // agent to abort only on "block", so a matched rm -rf / would have been run
1070
+ // with the warning text ignored. "warn" is the honest third state.
1071
+ decision = 'warn';
1072
+ flagged = true;
1073
+ }
1074
+ } catch (_) {
1075
+ // Unparseable engine output must never read as "allow" — fail closed.
1076
+ decision = 'error';
1077
+ reason = 'gate engine returned unparseable output';
1078
+ }
1079
+ const strict = process.env.THUMBGATE_STRICT_ENFORCEMENT === '1';
1080
+ return {
1081
+ content: [{
1082
+ type: 'text',
1083
+ text: JSON.stringify({
1084
+ decision,
1085
+ flagged,
1086
+ enforcement: strict ? 'strict' : 'warn-by-default',
1087
+ guidance: decision === 'block'
1088
+ ? 'Do NOT run this action. Surface the reason to the user.'
1089
+ : decision === 'warn'
1090
+ ? 'A policy gate matched but enforcement is warn-by-default. Do NOT run this action without explicit user confirmation; show them the reason.'
1091
+ : decision === 'error'
1092
+ ? 'Gate evaluation failed. Treat as unsafe and ask the user.'
1093
+ : 'No policy gate matched.',
1094
+ reason,
1095
+ }, null, 2),
1096
+ }],
1097
+ };
1098
+ }
1041
1099
  case 'feedback_stats':
1042
1100
  return toTextResult(analyzeFeedback(undefined, { humanOnly: true }));
1043
1101
  case 'diagnose_failure':
@@ -7,7 +7,7 @@
7
7
  "npx",
8
8
  "--yes",
9
9
  "--package",
10
- "thumbgate@1.30.0",
10
+ "thumbgate@1.31.0",
11
11
  "thumbgate",
12
12
  "serve"
13
13
  ],
@@ -4,7 +4,7 @@
4
4
  "id": "checkout-link-grounding",
5
5
  "traceId": "trace_checkout_001",
6
6
  "question": "Is the Pro checkout link working?",
7
- "response": "The Pro checkout link is working because the checkout diagnostic confirms Stripe Pro checkout is reachable.",
7
+ "response": "The Pro checkout link is working because the checkout diagnostic confirms Stripe Pro checkout is reachable [context-1].",
8
8
  "retrievedContexts": [
9
9
  "Stripe Pro checkout diagnostic confirms the Pro checkout link is reachable and returns a valid checkout target.",
10
10
  "The workflow sprint checkout is intake-led and should not expose a stale payment link."
@@ -15,7 +15,7 @@
15
15
  "id": "letta-tool-gate",
16
16
  "traceId": "trace_letta_001",
17
17
  "question": "Should Letta execute a force push tool call?",
18
- "response": "ThumbGate blocks force push before execution for Letta tool calls.",
18
+ "response": "ThumbGate blocks force push before execution for Letta tool calls [context-1].",
19
19
  "retrievedContexts": [
20
20
  "ThumbGate's Letta adapter wraps Letta tool calls and runs gate-check before the executor is called.",
21
21
  "Force push to main is a blocked high-risk git action."
package/bin/cli.js CHANGED
@@ -39,7 +39,6 @@ const {
39
39
  codexAutoUpdateCliEntry,
40
40
  codexAutoUpdateMcpEntry,
41
41
  isSourceCheckout,
42
- publishedCliAvailable,
43
42
  localMcpEntry,
44
43
  resolveMcpEntry,
45
44
  } = require(path.join(__dirname, '..', 'scripts', 'mcp-config'));
@@ -411,16 +410,17 @@ function canonicalMcpEntry(scope = 'project') {
411
410
  }
412
411
 
413
412
  function canonicalCodexMcpEntry() {
414
- const version = pkgVersion();
415
- if (isSourceCheckout(PKG_ROOT) && !publishedCliAvailable(version)) {
413
+ // Codex config is user-global and must survive disposable worktree cleanup.
414
+ // Use the stable published launcher even when init is invoked from source;
415
+ // developers can explicitly opt into a checkout-pinned runtime when needed.
416
+ if (isSourceCheckout(PKG_ROOT) && process.env.THUMBGATE_CODEX_USE_SOURCE_RUNTIME === '1') {
416
417
  return localMcpEntry(PKG_ROOT, 'home');
417
418
  }
418
419
  return codexAutoUpdateMcpEntry();
419
420
  }
420
421
 
421
422
  function canonicalCodexCliEntry(commandArgs) {
422
- const version = pkgVersion();
423
- if (isSourceCheckout(PKG_ROOT) && !publishedCliAvailable(version)) {
423
+ if (isSourceCheckout(PKG_ROOT) && process.env.THUMBGATE_CODEX_USE_SOURCE_RUNTIME === '1') {
424
424
  return {
425
425
  command: 'node',
426
426
  args: [path.join(PKG_ROOT, 'bin', 'cli.js'), ...commandArgs],
@@ -584,7 +584,20 @@ function detectPlatform(name, checks) {
584
584
  }
585
585
 
586
586
  function whichExists(cmd) {
587
- try { execSync(`which ${cmd}`, { stdio: 'pipe' }); return true; } catch (_) { return false; }
587
+ if (!cmd || /[\\/]/.test(cmd)) return false;
588
+ const pathEntries = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
589
+ const extensions = process.platform === 'win32'
590
+ ? String(process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
591
+ : [''];
592
+ return pathEntries.some((entry) => extensions.some((extension) => {
593
+ const candidate = path.join(entry, `${cmd}${extension}`);
594
+ try {
595
+ fs.accessSync(candidate, fs.constants.X_OK);
596
+ return fs.statSync(candidate).isFile();
597
+ } catch (_) {
598
+ return false;
599
+ }
600
+ }));
588
601
  }
589
602
 
590
603
  function setupClaude() {
@@ -636,18 +649,26 @@ function setupCodex() {
636
649
  return configChanged || hookResult.changed;
637
650
  }
638
651
 
639
- function setupGemini() {
640
- // Try to import custom commands as a Gemini plugin if the CLI is installed
641
- const { execSync } = require('child_process');
652
+ function setupGemini(options = {}) {
653
+ // Importing a plugin executes an external package manager and can mutate
654
+ // machine-wide Gemini state. Keep ordinary init/quick-start deterministic;
655
+ // operators can opt in explicitly when they want the plugin import too.
656
+ const importPlugin = options.importPlugin === true
657
+ || process.env.THUMBGATE_IMPORT_AGENT_PLUGINS === '1';
642
658
  let pluginImported = false;
643
- for (const binName of ['agy', 'gemini']) {
644
- try {
645
- execSync(`${binName} plugin import "${PKG_ROOT}" --force`, { stdio: 'ignore' });
646
- console.log(` Gemini: imported thumbgate plugin via ${binName}`);
647
- pluginImported = true;
648
- break;
649
- } catch (err) {
650
- // ignore errors if command doesn't exist or fails
659
+ if (importPlugin) {
660
+ for (const binName of ['agy', 'gemini']) {
661
+ try {
662
+ execFileSync(binName, ['plugin', 'import', PKG_ROOT, '--force'], {
663
+ stdio: 'ignore',
664
+ timeout: 5000,
665
+ });
666
+ console.log(` Gemini: imported thumbgate plugin via ${binName}`);
667
+ pluginImported = true;
668
+ break;
669
+ } catch (err) {
670
+ // A missing, failing, or slow optional plugin manager must not block init.
671
+ }
651
672
  }
652
673
  }
653
674
 
@@ -693,6 +714,66 @@ function setupAmp() {
693
714
  return true;
694
715
  }
695
716
 
717
+ function setupOpenCode() {
718
+ // OpenCode was listed in README's install table and in `init --help` since the
719
+ // adapter asset landed, but no setup function ever existed — `--agent opencode`
720
+ // silently wrote Claude/Codex/Gemini config and exited 0. OpenCode has no hook
721
+ // surface, so this wires the MCP server, which is its real integration point.
722
+ const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
723
+ const srcPath = path.join(PKG_ROOT, 'adapters', 'opencode', 'opencode.json');
724
+ if (!fs.existsSync(srcPath)) return false;
725
+
726
+ let src;
727
+ try { src = JSON.parse(fs.readFileSync(srcPath, 'utf8')); } catch (_) { return false; }
728
+ const desired = (src.mcp || {}).thumbgate;
729
+ if (!desired) return false;
730
+
731
+ let config = {};
732
+ if (fs.existsSync(configPath)) {
733
+ try {
734
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
735
+ } catch (error) {
736
+ // Resetting to {} here and writing would replace the whole file — silently deleting
737
+ // the user's model, provider, plugin and other MCP settings just to add ours.
738
+ // A config we cannot parse is a reason to stop, not to overwrite.
739
+ console.error(` OpenCode: ${configPath} could not be parsed (${error.message}).`);
740
+ console.error(' Refusing to overwrite it. Fix or move the file, then re-run.');
741
+ process.exitCode = 1;
742
+ return false;
743
+ }
744
+ }
745
+ config.mcp = config.mcp || {};
746
+ if (JSON.stringify(config.mcp[MCP_SERVER_NAME]) === JSON.stringify(desired)) return false;
747
+
748
+ config.$schema = config.$schema || src.$schema;
749
+ config.mcp[MCP_SERVER_NAME] = desired;
750
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
751
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
752
+ console.log(` OpenCode: registered thumbgate MCP server in ${configPath}`);
753
+ return true;
754
+ }
755
+
756
+ // Single source of truth for `--agent`. Every value advertised in `init --help`
757
+ // and README.md must appear here with a real handler, or the flag lies.
758
+ // `hookAgent: true` means scripts/auto-wire-hooks.js can wire a pre-tool hook;
759
+ // the rest integrate over MCP only and are wired by their setup function.
760
+ const AGENT_ALIASES = {
761
+ claude: 'claude-code',
762
+ 'claude_code': 'claude-code',
763
+ 'open-code': 'opencode',
764
+ };
765
+
766
+ const SUPPORTED_AGENTS = {
767
+ 'claude-code': { hookAgent: true },
768
+ codex: { hookAgent: true },
769
+ gemini: { hookAgent: true },
770
+ forge: { hookAgent: true },
771
+ cursor: { hookAgent: true },
772
+ cline: { hookAgent: false, setup: () => setupCline() },
773
+ amp: { hookAgent: false, setup: () => setupAmp() },
774
+ opencode: { hookAgent: false, setup: () => setupOpenCode() },
775
+ };
776
+
696
777
  function setupCursor() {
697
778
  return mergeMcpJson(path.join(CWD, '.cursor', 'mcp.json'), 'Cursor', 'project');
698
779
  }
@@ -934,14 +1015,23 @@ function quickstart() {
934
1015
 
935
1016
  function init(cliArgs = parseArgs(process.argv.slice(3))) {
936
1017
  const args = { ...cliArgs };
1018
+ // A typo used to exit 0 having wired nothing for the requested agent.
1019
+ // Aliases must resolve BEFORE validation: scripts/auto-wire-hooks.js accepts `claude`
1020
+ // for `claude-code`, and plugins/claude-skill/README.md publishes that exact command.
1021
+ if (args.agent && AGENT_ALIASES[args.agent]) args.agent = AGENT_ALIASES[args.agent];
1022
+ if (args.agent && !Object.prototype.hasOwnProperty.call(SUPPORTED_AGENTS, args.agent)) {
1023
+ console.error(`Unknown --agent "${args.agent}". Supported: ${Object.keys(SUPPORTED_AGENTS).join(', ')}`);
1024
+ process.exit(1);
1025
+ }
937
1026
  if (args.help || args.h) {
938
- console.log('Usage: npx thumbgate init [--agent <name>] [--wire-hooks] [--email you@company.com]');
1027
+ console.log('Usage: npx thumbgate init [--agent <name>] [--wire-hooks] [--import-agent-plugins] [--email you@company.com]');
939
1028
  console.log('');
940
1029
  console.log('Scaffold ThumbGate in the current project and wire detected agent integrations.');
941
1030
  console.log('');
942
1031
  console.log('Options:');
943
- console.log(' --agent <name> Wire a specific agent: claude-code, codex, gemini, amp, cursor, cline, opencode');
1032
+ console.log(` --agent <name> Wire a specific agent: ${Object.keys(SUPPORTED_AGENTS).join(', ')}`);
944
1033
  console.log(' --wire-hooks Wire hooks only; do not scaffold project files');
1034
+ console.log(' --import-agent-plugins Also import optional agent plugins when supported');
945
1035
  console.log(' --email <email> Subscribe installer to the setup guide and trial reminders');
946
1036
  console.log(' --dry-run Show hook changes without writing them');
947
1037
  return;
@@ -1037,17 +1127,26 @@ function init(cliArgs = parseArgs(process.argv.slice(3))) {
1037
1127
  let configured = 0;
1038
1128
 
1039
1129
  const platforms = [
1040
- { name: 'Claude Code', detect: [
1130
+ { agent: 'claude-code', name: 'Claude Code', detect: [
1041
1131
  () => whichExists('claude'),
1042
1132
  () => fs.existsSync(path.join(HOME, '.claude')),
1043
1133
  () => fs.existsSync(path.join(CWD, '.claude')),
1044
1134
  ], setup: setupClaude },
1045
- { name: 'Codex', detect: [() => whichExists('codex'), () => fs.existsSync(path.join(HOME, '.codex'))], setup: setupCodex },
1046
- { name: 'Gemini', detect: [() => whichExists('gemini'), () => fs.existsSync(path.join(HOME, '.gemini'))], setup: setupGemini },
1047
- { name: 'Amp', detect: [() => whichExists('amp'), () => fs.existsSync(path.join(HOME, '.amp'))], setup: setupAmp },
1048
- { name: 'Cursor', detect: [() => fs.existsSync(path.join(HOME, '.cursor', 'mcp.json')), () => fs.existsSync(path.join(CWD, '.cursor'))], setup: setupCursor },
1049
- { name: 'ForgeCode', detect: [() => whichExists('forge'), () => fs.existsSync(path.join(CWD, 'forge.yaml'))], setup: setupForge },
1050
- { name: 'Cline', detect: [
1135
+ { agent: 'codex', name: 'Codex', detect: [() => whichExists('codex'), () => fs.existsSync(path.join(HOME, '.codex'))], setup: setupCodex },
1136
+ {
1137
+ agent: 'gemini',
1138
+ name: 'Gemini',
1139
+ detect: [
1140
+ () => whichExists('gemini'),
1141
+ () => whichExists('agy'),
1142
+ () => fs.existsSync(path.join(HOME, '.gemini')),
1143
+ ],
1144
+ setup: () => setupGemini({ importPlugin: args['import-agent-plugins'] === true }),
1145
+ },
1146
+ { agent: 'amp', name: 'Amp', detect: [() => whichExists('amp'), () => fs.existsSync(path.join(HOME, '.amp'))], setup: setupAmp },
1147
+ { agent: 'cursor', name: 'Cursor', detect: [() => fs.existsSync(path.join(HOME, '.cursor', 'mcp.json')), () => fs.existsSync(path.join(CWD, '.cursor'))], setup: setupCursor },
1148
+ { agent: 'forge', name: 'ForgeCode', detect: [() => whichExists('forge'), () => fs.existsSync(path.join(CWD, 'forge.yaml'))], setup: setupForge },
1149
+ { agent: 'cline', name: 'Cline', detect: [
1051
1150
  () => fs.existsSync(path.join(CWD, '.clinerules')),
1052
1151
  () => process.platform === 'darwin' && fs.existsSync(path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev')),
1053
1152
  () => process.platform === 'linux' && fs.existsSync(path.join(HOME, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev')),
@@ -1056,6 +1155,11 @@ function init(cliArgs = parseArgs(process.argv.slice(3))) {
1056
1155
  ];
1057
1156
 
1058
1157
  for (const p of platforms) {
1158
+ // An explicit target must stay scoped to that integration. Quick-start
1159
+ // always resolves one target before calling init; configuring every other
1160
+ // CLI merely because it exists on PATH caused surprise machine mutations
1161
+ // and multi-second hangs in ordinary onboarding.
1162
+ if (args.agent && p.agent !== args.agent) continue;
1059
1163
  if (detectPlatform(p.name, p.detect)) {
1060
1164
  const didSetup = p.setup();
1061
1165
  if (didSetup) configured++;
@@ -1076,15 +1180,27 @@ function init(cliArgs = parseArgs(process.argv.slice(3))) {
1076
1180
 
1077
1181
  if (configured === 0) console.log(' All detected platforms already configured.');
1078
1182
 
1079
- // Cline uses .clinerules (no native hook surface). Run setupCline directly
1080
- // and skip wireHooks, which does not support cline.
1081
- if (args.agent === 'cline') {
1082
- setupCline();
1183
+ // Agents without a native pre-tool hook surface (cline, amp, opencode) are wired
1184
+ // by their own setup function. Previously only cline was handled here: `--agent amp`
1185
+ // and `--agent opencode` fell through to wireHooks, which rejected them, printed the
1186
+ // rejection as an ordinary log line, and exited 0 — so the flag appeared to work
1187
+ // while writing nothing for that agent.
1188
+ const agentSpec = args.agent ? SUPPORTED_AGENTS[args.agent] : null;
1189
+ if (agentSpec && agentSpec.setup) {
1190
+ // A setup function returns false both when nothing needed doing and when it refused
1191
+ // (e.g. an unparseable config it declined to overwrite). Reporting "already
1192
+ // configured" for a refusal would repeat the original sin of this PR: a failure
1193
+ // dressed up as success. The refusal path sets process.exitCode, so key off that.
1194
+ const changed = agentSpec.setup();
1195
+ if (!changed && !process.exitCode) console.log(` ${args.agent}: already configured`);
1083
1196
  } else if (args.agent || args['wire-hooks']) {
1084
1197
  const { wireHooks } = require(path.join(PKG_ROOT, 'scripts', 'auto-wire-hooks'));
1085
1198
  const hookResult = wireHooks({ agent: args.agent, dryRun: args['dry-run'] });
1086
1199
  if (hookResult.error) {
1087
- console.log(` Hook wiring: ${hookResult.error}`);
1200
+ // An explicit --agent that cannot be wired is a FAILURE, not a note. Exiting 0
1201
+ // here is what let a user believe `--agent opencode` had protected them.
1202
+ console.error(` Hook wiring failed for --agent ${args.agent}: ${hookResult.error}`);
1203
+ process.exitCode = 1;
1088
1204
  } else if (!hookResult.changed) {
1089
1205
  console.log(` Hooks: already wired for ${hookResult.agent}`);
1090
1206
  } else {