@assethub/cli 0.1.33 → 0.1.36

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.
@@ -1,11 +1,14 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { constants } from 'node:fs';
3
- import { access, copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
4
- import { homedir } from 'node:os';
3
+ import { access, copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
5
4
  import { delimiter, dirname, join } from 'node:path';
6
5
  import { API_KEY_ENV, launchAgentPath, launchAgentPlist, loadKeyIntoLaunchd, readLaunchdKey } from './appEnv.js';
7
6
  import { hookScopeError, sessionSaveDisclosure } from './hooks/install.js';
8
- import { mcpConfig } from './setup.js';
7
+ import { agentSpec, cliHome, detectAgents, installAgentSkills, mcpServerEntry, mergeJsonServer, readJsonServer, } from './agentSetup.js';
8
+ import { cliVersion, mcpConfig, validatedBaseUrl } from './setup.js';
9
+ // The steps `--only` and `--skip` name, in the order setup runs them. `agents`
10
+ // is not one: the mcp and skills steps need it.
11
+ export const SETUP_STEPS = ['login', 'workspace', 'mcp', 'skills', 'auto-update', 'app-env', 'hook', 'doctor'];
9
12
  export const redactKey = (key) => `ah_…${key.slice(-4)}`;
10
13
  const scrub = (text, key) => key ? text.split(key).join(redactKey(key)) : text;
11
14
  const TOML_HEADER = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/;
@@ -107,15 +110,48 @@ export const claudeServerConfig = (baseUrl, workspaceId) => ({
107
110
  ...(workspaceId ? { 'X-AssetHub-Workspace': workspaceId } : {}),
108
111
  },
109
112
  });
110
- export const claudeAddArgs = (baseUrl, workspaceId) => [
111
- 'mcp', 'add-json', 'assethub', JSON.stringify(claudeServerConfig(baseUrl, workspaceId)), '--scope', 'user',
113
+ export const claudeAddArgs = (baseUrl, workspaceId, scope = 'user') => [
114
+ 'mcp', 'add-json', 'assethub', JSON.stringify(claudeServerConfig(baseUrl, workspaceId)), '--scope', scope,
112
115
  ];
113
116
  const shellQuoteSingle = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
117
+ /** "A", "A and B", "A, B and C". */
118
+ const listJoin = (items) => items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
119
+ // How each agent's desktop app is named in restart instructions.
120
+ const APP_NAMES = { 'claude-code': 'Claude (Cmd+Q)', codex: 'Codex', cursor: 'Cursor' };
114
121
  export const runSetup = async (options, deps) => {
115
122
  const steps = [];
116
123
  const say = (line) => deps.log(line);
117
- const wantClaude = options.client !== 'codex';
118
- const wantCodex = options.client !== 'claude';
124
+ const skipped = (step) => options.skip?.includes(step) ?? false;
125
+ const scope = options.project ? 'project' : 'global';
126
+ const root = options.project ? deps.cwd : deps.home;
127
+ const needsAgents = !skipped('mcp') || !skipped('skills') || !skipped('hook');
128
+ // 0. Agents: the ones asked for, or every one found on this machine. Nothing
129
+ // else is changed when there is none to configure.
130
+ const version = await deps.version();
131
+ const detected = needsAgents ? await deps.detectAgents() : [];
132
+ const agents = options.agents ?? detected;
133
+ const agentNames = agents.map(agent => agentSpec(agent).name);
134
+ const failEarly = (step, nextStep) => ({
135
+ ok: false,
136
+ dryRun: options.dryRun,
137
+ profile: '',
138
+ baseUrl: '',
139
+ version,
140
+ scope,
141
+ agents,
142
+ detected,
143
+ steps: [step],
144
+ nextStep,
145
+ });
146
+ // MCP needs an agent to configure; the skill copy alone does not.
147
+ if (!skipped('mcp') && agents.length === 0)
148
+ return failEarly({ name: 'agents', status: 'fail', detail: `no Claude Code, Codex or Cursor found under ${deps.home} or on PATH` }, 'Install a coding agent, or pass --agent claude-code|codex|cursor to configure one anyway.');
149
+ if (needsAgents && agents.length === 0)
150
+ steps.push({ name: 'agents', status: 'skip', detail: 'no coding agent found; the skill is installed without agent links' });
151
+ else if (needsAgents)
152
+ steps.push({ name: 'agents', status: 'ok', detail: `${listJoin(agentNames)}${options.agents ? '' : ' (detected)'}` });
153
+ const wantClaude = agents.includes('claude-code');
154
+ const wantCodex = agents.includes('codex');
119
155
  const hookClients = [...(wantClaude ? ['claude'] : []), ...(wantCodex ? ['codex'] : [])];
120
156
  const hookNames = hookClients.map(client => (client === 'claude' ? 'Claude Code' : 'Codex')).join(' and ');
121
157
  let hookRefusal;
@@ -126,59 +162,72 @@ export const runSetup = async (options, deps) => {
126
162
  break;
127
163
  }
128
164
  }
129
- // 0. --save-sessions asks for something the home folder cannot have, so stop
165
+ // --save-sessions asks for something the home folder cannot have, so stop
130
166
  // before login, MCP or app-env changes rather than half-applying setup.
131
- if (hookRefusal && options.saveSessions && !options.noHook) {
132
- return {
133
- ok: false,
134
- dryRun: options.dryRun,
135
- profile: '',
136
- baseUrl: '',
137
- steps: [{ name: 'hook', status: 'fail', detail: hookRefusal }],
138
- nextStep: 'Run `assethub setup --save-sessions` inside the project folder whose sessions should be saved, or run it without --save-sessions.',
139
- };
140
- }
141
- // 1. Login (reuses the existing `auth login` path through deps.login)
142
- const explicitKey = options.apiKeySource === 'flag' ? options.apiKey : undefined;
143
- const hadKey = explicitKey ? false : await deps.hasWorkingKey();
144
- const givenKey = explicitKey ?? (options.apiKeySource === 'env' ? options.apiKey : undefined);
145
- const givenFrom = explicitKey ? '--api-key' : 'ASSETHUB_API_KEY';
146
- if (hadKey) {
147
- steps.push({ name: 'login', status: 'ok', detail: 'reusing the saved API key' });
148
- }
149
- else if (options.dryRun) {
150
- steps.push({
151
- name: 'login',
152
- status: 'skip',
153
- detail: givenKey
154
- ? `would verify and save the API key from ${givenFrom}`
155
- : options.apiKeyStdin
156
- ? 'would read the API key from stdin'
157
- : 'would prompt for an API key',
158
- });
167
+ if (hookRefusal && options.saveSessions && !options.noHook && !skipped('hook'))
168
+ return failEarly({ name: 'hook', status: 'fail', detail: hookRefusal }, 'Run `assethub setup --save-sessions` inside the project folder whose sessions should be saved, or run it without --save-sessions.');
169
+ // 1. Login (reuses the existing `auth login` path through deps.login). With
170
+ // login skipped, a saved key is still used for app-env and doctor if there is one.
171
+ let auth;
172
+ let hadKey = false;
173
+ if (skipped('login')) {
174
+ auth = await deps.resolveAuth().catch(() => undefined);
159
175
  }
160
176
  else {
161
- let apiKey = givenKey;
162
- if (!apiKey && !options.apiKeyStdin) {
163
- if (!deps.interactive)
164
- throw new Error('No saved API key. Re-run with --api-key-stdin, set ASSETHUB_API_KEY, or run interactively to be prompted.');
165
- apiKey = await deps.promptApiKey();
177
+ const explicitKey = options.apiKeySource === 'flag' ? options.apiKey : undefined;
178
+ hadKey = explicitKey ? false : await deps.hasWorkingKey();
179
+ const givenKey = explicitKey ?? (options.apiKeySource === 'env' ? options.apiKey : undefined);
180
+ const givenFrom = explicitKey ? '--api-key' : 'ASSETHUB_API_KEY';
181
+ if (hadKey) {
182
+ steps.push({ name: 'login', status: 'ok', detail: 'reusing the saved API key' });
166
183
  }
167
- await deps.login({ apiKey });
168
- steps.push({
169
- name: 'login',
170
- status: 'ok',
171
- detail: givenKey ? `API key from ${givenFrom} verified and saved` : 'API key verified and saved',
172
- });
184
+ else if (options.dryRun) {
185
+ steps.push({
186
+ name: 'login',
187
+ status: 'skip',
188
+ detail: givenKey
189
+ ? `would verify and save the API key from ${givenFrom}`
190
+ : options.apiKeyStdin
191
+ ? 'would read the API key from stdin'
192
+ : 'would prompt for an API key',
193
+ });
194
+ }
195
+ else {
196
+ let apiKey = givenKey;
197
+ if (!apiKey && !options.apiKeyStdin) {
198
+ if (!deps.interactive)
199
+ throw new Error('No saved API key. Re-run with --api-key-stdin, set ASSETHUB_API_KEY, or run interactively to be prompted.');
200
+ apiKey = await deps.promptApiKey();
201
+ }
202
+ await deps.login({ apiKey });
203
+ steps.push({
204
+ name: 'login',
205
+ status: 'ok',
206
+ detail: givenKey ? `API key from ${givenFrom} verified and saved` : 'API key verified and saved',
207
+ });
208
+ }
209
+ if (!options.dryRun || hadKey)
210
+ auth = await deps.resolveAuth();
173
211
  }
174
- let auth;
175
- if (!options.dryRun || hadKey)
176
- auth = await deps.resolveAuth();
212
+ // Without a login, the saved profile still names the server and workspace.
213
+ const target = auth ?? (skipped('login') ? await deps.resolveTarget() : undefined);
177
214
  const key = auth?.apiKey ?? '';
178
- const baseUrl = (auth?.baseUrl ?? '').replace(/\/+$/, '');
215
+ const baseUrl = (target?.baseUrl ?? '').replace(/\/+$/, '');
216
+ // Every agent entry is built from this URL: refuse a bad one before writing any of them.
217
+ if (baseUrl) {
218
+ try {
219
+ validatedBaseUrl(baseUrl);
220
+ }
221
+ catch (error) {
222
+ return failEarly({ name: 'mcp', status: 'fail', detail: `${baseUrl}: ${error instanceof Error ? error.message : String(error)}` }, 'Pass --base-url with an HTTPS origin (HTTP only for localhost), then re-run setup.');
223
+ }
224
+ }
179
225
  // 2. Workspace
180
- let workspaceId = auth?.workspaceId;
181
- if (options.workspace) {
226
+ let workspaceId = target?.workspaceId;
227
+ if (skipped('workspace')) {
228
+ workspaceId = options.workspace ?? workspaceId;
229
+ }
230
+ else if (options.workspace) {
182
231
  workspaceId = options.workspace;
183
232
  if (options.dryRun) {
184
233
  steps.push({ name: 'workspace', status: 'skip', detail: `would select workspace ${workspaceId}` });
@@ -209,114 +258,207 @@ export const runSetup = async (options, deps) => {
209
258
  auth = await deps.resolveAuth();
210
259
  steps.push({ name: 'workspace', status: 'ok', detail: `selected ${workspaceId}` });
211
260
  }
212
- const workspaceForConfig = workspaceId ?? '<workspace-id>';
261
+ // A placeholder is shown only in a dry run that would still ask for the workspace.
262
+ const workspaceForConfig = workspaceId ?? (options.dryRun && !skipped('workspace') ? '<workspace-id>' : undefined);
213
263
  const shownKey = key ? redactKey(key) : 'ah_…<key>';
214
- if (!options.dryRun &&
264
+ const writesConfig = !skipped('mcp') || !skipped('skills');
265
+ if (writesConfig &&
266
+ !options.dryRun &&
215
267
  deps.interactive &&
216
268
  !options.yes &&
217
- !(await deps.confirm('Write MCP configuration for the selected client(s)?')))
218
- throw new Error('Cancelled before changing any client configuration.');
219
- // 3. Claude Code
220
- if (wantClaude) {
269
+ !(await deps.confirm(`Write AssetHub configuration for ${listJoin(agentNames)}${options.project ? ` in ${deps.cwd}` : ''}?`)))
270
+ throw new Error('Cancelled before changing any agent configuration.');
271
+ // Writes a merged config file, backing up the previous one; reports "unchanged" when nothing differs.
272
+ const writeConfig = async (name, path, existing, merged, what, diff) => {
273
+ if (merged === existing) {
274
+ steps.push({ name, status: 'ok', detail: `${path} already up to date`, change: 'unchanged', path });
275
+ }
276
+ else if (options.dryRun) {
277
+ say(`[dry-run] would ${existing === undefined ? 'create' : 'update'} ${path}`);
278
+ if (existing !== undefined)
279
+ say(`[dry-run] would back up to ${path}.bak-<timestamp>`);
280
+ for (const line of diff)
281
+ say(` ${line}`);
282
+ steps.push({ name, status: 'skip', detail: `would write ${what} to ${path} (dry run)`, change: 'would-update', path });
283
+ }
284
+ else {
285
+ let backup = '';
286
+ if (existing !== undefined) {
287
+ // Never overwrite an earlier backup made in the same instant, even by
288
+ // another setup running at the same time: the copy refuses an existing name.
289
+ const base = `${path}.bak-${timestamp(deps.now())}`;
290
+ for (let n = 0;; n++) {
291
+ backup = n === 0 ? base : `${base}-${n}`;
292
+ if ((await deps.readFileIfExists(backup)) !== undefined)
293
+ continue;
294
+ try {
295
+ await deps.backupFile(path, backup);
296
+ break;
297
+ }
298
+ catch (error) {
299
+ if (error.code !== 'EEXIST')
300
+ throw error;
301
+ }
302
+ }
303
+ }
304
+ await deps.writeFileEnsuringDir(path, merged);
305
+ steps.push({ name, status: 'ok', detail: `wrote ${what} to ${path}${backup ? ` (backup: ${backup})` : ''}`, change: 'updated', path });
306
+ }
307
+ };
308
+ // 3. MCP: one writer per agent. Claude Code owns ~/.claude.json, so it is
309
+ // changed through `claude mcp` and read only to skip an identical entry. When
310
+ // `claude` is not on PATH (an IDE extension bundles its own), the same entry is
311
+ // written into the same file directly, as `claude mcp add-json` would.
312
+ if (!skipped('mcp') && wantClaude) {
313
+ const claudeScope = options.project ? 'project' : 'user';
314
+ const configPath = options.project ? join(deps.cwd, '.mcp.json') : join(deps.home, '.claude.json');
315
+ const desired = claudeServerConfig(baseUrl || '<base-url>', workspaceForConfig);
316
+ const configText = await deps.readFileIfExists(configPath);
317
+ const current = readJsonServer(configText);
221
318
  const claude = await deps.findBinary('claude');
222
- const args = claudeAddArgs(baseUrl || '<base-url>', workspaceForConfig);
319
+ const args = claudeAddArgs(baseUrl || '<base-url>', workspaceForConfig, claudeScope);
223
320
  const shown = `claude ${args.slice(0, 3).join(' ')} ${shellQuoteSingle(args[3])} ${args.slice(4).join(' ')}`;
224
- if (!claude) {
225
- say(`claude not found on PATH. Run this later:\n ${shown}`);
226
- // Claude was the only client asked for, so nothing got configured: say so
227
- // in the exit code rather than reporting success.
228
- steps.push({
229
- name: 'claude-mcp',
230
- status: wantCodex ? 'skip' : 'fail',
231
- detail: 'claude CLI not found; run the printed command later',
232
- });
321
+ if (JSON.stringify(current) === JSON.stringify(desired)) {
322
+ steps.push({ name: 'claude-mcp', status: 'ok', detail: `assethub is already registered in Claude Code (${claudeScope} scope)`, change: 'unchanged', path: configPath });
323
+ }
324
+ else if (!claude && !baseUrl) {
325
+ // Nothing real to write yet (a dry run before login): show the command instead.
326
+ say(`claude not found on PATH. Setup will write ${configPath} directly, or run:\n ${shown}`);
327
+ steps.push({ name: 'claude-mcp', status: 'skip', detail: `would write mcpServers.assethub to ${configPath} (dry run)`, change: 'would-update', path: configPath });
328
+ }
329
+ else if (!claude) {
330
+ let merged;
331
+ try {
332
+ merged = mergeJsonServer(configPath, configText, desired);
333
+ }
334
+ catch (error) {
335
+ say(`Run this once ${configPath} is fixed:\n ${shown}`);
336
+ steps.push({ name: 'claude-mcp', status: 'fail', detail: `${configPath} left unchanged: ${error instanceof Error ? error.message : String(error)}`, path: configPath });
337
+ }
338
+ if (merged !== undefined)
339
+ await writeConfig('claude-mcp', configPath, configText, merged, 'mcpServers.assethub directly (claude CLI not on PATH)', sectionDiff(current === undefined ? '' : JSON.stringify(current, null, 2), JSON.stringify(desired, null, 2)));
233
340
  }
234
341
  else if (options.dryRun) {
235
342
  say(`[dry-run] ${shown}`);
236
- say('[dry-run] if an assethub entry already exists: claude mcp remove assethub --scope user, then add again');
237
- steps.push({ name: 'claude-mcp', status: 'skip', detail: 'would register the assethub MCP server (dry run)' });
343
+ if (current !== undefined)
344
+ say(`[dry-run] the existing assethub entry would be replaced: claude mcp remove assethub --scope ${claudeScope}, then add again`);
345
+ steps.push({ name: 'claude-mcp', status: 'skip', detail: 'would register the assethub MCP server (dry run)', change: 'would-update', path: configPath });
238
346
  }
239
347
  else {
240
348
  // Add first, and replace only an entry that already exists, so a failing
241
349
  // add never costs the user a working one.
242
- const addArgs = claudeAddArgs(baseUrl, workspaceForConfig);
243
- let added = await deps.run(claude, addArgs);
350
+ let added = await deps.run(claude, args);
244
351
  let replaced = false;
352
+ let restored;
353
+ let removeFailure;
245
354
  if (added.code !== 0 && /already exists/i.test(added.output)) {
246
- await deps.run(claude, ['mcp', 'remove', 'assethub', '--scope', 'user']);
247
- replaced = true;
248
- added = await deps.run(claude, addArgs);
355
+ const removed = await deps.run(claude, ['mcp', 'remove', 'assethub', '--scope', claudeScope]);
356
+ if (removed.code !== 0) {
357
+ removeFailure = scrub(removed.output, key).trim().slice(0, 300);
358
+ }
359
+ else {
360
+ replaced = true;
361
+ added = await deps.run(claude, args);
362
+ // Put the previous entry back rather than leave Claude Code without one.
363
+ if (added.code !== 0 && current !== undefined)
364
+ restored =
365
+ (await deps.run(claude, ['mcp', 'add-json', 'assethub', JSON.stringify(current), '--scope', claudeScope]))
366
+ .code === 0;
367
+ }
249
368
  }
250
369
  const failure = scrub(added.output, key).trim().slice(0, 300);
251
- steps.push(added.code === 0
370
+ steps.push(removeFailure !== undefined
252
371
  ? {
253
- name: 'claude-mcp',
254
- status: 'ok',
255
- detail: `registered assethub in Claude Code (user scope; it reads the key from \$${API_KEY_ENV}, the key is not stored in Claude Code)`,
256
- }
257
- : {
258
372
  name: 'claude-mcp',
259
373
  status: 'fail',
260
- detail: replaced
261
- ? `claude mcp add failed after the old assethub entry was removed; run \`${shown}\` to restore it: ${failure}`
262
- : `claude mcp add failed: ${failure}`,
263
- });
374
+ detail: `the existing assethub entry was left as it is: claude mcp remove failed: ${removeFailure}`,
375
+ path: configPath,
376
+ }
377
+ : added.code === 0
378
+ ? {
379
+ name: 'claude-mcp',
380
+ status: 'ok',
381
+ detail: `registered assethub in Claude Code (${claudeScope} scope; it reads the key from \$${API_KEY_ENV}, the key is not stored in Claude Code)`,
382
+ change: 'updated',
383
+ path: configPath,
384
+ }
385
+ : {
386
+ name: 'claude-mcp',
387
+ status: 'fail',
388
+ detail: !replaced
389
+ ? `claude mcp add failed: ${failure}`
390
+ : restored
391
+ ? `claude mcp add failed, so the previous assethub entry was put back: ${failure}`
392
+ : `claude mcp add failed after the old assethub entry was removed; run \`${shown}\` to add it again: ${failure}`,
393
+ path: configPath,
394
+ });
264
395
  }
265
396
  }
266
- // 4. Codex
267
- let exportLine;
268
- if (wantCodex) {
269
- const path = join(deps.codexHome, 'config.toml');
397
+ if (!skipped('mcp') && wantCodex) {
398
+ const path = options.project ? join(deps.cwd, '.codex', 'config.toml') : join(deps.codexHome, 'config.toml');
270
399
  const section = mcpConfig('codex', baseUrl || 'https://app.assethub.io', false, workspaceId);
271
400
  const existing = await deps.readFileIfExists(path);
272
401
  let merged;
273
- let mergeError;
274
402
  try {
275
403
  merged = mergeCodexSection(existing ?? '', section);
276
404
  }
277
405
  catch (error) {
278
- mergeError = error instanceof Error ? error.message : String(error);
406
+ steps.push({ name: 'codex-mcp', status: 'fail', detail: `${path} left unchanged: ${error instanceof Error ? error.message : String(error)}`, path });
279
407
  }
280
- if (merged === undefined) {
281
- steps.push({ name: 'codex-mcp', status: 'fail', detail: `${path} left unchanged: ${mergeError}` });
408
+ if (merged !== undefined)
409
+ await writeConfig('codex-mcp', path, existing, merged, '[mcp_servers.assethub]', sectionDiff(extractCodexSection(existing ?? ''), extractCodexSection(merged)));
410
+ }
411
+ if (!skipped('mcp') && agents.includes('cursor')) {
412
+ const path = join(root, '.cursor', 'mcp.json');
413
+ const existing = await deps.readFileIfExists(path);
414
+ const entry = mcpServerEntry('cursor', baseUrl || 'https://app.assethub.io', workspaceId);
415
+ let merged;
416
+ try {
417
+ merged = mergeJsonServer(path, existing, entry);
282
418
  }
283
- else if (options.dryRun) {
284
- say(`[dry-run] would ${existing === undefined ? 'create' : 'update'} ${path}`);
285
- if (existing !== undefined)
286
- say(`[dry-run] would back up to ${path}.bak-<timestamp>`);
287
- for (const line of sectionDiff(extractCodexSection(existing ?? ''), extractCodexSection(merged)))
288
- say(` ${line}`);
289
- steps.push({ name: 'codex-mcp', status: 'skip', detail: `would write [mcp_servers.assethub] to ${path} (dry run)` });
419
+ catch (error) {
420
+ steps.push({ name: 'cursor-mcp', status: 'fail', detail: `${path} left unchanged: ${error instanceof Error ? error.message : String(error)}`, path });
290
421
  }
291
- else if (merged === existing) {
292
- steps.push({ name: 'codex-mcp', status: 'ok', detail: `${path} already up to date` });
293
- }
294
- else {
295
- let backup = '';
296
- if (existing !== undefined) {
297
- // Never overwrite an earlier backup made in the same instant.
298
- const base = `${path}.bak-${timestamp(deps.now())}`;
299
- backup = base;
300
- for (let n = 1; (await deps.readFileIfExists(backup)) !== undefined; n++)
301
- backup = `${base}-${n}`;
302
- await deps.backupFile(path, backup);
303
- }
304
- await deps.writeFileEnsuringDir(path, merged);
305
- steps.push({ name: 'codex-mcp', status: 'ok', detail: `wrote [mcp_servers.assethub] to ${path}${backup ? ` (backup: ${backup})` : ''}` });
422
+ if (merged !== undefined) {
423
+ const before = readJsonServer(existing);
424
+ await writeConfig('cursor-mcp', path, existing, merged, 'mcpServers.assethub', sectionDiff(before === undefined ? '' : JSON.stringify(before, null, 2), JSON.stringify(entry, null, 2)));
306
425
  }
307
426
  }
308
- // Both clients read the key from the environment; the MCP server needs nothing else.
309
- say(`${wantClaude && wantCodex ? 'Claude Code and Codex read' : wantClaude ? 'Claude Code reads' : 'Codex reads'} the key from the ${API_KEY_ENV} environment variable: export it in your shell (run \`assethub setup --print-env\` to print the line). Setup never writes it to a shell profile.`);
427
+ // Every agent reads the key from the environment; the MCP server needs nothing else.
428
+ if (!skipped('mcp'))
429
+ say(`${listJoin(agentNames)} ${agents.length > 1 ? 'read' : 'reads'} the key from the ${API_KEY_ENV} environment variable: export it in your shell (run \`assethub setup --print-env\` to print the line). Setup never writes it to a shell profile.`);
430
+ let exportLine;
310
431
  if (options.printEnv)
311
432
  exportLine = `export ${API_KEY_ENV}=${options.dryRun || !key ? shownKey : key}`;
433
+ // 4. Skill: one copy under <root>/.agents/skills, linked into each agent.
434
+ let skill;
435
+ if (!skipped('skills')) {
436
+ try {
437
+ skill = await deps.installSkills({ root, agents, dryRun: options.dryRun });
438
+ steps.push(skillsStep(skill, options.dryRun));
439
+ }
440
+ catch (error) {
441
+ steps.push({ name: 'skills', status: 'fail', detail: error instanceof Error ? error.message : String(error) });
442
+ }
443
+ }
444
+ // 4b. Auto-update: on by default; shown so nobody is surprised by it.
445
+ if (!skipped('auto-update')) {
446
+ try {
447
+ steps.push(await autoUpdateStep(options, deps));
448
+ }
449
+ catch (error) {
450
+ steps.push({ name: 'auto-update', status: 'fail', detail: error instanceof Error ? error.message : String(error) });
451
+ }
452
+ }
312
453
  // 5. Make the key visible to apps started outside a shell (macOS).
313
- const appEnv = await ensureAppEnv(options, deps, key);
314
- steps.push(appEnv);
454
+ const appEnv = skipped('app-env') ? undefined : await ensureAppEnv(options, deps, key);
455
+ if (appEnv)
456
+ steps.push(appEnv);
315
457
  // 6. Session saving: opt-in, and offered only to accounts that may upload
316
458
  // sessions (internal for now). Anyone else gets no question and no step.
317
- const sessionsAvailable = auth != null && (await deps.canSaveSessions(auth));
459
+ const sessionsAvailable = !skipped('hook') && hookClients.length > 0 && auth != null && (await deps.canSaveSessions(auth));
318
460
  if (!sessionsAvailable) {
319
- if (options.saveSessions)
461
+ if (options.saveSessions && !skipped('hook'))
320
462
  steps.push({ name: 'hook', status: 'skip', detail: 'session saving is not available for this account' });
321
463
  }
322
464
  else if (options.noHook) {
@@ -383,7 +525,10 @@ export const runSetup = async (options, deps) => {
383
525
  }
384
526
  }
385
527
  // 7. Doctor
386
- if (options.dryRun) {
528
+ if (skipped('doctor')) {
529
+ // left out on request
530
+ }
531
+ else if (options.dryRun) {
387
532
  steps.push({ name: 'doctor', status: 'skip', detail: 'skipped (dry run)' });
388
533
  }
389
534
  else {
@@ -403,24 +548,78 @@ export const runSetup = async (options, deps) => {
403
548
  }
404
549
  }
405
550
  const ok = steps.every(step => step.status !== 'fail');
551
+ const apps = listJoin(agents.map(agent => APP_NAMES[agent]));
406
552
  const nextStep = options.dryRun
407
553
  ? 'Re-run without --dry-run to apply these changes.'
408
554
  : !ok
409
555
  ? 'Fix the failed steps above, then re-run `assethub setup` (it is safe to repeat).'
410
- : appEnv.status === 'ok'
411
- ? `Quit and reopen ${wantClaude && wantCodex ? 'Claude (Cmd+Q) and Codex' : wantClaude ? 'Claude (Cmd+Q)' : 'Codex'} so it reads ${API_KEY_ENV}, then ask it to use the AssetHub tools. Terminal tabs opened before setup still need \`export ${API_KEY_ENV}=…\`.`
412
- : `Export ${API_KEY_ENV} in the shell that starts ${wantClaude && wantCodex ? 'Claude Code / Codex' : wantClaude ? 'Claude Code' : 'Codex'}, restart it, then ask it to use the AssetHub tools.`;
556
+ : skipped('mcp')
557
+ ? agents.length > 0
558
+ ? `Restart ${listJoin(agentNames)} so ${agents.length > 1 ? 'they reload their' : 'it reloads its'} skills.`
559
+ : 'Done.'
560
+ : appEnv?.status === 'ok'
561
+ ? `Quit and reopen ${apps} so it reads ${API_KEY_ENV}, then ask it to use the AssetHub tools. Terminal tabs opened before setup still need \`export ${API_KEY_ENV}=…\`.`
562
+ : `Export ${API_KEY_ENV} in the shell that starts ${agentNames.join(' / ')}, restart it, then ask it to use the AssetHub tools.`;
413
563
  return {
414
564
  ok,
415
565
  dryRun: options.dryRun,
416
566
  profile: auth?.profile ?? '',
417
567
  baseUrl,
418
568
  workspaceId,
569
+ version,
570
+ scope,
571
+ agents,
572
+ detected,
573
+ ...(skill ? { skill } : {}),
419
574
  steps,
420
575
  nextStep,
421
576
  ...(exportLine ? { exportLine } : {}),
422
577
  };
423
578
  };
579
+ const autoUpdateStep = async (options, deps) => {
580
+ const name = 'auto-update';
581
+ const saved = await deps.readAutoUpdate();
582
+ const wanted = options.autoUpdate ?? saved ?? true;
583
+ const changes = options.autoUpdate !== undefined && options.autoUpdate !== (saved ?? true);
584
+ const onText = 'on: once a day a newer CLI installs itself in the background, keeping your login (turn off: `assethub setup --no-auto-update`)';
585
+ const offText = 'off: run `assethub update` yourself (turn on: `assethub setup --auto-update`)';
586
+ if (changes && options.dryRun)
587
+ return { name, status: 'skip', detail: `would turn auto-update ${wanted ? 'on' : 'off'} (dry run)`, change: 'would-update' };
588
+ if (options.autoUpdate !== undefined && options.autoUpdate !== saved)
589
+ await deps.writeAutoUpdate(options.autoUpdate);
590
+ const change = changes ? 'updated' : 'unchanged';
591
+ if (!wanted)
592
+ return { name, status: 'ok', detail: offText, change };
593
+ if (!(await deps.canSelfUpdate()))
594
+ return { name, status: 'skip', detail: 'this CLI is not a global install (npx or a source checkout), so it cannot update itself', change };
595
+ if (deps.autoUpdateBlockedByEnv)
596
+ return { name, status: 'skip', detail: 'on, but ASSETHUB_NO_AUTO_UPDATE, ASSETHUB_NO_UPDATE_CHECK or CI turns it off in this environment', change };
597
+ return { name, status: 'ok', detail: onText, change };
598
+ };
599
+ const skillsStep = (report, dryRun) => {
600
+ const changedLinks = report.links.filter(link => link.status === 'linked' || link.status === 'relinked');
601
+ const keptLinks = report.links.filter(link => link.status === 'kept-existing');
602
+ const copyChanged = report.status === 'installed' || report.status === 'updated';
603
+ const changed = copyChanged || changedLinks.length > 0;
604
+ const parts = [
605
+ report.status === 'kept-existing'
606
+ ? `kept ${report.path} (not created by AssetHub)`
607
+ : report.status === 'kept-newer'
608
+ ? `kept ${report.path} (${report.version}, newer than this CLI)`
609
+ : `${dryRun && copyChanged ? `would ${report.status === 'installed' ? 'install' : 'update'}` : report.status} ${report.path}${report.version ? ` (${report.version})` : ''}`,
610
+ ...(changedLinks.length > 0
611
+ ? [`${dryRun ? 'would link' : 'linked'} into ${listJoin(changedLinks.map(link => agentSpec(link.agent).name))}`]
612
+ : []),
613
+ ...keptLinks.map(link => `kept ${link.path} (not created by AssetHub)`),
614
+ ];
615
+ return {
616
+ name: 'skills',
617
+ path: report.path,
618
+ status: dryRun && changed ? 'skip' : 'ok',
619
+ detail: changed || report.links.length === 0 ? parts.join('; ') : `${parts.join('; ')}; links already in place`,
620
+ change: !changed ? 'unchanged' : dryRun ? 'would-update' : 'updated',
621
+ };
622
+ };
424
623
  const APP_ENV_MISSING = `Claude and Codex apps cannot see ${API_KEY_ENV} yet, so their MCP calls get HTTP 401`;
425
624
  const ensureAppEnv = async (options, deps, key) => {
426
625
  const name = 'app-env';
@@ -492,11 +691,14 @@ export const runProcess = (file, args) => new Promise(done => {
492
691
  });
493
692
  export const defaultFileDeps = () => ({
494
693
  platform: process.platform,
495
- home: homedir(),
694
+ home: cliHome(),
496
695
  cwd: process.cwd(),
497
- codexHome: process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'),
696
+ codexHome: process.env.CODEX_HOME?.trim() || join(cliHome(), '.codex'),
498
697
  now: () => new Date(),
499
698
  findBinary: (name) => findOnPath(name),
699
+ detectAgents: () => detectAgents(cliHome(), name => findOnPath(name), process.env.CODEX_HOME?.trim() || undefined),
700
+ version: cliVersion,
701
+ installSkills: installAgentSkills,
500
702
  run: runProcess,
501
703
  readFileIfExists: async (path) => {
502
704
  try {
@@ -508,11 +710,22 @@ export const defaultFileDeps = () => ({
508
710
  throw error;
509
711
  }
510
712
  },
713
+ // Written beside the target and renamed over it, so an interrupted setup never
714
+ // leaves a truncated config. A symlinked config (dotfiles) is replaced at its target.
511
715
  writeFileEnsuringDir: async (path, content) => {
512
- await mkdir(dirname(path), { recursive: true });
513
- await writeFile(path, content);
716
+ const target = await realpath(path).catch(() => path);
717
+ await mkdir(dirname(target), { recursive: true });
718
+ const temporary = `${target}.assethub-${process.pid}-${Date.now()}.tmp`;
719
+ try {
720
+ await writeFile(temporary, content, { mode: await stat(target).then(info => info.mode & 0o777, () => 0o644) });
721
+ await rename(temporary, target);
722
+ }
723
+ catch (error) {
724
+ await rm(temporary, { force: true });
725
+ throw error;
726
+ }
514
727
  },
515
- backupFile: (from, to) => copyFile(from, to),
728
+ backupFile: (from, to) => copyFile(from, to, constants.COPYFILE_EXCL),
516
729
  });
517
730
  // ---- interactive prompts (TTY only; prompts go to stderr so stdout stays clean) ----
518
731
  export const promptLine = async (question) => {