@ours.network/install 0.17.0 → 0.18.0-nightly.2
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/README.md +72 -120
- package/install.mjs +23 -790
- package/lib/components.mjs +361 -0
- package/lib/detect.mjs +169 -0
- package/lib/effects.mjs +349 -0
- package/lib/extras.mjs +351 -0
- package/lib/journal.mjs +158 -0
- package/lib/logic.mjs +351 -25
- package/lib/orchestrate-uninstall.mjs +379 -0
- package/lib/orchestrate.mjs +984 -0
- package/lib/plan.mjs +270 -0
- package/lib/rerun.mjs +119 -0
- package/lib/target.mjs +390 -0
- package/lib/ui.mjs +15 -0
- package/lib/uninstall.mjs +736 -0
- package/lib/usage.mjs +48 -0
- package/package.json +2 -2
- package/uninstall.mjs +23 -194
- package/uninstall.sh +13 -4
package/install.mjs
CHANGED
|
@@ -1,794 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// ours.network — the unified `ours-install` experience (the real UX behind install.sh's thin
|
|
3
|
-
// bootstrap, and the `ours-install` command once the stack is on the machine).
|
|
4
|
-
//
|
|
5
|
-
// ONE installer for the WHOLE stack — ours core (the daemon) + the harness plugins (Claude Code /
|
|
6
|
-
// Codex / Hermes) + ours-fleet + the Telegram connector — for someone who ALREADY has Claude,
|
|
7
|
-
// Codex, and/or Hermes.
|
|
8
|
-
// Its whole job: install the stack cleanly, then hand back ONE copy-paste prompt the user drops
|
|
9
|
-
// into their agent to finish remaining configuration conversationally. Voice API credentials are
|
|
10
|
-
// the one guided secret flow: interactive, masked, optional, and written atomically with mode 0600.
|
|
11
|
-
// See packages/installer/README.md and the UX spec for the full contract.
|
|
12
|
-
//
|
|
13
|
-
// Design pillars (from the spec): config FIRST then act once; consent-first (Enter = no change);
|
|
14
|
-
// slow, per-step "✓ … no problems" + Continue?; never silently broken; idempotent + safe re-run;
|
|
15
|
-
// alias-safety / never-hang; most deep config deferred to the copy-paste hand-off.
|
|
16
|
-
//
|
|
17
|
-
// SAFETY: every side-effecting action goes through act(); with OURS_INSTALL_DRY_RUN=1 nothing is
|
|
18
|
-
// installed/started/restarted — it prints exactly what it WOULD do. That is the safe way to walk
|
|
19
|
-
// the whole flow on a machine you don't want to touch (and how the tests drive it).
|
|
20
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
21
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
22
|
-
import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
|
|
23
|
-
import { join } from 'node:path';
|
|
24
|
-
import { banner, heading, ok, info, warn, c, box, withSpinner, openTty, makeWriter, closeSync } from './lib/ui.mjs';
|
|
25
|
-
import { askLine, askYesNo, isCancel } from './lib/prompt.mjs';
|
|
26
|
-
import {
|
|
27
|
-
suggestPort, parsePort, validateBroker, mergeConfig, parseVersion, parseStatus,
|
|
28
|
-
detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
|
|
29
|
-
voiceSetupStatus,
|
|
30
|
-
DEFAULT_PORT, resolveChannel, pkgSpec,
|
|
31
|
-
} from './lib/logic.mjs';
|
|
32
|
-
import { atomicWriteConfig } from './lib/config.mjs';
|
|
33
2
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
code: r.status ?? (r.error ? -1 : 0),
|
|
58
|
-
out: r.stdout || '', err: r.stderr || '',
|
|
59
|
-
ok: !r.error && r.status === 0,
|
|
60
|
-
timedOut,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
function runAsync(bin, args) {
|
|
64
|
-
return new Promise((resolve) => {
|
|
65
|
-
let child;
|
|
66
|
-
try { child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
67
|
-
catch { resolve({ code: -1, out: '', err: '', ok: false }); return; }
|
|
68
|
-
let out = '', errOut = '';
|
|
69
|
-
child.stdout.on('data', (d) => { out += d; });
|
|
70
|
-
child.stderr.on('data', (d) => { errOut += d; });
|
|
71
|
-
child.on('error', () => resolve({ code: -1, out, err: errOut, ok: false }));
|
|
72
|
-
child.on('close', (code) => resolve({ code: code ?? -1, out, err: errOut, ok: code === 0 }));
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// act(): the single seam every side-effecting step passes through. In DRY-RUN it prints the exact
|
|
77
|
-
// command instead of running it and reports a synthetic success, so the whole UX can be walked on
|
|
78
|
-
// a machine we must not disturb. `fn` runs the real thing and returns {ok,...}.
|
|
79
|
-
async function act(desc, fn) {
|
|
80
|
-
if (DRY) { line(' ' + c.dim(`[dry-run] would: ${desc}`)); return { ok: true, dry: true }; }
|
|
81
|
-
return fn();
|
|
82
|
-
}
|
|
83
|
-
async function actSpin(label, desc, fn) {
|
|
84
|
-
if (DRY) { line(' ' + c.dim(`[dry-run] would: ${desc}`)); return { ok: true, dry: true }; }
|
|
85
|
-
return withSpinner(label, fn);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// --- daemon probes (always safe to run — read-only) --------------------------------------------
|
|
89
|
-
const daemonVersionLine = () => (run('ours-mcp', ['--version'], { capture: true }).out.split('\n')[0] || '').trim();
|
|
90
|
-
const daemonStatusText = () => run('ours-mcp', ['status'], { capture: true }).out;
|
|
91
|
-
function daemonLifecycleState() {
|
|
92
|
-
const status = run('ours-mcp', ['status'], { capture: true });
|
|
93
|
-
if (!status.ok) return 'stopped';
|
|
94
|
-
return /^\s*pid:\s*\d+/m.test(status.out) ? 'managed' : 'external';
|
|
95
|
-
}
|
|
96
|
-
const daemonRunning = () => daemonLifecycleState() !== 'stopped';
|
|
97
|
-
const globalVersion = (pkg) => {
|
|
98
|
-
const ls = run(NPM, ['ls', '-g', pkg], { capture: true }).out;
|
|
99
|
-
const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@([0-9][0-9.]*)'));
|
|
100
|
-
return m ? m[1] : '';
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
// --- config file (fixed home location; mirrors core/config.ts) ---------------------------------
|
|
104
|
-
function configPath() { return process.env.OURS_CONFIG || join(homedir(), '.ours', 'config.json'); }
|
|
105
|
-
function readConfigObject() { try { return JSON.parse(readFileSync(configPath(), 'utf8')); } catch { return {}; } }
|
|
106
|
-
function writeConfigPatch(patch) {
|
|
107
|
-
const p = configPath();
|
|
108
|
-
atomicWriteConfig(p, mergeConfig(readConfigObject(), patch));
|
|
109
|
-
return p;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function daemonVoiceCapability() {
|
|
113
|
-
const r = run('ours-mcp', ['voice-status', '--json'], { capture: true, timeout: 6000 });
|
|
114
|
-
if (!r.ok) return null;
|
|
115
|
-
try {
|
|
116
|
-
const parsed = JSON.parse(r.out.trim());
|
|
117
|
-
return typeof parsed?.ready === 'boolean' ? parsed : null;
|
|
118
|
-
} catch {
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Block the thread for `ms` without a subprocess — used for the brief daemon-reachability wait
|
|
124
|
-
// before creating the human identity (a freshly-started daemon needs a moment to bind its port).
|
|
125
|
-
function sleepMs(ms) {
|
|
126
|
-
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Is `port` already bound? Probe in a throwaway child and read its exit code (EADDRINUSE ⇒ taken).
|
|
130
|
-
function portTakenSync(port) {
|
|
131
|
-
const scriptSrc = `const net=require('net');const s=net.createServer();s.once('error',e=>{process.exit(e.code==='EADDRINUSE'?3:0)});s.listen(${port},'127.0.0.1',()=>{s.close(()=>process.exit(0))});`;
|
|
132
|
-
const r = spawnSync(process.execPath, ['-e', scriptSrc], { timeout: 3000 });
|
|
133
|
-
return r.status === 3;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// --- harness detection with ALIAS-SAFETY (never call an unsafe command) -------------------------
|
|
137
|
-
// Gather three read-only observations, then let the pure classifier decide. We NEVER run the
|
|
138
|
-
// harness in a way that could hang: --version is spawned directly (no shell → real PATH binary)
|
|
139
|
-
// with a hard timeout; the shell `type` lookup is also timeout-guarded.
|
|
140
|
-
function detectHarness(name) {
|
|
141
|
-
const onPath = run('bash', ['-c', `command -v ${name}`], { capture: true }).ok;
|
|
142
|
-
const probe = run(name, ['--version'], { capture: true, timeout: 6000 });
|
|
143
|
-
const versionOk = probe.ok && /\d+\.\d+/.test(probe.out);
|
|
144
|
-
const shell = process.env.SHELL || '/bin/bash';
|
|
145
|
-
const typeProbe = run(shell, ['-ic', `type -t ${name} 2>/dev/null`], { capture: true, timeout: 4000 });
|
|
146
|
-
const shellType = (typeProbe.out || '').trim();
|
|
147
|
-
const verdict = classifyHarnessProbe({ onPath, versionOk, timedOut: probe.timedOut, shellType });
|
|
148
|
-
return { name, ...verdict };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Hermes detection is DIFFERENT from Claude Code / Codex: Hermes has no driven CLI. Its ours plugin
|
|
152
|
-
// (`ours-hermes-install`) never calls a `hermes` binary — it just writes ~/.hermes/config.yaml + the
|
|
153
|
-
// skills — so "is it drivable?" is the wrong question. Per the Hermes plugin's own prerequisites,
|
|
154
|
-
// presence == the config dir (~/.hermes, override with HERMES_DIR) exists. We still run the alias-safe
|
|
155
|
-
// CLI probe in case a real `hermes` command is on PATH, purely to enrich detection; either signal
|
|
156
|
-
// makes it installable. No config dir and no CLI → absent (skipped, like an uninstalled harness).
|
|
157
|
-
function detectHermes() {
|
|
158
|
-
const dir = process.env.HERMES_DIR || join(homedir(), '.hermes');
|
|
159
|
-
const dirPresent = existsSync(dir);
|
|
160
|
-
const cli = detectHarness('hermes'); // best-effort — Hermes usually has no `--version` CLI
|
|
161
|
-
const status = dirPresent || cli.status === 'ok' ? 'ok' : 'absent';
|
|
162
|
-
const detail = dirPresent ? `config dir ${dir} present` : cli.detail;
|
|
163
|
-
return { name: 'hermes', label: 'Hermes', status, detail };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Set by main() so the top-level catch can route a Ctrl+C (InstallCancelled) through the same
|
|
167
|
-
// clean-exit path as the SIGINT handler.
|
|
168
|
-
let cancelHandler = null;
|
|
169
|
-
|
|
170
|
-
// A tiny package version read (best-effort) for `--version`.
|
|
171
|
-
function pkgVersion() {
|
|
172
|
-
try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || '?'; }
|
|
173
|
-
catch { return '?'; }
|
|
174
|
-
}
|
|
175
|
-
const USAGE = `ours-install — the unified ours.network stack installer.
|
|
176
|
-
|
|
177
|
-
Install: npm i -g @ours.network/install && ours-install (recommended)
|
|
178
|
-
npx @ours.network/install (one-off)
|
|
179
|
-
|
|
180
|
-
ours-install [--dry-run] [--help] [--version]
|
|
181
|
-
|
|
182
|
-
Guided ~3-minute setup for the whole stack: ours core (the daemon), the harness
|
|
183
|
-
plugins (Claude Code + Codex + Hermes), ours-fleet, and the Telegram connector — then one
|
|
184
|
-
copy-paste hand-off prompt. You approve each step; re-run any time to add a piece
|
|
185
|
-
or update.
|
|
186
|
-
|
|
187
|
-
--dry-run walk the whole flow and print what it WOULD do — install/change nothing
|
|
188
|
-
--help show this help and exit
|
|
189
|
-
--version print the installer version and exit
|
|
190
|
-
|
|
191
|
-
Env: OURS_ASSUME_YES=1 (accept defaults, no prompts) · OURS_INSTALL_DRY_RUN=1 ·
|
|
192
|
-
OURS_NPM · OURS_CONFIG (default ~/.ours/config.json). Docs: https://ours.network`;
|
|
193
|
-
|
|
194
|
-
// ===============================================================================================
|
|
195
|
-
async function main() {
|
|
196
|
-
const argv = process.argv.slice(2);
|
|
197
|
-
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(USAGE + '\n'); return; }
|
|
198
|
-
if (argv.includes('--version') || argv.includes('-V')) { process.stdout.write(`ours-install v${pkgVersion()}\n`); return; }
|
|
199
|
-
if (argv.includes('--dry-run')) DRY = true;
|
|
200
|
-
|
|
201
|
-
const ttyFd = openTty();
|
|
202
|
-
const interactive = ttyFd != null && !process.env.OURS_ASSUME_YES;
|
|
203
|
-
const write = makeWriter(ttyFd);
|
|
204
|
-
|
|
205
|
-
// Ctrl+C at ANY prompt aborts cleanly (never the old "^C^C^C and keeps going"): print one line
|
|
206
|
-
// and exit 130. The SIGINT handler covers a ^C while we're idle/spinning; the InstallCancelled
|
|
207
|
-
// thrown out of a blocked prompt read covers a ^C mid-prompt (both routed here, guarded once).
|
|
208
|
-
let cancelling = false;
|
|
209
|
-
const cancel = () => {
|
|
210
|
-
if (cancelling) return; cancelling = true;
|
|
211
|
-
try { process.stdout.write('\n' + warn('Installation cancelled — re-run any time.') + '\n'); } catch { /* ignore */ }
|
|
212
|
-
finish(ttyFd);
|
|
213
|
-
process.exit(130);
|
|
214
|
-
};
|
|
215
|
-
cancelHandler = cancel;
|
|
216
|
-
if (interactive) process.on('SIGINT', cancel);
|
|
217
|
-
const yes = (prompt, def) => (process.env.OURS_ASSUME_YES ? def : askYesNo(write, ttyFd, prompt, def));
|
|
218
|
-
const ask = (prompt, def) => (process.env.OURS_ASSUME_YES ? def : askLine(write, ttyFd, prompt, def));
|
|
219
|
-
// A Continue? beat: one clean acknowledgement AFTER a step that actually did something (delta
|
|
220
|
-
// #1860). A step the user SKIPPED (answered No) shows no "you skipped — press Enter" pause — we
|
|
221
|
-
// move straight on. No-op when we can't prompt (headless / assume-yes) so scripted runs stay linear.
|
|
222
|
-
const cont = (acted = true) => { if (interactive && acted) askLine(write, ttyFd, ' ' + c.gray('Continue? [Enter] '), ''); };
|
|
223
|
-
|
|
224
|
-
line(banner());
|
|
225
|
-
say('setting up the ours.network stack — ours core, your harness plugins, ours-fleet, Telegram.');
|
|
226
|
-
line(' ' + c.dim('~3 minutes. You approve each step; re-run any time to add a piece or update.'));
|
|
227
|
-
if (DRY) line(' ' + c.yellow('(dry-run: nothing will be installed or changed — this just walks the flow.)'));
|
|
228
|
-
|
|
229
|
-
// ============================================================================================
|
|
230
|
-
// PRE-FLIGHT — silent-ish checks, no changes. Detect the disasters up front. A checklist, not
|
|
231
|
-
// logs. (Native Windows → WSL pointer + exit; no harness at all → tell them + exit.)
|
|
232
|
-
// ============================================================================================
|
|
233
|
-
line(heading('Checking your machine'));
|
|
234
|
-
const plat = detectPlatform({ platform: osPlatform(), release: osRelease(), env: process.env });
|
|
235
|
-
if (!plat.supported) {
|
|
236
|
-
if (plat.os === 'windows') {
|
|
237
|
-
line(warn(`${plat.label} isn't supported directly yet.`));
|
|
238
|
-
line(info('Install this inside WSL (Windows Subsystem for Linux), then re-run there:'));
|
|
239
|
-
line(' ' + c.cyan('https://learn.microsoft.com/windows/wsl/install'));
|
|
240
|
-
} else {
|
|
241
|
-
line(warn(`Platform "${plat.label}" isn't supported. ours runs on Linux, macOS, or WSL.`));
|
|
242
|
-
}
|
|
243
|
-
finish(ttyFd); return;
|
|
244
|
-
}
|
|
245
|
-
line(ok(`Platform: ${plat.label} (supported)`));
|
|
246
|
-
|
|
247
|
-
const nodeMajor = Number.parseInt((process.versions.node || '0').split('.')[0], 10);
|
|
248
|
-
if (nodeMajor >= 20) line(ok(`Node.js ${process.versions.node}`));
|
|
249
|
-
else { line(warn(`Node.js ${process.versions.node} — ours needs v20 or newer. Update Node and re-run.`)); finish(ttyFd); return; }
|
|
250
|
-
|
|
251
|
-
// Harness detection with alias-safety. Report each, keep the drivable ones.
|
|
252
|
-
const harnessSpecs = [
|
|
253
|
-
{ name: 'claude', label: 'Claude Code' },
|
|
254
|
-
{ name: 'codex', label: 'Codex' },
|
|
255
|
-
];
|
|
256
|
-
// Claude Code + Codex are driven CLIs (alias-safe --version probe); Hermes is config-dir based
|
|
257
|
-
// (see detectHermes) so it gets its own detector, appended after them.
|
|
258
|
-
const harnesses = harnessSpecs.map((h) => ({ ...h, ...detectHarness(h.name) }));
|
|
259
|
-
harnesses.push(detectHermes());
|
|
260
|
-
for (const h of harnesses) {
|
|
261
|
-
if (h.status === 'ok') line(ok(`'${h.name}' → real program (its plugin can be installed)`));
|
|
262
|
-
else if (h.status === 'alias') line(warn(`'${h.name}' → ${h.detail} (I won't call it — see the note below; you can still install it by hand)`));
|
|
263
|
-
else if (h.status === 'unsafe') line(warn(`'${h.name}' → on your PATH but didn't answer safely (manual install shown below if you want it)`));
|
|
264
|
-
else line(info(`'${h.name}' → not installed (skipped — install it first if you want it wired up)`));
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const anyHarness = harnesses.some((h) => h.status !== 'absent');
|
|
268
|
-
if (!anyHarness) {
|
|
269
|
-
line('');
|
|
270
|
-
line(warn('No Claude Code, Codex, or Hermes found on this machine.'));
|
|
271
|
-
line(info('Install one of them first, then re-run ours-install to wire it up.'));
|
|
272
|
-
finish(ttyFd); return;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Daemon state up front (decides first-install vs update, and whether Step 0 runs at all).
|
|
276
|
-
const versionBefore = daemonVersionLine();
|
|
277
|
-
const daemonInstalled = !!versionBefore;
|
|
278
|
-
line(daemonInstalled
|
|
279
|
-
? ok(`ours core already present (${versionBefore})`)
|
|
280
|
-
: ok('No ours daemon yet — will offer to install it'));
|
|
281
|
-
line('');
|
|
282
|
-
cont();
|
|
283
|
-
|
|
284
|
-
// ============================================================================================
|
|
285
|
-
// STEP 0 — the two config questions (asked ONCE, up front). SKIPPED entirely when a daemon is
|
|
286
|
-
// already configured (update path reuses its port/broker; delta #1859).
|
|
287
|
-
// ============================================================================================
|
|
288
|
-
const status0 = parseStatus(daemonStatusText());
|
|
289
|
-
let chosenBroker; // undefined = keep default / existing
|
|
290
|
-
let chosenPort = status0.port || DEFAULT_PORT;
|
|
291
|
-
const configFirst = !daemonInstalled;
|
|
292
|
-
|
|
293
|
-
if (configFirst) {
|
|
294
|
-
line(heading('A couple of quick settings'));
|
|
295
|
-
// 0a — broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
|
|
296
|
-
line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
|
|
297
|
-
line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
|
|
298
|
-
line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
|
|
299
|
-
const custom = yes(' Use a custom broker address?', false);
|
|
300
|
-
if (custom) {
|
|
301
|
-
line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
|
|
302
|
-
const entered = ask(' Enter the broker address: ', '');
|
|
303
|
-
const v = validateBroker(entered);
|
|
304
|
-
if (entered && v.ok && !v.empty) {
|
|
305
|
-
// Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
|
|
306
|
-
const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
|
|
307
|
-
if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
|
|
308
|
-
else line(ok('using the standard broker.'));
|
|
309
|
-
} else {
|
|
310
|
-
if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
|
|
311
|
-
else line(ok('using the standard broker.'));
|
|
312
|
-
}
|
|
313
|
-
} else {
|
|
314
|
-
line(ok('using the standard broker.'));
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// 0b — port: probe 3050; only ask if busy. Minimize the concept.
|
|
318
|
-
if (!portTakenSync(DEFAULT_PORT)) {
|
|
319
|
-
chosenPort = DEFAULT_PORT;
|
|
320
|
-
line(ok(`Using local port ${DEFAULT_PORT}.`));
|
|
321
|
-
} else {
|
|
322
|
-
line(info(`The standard local port (${DEFAULT_PORT}) is already in use on your machine.`));
|
|
323
|
-
let candidate = suggestPort(DEFAULT_PORT + 1, portTakenSync);
|
|
324
|
-
const raw = ask(` Pick another number for the ours daemon? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
|
|
325
|
-
const parsed = parsePort(raw, candidate);
|
|
326
|
-
candidate = suggestPort(parsed.ok ? parsed.port : candidate, portTakenSync);
|
|
327
|
-
chosenPort = candidate;
|
|
328
|
-
line(ok(`Using local port ${chosenPort}.`));
|
|
329
|
-
}
|
|
330
|
-
line('');
|
|
331
|
-
line(ok(`Config ready — broker: ${chosenBroker ? 'custom' : 'standard'}, port: ${chosenPort}.`));
|
|
332
|
-
cont();
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Track outcomes for the summary + hand-off.
|
|
336
|
-
const summary = [];
|
|
337
|
-
const record = (row) => summary.push(row);
|
|
338
|
-
|
|
339
|
-
// Voice setup is part of the core lifecycle, not a follow-up after it. On a
|
|
340
|
-
// fresh install this runs after the CLI is installed/configured but before
|
|
341
|
-
// the first daemon start. On an update it runs before any pending restart;
|
|
342
|
-
// a successful canonical voice-setup owns that one restart/readiness check.
|
|
343
|
-
// Declining, headless skipping, or an already-complete setup leaves the
|
|
344
|
-
// caller's normal start/restart lifecycle untouched.
|
|
345
|
-
const offerVoiceSetup = ({ readinessAfterStart = false, daemonState = 'stopped' } = {}) => {
|
|
346
|
-
line(heading('Voice messages'));
|
|
347
|
-
const cfgBefore = readConfigObject();
|
|
348
|
-
const probed = daemonVoiceCapability();
|
|
349
|
-
const localStatus = voiceSetupStatus(cfgBefore, process.env);
|
|
350
|
-
const voiceStatus = probed ?? localStatus;
|
|
351
|
-
if (voiceStatus.ready) {
|
|
352
|
-
line(ok(`Voice transcription is configured (${voiceStatus.provider}). API key: configured, never displayed.`));
|
|
353
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'current', note: voiceStatus.provider });
|
|
354
|
-
cont(false);
|
|
355
|
-
return { setupRan: false, restartHandled: false };
|
|
356
|
-
}
|
|
357
|
-
if (!interactive) {
|
|
358
|
-
line(info('Voice transcription is not configured. Non-interactive mode leaves it unchanged.'));
|
|
359
|
-
line(info('Run `ours-mcp voice-setup` in a terminal, or supply OURS_STT_* environment values.'));
|
|
360
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'interactive setup available on re-run' });
|
|
361
|
-
cont(false);
|
|
362
|
-
return { setupRan: false, restartHandled: false };
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
line(info('Voice notes can be transcribed by a provider you choose. Audio is sent to that'));
|
|
366
|
-
line(info('provider; use a self-hosted endpoint if it must stay local. The API key is hidden.'));
|
|
367
|
-
if (!yes(' Set up voice transcription now?', true)) {
|
|
368
|
-
line(info('skipped — this will be offered again on the next installer run.'));
|
|
369
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'declined; offered again on re-run' });
|
|
370
|
-
cont(false);
|
|
371
|
-
return { setupRan: false, restartHandled: false };
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
line(info('Opening the canonical ours-mcp voice setup (same command you can re-run later).'));
|
|
375
|
-
const setupArgs = ['voice-setup', ...(DRY ? ['--dry-run'] : [])];
|
|
376
|
-
const setup = run('ours-mcp', setupArgs);
|
|
377
|
-
if (!setup.ok) {
|
|
378
|
-
line(warn('Voice setup did not complete; no installer-side credential fallback was used.'));
|
|
379
|
-
line(info('Run `ours-mcp voice-setup` directly to try again.'));
|
|
380
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'failed', note: 'ours-mcp voice-setup did not complete' });
|
|
381
|
-
cont();
|
|
382
|
-
// Exit 2 is the canonical command's explicit signal that it already
|
|
383
|
-
// attempted restart/readiness and rollback. Never layer the pending
|
|
384
|
-
// installer update restart on top of that recovery transaction.
|
|
385
|
-
return { setupRan: true, restartHandled: setup.code === 2 };
|
|
386
|
-
}
|
|
387
|
-
if (DRY) {
|
|
388
|
-
line(ok('Canonical voice setup dry-run completed; no config or daemon state changed.'));
|
|
389
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'current', note: 'voice-setup dry-run' });
|
|
390
|
-
cont();
|
|
391
|
-
return { setupRan: true, restartHandled: false };
|
|
392
|
-
}
|
|
393
|
-
if (readinessAfterStart) {
|
|
394
|
-
line(ok('Voice configuration saved securely; it will be readiness-checked after the first daemon start.'));
|
|
395
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'installed', note: 'configured before first start' });
|
|
396
|
-
cont();
|
|
397
|
-
return { setupRan: true, restartHandled: false };
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const verified = daemonVoiceCapability();
|
|
401
|
-
if (verified?.ready) {
|
|
402
|
-
line(ok(`Voice transcription is ready (${verified.provider}); API key remains hidden.`));
|
|
403
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'installed', note: verified.provider });
|
|
404
|
-
} else {
|
|
405
|
-
line(warn('Voice setup returned without a ready capability; inspect `ours-mcp voice-status`.'));
|
|
406
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'failed', note: 'readiness check failed' });
|
|
407
|
-
}
|
|
408
|
-
cont();
|
|
409
|
-
// Exit 0 also covers config-only success for a stopped daemon or an
|
|
410
|
-
// externally launched daemon. Only a still-managed daemon proves that
|
|
411
|
-
// canonical setup owned apply + readiness and may suppress an update
|
|
412
|
-
// restart.
|
|
413
|
-
return {
|
|
414
|
-
setupRan: true,
|
|
415
|
-
restartHandled: daemonState === 'managed' && daemonLifecycleState() === 'managed',
|
|
416
|
-
};
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
// ============================================================================================
|
|
420
|
-
// STEP 1 / 4 — ours core (the daemon). Config-first: write config → optional voice → start ONCE.
|
|
421
|
-
// ============================================================================================
|
|
422
|
-
line(heading('1/4 — ours core (the daemon)'));
|
|
423
|
-
line(info('This is the piece that lets your agents talk to each other securely. Everything else'));
|
|
424
|
-
line(info('needs it.'));
|
|
425
|
-
const before = parseVersion(versionBefore);
|
|
426
|
-
|
|
427
|
-
if (!daemonInstalled) {
|
|
428
|
-
const goCore = yes(' Install and start it?', true);
|
|
429
|
-
if (!goCore) {
|
|
430
|
-
line(info("skipped — nothing else can run without it. Re-run ours-install when you're ready."));
|
|
431
|
-
record({ key: 'core', label: 'ours core (daemon)', state: 'skipped', note: 'declined' });
|
|
432
|
-
// Without a daemon the rest is moot; go straight to the summary.
|
|
433
|
-
return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
|
|
434
|
-
}
|
|
435
|
-
await actSpin(`ensuring ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
|
|
436
|
-
const patch = { port: chosenPort };
|
|
437
|
-
if (chosenBroker) patch.brokerUrl = chosenBroker;
|
|
438
|
-
await act(`write config (${configPath()}) with port ${chosenPort}${chosenBroker ? ' + custom broker' : ''}`, async () => { writeConfigPatch(patch); return { ok: true }; });
|
|
439
|
-
const voice = offerVoiceSetup({ readinessAfterStart: true });
|
|
440
|
-
const started = await act(`ours-mcp start (port ${chosenPort})`, async () => run('ours-mcp', ['start']));
|
|
441
|
-
const svc = await act('ours-mcp install-service (survives reboot)', async () => run('ours-mcp', ['install-service']));
|
|
442
|
-
if (started.ok) line(ok(`ours core ready — running on port ${chosenPort}. No problems.`));
|
|
443
|
-
else line(warn(`could not auto-start — run '${c.cyan('ours-mcp start')}' to bring it up.`));
|
|
444
|
-
if (!svc.ok && !svc.dry) line(warn(`boot-service not installed — retry '${c.cyan('ours-mcp install-service')}' later.`));
|
|
445
|
-
if (voice.setupRan && !DRY && started.ok) {
|
|
446
|
-
const verified = daemonVoiceCapability();
|
|
447
|
-
if (verified?.ready) {
|
|
448
|
-
line(ok(`Voice transcription readiness confirmed (${verified.provider}) after the first start.`));
|
|
449
|
-
} else {
|
|
450
|
-
line(warn('Voice configuration was saved, but readiness was not confirmed after start; run `ours-mcp voice-status`.'));
|
|
451
|
-
const row = summary.find((entry) => entry.key === 'voice');
|
|
452
|
-
if (row) {
|
|
453
|
-
row.state = 'failed';
|
|
454
|
-
row.note = 'readiness check failed after first start';
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
record({ key: 'core', label: 'ours core (daemon)', state: started.ok ? 'installed' : 'failed', version: parseVersion(daemonVersionLine()), note: 'starts on boot' });
|
|
459
|
-
} else {
|
|
460
|
-
// Installed: offer an update; never re-ask config; reuse the running port everywhere.
|
|
461
|
-
const daemonState = daemonLifecycleState();
|
|
462
|
-
const running = daemonState !== 'stopped';
|
|
463
|
-
const upd = yes(` ours core is installed (${before || '?'}) — check for an update now?`, false);
|
|
464
|
-
let pendingUpdateRestart = false;
|
|
465
|
-
let after = before;
|
|
466
|
-
if (upd) {
|
|
467
|
-
await actSpin(`updating ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
|
|
468
|
-
after = parseVersion(daemonVersionLine());
|
|
469
|
-
pendingUpdateRestart = !!(before && after && before !== after);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
const voice = offerVoiceSetup({ daemonState });
|
|
473
|
-
if (pendingUpdateRestart && !voice.restartHandled) {
|
|
474
|
-
const restartState = daemonLifecycleState();
|
|
475
|
-
if (restartState === 'external') {
|
|
476
|
-
line(warn(`ours core updated (v${before} → v${after}); restart its external launcher to load the update.`));
|
|
477
|
-
} else {
|
|
478
|
-
await act(`ours-mcp restart (now v${after})`, async () => { if (!run('ours-mcp', ['restart']).ok) run('ours-mcp', ['start']); return { ok: true }; });
|
|
479
|
-
line(ok(`ours core updated (v${before} → v${after}) and restarted. No problems.`));
|
|
480
|
-
}
|
|
481
|
-
} else if (pendingUpdateRestart) {
|
|
482
|
-
const voiceFailed = summary.find((entry) => entry.key === 'voice')?.state === 'failed';
|
|
483
|
-
line(voiceFailed
|
|
484
|
-
? warn(`ours core updated (v${before} → v${after}); voice setup handled restart/rollback but did not change voice settings.`)
|
|
485
|
-
: ok(`ours core updated (v${before} → v${after}); voice setup performed the required restart and readiness check.`));
|
|
486
|
-
} else if (upd) {
|
|
487
|
-
line(ok(`ours core already current${after ? ` (v${after})` : ''} — nothing to change.`));
|
|
488
|
-
} else {
|
|
489
|
-
line(ok(`ours core ready — running on port ${chosenPort}${running ? '' : ' (start with ours-mcp start)'}. No problems.`));
|
|
490
|
-
}
|
|
491
|
-
record({ key: 'core', label: 'ours core (daemon)', state: 'current', version: (parseVersion(daemonVersionLine()) || before), note: `port ${chosenPort}` });
|
|
492
|
-
}
|
|
493
|
-
cont();
|
|
494
|
-
|
|
495
|
-
// ============================================================================================
|
|
496
|
-
// Human identity — created DURING install, right after the daemon is confirmed reachable (the
|
|
497
|
-
// owner change that supersedes "defer to the hand-off"). `ours-mcp create-root` is the internal
|
|
498
|
-
// seam; ALL user-facing copy says "human identity". Already-exists is a friendly keep, not an
|
|
499
|
-
// error; an unreachable daemon gives an exact retry command (never "ask your agent later").
|
|
500
|
-
// ============================================================================================
|
|
501
|
-
const coreReady = summary.some((r) => r.key === 'core' && (r.state === 'installed' || r.state === 'current'));
|
|
502
|
-
if (coreReady) {
|
|
503
|
-
line(heading('Your human identity'));
|
|
504
|
-
line(info('This is you — the human. Your agents act on your behalf, and it lets you message'));
|
|
505
|
-
line(info('people. (Internally this is your ours root; you just give it a name.)'));
|
|
506
|
-
let defName = 'me';
|
|
507
|
-
try { defName = userInfo().username || defName; } catch { /* keep fallback */ }
|
|
508
|
-
const name = (ask(` What name should others see? ${c.gray(`[${defName}]`)}: `, defName) || defName).trim() || defName;
|
|
509
|
-
let idActed = true;
|
|
510
|
-
if (DRY) {
|
|
511
|
-
line(' ' + c.dim(`[dry-run] would: ours-mcp create-root "${name}"`));
|
|
512
|
-
line(ok(`Your human identity "${name}" is created.`));
|
|
513
|
-
record({ key: 'identity', label: 'Human identity', state: 'installed', note: name });
|
|
514
|
-
} else {
|
|
515
|
-
// A freshly-started daemon may need a moment to bind its port before create-root can reach it.
|
|
516
|
-
let reachable = daemonRunning();
|
|
517
|
-
for (let i = 0; i < 6 && !reachable; i++) { sleepMs(400); reachable = daemonRunning(); }
|
|
518
|
-
const r = reachable ? run('ours-mcp', ['create-root', name], { capture: true }) : { ok: false, out: '', err: 'daemon not running' };
|
|
519
|
-
const outText = `${r.out} ${r.err}`;
|
|
520
|
-
const existing = outText.match(/already exists \("([^"]+)"\)/);
|
|
521
|
-
if (r.ok && existing) {
|
|
522
|
-
line(ok(`You already have a human identity ("${existing[1]}") — keeping it.`));
|
|
523
|
-
record({ key: 'identity', label: 'Human identity', state: 'current', note: existing[1] });
|
|
524
|
-
} else if (r.ok) {
|
|
525
|
-
line(ok(`Your human identity "${name}" is created.`));
|
|
526
|
-
record({ key: 'identity', label: 'Human identity', state: 'installed', note: name });
|
|
527
|
-
} else if (!reachable || /not running|not reachable/i.test(outText)) {
|
|
528
|
-
line(warn("The daemon isn't reachable yet — couldn't create your human identity."));
|
|
529
|
-
line(info(`Fix: run '${c.cyan('ours-mcp start')}', then '${c.cyan(`ours-mcp create-root "${name}"`)}'.`));
|
|
530
|
-
record({ key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' });
|
|
531
|
-
} else {
|
|
532
|
-
const msg = (r.err || r.out || '').trim().split('\n')[0] || 'unknown error';
|
|
533
|
-
line(warn(`Couldn't create your human identity: ${msg}`));
|
|
534
|
-
line(info(`Retry any time: '${c.cyan(`ours-mcp create-root "${name}"`)}'.`));
|
|
535
|
-
record({ key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' });
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
cont(idActed);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Step 2 harness installers (closures — share the helpers + `record` above). Alias / failure →
|
|
542
|
-
// NEVER dead-end (owner edit #3): plain reason + manual path, always. Each returns whether it
|
|
543
|
-
// ACTED (so a plain user-No skip shows no Continue pause).
|
|
544
|
-
async function installClaude(h) {
|
|
545
|
-
if (h.status !== 'ok') { manualClaude(h); record({ key: 'claude', label: 'Claude Code plugin', state: 'skipped', note: h.status === 'alias' ? 'installed as an alias' : 'not drivable' }); return true; }
|
|
546
|
-
const go = yes(' Install the ours plugin into Claude Code?', true);
|
|
547
|
-
if (!go) { line(info('skipped — re-run ours-install to add it.')); record({ key: 'claude', label: 'Claude Code plugin', state: 'skipped' }); return false; }
|
|
548
|
-
const add = await act(`claude plugin marketplace add ${CLAUDE_MARKET}`, async () => run('claude', ['plugin', 'marketplace', 'add', CLAUDE_MARKET], { capture: true }));
|
|
549
|
-
const inst = add.ok ? await act('claude plugin install ours@ours.network', async () => run('claude', ['plugin', 'install', 'ours@ours.network'], { capture: true })) : add;
|
|
550
|
-
if (inst.ok) { line(ok(`Claude Code plugin installed — pointed at port ${chosenPort}. No problems.`)); line(info('(restart Claude Code to load it.)')); record({ key: 'claude', label: 'Claude Code plugin', state: 'installed', note: 'restart Claude Code' }); }
|
|
551
|
-
else { failClaude(); record({ key: 'claude', label: 'Claude Code plugin', state: 'failed', note: 'marketplace/install step failed' }); }
|
|
552
|
-
return true;
|
|
553
|
-
}
|
|
554
|
-
async function installCodex(h) {
|
|
555
|
-
if (h.status !== 'ok') { manualCodex(h); record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'skipped', note: h.status === 'alias' ? 'installed as an alias' : 'not drivable' }); return true; }
|
|
556
|
-
const go = yes(' Install the ours plugin into Codex?', true);
|
|
557
|
-
if (!go) { line(info('skipped — re-run ours-install to add it.')); record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'skipped' }); return false; }
|
|
558
|
-
const add = await act(`codex plugin marketplace add ${CODEX_MARKET}`, async () => run('codex', ['plugin', 'marketplace', 'add', CODEX_MARKET], { capture: true }));
|
|
559
|
-
const inst = add.ok ? await act('codex plugin add ours@ours-codex-marketplace', async () => run('codex', ['plugin', 'add', 'ours@ours-codex-marketplace'], { capture: true })) : add;
|
|
560
|
-
// Owner-mandated: choosing the Codex plugin ALSO installs the ours-codex live launcher, same step.
|
|
561
|
-
const wrap = inst.ok ? await actSpin('installing the ours-codex live launcher…', `npm i -g ${spec('codex')} (provides ours-codex)`, () => runAsync(NPM, ['i', '-g', spec('codex')])) : inst;
|
|
562
|
-
if (inst.ok && wrap.ok) {
|
|
563
|
-
line(ok(`Codex plugin + ours-codex live launcher installed — pointed at port ${chosenPort}. No problems.`));
|
|
564
|
-
// Plain-language: what ours-codex is and why you'd use it (background wake vs blocking).
|
|
565
|
-
line(info('Two ways to run Codex now:'));
|
|
566
|
-
line(info(' • plain "codex" — waits for mail in the foreground: it EITHER watches for new'));
|
|
567
|
-
line(info(' messages OR takes your typing, one at a time — not both at once.'));
|
|
568
|
-
line(info(' • "ours-codex" — our wrapper that turns on AUTO wake-up: it uses Codex\'s built-in'));
|
|
569
|
-
line(info(' app server to watch for new mail in the BACKGROUND while you keep typing, so a'));
|
|
570
|
-
line(info(" reply wakes it without interrupting you. Use 'ours-codex' for hands-off replies."));
|
|
571
|
-
record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'installed', note: 'new Codex thread' });
|
|
572
|
-
} else { failCodex(); record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'failed', note: 'marketplace/install step failed' }); }
|
|
573
|
-
return true;
|
|
574
|
-
}
|
|
575
|
-
// Hermes: NOT a driven CLI. Its plugin install is `npm i -g @ours.network/hermes@<channel>` then
|
|
576
|
-
// `ours-hermes-install`, which writes ~/.hermes/config.yaml (the ours MCP server) + the skills. No
|
|
577
|
-
// marketplace/plugin-add, no alias-safety gate — nothing here calls the `hermes` binary. We pass
|
|
578
|
-
// --skip-daemon because the unified installer already owns the daemon (Step 1, chosen port);
|
|
579
|
-
// ours-hermes-install would otherwise re-ensure/restart it. Same never-dead-end contract as above.
|
|
580
|
-
async function installHermes() {
|
|
581
|
-
const go = yes(' Install the ours plugin into Hermes?', true);
|
|
582
|
-
if (!go) { line(info('skipped — re-run ours-install to add it.')); record({ key: 'hermes', label: 'Hermes plugin', state: 'skipped' }); return false; }
|
|
583
|
-
const npmOk = await actSpin(`installing ${spec('hermes')}…`, `npm i -g ${spec('hermes')} (provides ours-hermes-install)`, () => runAsync(NPM, ['i', '-g', spec('hermes')]));
|
|
584
|
-
const inst = npmOk.ok
|
|
585
|
-
? await act('ours-hermes-install --skip-daemon (writes ~/.hermes: ours MCP server + skills)', async () => run('ours-hermes-install', ['--skip-daemon'], { capture: true }))
|
|
586
|
-
: npmOk;
|
|
587
|
-
if (inst.ok) {
|
|
588
|
-
line(ok('Hermes plugin installed — the ours MCP server + skills are registered in ~/.hermes. No problems.'));
|
|
589
|
-
line(info("(run '/reload-mcp' in Hermes to load the ours tools.)"));
|
|
590
|
-
record({ key: 'hermes', label: 'Hermes plugin', state: 'installed', note: 'run /reload-mcp' });
|
|
591
|
-
} else { failHermes(); record({ key: 'hermes', label: 'Hermes plugin', state: 'failed', note: 'npm/ours-hermes-install step failed' }); }
|
|
592
|
-
return true;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// ============================================================================================
|
|
596
|
-
// STEP 2 / 4 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
|
|
597
|
-
// CLIs for Claude/Codex; Hermes installs via npm + ours-hermes-install (no CLI driving).
|
|
598
|
-
// ============================================================================================
|
|
599
|
-
line(heading('2/4 — harness plugins'));
|
|
600
|
-
line(info('These teach Claude Code, Codex, and Hermes the ours skills, so you can just talk to your'));
|
|
601
|
-
line(info("agent to message people and set things up. I'll install them for you — no commands to type."));
|
|
602
|
-
for (const h of harnesses) {
|
|
603
|
-
if (h.status === 'absent') continue; // nothing to offer; pre-flight already noted it
|
|
604
|
-
line('');
|
|
605
|
-
const acted = h.name === 'claude' ? await installClaude(h)
|
|
606
|
-
: h.name === 'codex' ? await installCodex(h)
|
|
607
|
-
: await installHermes(h);
|
|
608
|
-
cont(acted);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// ============================================================================================
|
|
612
|
-
// STEP 3 / 4 — ours-fleet. Appealing wording (owner edit #4); default YES.
|
|
613
|
-
// ============================================================================================
|
|
614
|
-
line(heading('3/4 — ours-fleet (your always-online agent team)'));
|
|
615
|
-
line(info('This makes your harnesses PERSISTENT: Claude Code and Codex stop being just a terminal'));
|
|
616
|
-
line(info('session and become always-online daemons that survive a reboot. Stand up your own team'));
|
|
617
|
-
line(info('of always-online developers, combine harnesses, run several Claude Codes, and link them'));
|
|
618
|
-
line(info('over Telegram so they talk to each other — and it all configures maximally easily.'));
|
|
619
|
-
const goFleet = yes(' Install it?', true);
|
|
620
|
-
if (goFleet) {
|
|
621
|
-
// ours-fleet is ALWAYS @latest — it has no nightly tag (pkgSpec pins it even under OURS_CHANNEL=nightly).
|
|
622
|
-
await actSpin(`installing ${spec('fleet')}…`, `npm i -g ${spec('fleet')}`, () => runAsync(NPM, ['i', '-g', spec('fleet')]));
|
|
623
|
-
const init = await act('ours-fleet init (one-time host setup: units, dirs, linger)', async () => run('ours-fleet', ['init']));
|
|
624
|
-
if (!init.ok) line(warn(`ours-fleet host setup didn't finish — retry '${c.cyan('ours-fleet init')}'.`));
|
|
625
|
-
|
|
626
|
-
if (init.ok) {
|
|
627
|
-
line(ok('ours-fleet ready — the core ours plugin discovers every option through `ours-fleet docs`. No problems.'));
|
|
628
|
-
}
|
|
629
|
-
record({
|
|
630
|
-
key: 'fleet',
|
|
631
|
-
label: 'ours-fleet',
|
|
632
|
-
state: init.ok ? 'installed' : 'failed',
|
|
633
|
-
version: globalVersion('@ours.network/fleet'),
|
|
634
|
-
note: init.ok ? 'CLI + core-plugin discovery' : 'ours-fleet init failed',
|
|
635
|
-
});
|
|
636
|
-
} else {
|
|
637
|
-
line(info('skipped cleanly — re-run ours-install any time to add it.'));
|
|
638
|
-
record({ key: 'fleet', label: 'ours-fleet', state: 'skipped' });
|
|
639
|
-
}
|
|
640
|
-
cont(goFleet);
|
|
641
|
-
|
|
642
|
-
// ============================================================================================
|
|
643
|
-
// STEP 4 / 4 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
|
|
644
|
-
// ============================================================================================
|
|
645
|
-
line(heading('4/4 — Telegram connector'));
|
|
646
|
-
line(info('This bridges a Telegram bot to your Ours node, so you can talk to your agent from'));
|
|
647
|
-
line(info("Telegram. (You'll set up the actual bot later, with your agent — not here.)"));
|
|
648
|
-
const goTg = yes(' Install it?', false);
|
|
649
|
-
if (goTg) {
|
|
650
|
-
await actSpin(`installing ${spec('tg-connector')}…`, `npm i -g ${spec('tg-connector')}`, () => runAsync(NPM, ['i', '-g', spec('tg-connector')]));
|
|
651
|
-
const asService = yes(' Keep it running in the background so it starts automatically on boot?', true);
|
|
652
|
-
if (asService) {
|
|
653
|
-
const svc = await act('ours-tg-connector install-service (starts on boot)', async () => run('ours-tg-connector', ['install-service']));
|
|
654
|
-
if (svc.ok) line(ok('Telegram connector installed and running as a service (starts on boot). No problems.'));
|
|
655
|
-
else line(warn(`connector installed, but the service didn't start — retry '${c.cyan('ours-tg-connector install-service')}'.`));
|
|
656
|
-
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: 'service (boot)' });
|
|
657
|
-
} else {
|
|
658
|
-
line(ok(`Telegram connector installed. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
|
|
659
|
-
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: 'start on demand' });
|
|
660
|
-
}
|
|
661
|
-
} else {
|
|
662
|
-
line(info('skipped cleanly.'));
|
|
663
|
-
record({ key: 'telegram', label: 'Telegram connector', state: 'skipped' });
|
|
664
|
-
}
|
|
665
|
-
cont(goTg);
|
|
666
|
-
|
|
667
|
-
return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// --- never-dead-end messaging (owner edit #3) --------------------------------------------------
|
|
671
|
-
function manualClaude(h) {
|
|
672
|
-
if (h.status === 'alias') {
|
|
673
|
-
line(warn('Heads-up: on your machine, "claude" is installed as an alias, not the real command,'));
|
|
674
|
-
line(info('so I can\'t drive it safely. To fix it: run ' + c.cyan('type claude') + ' , remove/rename that'));
|
|
675
|
-
line(info('alias in your shell config, open a new terminal, and re-run ours-install.'));
|
|
676
|
-
} else {
|
|
677
|
-
line(warn('I couldn\'t safely drive the "claude" command on this machine.'));
|
|
678
|
-
}
|
|
679
|
-
line(info('You can still install the plugin yourself — inside Claude Code, run these two:'));
|
|
680
|
-
line(' ' + c.cyan(`/plugin marketplace add ${CLAUDE_MARKET}`));
|
|
681
|
-
line(' ' + c.cyan('/plugin install ours'));
|
|
682
|
-
}
|
|
683
|
-
function failClaude() {
|
|
684
|
-
line(warn('Couldn\'t install the Claude Code plugin automatically (network or plugin cache).'));
|
|
685
|
-
line(info('Install it by hand — inside Claude Code, run these two, then re-run ours-install:'));
|
|
686
|
-
line(' ' + c.cyan(`/plugin marketplace add ${CLAUDE_MARKET}`));
|
|
687
|
-
line(' ' + c.cyan('/plugin install ours'));
|
|
688
|
-
line(info('Your daemon and other steps are intact. Continuing.'));
|
|
689
|
-
}
|
|
690
|
-
function manualCodex(h) {
|
|
691
|
-
if (h.status === 'alias') {
|
|
692
|
-
line(warn('Heads-up: on your machine, "codex" is installed as an alias, not the real command,'));
|
|
693
|
-
line(info('so I can\'t drive it safely. To fix it: run ' + c.cyan('type codex') + ' , remove/rename that'));
|
|
694
|
-
line(info('alias in your shell config, open a new terminal, and re-run ours-install.'));
|
|
695
|
-
} else {
|
|
696
|
-
line(warn('I couldn\'t safely drive the "codex" command on this machine.'));
|
|
697
|
-
}
|
|
698
|
-
line(info('You can still install it yourself — run these three in your terminal:'));
|
|
699
|
-
line(' ' + c.cyan(`codex plugin marketplace add ${CODEX_MARKET}`));
|
|
700
|
-
line(' ' + c.cyan('codex plugin add ours@ours-codex-marketplace'));
|
|
701
|
-
line(' ' + c.cyan('npm i -g @ours.network/codex') + c.gray(' (adds the ours-codex live launcher)'));
|
|
702
|
-
}
|
|
703
|
-
function failCodex() {
|
|
704
|
-
line(warn('Couldn\'t install the Codex plugin automatically (network or plugin cache).'));
|
|
705
|
-
line(info('Install it by hand — run these three, then re-run ours-install:'));
|
|
706
|
-
line(' ' + c.cyan(`codex plugin marketplace add ${CODEX_MARKET}`));
|
|
707
|
-
line(' ' + c.cyan('codex plugin add ours@ours-codex-marketplace'));
|
|
708
|
-
line(' ' + c.cyan('npm i -g @ours.network/codex'));
|
|
709
|
-
line(info('Your daemon and other steps are intact. Continuing.'));
|
|
710
|
-
}
|
|
711
|
-
function failHermes() {
|
|
712
|
-
line(warn('Couldn\'t install the Hermes plugin automatically (network or npm).'));
|
|
713
|
-
line(info('Install it by hand — run these two, then run /reload-mcp in Hermes:'));
|
|
714
|
-
line(' ' + c.cyan('npm i -g @ours.network/hermes'));
|
|
715
|
-
line(' ' + c.cyan('ours-hermes-install'));
|
|
716
|
-
line(info('Your daemon and other steps are intact. Continuing.'));
|
|
717
|
-
}
|
|
718
|
-
// --- final summary + copy-paste hand-off -------------------------------------------------------
|
|
719
|
-
function endScreen({ ttyFd, summary, chosenPort, chosenBroker }) {
|
|
720
|
-
line('');
|
|
721
|
-
line(' ' + c.cyan('═'.repeat(64)));
|
|
722
|
-
line(' ' + c.bold('ours.network — install complete'));
|
|
723
|
-
line(' ' + c.gray(`Daemon port: ${chosenPort} • Broker: ${chosenBroker ? 'custom' : 'standard'}`));
|
|
724
|
-
line(' ' + c.cyan('═'.repeat(64)));
|
|
725
|
-
for (const row of summary) {
|
|
726
|
-
const m = row.state === 'failed' ? c.red('✗') : row.state === 'skipped' ? c.gray('·') : c.green('✓');
|
|
727
|
-
const label = row.label.padEnd(26);
|
|
728
|
-
const ver = (row.version ? `v${row.version}` : '').padEnd(9);
|
|
729
|
-
const state = (row.state === 'installed' || row.state === 'current') ? (row.note || 'ready')
|
|
730
|
-
: row.state === 'skipped' ? c.gray('skipped' + (row.note ? ` (${row.note})` : ''))
|
|
731
|
-
: c.red('needs attention' + (row.note ? ` — ${row.note}` : ''));
|
|
732
|
-
line(` ${m} ${label}${ver}${state}`);
|
|
733
|
-
}
|
|
734
|
-
const anyFail = summary.some((r) => r.state === 'failed');
|
|
735
|
-
line('');
|
|
736
|
-
line(anyFail
|
|
737
|
-
? ' ' + c.yellow('Some pieces need a hand — see the notes above; re-run ours-install after fixing.')
|
|
738
|
-
: ' ' + c.green('Everything installed cleanly. No problems.'));
|
|
739
|
-
|
|
740
|
-
// The literal copy-paste hand-off — skipped/failed components drop out. The human identity is
|
|
741
|
-
// normally created in-install, so its step appears ONLY as a fallback when that didn't succeed.
|
|
742
|
-
const has = (k) => summary.some((r) => r.key === k && (r.state === 'installed' || r.state === 'current'));
|
|
743
|
-
if (has('core')) {
|
|
744
|
-
const identityDone = has('identity');
|
|
745
|
-
const { text, empty } = buildHandoffPrompt({ identity: !identityDone, fleet: has('fleet'), telegram: has('telegram') });
|
|
746
|
-
if (empty) {
|
|
747
|
-
// Nothing left to finish (identity created in-install, no fleet/Telegram). Don't show an empty box.
|
|
748
|
-
line('');
|
|
749
|
-
line(' ' + c.green("You're all set — open Claude Code or Codex and just start talking to your agent."));
|
|
750
|
-
} else {
|
|
751
|
-
line('');
|
|
752
|
-
line(' ' + c.gray('─'.repeat(64)));
|
|
753
|
-
line(' ' + c.bold('ONE LAST STEP') + ' — copy the prompt below and paste it into Claude Code');
|
|
754
|
-
line(' (or Codex). Your agent walks you through the rest, conversationally.');
|
|
755
|
-
line(' ' + c.gray('─'.repeat(64)));
|
|
756
|
-
line('');
|
|
757
|
-
line(box(text.split('\n'), 'paste this into your agent'));
|
|
758
|
-
copyToClipboard(text);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
line('');
|
|
762
|
-
line(' ' + c.gray('Re-run ') + c.cyan('ours-install') + c.gray(' any time to add a skipped piece or update.'));
|
|
763
|
-
line(' ' + c.cyan('═'.repeat(64)));
|
|
764
|
-
finish(ttyFd);
|
|
765
|
-
// Exit CLEANLY right after the summary — never leave the user at a hung installer (the tty is
|
|
766
|
-
// closed above; a lingering clipboard child or signal listener must not keep us alive).
|
|
767
|
-
process.exit(0);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// Best-effort clipboard copy (pbcopy/wl-copy/xclip/clip). Silent when unsupported. A hard timeout
|
|
771
|
-
// keeps a lingering/blocking clipboard helper (e.g. xclip holding the selection) from ever stalling
|
|
772
|
-
// the exit.
|
|
773
|
-
function copyToClipboard(text) {
|
|
774
|
-
if (DRY) return;
|
|
775
|
-
const tools = [['pbcopy', []], ['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['clip.exe', []]];
|
|
776
|
-
for (const [bin, args] of tools) {
|
|
777
|
-
try {
|
|
778
|
-
const r = spawnSync(bin, args, { input: text, timeout: 2000 });
|
|
779
|
-
if (!r.error && !r.timedOut && (r.status === 0 || r.status == null)) { line(' ' + c.gray('(copied to your clipboard.)')); return; }
|
|
780
|
-
} catch { /* try the next one */ }
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
import { realEffects } from './lib/effects.mjs';
|
|
6
|
+
import { runInstall } from './lib/orchestrate.mjs';
|
|
7
|
+
import { closeSync, makeWriter, openTty } from './lib/ui.mjs';
|
|
8
|
+
|
|
9
|
+
const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
10
|
+
const ttyFd = openTty();
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const code = await runInstall(process.argv.slice(2), realEffects({
|
|
14
|
+
write: makeWriter(ttyFd),
|
|
15
|
+
ttyFd,
|
|
16
|
+
env: process.env,
|
|
17
|
+
version,
|
|
18
|
+
}));
|
|
19
|
+
process.exitCode = code;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
process.stderr.write(`ours-install: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
} finally {
|
|
24
|
+
if (ttyFd != null) {
|
|
25
|
+
try { closeSync(ttyFd); } catch { /* already closed */ }
|
|
781
26
|
}
|
|
782
27
|
}
|
|
783
|
-
|
|
784
|
-
function finish(ttyFd) {
|
|
785
|
-
if (ttyFd != null) { try { closeSync(ttyFd); } catch { /* ignore */ } }
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
main().catch((e) => {
|
|
789
|
-
// A Ctrl+C thrown out of a blocked prompt read → the clean cancel path (exit 130).
|
|
790
|
-
if (isCancel(e)) { if (cancelHandler) return cancelHandler(); process.exit(130); }
|
|
791
|
-
// Otherwise: degrade, don't crash — one honest line, non-zero exit.
|
|
792
|
-
say(`unexpected error: ${String(e)}`);
|
|
793
|
-
process.exitCode = 1;
|
|
794
|
-
});
|