@ours.network/install 1.2.1-nightly.42 → 1.2.1-nightly.43

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.
@@ -5,6 +5,7 @@ import { join, resolve, isAbsolute } from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { installationPaths } from './plan.mjs';
7
7
  import { validateHostProfile } from './target.mjs';
8
+ import { isPluginOnly, PLUGIN_ONLY_INTEGRATIONS } from './product.mjs';
8
9
  /** Read by Fleet beside the profile credential; bound to one server installation. */
9
10
  export const NOTIFICATIONS_PRODUCER_FILE = 'notifications-producer.json';
10
11
 
@@ -23,6 +24,17 @@ function privatePath(path, directory = false) {
23
24
  }
24
25
  return stat;
25
26
  }
27
+ /** True only when this server answers the saved credential with 401/403; unreachable is not proof. */
28
+ export async function retainedCredentialRefused(profile, { fetchImpl = fetch, timeoutMs = 5000 } = {}) {
29
+ let token;
30
+ try { token = readFileSync(profile.credentialPath, 'utf8').trim(); } catch { return false; }
31
+ if (!token) return false;
32
+ try {
33
+ const response = await fetchImpl(`${profile.endpoint}/version`, { redirect: 'error', signal: AbortSignal.timeout(timeoutMs), headers: { 'x-ours-api-token': token } });
34
+ return response.status === 401 || response.status === 403;
35
+ } catch { return false; }
36
+ }
37
+
26
38
  function validateRecord(record) {
27
39
  if (!record || !['packages', 'docker'].includes(record.mode) || typeof record.root !== 'string'
28
40
  || !isAbsolute(record.root) || resolve(record.root) !== record.root
@@ -213,12 +225,14 @@ else { await request('identity/profile', selection.human); process.stdout.write(
213
225
  validateRecord(record);
214
226
  if (!Array.isArray(integrations) || new Set(integrations).size !== integrations.length
215
227
  || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name))) throw new Error('Client integrations must select codex, claude-code and/or fleet');
228
+ if (isPluginOnly(record) && integrations.some(name => !PLUGIN_ONLY_INTEGRATIONS.includes(name))) throw new Error('Collaboration tools connect Claude Code and Codex; Fleet is part of the complete workspace');
216
229
  if (fleetSettingsPath !== undefined) {
217
230
  if (typeof fleetSettingsPath !== 'string' || !isAbsolute(fleetSettingsPath) || resolve(fleetSettingsPath) !== fleetSettingsPath) throw new Error('Fleet settings path must be absolute');
218
231
  const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
219
232
  if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
220
233
  }
221
- const serverUrl = record.gateway ? gatewayAddress(record).base : undefined;
234
+ // Plugin-only clients reach the daemon's own client prefix on the published loopback port.
235
+ const serverUrl = record.gateway ? gatewayAddress(record).base : isPluginOnly(record) ? `http://127.0.0.1:${record.port}` : undefined;
222
236
  const endpoint = serverUrl ? `${serverUrl}/daemon` : `http://127.0.0.1:${record.port}`;
223
237
  const current = effects.readManagedClientProfile();
224
238
  if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
@@ -230,16 +244,20 @@ else { await request('identity/profile', selection.human); process.stdout.write(
230
244
  const published = join(root, 'issued-' + randomUUID());
231
245
  const credential = join(stage, 'credential');
232
246
  try {
233
- if (!current) {
247
+ // After `server access-replace` the retained credential is refused by this
248
+ // same server; a repeat setup replaces it rather than keeping a dead one.
249
+ const replace = current && effects.credentialRefused ? await effects.credentialRefused(current) : false;
250
+ if (replace) effects.out?.('The saved client credential is no longer accepted by this server; issuing a replacement.');
251
+ if (!current || replace) {
234
252
  effects.out?.('Issuing a separate local client credential with the retained server authority.');
235
253
  try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
236
254
  catch (cause) { throw new Error('Client credential issuance failed; existing profiles were retained', { cause }); }
237
255
  }
238
- const retainedCredential = current?.credentialPath ?? credential;
256
+ const retainedCredential = current && !replace ? current.credentialPath : credential;
239
257
  const stat = privatePath(retainedCredential);
240
258
  if (stat.size > 4096 || !readFileSync(retainedCredential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
241
259
  const profile = {
242
- ...validateHostProfile({ ...(serverUrl ? { serverUrl } : {}), endpoint, expectedInstanceId: record.instanceId, credentialPath: current?.credentialPath ?? join(published, 'credential') }),
260
+ ...validateHostProfile({ ...(serverUrl ? { serverUrl } : {}), endpoint, expectedInstanceId: record.instanceId, credentialPath: current && !replace ? current.credentialPath : join(published, 'credential') }),
243
261
  installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
244
262
  };
245
263
  writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
@@ -1,6 +1,7 @@
1
1
  import { hostname } from 'node:os';
2
2
  import { serverBase } from './gateway.mjs';
3
3
  import { join, resolve, isAbsolute, dirname } from 'node:path';
4
+ import { PRODUCTS, PLUGIN_ONLY, PLUGIN_ONLY_INTEGRATIONS } from './product.mjs';
4
5
 
5
6
  const scopes = ['all', 'server', 'client'];
6
7
  const integrations = ['codex', 'claude-code', 'fleet'];
@@ -11,7 +12,7 @@ const valueFlags = new Map(Object.entries({
11
12
  '--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
12
13
  }));
13
14
  const boolFlags = new Map([['--compatible', 'compatible'], ['--dry-run', 'dryRun'], ['--migrate', 'migrate'], ['--disable-fleet-agents-setup', 'disableFleetAgentsSetup']]);
14
- const allowed = new Set([...valueFlags.values(), ...boolFlags.values(), 'interactive', 'explicitPorts']);
15
+ const allowed = new Set([...valueFlags.values(), ...boolFlags.values(), 'interactive', 'explicitPorts', 'product']);
15
16
  const defaults = { port: 3050, coworkPort: 3052, messengerPort: 8420 };
16
17
  const paths = ['stateDir', 'config', 'sources', 'fleetSettingsPath', 'migrateFrom'];
17
18
  const nonempty = value => typeof value === 'string' && value.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(value);
@@ -63,6 +64,20 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
63
64
  if (options.disableFleetAgentsSetup) throw new Error('--fleet-task-workflow conflicts with --disable-fleet-agents-setup: no agents are configured by this run');
64
65
  options.fleetTaskWorkflowAgents = [...agents];
65
66
  }
67
+ if (options.product !== undefined) {
68
+ if (!PRODUCTS.includes(options.product)) throw new Error('Product must be workspace or plugin-only');
69
+ if (options.product === PLUGIN_ONLY) {
70
+ if (!server) throw new Error('--plugin-only installs the daemon on this computer; it cannot be combined with client scope');
71
+ if (options.mode !== undefined && !['docker'].includes(options.mode)) throw new Error('--plugin-only runs the daemon in Docker or Podman; native packages are not supported for it');
72
+ if (options.integrations?.some?.(name => !PLUGIN_ONLY_INTEGRATIONS.includes(name))) throw new Error('--plugin-only connects Claude Code and/or Codex; Fleet is part of the complete workspace');
73
+ for (const [key, flag] of [['fleetSettingsPath', '--fleet-settings'], ['fleetTaskWorkflowAgents', '--fleet-task-workflow'], ['disableFleetAgentsSetup', '--disable-fleet-agents-setup'],
74
+ ['name', '--name'], ['surname', '--surname'], ['migrateFrom', '--migrate-from'], ['serverUrl', '--server-url']]) {
75
+ if (options[key] !== undefined && options[key] !== false) throw new Error(`${flag} belongs to the complete workspace and cannot be combined with --plugin-only`);
76
+ }
77
+ if (options.explicitPorts?.some?.(key => key !== 'port')) throw new Error('--plugin-only uses one port; --cowork-port and --messenger-port do not apply');
78
+ options.mode ??= 'docker';
79
+ }
80
+ }
66
81
  const missing = [];
67
82
  if (server) for (const [key, flag] of [['mode', '--mode'], ['stateDir', '--state-dir'], ...(options.migrateFrom === undefined ? [['identityName', '--identity-name']] : [])]) if (!nonempty(options[key])) missing.push(flag);
68
83
  if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
@@ -135,6 +150,10 @@ export function parseSetupArgs(argv, { home, validate = true } = {}) {
135
150
  if (!arg.startsWith('-')) { positional.push(arg); continue; }
136
151
  const equal = arg.indexOf('=');
137
152
  const flag = equal < 0 ? arg : arg.slice(0, equal);
153
+ if (flag === '--plugin-only') {
154
+ if (equal >= 0) throw new Error(`${flag} takes no value`);
155
+ put('product', PLUGIN_ONLY); continue;
156
+ }
138
157
  if (boolFlags.has(flag)) {
139
158
  if (equal >= 0) throw new Error(`${flag} takes no value`);
140
159
  put(boolFlags.get(flag), true); continue;
package/lib/setup.mjs CHANGED
@@ -12,6 +12,9 @@ import { USAGE } from './usage.mjs';
12
12
  import { validateIdentityName } from './server-onboarding.mjs';
13
13
  import { validateFleetSettings } from './fleet-settings.mjs';
14
14
  import { installerFailure } from './diagnostics.mjs';
15
+ import { productOf, isPluginOnly, serverRole, PRODUCT_LABELS, PLUGIN_ONLY_INTEGRATIONS } from './product.mjs';
16
+ import { collectWelcome, customHarnessGuidance, KEEPS_YOUR_SETUP } from './welcome.mjs';
17
+ import { runRemoval } from './removal.mjs';
15
18
 
16
19
  const maintenance = new Set(['status', 'start', 'stop', 'restart', 'uninstall', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
17
20
 
@@ -58,6 +61,10 @@ export async function prepareSetupPlan(options, effects) {
58
61
  const value = effects.readJson(join(options.stateDir, 'installation.json'));
59
62
  if (value) {
60
63
  plan.existing = validateInstallation(value, options.stateDir);
64
+ // The kind of installation is retained exactly; it is never widened or narrowed implicitly.
65
+ if (options.product !== undefined && options.product !== productOf(plan.existing))
66
+ throw new InstallUsageError(`This folder contains ${PRODUCT_LABELS[productOf(plan.existing)].toLowerCase()}. It is kept as it is; nothing was changed. Use a separate empty folder for the other kind of installation, or remove this one first.`);
67
+ plan.product = productOf(plan.existing);
61
68
  if (options.containerEngine && options.containerEngine !== (plan.existing.containerEngine ?? 'docker')) throw new InstallUsageError('Container engine conflicts with retained installation');
62
69
  if (options.mode !== plan.existing.mode) throw new InstallUsageError('The selected mode conflicts with the retained installation; choose its existing mode or a separate empty directory');
63
70
  for (const key of ['port', 'coworkPort', 'messengerPort']) {
@@ -71,9 +78,14 @@ export async function prepareSetupPlan(options, effects) {
71
78
  } else if (options.operation === 'update') {
72
79
  throw new InstallUsageError('Update requires an existing installation.json; choose install for a new installation');
73
80
  }
81
+ plan.product ??= options.product ?? 'workspace';
82
+ if (isPluginOnly(plan.product) && plan.integrations?.some(name => !PLUGIN_ONLY_INTEGRATIONS.includes(name)))
83
+ throw new InstallUsageError('Collaboration tools connect Claude Code and/or Codex; Fleet is part of the complete workspace. Nothing was changed.');
74
84
  // Reject a local client already attached to another server before changing the server.
75
85
  const saved = options.scope === 'all' ? effects.readManagedClientProfile() : null;
76
- if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== (plan.existing?.gateway ? gatewayAddress(plan.existing).base + '/daemon' : `http://127.0.0.1:${plan.port}`))) {
86
+ const savedEndpoint = plan.existing?.gateway ? gatewayAddress(plan.existing).base + '/daemon'
87
+ : isPluginOnly(plan.product) ? `http://127.0.0.1:${plan.port}/daemon` : `http://127.0.0.1:${plan.port}`;
88
+ if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== savedEndpoint)) {
77
89
  throw new InstallUsageError('This user already has clients attached to a different server; their saved connection was not changed');
78
90
  }
79
91
  } else {
@@ -91,7 +103,7 @@ export async function prepareSetupPlan(options, effects) {
91
103
  const running = await effects.serverLifecycle(plan.existing, 'status', ['daemon']);
92
104
  if (!running.includes('daemon')) throw new InstallUsageError('Full-stack update requires the selected daemon to be running for client verification. Start it first, or use server update to preserve its stopped state.');
93
105
  }
94
- if (options.scope !== 'client') await effects.resolveSourcePolicy(plan.sourcePolicy, 'server');
106
+ if (options.scope !== 'client') await effects.resolveSourcePolicy(plan.sourcePolicy, serverRole(plan.product));
95
107
  if (options.scope !== 'server') await effects.resolveSourcePolicy(plan.sourcePolicy, 'client', clientPackageNames(options.integrations));
96
108
  }
97
109
  return plan;
@@ -112,7 +124,7 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
112
124
  let clientPolicy = plan.sourcePolicy;
113
125
  if (plan.scope !== 'client') {
114
126
  effects.out(heading(plan.operation === 'update' ? 'Server update and identity restoration' : 'Server installation'));
115
- const result = await server({ ...plan, role: 'server', operation: plan.operation, sourcePolicy: plan.sourcePolicy }, effects);
127
+ const result = await server({ ...plan, product: plan.product === 'workspace' ? undefined : plan.product, role: 'server', operation: plan.operation, sourcePolicy: plan.sourcePolicy }, effects);
116
128
  if (result !== 0) return result;
117
129
  const record = validateInstallation(effects.readJson(join(plan.stateDir, 'installation.json')), plan.stateDir);
118
130
  if (plan.name && plan.surname && plan.operation === 'install') {
@@ -140,33 +152,64 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
140
152
  preset: true, nonInteractive: !plan.interactive }, effects);
141
153
  if (result !== 0) return result;
142
154
  }
143
- if (plan.disableFleetAgentsSetup && plan.integrations?.includes('fleet')) effects.out(info('Fleet is installed. Run ours-fleet setup-tunnel with the expiring one-use payload from the App command; a compatible Fleet prints the QR and connection code automatically. Configure agents and models in the App after linking.'));
155
+ if (plan.retainFleetConfiguration && plan.integrations?.includes('fleet')) effects.out(info('Fleet was updated; your existing Fleet agents and their configuration were kept. Restart running agents when convenient: ours-fleet restart'));
156
+ else if (plan.disableFleetAgentsSetup && plan.integrations?.includes('fleet')) effects.out(info('Fleet is installed. Run ours-fleet setup-tunnel with the expiring one-use payload from the App command; a compatible Fleet prints the QR and connection code automatically. Configure agents and models in the App after linking.'));
144
157
  effects.out(ok(`Requested ${plan.operation} completed. Existing identities were retained.`));
145
158
  if (plan.integrations?.includes('fleet') && !plan.disableFleetAgentsSetup) effects.out(info('Fleet is configured but stopped. Review its settings, then run ours-fleet doctor, ours-fleet config and ours-fleet up.'));
159
+ if (isPluginOnly(plan.product) && plan.scope === 'all') {
160
+ effects.out(info('Collaboration tools are ready. Restart Claude Code and start a new Codex session so they load the Ours tools; then ask your agent to create its Ours identity.'));
161
+ effects.out(info(KEEPS_YOUR_SETUP));
162
+ }
163
+ if (plan.customHarness) {
164
+ const release = plan.sourcePolicy?.release?.packages?.['@ours.network/claude-code'];
165
+ if (release?.version) for (const line of customHarnessGuidance({ version: release.version, profilePath: join(effects.home, '.ours-client', 'profile.json'),
166
+ marketplace: preparedClaudeMarketplace(effects) })) effects.out(info(line));
167
+ else effects.out(warn('This installer release does not pin an Ours plugin version, so no instructions for another agent app can be given safely.'));
168
+ }
169
+ if (plan.operation === 'install' || plan.operation === 'update') effects.out(info('To remove Ours completely later, run: ours-install remove'));
146
170
  return 0;
147
171
  }
148
172
 
173
+ /** The exact local Claude Code marketplace this run prepared, when Claude Code was selected. */
174
+ function preparedClaudeMarketplace(effects) {
175
+ const root = join(effects.home, '.ours-client-install');
176
+ for (const name of effects.list?.(root) ?? []) {
177
+ const path = join(root, name, 'marketplaces', 'claude-code');
178
+ if (effects.exists?.(join(path, 'plugins', 'ours', 'bin', 'proxy.mjs'))) return path;
179
+ }
180
+ return null;
181
+ }
182
+
149
183
  /** The sole executable entry: manual answers and CLI presets share one plan/executor. */
150
184
  export async function runSetup(argv, effects) {
151
185
  try {
152
186
  if (argv.includes('--help') || argv.includes('-h')) { effects.out(USAGE); return 0; }
153
187
  if (argv.length === 1 && ['--version', '-V'].includes(argv[0])) { effects.out(effects.version ?? 'unknown'); return 0; }
154
188
  if (argv[0] === 'server' && maintenance.has(argv[1])) return await runServerCommand(parseNetworkArgs(argv), effects);
189
+ if (argv[0] === 'remove' || argv[0] === 'uninstall') return await runRemoval(argv.slice(1), effects);
155
190
  if (!argv.length && effects.env?.OURS_ASSUME_YES) throw new InstallUsageError('OURS_ASSUME_YES cannot fill an interactive setup plan. Supply complete CLI presets for unattended installation.');
156
- if (!argv.length) { effects.out(banner()); effects.out(heading('Interactive setup')); }
157
191
  let options;
158
- if (argv.length) {
192
+ let extra = {};
193
+ const welcome = !argv.length || (argv.length === 1 && argv[0] === 'update' && effects.interactive && !effects.env?.OURS_ASSUME_YES);
194
+ if (welcome) {
195
+ effects.out(banner());
196
+ const choice = await collectWelcome(effects, { update: argv[0] === 'update' });
197
+ if (choice.action === 'cancel') { effects.out(info('Nothing was changed.')); return 0; }
198
+ if (choice.action === 'remove') return await runRemoval([], effects);
199
+ if (choice.action === 'setup') { options = choice.options; extra = { customHarness: choice.customHarness, retainFleetConfiguration: choice.retainFleetConfiguration }; }
200
+ else options = await collectSetupOptions(effects, { ...choice.presets });
201
+ } else if (argv.length) {
159
202
  const presets = parseSetupArgs(argv, { home: effects.home, validate: false });
160
203
  try { options = validateSetupOptions(presets, { interactive: false }); }
161
204
  catch (error) {
162
205
  if (!effects.interactive || effects.env?.OURS_ASSUME_YES || !/^Missing (required setup options|human profile option):/.test(error.message)) throw error;
163
206
  options = await collectSetupOptions(effects, presets);
164
207
  }
165
- } else options = await collectSetupOptions(effects);
166
- const plan = await prepareSetupPlan(options, effects);
208
+ }
209
+ const plan = { ...await prepareSetupPlan(options, effects), ...extra };
167
210
  return await executeSetupPlan(plan, effects);
168
211
  } catch (error) {
169
- if (isCancel(error)) { effects.out(warn('Installation cancelled.')); return 130; }
212
+ if (isCancel(error) || error?.cancelled) { effects.out(warn(error?.cancelled ? error.message : 'Installation cancelled.')); return 130; }
170
213
  effects.out(warn(`ours-install: ${installerFailure(error, effects.env)}`));
171
214
  return 2;
172
215
  }
package/lib/usage.mjs CHANGED
@@ -2,11 +2,21 @@
2
2
  export const USAGE = `ours-install — interactive setup with optional CLI presets.
3
3
 
4
4
  ours-install
5
- Opens the console form. Choose all (server + clients), server, or client;
6
- runtime, installation directory, identity, integrations and Fleet settings.
5
+ Asks what you would like to do: add collaboration tools to the agent apps
6
+ you already use, install the complete Ours workspace, connect to a workspace
7
+ on another computer, or another setup. When Ours is already installed it
8
+ offers Update (recommended), Repair, Remove or Cancel instead, keeping the
9
+ installation's product, runtime and connected apps. Your existing agent
10
+ configuration, skills, plugins and settings are kept.
7
11
  Full setup runs the server in Docker or Podman, which provide the gateway local clients need.
8
12
  Native mode is offered for server-only setup. Windows uses WSL.
9
13
 
14
+ Collaboration tools only (one Ours service in Docker, plugins for Claude Code and Codex):
15
+ ours-install --plugin-only --state-dir ~/.ours-install --identity-name "Your Name" --integrations claude-code,codex
16
+
17
+ Remove Ours completely (lists everything first; asks you to type "remove ours"):
18
+ ours-install remove [--state-dir PATH] [--dry-run]
19
+
10
20
  Install now, configure Fleet agents in the App after linking:
11
21
  ours-install --username alex --name Alex --surname Taylor --disable-fleet-agents-setup
12
22
  Remaining installation choices are prompted on a terminal. No enrollment credentials or expiry.
@@ -24,6 +34,8 @@ export const USAGE = `ours-install — interactive setup with optional CLI prese
24
34
  Update a retained installation, preserving its identities:
25
35
  ours-install all update --mode docker --state-dir /private/ours --identity-name "Your Name" --integrations codex,fleet --fleet-settings /private/fleet.json --compatible
26
36
 
37
+ --plugin-only collaboration tools only: the daemon service and the Claude Code/Codex
38
+ plugins; no workspace apps, Fleet, Telegram or gateway (Docker or Podman)
27
39
  --scope all|server|client preset which parts to configure (default all)
28
40
  --action install|update equivalent to the positional operation
29
41
  --mode docker|native packages is also accepted for native mode
@@ -0,0 +1,219 @@
1
+ // The first screen of `ours-install` with no arguments.
2
+ //
3
+ // It starts from what the person wants, not from how Ours is built: add
4
+ // collaboration tools to the agent apps they already use, install the complete
5
+ // workspace, or connect to a workspace that already runs elsewhere. When Ours is
6
+ // already here, it offers to update, repair or remove what is installed, and
7
+ // keeps the kind of installation the person chose.
8
+ //
9
+ // This file only asks and explains. It returns the same validated setup options
10
+ // the CLI presets produce (or a removal request), so every path runs the same
11
+ // installer underneath.
12
+
13
+ import { join } from 'node:path';
14
+ import { validateInstallation } from './plan.mjs';
15
+ import { productOf, isPluginOnly, PLUGIN_ONLY, PRODUCT_LABELS, PLUGIN_ONLY_INTEGRATIONS } from './product.mjs';
16
+ import { validateSetupOptions, defaultHostname } from './setup-options.mjs';
17
+ import { validateIdentityName } from './server-onboarding.mjs';
18
+ import { heading, info } from './ui.mjs';
19
+
20
+ export const KEEPS_YOUR_SETUP = 'Your existing agent configuration, skills, plugins and custom settings stay as they are. Ours adds its own tools next to them; it does not replace or reconfigure your setup.';
21
+
22
+ export const WORKSPACE_BENEFITS = [
23
+ 'The complete Ours workspace adds, on this computer:',
24
+ ' - Rooms: a room of agents for each task, where they work on it together',
25
+ ' - Invitations: bring in agents and people from outside, with their own identities',
26
+ ' - Tracking: follow tasks and every agent\'s progress in one place',
27
+ ' - Mobile: reach your agents from the Ours app on your phone',
28
+ ' - Persistent agents: define agents that keep running and remember their role',
29
+ ' - Orchestration: Claude Code, Codex and other agent apps and vendors working together',
30
+ ];
31
+
32
+ export const TOOLS_SUMMARY = [
33
+ 'Collaboration tools give the agent apps you already use their own secure Ours identity,',
34
+ 'so they can message other agents and people, share files and be woken when mail arrives.',
35
+ 'It runs one small background service in Docker or Podman on this computer — nothing else.',
36
+ ];
37
+
38
+ /** Managed installations on this computer, in the default and ~/.ours* folders. */
39
+ export function findInstallations(effects, extra = []) {
40
+ const home = effects.home;
41
+ const names = typeof effects.list === 'function' ? effects.list(home) ?? [] : [];
42
+ const roots = [...new Set([join(home, '.ours-install'), ...names.filter(n => n.startsWith('.ours')).map(n => join(home, n)), ...extra])];
43
+ const found = [];
44
+ for (const root of roots) {
45
+ const value = effects.readJson(join(root, 'installation.json'));
46
+ if (!value) continue;
47
+ try {
48
+ const record = validateInstallation(value, root);
49
+ const journal = record.legacyMigrationSource ? effects.readJson(join(root, 'legacy-migration.json')) : null;
50
+ found.push({ root, record, product: productOf(record),
51
+ pending: record.buildTransition ? record.buildTransition.operation : record.layoutConversion ? 'install'
52
+ : record.legacyMigrationSource && journal?.phase !== 'complete' ? 'install' : null });
53
+ } catch { found.push({ root, invalid: true }); }
54
+ }
55
+ return found;
56
+ }
57
+
58
+ function describe(item) {
59
+ if (item.invalid) return `An Ours folder that could not be read: ${item.root}`;
60
+ const engine = item.record.mode === 'packages' ? 'directly on this computer' : item.record.containerEngine === 'podman' ? 'in Podman' : 'in Docker';
61
+ return `${PRODUCT_LABELS[item.product]} — folder ${item.root}, running ${engine}`;
62
+ }
63
+
64
+ const defaultIdentity = effects => `${String(effects.username?.() ?? 'me').toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+/, '').slice(0, 30) || 'me'}@${defaultHostname()}`;
65
+
66
+ async function askIdentity(effects) {
67
+ effects.out('Choose the name other people and agents will see for you. Your agents get their own identities under it later.');
68
+ for (;;) {
69
+ const name = (await effects.askLine('Your name in Ours: ', defaultIdentity(effects))).trim();
70
+ try { validateIdentityName(name); return name; }
71
+ catch { effects.out('Use 1–64 characters without slashes or control characters.'); }
72
+ }
73
+ }
74
+
75
+ async function askFolder(effects) {
76
+ const recommended = join(effects.home, '.ours-install');
77
+ effects.out(`Ours keeps its service and your data in one folder. The recommended folder is ${recommended}.`);
78
+ const where = await effects.select('Where should Ours keep its data?', [
79
+ { value: 'recommended', label: `Use the recommended folder (${recommended})` },
80
+ { value: 'custom', label: 'Choose another folder' },
81
+ ], 'recommended');
82
+ return where === 'recommended' ? recommended : await effects.askLine('Folder for Ours: ', recommended);
83
+ }
84
+
85
+ async function askEngine(effects) {
86
+ let podman = false;
87
+ try { podman = (await effects.run('sh', ['-c', 'command -v podman'], { allowCodes: [1, 127] })).code === 0; } catch { podman = false; }
88
+ if (!podman) return {};
89
+ effects.out('Ours runs its service in containers. Docker is recommended; choose Podman if that is what you use.');
90
+ const engine = await effects.select('Which container app should run Ours?', [
91
+ { value: 'docker', label: 'Docker (recommended)' }, { value: 'podman', label: 'Podman' },
92
+ ], 'docker');
93
+ return engine === 'podman' ? { containerEngine: 'podman' } : {};
94
+ }
95
+
96
+ /** Collaboration tools for existing agent apps: the plugin-only installation. */
97
+ async function collectTools(effects) {
98
+ effects.out(heading('Collaboration tools for your agent apps'));
99
+ for (const line of TOOLS_SUMMARY) effects.out(line);
100
+ effects.out(KEEPS_YOUR_SETUP);
101
+ const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
102
+ const found = name => detected.some(item => item.name === name && item.status === 'ok');
103
+ const choices = [
104
+ ...PLUGIN_ONLY_INTEGRATIONS.filter(found).map(name => ({ value: name, label: name === 'claude-code' ? 'Claude Code (found on this computer)' : 'Codex (found on this computer)' })),
105
+ { value: 'custom', label: 'Another agent app — show me how to connect it' },
106
+ ];
107
+ const missing = PLUGIN_ONLY_INTEGRATIONS.filter(name => !found(name)).map(name => name === 'claude-code' ? 'Claude Code' : 'Codex');
108
+ effects.out(`Choose the agent apps that should get the Ours tools. Use Space to select and Enter to continue.${missing.length ? ` Not found on this computer: ${missing.join(' and ')} — install it first and run ours-install again to connect it.` : ''}`);
109
+ const selected = await effects.multiselect('Which agent apps should get the Ours tools?', choices, choices.filter(c => c.value !== 'custom').map(c => c.value));
110
+ const integrations = selected.filter(name => name !== 'custom');
111
+ const customHarness = selected.includes('custom');
112
+ if (!integrations.length && !customHarness) throw Object.assign(new Error('Setup cancelled; no agent app was selected and nothing was changed'), { cancelled: true });
113
+ const engine = await askEngine(effects);
114
+ const stateDir = await askFolder(effects);
115
+ const identityName = await askIdentity(effects);
116
+ const options = validateSetupOptions({ scope: 'all', operation: 'install', product: PLUGIN_ONLY, mode: 'docker', ...engine, stateDir, identityName, integrations, interactive: true, explicitPorts: [] }, { interactive: true });
117
+ effects.out(`Ready to install: collaboration tools for ${[...integrations.map(n => n === 'claude-code' ? 'Claude Code' : 'Codex'), ...(customHarness ? ['another agent app (instructions at the end)'] : [])].join(', ')}; data in ${stateDir}; your name: ${identityName}.`);
118
+ effects.out('Nothing is changed until you continue. You can remove everything later with: ours-install remove');
119
+ if (!await effects.ask('Install now?', true)) throw Object.assign(new Error('Setup cancelled; nothing was changed'), { cancelled: true });
120
+ return { action: 'setup', options, customHarness };
121
+ }
122
+
123
+ /** Update, repair or remove what is already installed. */
124
+ async function collectExisting(effects, installations) {
125
+ effects.out(heading('Ours is already set up on this computer'));
126
+ for (const item of installations) effects.out(info(describe(item)));
127
+ const usable = installations.filter(item => !item.invalid);
128
+ let item = usable[0];
129
+ if (usable.length > 1) {
130
+ effects.out('Choose the installation to work with.');
131
+ const root = await effects.select('Which installation?', usable.map(i => ({ value: i.root, label: describe(i) })), usable[0].root);
132
+ item = usable.find(i => i.root === root);
133
+ }
134
+ if (!item) {
135
+ effects.out('Its record could not be read, so it is not changed automatically. You can remove Ours completely, which lists everything first.');
136
+ const next = await effects.select('What would you like to do?', [{ value: 'remove', label: 'Remove Ours from this computer' }, { value: 'cancel', label: 'Cancel' }], 'cancel');
137
+ return { action: next };
138
+ }
139
+ const saved = (() => { try { return effects.readManagedClientProfile?.() ?? null; } catch { return null; } })();
140
+ const clientBound = saved?.expectedInstanceId === item.record.instanceId;
141
+ if (item.pending) {
142
+ effects.out('An earlier update or setup step did not finish. Finishing it keeps your identities and data and repairs the installation.');
143
+ } else {
144
+ effects.out(`Update brings this ${item.product === PLUGIN_ONLY ? 'installation' : 'workspace'} to installer ${effects.version ?? 'this version'}, keeping your identities, data, connected agent apps and their settings. Repair runs setup again with the same choices. To switch between collaboration tools and the complete workspace, remove this installation first.`);
145
+ }
146
+ const action = await effects.select('What would you like to do?', [
147
+ ...(item.pending ? [{ value: item.pending, label: 'Finish the earlier update or setup (recommended)' }]
148
+ : [{ value: 'update', label: 'Update Ours (recommended)' }, { value: 'install', label: 'Repair: run setup again with the same choices' }]),
149
+ { value: 'remove', label: 'Remove Ours from this computer' },
150
+ { value: 'cancel', label: 'Cancel' },
151
+ ], item.pending ?? 'update');
152
+ if (action === 'remove' || action === 'cancel') return { action };
153
+ const record = item.record;
154
+ const product = productOf(record);
155
+ const integrations = clientBound ? (saved.installer?.integrations ?? []).filter(name => !isPluginOnly(product) || PLUGIN_ONLY_INTEGRATIONS.includes(name)) : undefined;
156
+ const fleetConfigured = integrations?.includes('fleet') && effects.readText?.(join(effects.home, 'fleet.yaml')) != null;
157
+ if (action === 'update') {
158
+ effects.out('An update keeps a recovery backup of your stored data before changing it. Newer releases are checked for compatibility, but older data cannot be guaranteed to work with every release automatically.');
159
+ if (!await effects.ask('Update now and keep a recovery backup?', true)) return { action: 'cancel' };
160
+ }
161
+ const options = {
162
+ scope: clientBound ? 'all' : 'server', operation: action, ...(product === PLUGIN_ONLY ? { product } : {}),
163
+ mode: record.mode, ...(record.containerEngine ? { containerEngine: record.containerEngine } : {}),
164
+ stateDir: item.root, identityName: record.messengerIdentity ?? defaultIdentity(effects),
165
+ ...(clientBound ? { integrations } : {}),
166
+ ...(action === 'update' ? { compatible: true } : {}),
167
+ ...(fleetConfigured ? { disableFleetAgentsSetup: true } : {}),
168
+ interactive: true, explicitPorts: [],
169
+ };
170
+ return { action: 'setup', options: validateSetupOptions(options, { interactive: true }), existing: item, retainFleetConfiguration: Boolean(fleetConfigured) };
171
+ }
172
+
173
+ /** The goal-first screen. Returns { action: 'setup' | 'remove' | 'cancel' | 'advanced', ... }. */
174
+ export async function collectWelcome(effects) {
175
+ const installations = findInstallations(effects);
176
+ if (installations.length) return collectExisting(effects, installations);
177
+ effects.out(heading('Welcome to Ours'));
178
+ effects.out('Ours is where people and AI agents work together: each agent gets its own secure identity and can message, share files and coordinate with other agents and people.');
179
+ effects.out('Choose what you would like to set up. Collaboration tools add Ours to the agent apps you already use. The complete workspace adds rooms, task tracking, mobile access and persistent agents.');
180
+ effects.out(KEEPS_YOUR_SETUP);
181
+ const goal = await effects.select('What would you like to do?', [
182
+ { value: 'tools', label: 'Add collaboration tools to my agent apps (Claude Code, Codex or another app)' },
183
+ { value: 'workspace', label: 'Install the complete Ours workspace' },
184
+ { value: 'join', label: 'Connect my agent apps to an Ours workspace that runs on another computer' },
185
+ { value: 'advanced', label: 'Other setups (server only, custom ports or packages)' },
186
+ ], 'tools');
187
+ if (goal === 'tools') return collectTools(effects);
188
+ if (goal === 'workspace') {
189
+ effects.out(heading('The complete Ours workspace'));
190
+ for (const line of WORKSPACE_BENEFITS) effects.out(line);
191
+ effects.out(KEEPS_YOUR_SETUP);
192
+ return { action: 'advanced', presets: { scope: 'all' } };
193
+ }
194
+ if (goal === 'join') return { action: 'advanced', presets: { scope: 'client' } };
195
+ return { action: 'advanced', presets: {} };
196
+ }
197
+
198
+ /** Exact, verified guidance for an agent app the installer cannot configure itself. */
199
+ export function customHarnessGuidance({ version, profilePath, marketplace }) {
200
+ const folder = marketplace ? `${marketplace}/plugins/ours` : null;
201
+ return [
202
+ 'Connect another agent app:',
203
+ ' Ours provides its tools as an MCP server (stdio) plus instructions for the agent. Any agent app that can',
204
+ ' run a local stdio MCP server can use them; apps that cannot run MCP servers cannot use Ours this way.',
205
+ ...(folder ? [
206
+ ` Ready on this computer: ${folder}`,
207
+ ' Add this MCP server in the agent app\'s settings:',
208
+ ` command: node arguments: ${folder}/bin/proxy.mjs`,
209
+ ' environment: CLAUDE_CODE_SESSION_ID = a value unique to each agent session (for example a new UUID).',
210
+ ' Without it the Ours tools refuse to act, because they keep each session\'s identity separate.',
211
+ ` Give the agent ${folder}/skills/ours/SKILL.md as its instructions for the Ours tools.`,
212
+ ] : []),
213
+ ` It connects to Ours on this computer through ${profilePath}; no other settings are needed.`,
214
+ ` The same plugin is published as npm package @ours.network/claude-code@${version}`,
215
+ ` (https://www.npmjs.com/package/@ours.network/claude-code/v/${version}); after npm install in its folder it is used`,
216
+ ' the same way. Use this exact version: the public GitHub marketplace adapt-toolkit/ours-claude-marketplace',
217
+ ' currently selects an older plugin that does not work with this release.',
218
+ ];
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "1.2.1-nightly.42",
3
+ "version": "1.2.1-nightly.43",
4
4
  "private": false,
5
5
  "description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
6
6
  "type": "module",