ultra-builder-pro-cli 0.5.2 → 0.6.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 (48) hide show
  1. package/CHANGELOG.md +44 -7
  2. package/README.md +14 -16
  3. package/adapters/_shared/codex-assets.cjs +32 -151
  4. package/adapters/_shared/frontmatter.cjs +2 -2
  5. package/adapters/_shared/handbook.cjs +3 -3
  6. package/adapters/_shared/path-rewrite.cjs +0 -3
  7. package/adapters/_shared/runtime-assets.cjs +11 -4
  8. package/adapters/_shared/tests/path-rewrite.test.cjs +1 -3
  9. package/adapters/_shared/tests/resolve-target.test.cjs +1 -11
  10. package/adapters/_shared/tests/runtime-assets.test.cjs +17 -6
  11. package/adapters/opencode.js +1 -6
  12. package/adapters/tests/claude.test.cjs +1 -4
  13. package/adapters/tests/codex.test.cjs +5 -6
  14. package/adapters/tests/opencode.test.cjs +1 -2
  15. package/agents/tdd-runner.md +4 -3
  16. package/bin/install.js +6 -9
  17. package/bin/orchestrator.js +11 -4
  18. package/docs/AGENT-CONTEXT.md +4 -5
  19. package/docs/ARCHITECTURE.md +3 -4
  20. package/docs/LEGACY-HERMES.md +5 -81
  21. package/docs/PLAN.zh-CN.md +35 -42
  22. package/docs/ROADMAP.md +3 -3
  23. package/docs/RUNTIME-COMPAT-MATRIX.md +61 -60
  24. package/mcp-server/lib/pricing.cjs +0 -6
  25. package/mcp-server/lib/state-ops.cjs +7 -0
  26. package/mcp-server/lib/telemetry.cjs +4 -0
  27. package/mcp-server/tests/server-contract.test.cjs +17 -0
  28. package/mcp-server/tests/state-ops.test.cjs +23 -0
  29. package/mcp-server/tests/telemetry.test.cjs +14 -0
  30. package/orchestrator/dispatch-rules.cjs +15 -4
  31. package/orchestrator/tests/daemon.test.cjs +13 -12
  32. package/orchestrator/tests/dispatch-rules.test.cjs +12 -10
  33. package/package.json +3 -5
  34. package/skills/ultra-init/SKILL.md +0 -1
  35. package/skills/ultra-verify/SKILL.md +22 -41
  36. package/skills/ultra-verify/evals/evals.json +22 -24
  37. package/skills/ultra-verify/references/confidence-system.md +15 -32
  38. package/skills/ultra-verify/references/cross-verify-modes.md +14 -59
  39. package/skills/ultra-verify/scripts/verify_wait.py +44 -81
  40. package/spec/mcp-tools.yaml +3 -3
  41. package/spec/schemas/skill-manifest.schema.json +2 -2
  42. package/spec/schemas/state-db.sql +2 -2
  43. package/adapters/_shared/md-to-toml.cjs +0 -54
  44. package/adapters/_shared/tests/md-to-toml.test.cjs +0 -48
  45. package/adapters/gemini.js +0 -204
  46. package/adapters/tests/gemini.test.cjs +0 -112
  47. package/skills/gemini-collab/SKILL.md +0 -51
  48. package/skills/gemini-collab/evals/evals.json +0 -42
@@ -1,23 +1,8 @@
1
1
  #!/usr/bin/env python3
2
- """Verify Wait - File-based completion waiter for /ultra-verify pipeline.
2
+ """File-based completion waiter for the Ultra Verify advisor.
3
3
 
4
- Polls the session directory for expected AI output files.
5
- Blocks until both AIs produce output OR timeout. Returns structured JSON on stdout.
6
-
7
- Two exit conditions only:
8
- 1. Both AIs have output (non-empty + stable size) → exit 0, status="complete"
9
- 2. Timeout → exit 0, status="timeout" (JSON status field tells the story)
10
-
11
- Expected files:
12
- - gemini-output.md (Gemini)
13
- - codex-output.md (Codex)
14
-
15
- Usage:
16
- python3 verify_wait.py <session_path> [--timeout SECONDS]
17
-
18
- Exit codes:
19
- 0 - Always (result expressed via JSON status field)
20
- 2 - Invalid arguments (missing path, bad --timeout)
4
+ Poll the session directory until the expected output is non-empty and stable, or until timeout.
5
+ The JSON result carries the operational status; timeout is not a process error.
21
6
  """
22
7
 
23
8
  import json
@@ -25,106 +10,84 @@ import sys
25
10
  import time
26
11
  from pathlib import Path
27
12
 
28
- POLL_INTERVAL = 3 # seconds
29
- DEFAULT_TIMEOUT = 1200 # 20 min — runs via run_in_background (no Bash 600s limit)
30
-
31
- # Each AI produces exactly one output file
32
- EXPECTED = {
33
- "gemini": "gemini-output.md",
34
- "codex": "codex-output.md",
35
- }
13
+ POLL_INTERVAL = 3
14
+ DEFAULT_TIMEOUT = 1200
15
+ ADVISOR = "codex"
16
+ OUTPUT_FILE = "codex-output.md"
36
17
 
37
18
 
38
19
  def _file_size(path: Path) -> int:
39
- """Return file size in bytes, or -1 if not found."""
40
20
  try:
41
21
  return path.stat().st_size
42
22
  except (FileNotFoundError, OSError):
43
23
  return -1
44
24
 
45
25
 
46
- def _check_output(session_path: Path, name: str) -> dict:
47
- """Check if an AI has produced non-empty output."""
48
- output = session_path / EXPECTED[name]
26
+ def _check_output(session_path: Path) -> dict:
27
+ output = session_path / OUTPUT_FILE
49
28
  if _file_size(output) > 0:
50
- return {"name": name, "status": "complete", "file": str(output)}
51
- return {"name": name, "status": "pending", "file": None}
29
+ return {"name": ADVISOR, "status": "complete", "file": str(output)}
30
+ return {"name": ADVISOR, "status": "pending", "file": None}
52
31
 
53
32
 
54
- def _timeout_status(session_path: Path, name: str) -> dict:
55
- """Determine final status at timeout. Check error logs and empty files."""
56
- output = session_path / EXPECTED[name]
57
- error = session_path / f"{name}-error.log"
33
+ def _timeout_status(session_path: Path) -> dict:
34
+ output = session_path / OUTPUT_FILE
35
+ error = session_path / f"{ADVISOR}-error.log"
58
36
  output_size = _file_size(output)
59
37
  if output_size > 0:
60
- return {"name": name, "status": "complete", "file": str(output)}
38
+ return {"name": ADVISOR, "status": "complete", "file": str(output)}
61
39
  if _file_size(error) > 0:
62
- return {"name": name, "status": "failed", "file": str(error)}
40
+ return {"name": ADVISOR, "status": "failed", "file": str(error)}
63
41
  if output_size == 0:
64
- return {"name": name, "status": "empty", "file": str(output)}
65
- return {"name": name, "status": "pending", "file": None}
42
+ return {"name": ADVISOR, "status": "empty", "file": str(output)}
43
+ return {"name": ADVISOR, "status": "pending", "file": None}
66
44
 
67
45
 
68
- def main():
46
+ def main() -> None:
69
47
  if len(sys.argv) < 2:
70
48
  print(__doc__, file=sys.stderr)
71
49
  sys.exit(2)
72
50
 
73
51
  session_path = Path(sys.argv[1])
74
52
  timeout = DEFAULT_TIMEOUT
75
-
76
53
  if "--timeout" in sys.argv:
77
54
  idx = sys.argv.index("--timeout")
78
- if idx + 1 < len(sys.argv):
79
- try:
80
- timeout = int(sys.argv[idx + 1])
81
- except ValueError:
82
- print(f"Error: --timeout must be integer: {sys.argv[idx + 1]}", file=sys.stderr)
83
- sys.exit(2)
55
+ if idx + 1 >= len(sys.argv):
56
+ print("Error: --timeout requires an integer", file=sys.stderr)
57
+ sys.exit(2)
58
+ try:
59
+ timeout = int(sys.argv[idx + 1])
60
+ except ValueError:
61
+ print(f"Error: --timeout must be integer: {sys.argv[idx + 1]}", file=sys.stderr)
62
+ sys.exit(2)
63
+ if timeout < 0:
64
+ print("Error: --timeout must be non-negative", file=sys.stderr)
65
+ sys.exit(2)
84
66
 
85
67
  if not session_path.is_dir():
86
68
  print(f"Error: session directory not found: {session_path}", file=sys.stderr)
87
69
  sys.exit(2)
88
70
 
89
71
  deadline = time.monotonic() + timeout
90
- prev_sizes = {name: _file_size(session_path / f) for name, f in EXPECTED.items()}
91
-
92
- # Poll loop — exit condition 1: both outputs ready + stable
72
+ previous_size = _file_size(session_path / OUTPUT_FILE)
93
73
  while time.monotonic() < deadline:
94
- cur_sizes = {name: _file_size(session_path / f) for name, f in EXPECTED.items()}
95
- gemini = _check_output(session_path, "gemini")
96
- codex = _check_output(session_path, "codex")
97
-
98
- gemini_done = gemini["status"] == "complete" and cur_sizes["gemini"] == prev_sizes["gemini"]
99
- codex_done = codex["status"] == "complete" and cur_sizes["codex"] == prev_sizes["codex"]
100
-
101
- if gemini_done and codex_done:
74
+ current_size = _file_size(session_path / OUTPUT_FILE)
75
+ advisor = _check_output(session_path)
76
+ stable = advisor["status"] == "complete" and current_size == previous_size
77
+ if stable:
102
78
  elapsed = int(timeout - (deadline - time.monotonic()))
103
- print(json.dumps({"status": "complete", "gemini": gemini, "codex": codex, "elapsed_seconds": elapsed}))
104
- sys.exit(0)
105
-
106
- prev_sizes = cur_sizes
107
-
108
- # Progress display
109
- remaining = int(deadline - time.monotonic())
110
- parts = []
111
- for name, done, result in [("gemini", gemini_done, gemini), ("codex", codex_done, codex)]:
112
- if done:
113
- parts.append(f"{name}:complete")
114
- elif result["status"] == "complete":
115
- parts.append(f"{name}:stabilizing")
116
- else:
117
- parts.append(f"{name}:waiting")
118
- done_count = int(gemini_done) + int(codex_done)
119
- sys.stderr.write(f"\r [{done_count}/2] {' | '.join(parts)} ({remaining}s remaining) ")
79
+ print(json.dumps({"status": "complete", ADVISOR: advisor, "elapsed_seconds": elapsed}))
80
+ return
81
+
82
+ state = "stabilizing" if advisor["status"] == "complete" else "waiting"
83
+ remaining = max(0, int(deadline - time.monotonic()))
84
+ sys.stderr.write(f"\r [0/1] {ADVISOR}:{state} ({remaining}s remaining) ")
120
85
  sys.stderr.flush()
86
+ previous_size = current_size
121
87
  time.sleep(POLL_INTERVAL)
122
88
 
123
- # Exit condition 2: timeout — check error logs NOW for final status
124
- gemini = _timeout_status(session_path, "gemini")
125
- codex = _timeout_status(session_path, "codex")
126
- print(json.dumps({"status": "timeout", "gemini": gemini, "codex": codex, "elapsed_seconds": timeout}))
127
- sys.exit(0)
89
+ advisor = _timeout_status(session_path)
90
+ print(json.dumps({"status": "timeout", ADVISOR: advisor, "elapsed_seconds": timeout}))
128
91
 
129
92
 
130
93
  if __name__ == "__main__":
@@ -359,7 +359,7 @@ tools:
359
359
  properties:
360
360
  task_id: { type: string }
361
361
  session_id: { type: string }
362
- runtime: { type: string, enum: [claude, opencode, codex, gemini] }
362
+ runtime: { type: string, enum: [claude, opencode, codex] }
363
363
  type:
364
364
  type: string
365
365
  enum:
@@ -439,7 +439,7 @@ tools:
439
439
  additionalProperties: false
440
440
  properties:
441
441
  task_id: { type: string }
442
- runtime: { type: string, enum: [claude, opencode, codex, gemini] }
442
+ runtime: { type: string, enum: [claude, opencode, codex] }
443
443
  takeover: { type: boolean, default: false, description: 'override active lease (D33 takeover strategy)' }
444
444
  worktree_base: { type: string }
445
445
  output_schema:
@@ -541,7 +541,7 @@ tools:
541
541
  additionalProperties: false
542
542
  properties:
543
543
  task_id: { type: string }
544
- runtime: { type: string, enum: [claude, opencode, codex, gemini] }
544
+ runtime: { type: string, enum: [claude, opencode, codex] }
545
545
  output_schema:
546
546
  type: object
547
547
  required: [can_spawn, recommended_action]
@@ -19,10 +19,10 @@
19
19
  },
20
20
  "runtime": {
21
21
  "oneOf": [
22
- { "type": "string", "enum": ["claude", "opencode", "codex", "gemini", "all"] },
22
+ { "type": "string", "enum": ["claude", "opencode", "codex", "all"] },
23
23
  {
24
24
  "type": "array",
25
- "items": { "type": "string", "enum": ["claude", "opencode", "codex", "gemini"] },
25
+ "items": { "type": "string", "enum": ["claude", "opencode", "codex"] },
26
26
  "minItems": 1,
27
27
  "uniqueItems": true
28
28
  }
@@ -54,7 +54,7 @@ CREATE TABLE IF NOT EXISTS events (
54
54
  type TEXT NOT NULL,
55
55
  task_id TEXT,
56
56
  session_id TEXT,
57
- runtime TEXT CHECK (runtime IS NULL OR runtime IN ('claude', 'opencode', 'codex', 'gemini')),
57
+ runtime TEXT CHECK (runtime IS NULL OR runtime IN ('claude', 'opencode', 'codex')),
58
58
  payload_json TEXT
59
59
  );
60
60
 
@@ -69,7 +69,7 @@ CREATE INDEX IF NOT EXISTS events_session ON events(session_id, id);
69
69
  CREATE TABLE IF NOT EXISTS sessions (
70
70
  sid TEXT PRIMARY KEY,
71
71
  task_id TEXT NOT NULL REFERENCES tasks(id),
72
- runtime TEXT NOT NULL CHECK (runtime IN ('claude', 'opencode', 'codex', 'gemini')),
72
+ runtime TEXT NOT NULL CHECK (runtime IN ('claude', 'opencode', 'codex')),
73
73
  pid INTEGER,
74
74
  worktree_path TEXT NOT NULL,
75
75
  artifact_dir TEXT NOT NULL,
@@ -1,54 +0,0 @@
1
- 'use strict';
2
-
3
- // Convert a thin-shell command .md file to a Gemini custom-command TOML.
4
- // Gemini commands live in `~/.gemini/commands/<name>.toml` with shape:
5
- //
6
- // description = "…"
7
- // prompt = """
8
- // <command body>
9
- // """
10
- //
11
- // Only the description (from frontmatter) and the body text cross over.
12
- // Frontmatter keys like `workflow-ref` are inlined into the prompt so the
13
- // agent still sees the skill reference.
14
-
15
- const { parse: parseFrontmatter } = require('./frontmatter.cjs');
16
-
17
- function escapeTomlLiteral(str) {
18
- // Gemini accepts """multi-line""" literals. Escape any triple-quote to be safe.
19
- return String(str).replace(/"""/g, '\\"\\"\\"');
20
- }
21
-
22
- function buildPromptSection(fm, body) {
23
- const lines = [];
24
- if (fm && fm['workflow-ref']) {
25
- lines.push(`[ultra-builder-pro] workflow reference: ${fm['workflow-ref']}`);
26
- }
27
- if (fm && Array.isArray(fm.mcp_tools_required) && fm.mcp_tools_required.length > 0) {
28
- lines.push(`[ultra-builder-pro] MCP tools required: ${fm.mcp_tools_required.join(', ')}`);
29
- }
30
- if (fm && fm.cli_fallback) {
31
- lines.push(`[ultra-builder-pro] CLI fallback: ultra-tools ${fm.cli_fallback}`);
32
- }
33
- if (lines.length > 0) lines.push('');
34
- lines.push(body.trimEnd());
35
- return lines.join('\n');
36
- }
37
-
38
- function mdCommandToToml(mdText) {
39
- if (typeof mdText !== 'string') throw new TypeError('mdCommandToToml: expects string');
40
- const { fm, body } = parseFrontmatter(mdText);
41
- const description = (fm && fm.description) || 'Ultra Builder Pro command';
42
- const prompt = buildPromptSection(fm, body);
43
-
44
- const tomlLines = [
45
- `description = ${JSON.stringify(description)}`,
46
- 'prompt = """',
47
- escapeTomlLiteral(prompt),
48
- '"""',
49
- '',
50
- ];
51
- return tomlLines.join('\n');
52
- }
53
-
54
- module.exports = { mdCommandToToml };
@@ -1,48 +0,0 @@
1
- 'use strict';
2
-
3
- const { test } = require('node:test');
4
- const assert = require('node:assert/strict');
5
-
6
- const { mdCommandToToml } = require('../md-to-toml.cjs');
7
-
8
- test('mdCommandToToml extracts description from frontmatter', () => {
9
- const md = [
10
- '---',
11
- 'description: Initialize project',
12
- 'workflow-ref: "@skills/ultra-init/SKILL.md"',
13
- '---',
14
- '',
15
- '# /ultra-init',
16
- 'body content here',
17
- ].join('\n');
18
-
19
- const toml = mdCommandToToml(md);
20
- assert.match(toml, /description = "Initialize project"/);
21
- assert.match(toml, /prompt = """/);
22
- assert.match(toml, /body content here/);
23
- assert.match(toml, /workflow reference: @skills\/ultra-init\/SKILL\.md/);
24
- });
25
-
26
- test('mdCommandToToml includes mcp_tools_required and cli_fallback when present', () => {
27
- const md = [
28
- '---',
29
- 'description: plan tasks',
30
- 'mcp_tools_required:',
31
- ' - task.create',
32
- ' - ask.question',
33
- 'cli_fallback: "task create"',
34
- '---',
35
- 'body',
36
- ].join('\n');
37
-
38
- const toml = mdCommandToToml(md);
39
- assert.match(toml, /MCP tools required: task\.create, ask\.question/);
40
- assert.match(toml, /CLI fallback: ultra-tools task create/);
41
- });
42
-
43
- test('mdCommandToToml defaults description when frontmatter absent', () => {
44
- const md = '# plain markdown, no frontmatter';
45
- const toml = mdCommandToToml(md);
46
- assert.match(toml, /description = "Ultra Builder Pro command"/);
47
- assert.match(toml, /plain markdown, no frontmatter/);
48
- });
@@ -1,204 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Gemini CLI adapter — Phase 4.5 installer (extension-packaged).
5
- *
6
- * Target config dirs:
7
- * global: $GEMINI_CONFIG_DIR → ~/.gemini
8
- * local: <cwd>/.gemini
9
- *
10
- * Layout — everything is packaged as a Gemini extension under
11
- * <target>/extensions/ultra-builder-pro/:
12
- *
13
- * gemini-extension.json → extension manifest (mcpServers declared here)
14
- * commands/*.toml → generated from commands/*.md via md-to-toml
15
- * skills/** → Ultra-owned allowlist (Gemini reads these as prompt includes)
16
- * GEMINI.md → canonical agent context (docs/AGENT-CONTEXT.md copy)
17
- * hooks/ → NOT installed; Gemini has no hook surface.
18
- * prompt-guard downgrade is described inside GEMINI.md.
19
- *
20
- * Scope boundaries (documented):
21
- * - Gemini has no hook event loop; runtime-enforced guards degrade to
22
- * prompt-level guidance (hooks/adapters/gemini.py explains).
23
- * - Subagent registration requires the upstream spec to settle — Phase 4.5
24
- * spike captures whatever shape lands.
25
- */
26
-
27
- const fs = require('node:fs');
28
- const os = require('node:os');
29
- const path = require('node:path');
30
-
31
- const {
32
- copyTree,
33
- writeAtomic,
34
- ensureDir,
35
- removeTree,
36
- } = require('./_shared/file-ops.cjs');
37
- const { mdCommandToToml } = require('./_shared/md-to-toml.cjs');
38
- const { buildMcpRuntime } = require('./_shared/codex-assets.cjs');
39
- const { skillsForRuntime } = require('./_shared/runtime-assets.cjs');
40
-
41
- const EXTENSION_NAME = 'ultra-builder-pro';
42
- const MCP_SERVER_NAME = 'ultra-builder-pro';
43
- const SOURCE_TAG = 'ubp';
44
-
45
- function resolveTarget(ctx) {
46
- if (ctx.configDir) return ctx.configDir;
47
- if (ctx.scope === 'global') {
48
- return process.env.GEMINI_CONFIG_DIR || path.join(ctx.homeDir || os.homedir(), '.gemini');
49
- }
50
- return path.join(ctx.cwd || process.cwd(), '.gemini');
51
- }
52
-
53
- function resolveExtensionRoot(ctx) {
54
- return path.join(resolveTarget(ctx), 'extensions', EXTENSION_NAME);
55
- }
56
-
57
- function resolveRepoRoot(ctx) {
58
- return ctx.repoRoot || path.resolve(__dirname, '..');
59
- }
60
-
61
- function loadPkgVersion(repoRoot) {
62
- try {
63
- return JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
64
- } catch (err) {
65
- // Fall back to 0.0.0 but surface the reason — silent swallow hid a
66
- // real "package.json moved" regression in a Phase 4 code review.
67
- process.stderr.write(`gemini adapter: loadPkgVersion fell back to 0.0.0 (${err.message})\n`);
68
- return '0.0.0';
69
- }
70
- }
71
-
72
- function buildManifest(repoRoot, runtime) {
73
- return {
74
- name: EXTENSION_NAME,
75
- version: loadPkgVersion(repoRoot),
76
- description: 'Ultra Builder Pro — skill/MCP/CLI agent engineering system (Gemini packaging).',
77
- // Sibling identification block — outside env so it cannot leak into the
78
- // MCP server's child process (P2 #9). Uninstall reads manifest._ubp.source.
79
- _ubp: { source: SOURCE_TAG },
80
- mcpServers: {
81
- [MCP_SERVER_NAME]: {
82
- command: process.execPath,
83
- args: [runtime.launcher],
84
- _ubp: { source: SOURCE_TAG },
85
- },
86
- },
87
- contextFileName: 'GEMINI.md',
88
- };
89
- }
90
-
91
- function ownsExtension(extensionRoot) {
92
- const manifestFile = path.join(extensionRoot, 'gemini-extension.json');
93
- if (!fs.existsSync(manifestFile)) return false;
94
- try {
95
- const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
96
- return manifest?._ubp?.source === SOURCE_TAG;
97
- } catch (_error) {
98
- return false;
99
- }
100
- }
101
-
102
- function install(ctx) {
103
- const extensionRoot = resolveExtensionRoot(ctx);
104
- const repoRoot = resolveRepoRoot(ctx);
105
- if (fs.existsSync(extensionRoot)) {
106
- const entries = fs.readdirSync(extensionRoot);
107
- if (entries.length > 0 && !ownsExtension(extensionRoot)) {
108
- throw new Error(`refusing to replace unmanaged Gemini extension: ${extensionRoot}`);
109
- }
110
- if (entries.length > 0) {
111
- for (const managed of ['skills', 'commands', 'runtime', 'spec', 'templates']) {
112
- removeTree(path.join(extensionRoot, managed));
113
- }
114
- for (const managed of ['GEMINI.md', 'gemini-extension.json']) {
115
- const file = path.join(extensionRoot, managed);
116
- if (fs.existsSync(file)) fs.unlinkSync(file);
117
- }
118
- }
119
- }
120
- ensureDir(extensionRoot);
121
-
122
- const report = { target: extensionRoot, copied: {}, config: { updated: false } };
123
-
124
- // 1. Skills — copy only the canonical Ultra-owned allowlist.
125
- const skillsSrc = path.join(repoRoot, 'skills');
126
- report.copied.skills = [];
127
- for (const name of skillsForRuntime('gemini')) {
128
- const source = path.join(skillsSrc, name);
129
- if (!fs.existsSync(path.join(source, 'SKILL.md'))) {
130
- throw new Error(`missing allowlisted Gemini skill: ${name}`);
131
- }
132
- const files = copyTree(source, path.join(extensionRoot, 'skills', name));
133
- report.copied.skills.push(...files.map((file) => path.join(name, file)));
134
- }
135
-
136
- // 2. Commands — md → toml
137
- const commandsSrc = path.join(repoRoot, 'commands');
138
- if (fs.existsSync(commandsSrc)) {
139
- const dstCommands = path.join(extensionRoot, 'commands');
140
- ensureDir(dstCommands);
141
- const tomlFiles = [];
142
- for (const f of fs.readdirSync(commandsSrc)) {
143
- if (!f.endsWith('.md')) continue;
144
- const md = fs.readFileSync(path.join(commandsSrc, f), 'utf8');
145
- const tomlName = f.replace(/\.md$/, '.toml');
146
- writeAtomic(path.join(dstCommands, tomlName), mdCommandToToml(md));
147
- tomlFiles.push(tomlName);
148
- }
149
- report.copied.commands = tomlFiles;
150
- }
151
-
152
- // 3. GEMINI.md — copy docs/AGENT-CONTEXT.md into the extension context file
153
- const agentContext = path.join(repoRoot, 'docs', 'AGENT-CONTEXT.md');
154
- if (fs.existsSync(agentContext)) {
155
- const dst = path.join(extensionRoot, 'GEMINI.md');
156
- fs.copyFileSync(agentContext, dst);
157
- report.copied.context = 'GEMINI.md';
158
- }
159
-
160
- // 4. Manifest
161
- const runtime = buildMcpRuntime(repoRoot, extensionRoot, { runtime: 'gemini' });
162
- const manifest = buildManifest(repoRoot, runtime);
163
- writeAtomic(
164
- path.join(extensionRoot, 'gemini-extension.json'),
165
- JSON.stringify(manifest, null, 2) + '\n',
166
- );
167
- report.config.updated = true;
168
- return report;
169
- }
170
-
171
- function uninstall(ctx) {
172
- const extensionRoot = resolveExtensionRoot(ctx);
173
- const report = { target: extensionRoot, removed: {}, config: { updated: false } };
174
- if (fs.existsSync(extensionRoot)) {
175
- // Manifest carries the _ubp sibling block — if tampered with, bail loud.
176
- const manifestFile = path.join(extensionRoot, 'gemini-extension.json');
177
- if (fs.existsSync(manifestFile)) {
178
- try {
179
- const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
180
- if (!manifest._ubp || manifest._ubp.source !== SOURCE_TAG) {
181
- throw new Error(`refusing to uninstall: ${manifestFile} was not generated by this adapter`);
182
- }
183
- } catch (err) {
184
- if (err.message.startsWith('refusing')) throw err;
185
- // malformed manifest: proceed — we still own the directory name
186
- }
187
- }
188
- removeTree(extensionRoot);
189
- report.removed.extension = true;
190
- report.config.updated = true;
191
- }
192
- return report;
193
- }
194
-
195
- module.exports = {
196
- name: 'gemini',
197
- EXTENSION_NAME,
198
- MCP_SERVER_NAME,
199
- SOURCE_TAG,
200
- resolveTarget,
201
- resolveExtensionRoot,
202
- install,
203
- uninstall,
204
- };
@@ -1,112 +0,0 @@
1
- 'use strict';
2
-
3
- const { test } = require('node:test');
4
- const assert = require('node:assert/strict');
5
- const fs = require('node:fs');
6
- const os = require('node:os');
7
- const path = require('node:path');
8
-
9
- const gemini = require('../gemini.js');
10
- const { RETIRED_SKILLS, skillsForRuntime } = require('../_shared/runtime-assets.cjs');
11
-
12
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
13
-
14
- function mkTarget() {
15
- return fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-gemini-'));
16
- }
17
-
18
- test('install packages into extensions/ultra-builder-pro with manifest + commands.toml + skills', () => {
19
- const target = mkTarget();
20
- try {
21
- const r = gemini.install({ configDir: target, repoRoot: REPO_ROOT });
22
- const extRoot = r.target;
23
- assert.equal(extRoot, path.join(target, 'extensions', 'ultra-builder-pro'));
24
- assert.ok(fs.existsSync(path.join(extRoot, 'gemini-extension.json')));
25
- assert.ok(fs.existsSync(path.join(extRoot, 'GEMINI.md')));
26
- assert.ok(r.copied.commands.includes('ultra-init.toml'));
27
- assert.ok(r.copied.skills.some((p) => p.includes('ultra-init/SKILL.md')));
28
-
29
- const installedSkills = fs.readdirSync(path.join(extRoot, 'skills')).sort();
30
- assert.deepEqual(installedSkills, skillsForRuntime('gemini').sort());
31
- for (const retired of RETIRED_SKILLS) assert.ok(!installedSkills.includes(retired));
32
-
33
- const tomlContent = fs.readFileSync(path.join(extRoot, 'commands', 'ultra-init.toml'), 'utf8');
34
- assert.match(tomlContent, /description = "/);
35
- assert.match(tomlContent, /prompt = """/);
36
- } finally {
37
- fs.rmSync(target, { recursive: true, force: true });
38
- }
39
- });
40
-
41
- test('reinstall removes stale managed skill assets while preserving runtime state', () => {
42
- const target = mkTarget();
43
- try {
44
- gemini.install({ configDir: target, repoRoot: REPO_ROOT });
45
- const extRoot = gemini.resolveExtensionRoot({ configDir: target });
46
- const stale = path.join(extRoot, 'skills', 'recall');
47
- fs.mkdirSync(stale, { recursive: true });
48
- fs.writeFileSync(path.join(stale, 'SKILL.md'), 'retired');
49
- fs.writeFileSync(path.join(extRoot, 'state.db'), 'preserve');
50
-
51
- gemini.install({ configDir: target, repoRoot: REPO_ROOT });
52
- assert.equal(fs.existsSync(stale), false);
53
- assert.equal(fs.readFileSync(path.join(extRoot, 'state.db'), 'utf8'), 'preserve');
54
- } finally {
55
- fs.rmSync(target, { recursive: true, force: true });
56
- }
57
- });
58
-
59
- test('manifest declares mcpServers with _ubp tag (not in env — P2 #9)', () => {
60
- const target = mkTarget();
61
- try {
62
- gemini.install({ configDir: target, repoRoot: REPO_ROOT });
63
- const extRoot = gemini.resolveExtensionRoot({ configDir: target });
64
- const manifest = JSON.parse(fs.readFileSync(path.join(extRoot, 'gemini-extension.json'), 'utf8'));
65
- assert.equal(manifest.name, gemini.EXTENSION_NAME);
66
- assert.equal(manifest._ubp.source, gemini.SOURCE_TAG);
67
- assert.ok(manifest.mcpServers[gemini.MCP_SERVER_NAME]);
68
- // D45: identification lives outside env so it never leaks into spawned MCP server.
69
- assert.equal(manifest.mcpServers[gemini.MCP_SERVER_NAME]._ubp.source, gemini.SOURCE_TAG);
70
- assert.equal(manifest.mcpServers[gemini.MCP_SERVER_NAME].env, undefined, 'project cwd must own MCP state');
71
- assert.equal(manifest.contextFileName, 'GEMINI.md');
72
- } finally {
73
- fs.rmSync(target, { recursive: true, force: true });
74
- }
75
- });
76
-
77
- test('manifest uses a durable bundled MCP runtime and project-local state defaults', () => {
78
- const target = mkTarget();
79
- try {
80
- gemini.install({ configDir: target, repoRoot: REPO_ROOT });
81
- const extRoot = gemini.resolveExtensionRoot({ configDir: target });
82
- const manifest = JSON.parse(fs.readFileSync(path.join(extRoot, 'gemini-extension.json'), 'utf8'));
83
- const server = manifest.mcpServers[gemini.MCP_SERVER_NAME];
84
- const launcher = server.args[0];
85
- assert.ok(launcher.startsWith(path.join(extRoot, 'runtime') + path.sep));
86
- assert.ok(fs.existsSync(launcher), 'bundled launcher must survive the installer source directory');
87
- assert.equal(server.env, undefined, 'Gemini must resolve .ultra/state.db from the active project cwd');
88
- } finally {
89
- fs.rmSync(target, { recursive: true, force: true });
90
- }
91
- });
92
-
93
- test('uninstall removes extension dir; refuses on foreign manifest', () => {
94
- const target = mkTarget();
95
- const extRoot = gemini.resolveExtensionRoot({ configDir: target });
96
- try {
97
- gemini.install({ configDir: target, repoRoot: REPO_ROOT });
98
- assert.ok(fs.existsSync(extRoot));
99
- gemini.uninstall({ configDir: target });
100
- assert.ok(!fs.existsSync(extRoot));
101
-
102
- // Re-install then tamper with the manifest's _ubp identification block.
103
- gemini.install({ configDir: target, repoRoot: REPO_ROOT });
104
- const manifestFile = path.join(extRoot, 'gemini-extension.json');
105
- const m = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
106
- m._ubp = { source: 'someone-else' };
107
- fs.writeFileSync(manifestFile, JSON.stringify(m, null, 2));
108
- assert.throws(() => gemini.uninstall({ configDir: target }), /refusing to uninstall/);
109
- } finally {
110
- fs.rmSync(target, { recursive: true, force: true });
111
- }
112
- });