habithive-code 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.js CHANGED
@@ -1,21 +1,30 @@
1
1
  // habithive-code — one-command full-stack setup.
2
- // 1. opencode — official npm package (installed if missing) [required]
2
+ // 1. opencode — pinned version (installed, or moved to the pin) [required]
3
3
  // 2. oh-my-opencode — pinned version wired into config (npm fetches it) [required]
4
4
  // 3. habithive — plugin wired into tui.json [required]
5
5
  // 4. playwright MCP — wired into the opencode config [extra]
6
- // 5. openspec CLI — @fission-ai/openspec (npm) [extra]
7
- // 6. graphify CLI — graphifyy (PyPI, via uv/pip) [extra]
6
+ // 5. openspec CLI — @fission-ai/openspec pinned + intent-driven schema [extra]
7
+ // 6. graphify CLI — graphifyy (PyPI, via uv/pip; installed or upgraded) [extra]
8
8
  // 7. bundled skills — karpathy-guidelines (MIT) + graphify → config/skills [extra]
9
9
  // Nothing is bundled/redistributed except the two skills under skills/. Every
10
- // tool is installed from its official source. Use `habithive setup --minimal` to
11
- // skip the extras (4-7).
10
+ // tool is installed from its official source.
11
+ //
12
+ // reconcile() = everything after the recorded mode: the same steps, applied to every
13
+ // mode file set that exists. It never records a mode or renames files; `habithive update`
14
+ // runs it after installing a release, runSetup() runs it and records "complex" on first setup.
12
15
 
13
16
  import { spawnSync } from "node:child_process";
14
17
  import { createInterface } from "node:readline";
15
18
  import { mkdirSync, cpSync, existsSync, readdirSync } from "node:fs";
16
19
  import { join } from "node:path";
20
+ import { homedir } from "node:os";
17
21
  import { fileURLToPath } from "node:url";
18
- import { setMode, ensureMcp, OMO_SPEC, PATHS } from "./config.js";
22
+ import { ensureMcp, HABITHIVE_PLUGIN_URL, PATHS, isOmo, isHabithive, readMode, writeMode, currentMode, filesForMode, hasModeSet } from "./config.js";
23
+ import { safeEdit, ConfigParseError } from "./jsonc.js";
24
+ import { OMNIROUTE_MODELS, ensureOmniroute, omnirouteTargets } from "./omniroute.js";
25
+ import { installSchema } from "./openspec.js";
26
+ import { realpathSync } from "node:fs";
27
+ import { OPENCODE_PIN, OMO_SPEC, OPENSPEC_PIN, compareVersions, versionIn, isUnsupportedMajor, opencodeInstallMethod, v2Notice } from "./versions.js";
19
28
 
20
29
  const c = { reset: "\x1b[0m", cyan: "\x1b[36m", green: "\x1b[32m", yellow: "\x1b[33m", dim: "\x1b[2m", red: "\x1b[31m" };
21
30
  const say = (m) => process.stdout.write(m + "\n");
@@ -38,6 +47,52 @@ async function confirm(q, yes) {
38
47
  return !/^n/i.test(a.trim());
39
48
  }
40
49
 
50
+ // Bring opencode to OPENCODE_PIN: install it if missing, else `opencode upgrade <pin>` (works for npm,
51
+ // brew and curl installs). An upgrade failure prints the manual command and setup continues.
52
+ // A different major (OpenCode 2) is never upgraded — it cannot downgrade (404): print the recovery notice, continue.
53
+ // Returns the final version (null only when opencode is missing and could not be installed).
54
+ async function ensureOpencode({ yes = false } = {}) {
55
+ const ver = () => { // null = missing, "" = present but unparseable
56
+ const r = spawnSync("opencode", ["--version"], { encoding: "utf8", shell: win, timeout: 5000 });
57
+ return r.status === 0 ? (versionIn(r.stdout) ?? "") : null;
58
+ };
59
+ let v = ver();
60
+ if (v === null) {
61
+ warn("opencode not found");
62
+ if (!(await confirm(`Install opencode ${OPENCODE_PIN} now (npm i -g opencode-ai@${OPENCODE_PIN})?`, yes))) {
63
+ say(`${c.red}aborted${c.reset} — install opencode first, then re-run: habithive setup`);
64
+ return null;
65
+ }
66
+ step(`installing opencode ${OPENCODE_PIN}…`);
67
+ if (!run("npm", ["i", "-g", `opencode-ai@${OPENCODE_PIN}`])) { say(`${c.red}✗ opencode install failed${c.reset}`); return null; }
68
+ v = ver() || OPENCODE_PIN;
69
+ ok(`opencode ${v} installed`);
70
+ } else if (compareVersions(v, OPENCODE_PIN) === 0) ok(`opencode ${v} (tested version)`);
71
+ else if (isUnsupportedMajor(v)) {
72
+ const [head, ...steps] = v2Notice(v, opencodeInstallMethod()).split("\n");
73
+ say(`${c.red}! ${head}${c.reset}\n${steps.map((l) => `${c.dim}${l}${c.reset}`).join("\n")}`);
74
+ } else {
75
+ step(`moving opencode ${v || "(unknown version)"} → ${OPENCODE_PIN}…`);
76
+ run("opencode", ["upgrade", OPENCODE_PIN]);
77
+ const after = ver();
78
+ if (after && compareVersions(after, OPENCODE_PIN) === 0) { v = after; ok(`opencode ${v} (tested version)`); }
79
+ else { v = after || v; warn(`opencode upgrade failed — run: opencode upgrade ${OPENCODE_PIN}`); }
80
+ // opencode 1.19 ends its upgrade output by suggesting `opencode upgrade --major` (OpenCode 2)
81
+ say(`${c.dim} ignore opencode's "opencode upgrade --major" suggestion: Hive needs OpenCode 1${c.reset}`);
82
+ }
83
+ return v;
84
+ }
85
+
86
+ // opencode updates only through Hive: set autoupdate:false in the opencode.jsonc of each mode set.
87
+ function pinAutoupdate(files) {
88
+ return [...new Set(files.filter((f) => base(f) === "opencode.jsonc"))].filter((f) =>
89
+ safeEdit(f, (cfg) => {
90
+ if (!Object.keys(cfg).length) cfg.$schema = "https://opencode.ai/config.json";
91
+ cfg.autoupdate = false;
92
+ return cfg;
93
+ }).changed);
94
+ }
95
+
41
96
  // Install the graphify (graphifyy on PyPI) Python CLI — prefer uv, fall back to pip.
42
97
  function installGraphify() {
43
98
  if (has("uv")) return run("uv", ["tool", "install", "--upgrade", "graphifyy"]);
@@ -47,6 +102,37 @@ function installGraphify() {
47
102
  run(py, ["-m", "pip", "install", "-U", "--break-system-packages", "graphifyy"]);
48
103
  }
49
104
 
105
+ const nm = (e) => (Array.isArray(e) ? e[0] : e); // plugin entry is "name" or ["name", options]
106
+ const put = (a, is, v) => { // drop stale/duplicate managed entries (keep the first v, with its options), append v if absent
107
+ let seen = false;
108
+ const r = a.filter((e) => { if (!is(e) && nm(e) !== v) return true; if (nm(e) !== v || seen) return false; return (seen = true); });
109
+ return seen ? r : [...r, v];
110
+ };
111
+ const base = (f) => f.split(/[\\/]/).pop();
112
+ const cfgErr = (e) => {
113
+ if (!(e instanceof ConfigParseError)) throw e;
114
+ say(`${c.red}✗ cannot read ${e.file}:${e.line}:${e.column} — fix it and re-run${c.reset}`);
115
+ return 1;
116
+ };
117
+
118
+ // Ensure Hive-managed plugin entries in one mode's file set via safeEdit (backup + no-op when unchanged).
119
+ // A stale entry (old path / old pin) is replaced; every other plugin is left alone. Returns the files changed.
120
+ function wireSet(files, { omo }) {
121
+ return files.filter((f) => {
122
+ const n = base(f), tui = n === "tui.json";
123
+ if (!omo && !tui) return false; // simple set: Hive plugin in tui.json only
124
+ const fresh = !existsSync(f);
125
+ return safeEdit(f, (cfg) => {
126
+ if (fresh && n === "opencode.jsonc") cfg.$schema = "https://opencode.ai/config.json";
127
+ let a = Array.isArray(cfg.plugin) ? cfg.plugin : [];
128
+ if (tui) a = put(a, isHabithive, HABITHIVE_PLUGIN_URL);
129
+ if (omo) a = put(a, isOmo, OMO_SPEC);
130
+ cfg.plugin = a;
131
+ return cfg;
132
+ }).changed;
133
+ });
134
+ }
135
+
50
136
  // Copy the skills bundled with this package into ~/.config/opencode/skills/.
51
137
  function installSkills() {
52
138
  const srcDir = fileURLToPath(new URL("../skills", import.meta.url));
@@ -58,46 +144,96 @@ function installSkills() {
58
144
  return names;
59
145
  }
60
146
 
61
- export async function runSetup({ yes = false, mode = "complex", minimal = false } = {}) {
62
- say(`\n${c.cyan}habithive-code setup${c.reset} ${c.dim}— opencode + oh-my-opencode + habithive${minimal ? "" : " + tools + skills"}${c.reset}\n`);
147
+ const tilde = (f) => f.replace(homedir(), "~");
63
148
 
64
- // 1) opencode (required)
65
- if (has("opencode")) ok("opencode already installed");
66
- else {
67
- warn("opencode not found");
68
- if (!(await confirm("Install opencode now (npm i -g opencode-ai)?", yes))) {
69
- say(`${c.red}aborted${c.reset} — install opencode first, then re-run: habithive setup`);
70
- return 1;
71
- }
72
- step("installing opencode…");
73
- if (!run("npm", ["i", "-g", "opencode-ai"])) { say(`${c.red}✗ opencode install failed${c.reset}`); return 1; }
74
- ok("opencode installed");
75
- }
149
+ // `hive` is the short alias for habithive; warn when another program already owns that name on PATH.
150
+ const real = (p) => { try { return realpathSync(p); } catch { return null; } };
151
+ function hiveShadowCheck() {
152
+ try {
153
+ const r = spawnSync(win ? "where" : "which", ["hive"], { encoding: "utf8", shell: win, timeout: 5000 });
154
+ const p = r.status === 0 ? (r.stdout || "").split(/\r?\n/)[0].trim() : "";
155
+ if (!p) return;
156
+ const mine = realpathSync(fileURLToPath(new URL("../bin/habithive.mjs", import.meta.url)));
157
+ if ((real(p) ?? p) !== mine) warn(`\`hive\` on your PATH is another program (${p}) — use \`habithive\` instead`);
158
+ } catch {}
159
+ }
160
+
161
+ // Bring the machine to this release without touching the mode record: opencode pin, then each Hive-managed
162
+ // entry in EVERY mode file set that exists (oh-my-openagent only in the complex set), then the extras.
163
+ // Returns an exit code. `beforeWire` is runSetup's seam for the first-setup case (returns the files it changed; may throw ConfigParseError).
164
+ export async function reconcile({ yes = false, beforeWire } = {}) {
165
+ // 1) opencode (required, pinned)
166
+ if ((await ensureOpencode({ yes })) === null) return 1;
76
167
 
77
- // 2)+3) wire oh-my-opencode (pinned) + habithive, default complex
168
+ // 2)+3) wire oh-my-opencode (pinned, complex set only) + habithive + autoupdate:false in each set
78
169
  mkdirSync(PATHS.CONFIG_DIR, { recursive: true });
79
170
  step(`wiring oh-my-opencode (${OMO_SPEC}) + habithive…`);
80
- setMode(mode);
81
- ok(`config wired (${mode} mode)`);
171
+ let changed, sets;
172
+ try {
173
+ const first = beforeWire?.() ?? [];
174
+ const complex = filesForMode("complex"), simple = filesForMode("simple");
175
+ sets = [...(complex || []), ...(simple || [])];
176
+ changed = new Set([
177
+ ...first,
178
+ ...(complex ? wireSet(complex, { omo: true }) : []),
179
+ ...(simple ? wireSet(simple, { omo: false }) : []),
180
+ ...pinAutoupdate(sets),
181
+ ]);
182
+ } catch (e) { return cfgErr(e); }
183
+ ok(`config ${changed.size ? `updated: ${[...changed].map(tilde).join(", ")}` : "already current"} (${currentMode()} mode active) · opencode autoupdate off`);
82
184
 
83
- if (!minimal) {
84
- // 4) playwright MCP
85
- if (ensureMcp("playwright", { type: "local", enabled: true, command: "npx", args: ["@playwright/mcp@latest"] }))
86
- ok("playwright MCP added"); else ok("playwright MCP already configured");
185
+ // OmniRoute provider: Hive-owned values reset in each mode set, user additions and any apiKey kept
186
+ try {
187
+ const n = omnirouteTargets().filter((f) => safeEdit(f, ensureOmniroute).changed).length;
188
+ ok(n ? `OmniRoute provider (${Object.keys(OMNIROUTE_MODELS).length} models)` : "OmniRoute provider already current");
189
+ } catch (e) { return cfgErr(e); }
87
190
 
88
- // 5) openspec CLI
89
- if (has("openspec")) ok("openspec already installed");
90
- else { step("installing openspec (@fission-ai/openspec)…"); run("npm", ["i", "-g", "@fission-ai/openspec"]) ? ok("openspec installed") : warn("openspec install skipped/failed"); }
191
+ // 4) playwright MCP
192
+ const spec = { type: "local", enabled: true, command: "npx", args: ["@playwright/mcp@latest"] };
193
+ let added = false;
194
+ try { for (const f of new Set(sets.filter((f) => base(f) === "opencode.jsonc"))) added = ensureMcp("playwright", spec, f) || added; }
195
+ catch (e) { return cfgErr(e); }
196
+ if (added) ok("playwright MCP added"); else ok("playwright MCP already configured");
91
197
 
92
- // 6) graphify CLI (graphifyy on PyPI)
93
- if (has("graphify")) ok("graphify already installed");
94
- else { step("installing graphify (graphifyy via uv/pip)…"); installGraphify() ? ok("graphify installed") : warn("graphify install skipped/failed (needs uv or pip)"); }
95
-
96
- // 7) bundled skills → config/skills (loaded by oh-my-opencode in complex mode)
97
- const skills = installSkills();
98
- if (skills.length) ok(`skills installed: ${skills.join(", ")}`);
198
+ // 5) openspec CLI — pinned (newer releases reject the intent-driven schema); installed only when it differs
199
+ const osv = versionIn(spawnSync("openspec", ["--version"], { encoding: "utf8", shell: win, timeout: 5000 }).stdout);
200
+ if (osv && compareVersions(osv, OPENSPEC_PIN) === 0) ok(`openspec ${osv} (tested version)`);
201
+ else {
202
+ step(`installing openspec ${OPENSPEC_PIN}${osv ? ` (was ${osv})` : ""}…`);
203
+ run("npm", ["i", "-g", `@fission-ai/openspec@${OPENSPEC_PIN}`]) ? ok(`openspec ${OPENSPEC_PIN} installed`) : warn("openspec install failed");
99
204
  }
100
205
 
206
+ // 5b) intent-driven schema → user-level OpenSpec schemas folder
207
+ try {
208
+ const r = installSchema();
209
+ ok(r.changed ? `intent-driven schema installed (${tilde(r.path)})` : "intent-driven schema up to date");
210
+ if (r.backup) say(`${c.dim} previous copy backed up: ${tilde(r.backup)}${c.reset}`);
211
+ } catch (e) { warn(`intent-driven schema install failed: ${e?.message || e}`); }
212
+
213
+ // 6) graphify CLI (graphifyy on PyPI) — upgraded if uv manages it, else installed/upgraded via uv/pip
214
+ step("installing/upgrading graphify (graphifyy via uv/pip)…");
215
+ const uv = has("uv") && /graphifyy/.test(spawnSync("uv", ["tool", "list"], { encoding: "utf8", shell: win }).stdout || "");
216
+ (uv ? run("uv", ["tool", "upgrade", "graphifyy"]) : installGraphify()) ? ok("graphify up to date") : warn("graphify upgrade failed (needs uv or pip)");
217
+
218
+ // 7) bundled skills → config/skills (loaded by oh-my-opencode in complex mode)
219
+ const skills = installSkills();
220
+ if (skills.length) ok(`skills installed: ${skills.join(", ")}`);
221
+ hiveShadowCheck();
222
+ return 0;
223
+ }
224
+
225
+ export async function runSetup({ yes = false } = {}) {
226
+ say(`\n${c.cyan}habithive-code setup${c.reset} ${c.dim}— opencode + oh-my-opencode + habithive + tools + skills${c.reset}\n`);
227
+
228
+ // first setup (no mode record): the active files become the complex set, then complex is recorded
229
+ const firstSetup = readMode() === null;
230
+ const code = await reconcile({
231
+ yes,
232
+ beforeWire: () => (firstSetup && !hasModeSet("complex") ? wireSet([PATHS.OPENCODE_JSONC, PATHS.TUI_JSON], { omo: true }) : []),
233
+ });
234
+ if (code !== 0) return code;
235
+ if (firstSetup) writeMode("complex");
236
+
101
237
  say(`\n${c.green}done.${c.reset} start with: ${c.cyan}habithive${c.reset}`);
102
238
  say(`${c.dim} habithive complex mode, new session`);
103
239
  say(` habithive simple vanilla opencode`);
package/src/spec.js ADDED
@@ -0,0 +1,45 @@
1
+ // habithive-code — `hive spec init`: set a project up with OpenSpec + the intent-driven schema.
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { SCHEMA_NAME, SHIPPED_SCHEMA_DIR, copyTree } from "./openspec.js";
6
+
7
+ const defaultRun = (cmd, args, { cwd } = {}) => {
8
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf8", shell: process.platform === "win32" });
9
+ return { status: r.status ?? 1, stdout: r.stdout || "", stderr: r.stderr || (r.error ? String(r.error.message) : "") };
10
+ };
11
+
12
+ const SCHEMA_LINE = /^schema:.*$/m;
13
+
14
+ export async function specInit(dir = process.cwd(), { run = defaultRun, out = (s) => process.stdout.write(s + "\n") } = {}) {
15
+ if (!existsSync(dir)) { out(`folder not found: ${dir}`); return 1; }
16
+ if (run("openspec", ["--version"], { cwd: dir }).status !== 0) {
17
+ out("openspec not found — run: habithive setup");
18
+ return 1;
19
+ }
20
+ const cfg = join(dir, "openspec", "config.yaml");
21
+ if (existsSync(cfg)) {
22
+ const m = readFileSync(cfg, "utf8").match(/^schema:\s*(\S+)/m);
23
+ const cur = m ? m[1].replace(/^["']|["']$/g, "") : "(none)";
24
+ out(`OpenSpec is already set up here (schema: ${cur})`);
25
+ if (cur !== SCHEMA_NAME) {
26
+ out("To switch to intent-driven:");
27
+ out(` 1. openspec schema fork ${SCHEMA_NAME} ${SCHEMA_NAME}`);
28
+ out(` 2. set \`schema: ${SCHEMA_NAME}\` in openspec/config.yaml`);
29
+ }
30
+ return 0;
31
+ }
32
+ const init = run("openspec", ["init", "--tools", "opencode", dir], { cwd: dir });
33
+ if (init.status !== 0 || !existsSync(cfg)) {
34
+ out(`openspec init failed${init.stderr ? ": " + init.stderr.trim() : ""}`);
35
+ return 1;
36
+ }
37
+ copyTree(SHIPPED_SCHEMA_DIR, join(dir, "openspec", "schemas", SCHEMA_NAME));
38
+ const text = readFileSync(cfg, "utf8");
39
+ const line = `schema: ${SCHEMA_NAME}`;
40
+ writeFileSync(cfg, SCHEMA_LINE.test(text) ? text.replace(SCHEMA_LINE, line) : line + "\n" + text);
41
+ const v = run("openspec", ["schema", "validate", SCHEMA_NAME], { cwd: dir });
42
+ out(`schema validate: ${v.status === 0 ? "ok" : "failed"}${(v.stdout + v.stderr).trim() ? "\n" + (v.stdout + v.stderr).trim() : ""}`);
43
+ out("Next: commit openspec/ and .opencode/");
44
+ return 0;
45
+ }
package/src/update.js ADDED
@@ -0,0 +1,62 @@
1
+ // habithive-code — daily "new release?" check against the public npm registry.
2
+ // Plain Node/Bun ESM, no deps. Never throws: a failed check is silent and only throttles retries.
3
+ // Cache (~/.habithive/update-check.json): { checkedAt, latest, lastAttemptAt }.
4
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { STORE_DIR } from "./core.js";
7
+ import { compareVersions } from "./versions.js";
8
+
9
+ export const UPDATE_CACHE = join(STORE_DIR, "update-check.json");
10
+ export const REGISTRY_URL = process.env.HABITHIVE_REGISTRY_URL || "https://registry.npmjs.org/habithive-code/latest"; // env: test-only stub
11
+ const DAY = 24 * 3600 * 1000, HOUR = 3600 * 1000;
12
+
13
+ export function installedVersion() {
14
+ try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || "0.0.0"; } catch { return "0.0.0"; }
15
+ }
16
+
17
+ export function readCache() {
18
+ try {
19
+ const j = JSON.parse(readFileSync(UPDATE_CACHE, "utf8")), out = {};
20
+ if (Number.isFinite(j?.checkedAt)) out.checkedAt = j.checkedAt;
21
+ if (typeof j?.latest === "string" && j.latest) out.latest = j.latest;
22
+ if (Number.isFinite(j?.lastAttemptAt)) out.lastAttemptAt = j.lastAttemptAt;
23
+ return out;
24
+ } catch { return {}; }
25
+ }
26
+
27
+ // due = last success >= 24h ago (or never) and last attempt >= 1h ago (or never)
28
+ export function isDue(cache, now = Date.now()) {
29
+ const c = cache || {};
30
+ return (!c.checkedAt || now - c.checkedAt >= DAY) && (!c.lastAttemptAt || now - c.lastAttemptAt >= HOUR);
31
+ }
32
+
33
+ function writeCache(obj) {
34
+ try {
35
+ mkdirSync(dirname(UPDATE_CACHE), { recursive: true });
36
+ const tmp = `${UPDATE_CACHE}.tmp-${process.pid}`;
37
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
38
+ renameSync(tmp, UPDATE_CACHE);
39
+ } catch {}
40
+ }
41
+
42
+ // Latest published version string, or null. Success refreshes the cache; failure keeps it and only stamps lastAttemptAt.
43
+ export async function checkLatest({ now = Date.now(), fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
44
+ let latest = null, timer;
45
+ try {
46
+ const ac = new AbortController();
47
+ const timeout = new Promise((_, rej) => { timer = setTimeout(() => { ac.abort(); rej(new Error("timeout")); }, timeoutMs); });
48
+ const get = (async () => {
49
+ const r = await fetchImpl(REGISTRY_URL, { headers: { accept: "application/json" }, signal: ac.signal });
50
+ if (!r || !r.ok) throw new Error(`http ${r?.status}`);
51
+ return (await r.json())?.version;
52
+ })();
53
+ get.catch(() => {}); // a late rejection after the timeout won the race must not go unhandled
54
+ const v = await Promise.race([get, timeout]);
55
+ if (typeof v === "string" && /^\d+\.\d+\.\d+/.test(v)) latest = v;
56
+ } catch {} finally { clearTimeout(timer); }
57
+ const prev = readCache();
58
+ writeCache(latest ? { checkedAt: now, latest, lastAttemptAt: now } : { ...prev, lastAttemptAt: now });
59
+ return latest;
60
+ }
61
+
62
+ export const updateAvailable = (cache, installed = installedVersion()) => !!cache?.latest && compareVersions(cache.latest, installed) > 0;
@@ -0,0 +1,71 @@
1
+ // habithive-code — the toolchain versions this release is tested against.
2
+ // Both move only with a habithive-code release (see docs/COMPATIBILITY.md); README
3
+ // "Compatibility" quotes them and test/versions.test.mjs keeps the two in sync.
4
+ // Fallback for opencode if a pin is ruled out: 1.18.34.
5
+ import { spawnSync } from "node:child_process";
6
+ import { realpathSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+
9
+ export const OPENCODE_PIN = "1.19.4";
10
+ export const OMO_SPEC = "oh-my-openagent@4.19.1"; // 5.x blocked: moves this config to ~/.omo/omo.jsonc (docs/COMPATIBILITY.md #9)
11
+ export const NODE_MIN = "20.19.0"; // OpenSpec 1.5.0 needs it (docs/COMPATIBILITY.md)
12
+ export const OPENSPEC_PIN = "1.5.0"; // 1.14.x rejects the intent-driven schema (ADR step writes outside the change folder)
13
+
14
+ const parse = (v) => {
15
+ const m = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(.+))?$/.exec(String(v ?? "").trim());
16
+ return m ? { n: [+m[1], +(m[2] || 0), +(m[3] || 0)], pre: m[4] || "" } : { n: [0, 0, 0], pre: "" };
17
+ };
18
+
19
+ // -1 | 0 | 1 — numeric major.minor.patch; a leading "v" is ignored, a pre-release ("-x")
20
+ // sorts before its release, unparseable counts as 0.0.0.
21
+ export function compareVersions(a, b) {
22
+ const x = parse(a), y = parse(b);
23
+ for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] < y.n[i] ? -1 : 1;
24
+ if (x.pre === y.pre) return 0;
25
+ if (!x.pre || !y.pre) return x.pre ? -1 : 1;
26
+ return x.pre < y.pre ? -1 : 1;
27
+ }
28
+
29
+ // first x.y.z[-pre] in a tool's `--version` output, else null
30
+ export const versionIn = (s) => /\d+\.\d+\.\d+(?:-[\w.]+)?/.exec(String(s ?? ""))?.[0] ?? null;
31
+
32
+ // OpenCode 2 guard (ADR-0010): Hive needs OpenCode 1. Major of a `--version` text ("opencode v2.1.3"), null if none.
33
+ export const opencodeMajor = (text) => { const v = versionIn(text); return v ? parseInt(v, 10) : null; };
34
+ // true when the installed major differs from OPENCODE_PIN's (unparseable is never "unsupported")
35
+ export const isUnsupportedMajor = (v) => { const m = opencodeMajor(v); return m !== null && m !== opencodeMajor(OPENCODE_PIN); };
36
+
37
+ const slash = (p) => String(p ?? "").replace(/\\/g, "/");
38
+ const whichOpencode = () => {
39
+ const r = spawnSync(process.platform === "win32" ? "where" : "which", ["opencode"], { encoding: "utf8", timeout: 2000 });
40
+ return r.status === 0 ? r.stdout.split(/\r?\n/)[0].trim() || null : null;
41
+ };
42
+
43
+ // How the opencode on PATH was installed, from its resolved path: npm | brew | curl | unknown. Deps injectable for tests.
44
+ export function opencodeInstallMethod({ which = whichOpencode, realpath = realpathSync, home = homedir() } = {}) {
45
+ let p; try { p = which(); } catch {}
46
+ if (!p) return "unknown";
47
+ try { p = realpath(p); } catch {}
48
+ p = slash(p);
49
+ if (p.includes("/@opencode/cli/") || p.includes("/node_modules/")) return "npm";
50
+ if (["/opt/homebrew/", "/usr/local/Cellar/", "/home/linuxbrew/"].some((b) => p.startsWith(b))) return "brew";
51
+ if (p.startsWith(`${slash(home)}/.opencode/bin/`)) return "curl";
52
+ return "unknown";
53
+ }
54
+
55
+ // Lines telling the user how to get back to OpenCode 1; unknown method lists all three.
56
+ export function recoverySteps(method, pin = OPENCODE_PIN) {
57
+ const cmds = {
58
+ npm: ["npm rm -g @opencode/cli", "habithive setup"],
59
+ brew: ["brew uninstall opencode-v2", "habithive setup"],
60
+ curl: [`curl -fsSL https://opencode.ai/install | bash -s -- --version ${pin}`],
61
+ };
62
+ const lines = cmds[method]
63
+ ? ["To go back to OpenCode 1:", ...cmds[method].map((c) => ` ${c}`)]
64
+ : ["Install method not detected — use the one you installed OpenCode 2 with:",
65
+ ...Object.entries(cmds).flatMap(([m, cs]) => cs.map((c, i) => ` ${i ? " " : `${m}:`.padEnd(5)} ${c}`))];
66
+ return [...lines, "Sessions created under OpenCode 2 may not open in OpenCode 1."];
67
+ }
68
+
69
+ // Full multi-line notice: header line, then the recovery steps.
70
+ export const v2Notice = (version, method, pin = OPENCODE_PIN) =>
71
+ [`OpenCode ${version} is installed — habithive needs OpenCode 1 (tested ${pin}); complex mode and the model tree do not work on OpenCode 2.`, ...recoverySteps(method, pin)].join("\n");