@assethub/cli 0.1.33 → 0.1.35

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', '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,198 @@ 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);
279
- }
280
- if (merged === undefined) {
281
- steps.push({ name: 'codex-mcp', status: 'fail', detail: `${path} left unchanged: ${mergeError}` });
406
+ steps.push({ name: 'codex-mcp', status: 'fail', detail: `${path} left unchanged: ${error instanceof Error ? error.message : String(error)}`, path });
282
407
  }
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)` });
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);
290
418
  }
291
- else if (merged === existing) {
292
- steps.push({ name: 'codex-mcp', status: 'ok', detail: `${path} already up to date` });
419
+ catch (error) {
420
+ steps.push({ name: 'cursor-mcp', status: 'fail', detail: `${path} left unchanged: ${error instanceof Error ? error.message : String(error)}`, path });
293
421
  }
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
+ }
312
444
  // 5. Make the key visible to apps started outside a shell (macOS).
313
- const appEnv = await ensureAppEnv(options, deps, key);
314
- steps.push(appEnv);
445
+ const appEnv = skipped('app-env') ? undefined : await ensureAppEnv(options, deps, key);
446
+ if (appEnv)
447
+ steps.push(appEnv);
315
448
  // 6. Session saving: opt-in, and offered only to accounts that may upload
316
449
  // sessions (internal for now). Anyone else gets no question and no step.
317
- const sessionsAvailable = auth != null && (await deps.canSaveSessions(auth));
450
+ const sessionsAvailable = !skipped('hook') && hookClients.length > 0 && auth != null && (await deps.canSaveSessions(auth));
318
451
  if (!sessionsAvailable) {
319
- if (options.saveSessions)
452
+ if (options.saveSessions && !skipped('hook'))
320
453
  steps.push({ name: 'hook', status: 'skip', detail: 'session saving is not available for this account' });
321
454
  }
322
455
  else if (options.noHook) {
@@ -383,7 +516,10 @@ export const runSetup = async (options, deps) => {
383
516
  }
384
517
  }
385
518
  // 7. Doctor
386
- if (options.dryRun) {
519
+ if (skipped('doctor')) {
520
+ // left out on request
521
+ }
522
+ else if (options.dryRun) {
387
523
  steps.push({ name: 'doctor', status: 'skip', detail: 'skipped (dry run)' });
388
524
  }
389
525
  else {
@@ -403,24 +539,56 @@ export const runSetup = async (options, deps) => {
403
539
  }
404
540
  }
405
541
  const ok = steps.every(step => step.status !== 'fail');
542
+ const apps = listJoin(agents.map(agent => APP_NAMES[agent]));
406
543
  const nextStep = options.dryRun
407
544
  ? 'Re-run without --dry-run to apply these changes.'
408
545
  : !ok
409
546
  ? '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.`;
547
+ : skipped('mcp')
548
+ ? agents.length > 0
549
+ ? `Restart ${listJoin(agentNames)} so ${agents.length > 1 ? 'they reload their' : 'it reloads its'} skills.`
550
+ : 'Done.'
551
+ : appEnv?.status === 'ok'
552
+ ? `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}=…\`.`
553
+ : `Export ${API_KEY_ENV} in the shell that starts ${agentNames.join(' / ')}, restart it, then ask it to use the AssetHub tools.`;
413
554
  return {
414
555
  ok,
415
556
  dryRun: options.dryRun,
416
557
  profile: auth?.profile ?? '',
417
558
  baseUrl,
418
559
  workspaceId,
560
+ version,
561
+ scope,
562
+ agents,
563
+ detected,
564
+ ...(skill ? { skill } : {}),
419
565
  steps,
420
566
  nextStep,
421
567
  ...(exportLine ? { exportLine } : {}),
422
568
  };
423
569
  };
570
+ const skillsStep = (report, dryRun) => {
571
+ const changedLinks = report.links.filter(link => link.status === 'linked' || link.status === 'relinked');
572
+ const keptLinks = report.links.filter(link => link.status === 'kept-existing');
573
+ const copyChanged = report.status === 'installed' || report.status === 'updated';
574
+ const changed = copyChanged || changedLinks.length > 0;
575
+ const parts = [
576
+ report.status === 'kept-existing'
577
+ ? `kept ${report.path} (not created by AssetHub)`
578
+ : `${dryRun && copyChanged ? `would ${report.status === 'installed' ? 'install' : 'update'}` : report.status} ${report.path}${report.version ? ` (${report.version})` : ''}`,
579
+ ...(changedLinks.length > 0
580
+ ? [`${dryRun ? 'would link' : 'linked'} into ${listJoin(changedLinks.map(link => agentSpec(link.agent).name))}`]
581
+ : []),
582
+ ...keptLinks.map(link => `kept ${link.path} (not created by AssetHub)`),
583
+ ];
584
+ return {
585
+ name: 'skills',
586
+ path: report.path,
587
+ status: dryRun && changed ? 'skip' : 'ok',
588
+ detail: changed || report.links.length === 0 ? parts.join('; ') : `${parts.join('; ')}; links already in place`,
589
+ change: !changed ? 'unchanged' : dryRun ? 'would-update' : 'updated',
590
+ };
591
+ };
424
592
  const APP_ENV_MISSING = `Claude and Codex apps cannot see ${API_KEY_ENV} yet, so their MCP calls get HTTP 401`;
425
593
  const ensureAppEnv = async (options, deps, key) => {
426
594
  const name = 'app-env';
@@ -492,11 +660,14 @@ export const runProcess = (file, args) => new Promise(done => {
492
660
  });
493
661
  export const defaultFileDeps = () => ({
494
662
  platform: process.platform,
495
- home: homedir(),
663
+ home: cliHome(),
496
664
  cwd: process.cwd(),
497
- codexHome: process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'),
665
+ codexHome: process.env.CODEX_HOME?.trim() || join(cliHome(), '.codex'),
498
666
  now: () => new Date(),
499
667
  findBinary: (name) => findOnPath(name),
668
+ detectAgents: () => detectAgents(cliHome(), name => findOnPath(name), process.env.CODEX_HOME?.trim() || undefined),
669
+ version: cliVersion,
670
+ installSkills: installAgentSkills,
500
671
  run: runProcess,
501
672
  readFileIfExists: async (path) => {
502
673
  try {
@@ -508,11 +679,22 @@ export const defaultFileDeps = () => ({
508
679
  throw error;
509
680
  }
510
681
  },
682
+ // Written beside the target and renamed over it, so an interrupted setup never
683
+ // leaves a truncated config. A symlinked config (dotfiles) is replaced at its target.
511
684
  writeFileEnsuringDir: async (path, content) => {
512
- await mkdir(dirname(path), { recursive: true });
513
- await writeFile(path, content);
685
+ const target = await realpath(path).catch(() => path);
686
+ await mkdir(dirname(target), { recursive: true });
687
+ const temporary = `${target}.assethub-${process.pid}-${Date.now()}.tmp`;
688
+ try {
689
+ await writeFile(temporary, content, { mode: await stat(target).then(info => info.mode & 0o777, () => 0o644) });
690
+ await rename(temporary, target);
691
+ }
692
+ catch (error) {
693
+ await rm(temporary, { force: true });
694
+ throw error;
695
+ }
514
696
  },
515
- backupFile: (from, to) => copyFile(from, to),
697
+ backupFile: (from, to) => copyFile(from, to, constants.COPYFILE_EXCL),
516
698
  });
517
699
  // ---- interactive prompts (TTY only; prompts go to stderr so stdout stays clean) ----
518
700
  export const promptLine = async (question) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assethub/cli",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Command line interface for AssetHub.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@ description: |
12
12
  - Recording whether a generated asset is acceptable
13
13
 
14
14
  DO NOT TRIGGER for:
15
- - Installing or configuring the CLI (use `assethub init`, `assethub auth login`)
15
+ - Installing or configuring the CLI (use `assethub setup`, `assethub doctor --setup`)
16
16
  - Managing workspace members (use `assethub workspace ...`)
17
17
  ---
18
18
 
@@ -115,7 +115,7 @@ assethub evaluations submit --canvas <canvas-id> --artifact <asset-id> \
115
115
 
116
116
  - `production run` defaults to `full_auto`, which never pauses for a human. **Always pass `--max-cost-credits <n>`.** The server caps `--max-iterations` at 5.
117
117
  - Over MCP the same rule applies: always set `maxCostCredits` on `production_run`.
118
- - `assethub account get` shows the balance. Check it before a batch.
118
+ - Before a batch, run `production run --image <file> --estimate` to see the cost. The CLI has no balance command; find the credit routes with `assethub api search credit`.
119
119
  - Prefer a dedicated, scoped API key for agent work rather than a full-access one.
120
120
 
121
121
  ## Rules learned the hard way
@@ -124,7 +124,7 @@ assethub evaluations submit --canvas <canvas-id> --artifact <asset-id> \
124
124
  - Part extractor names are the public product names: `V1.5`, `V2.0 alpha`, `V2.1 alpha`. Internal IDs are not accepted.
125
125
  - `parts split` and `parts compare` with `--preprocess-prompt` create a second production order; use `--all-ready`, not `--task-id`.
126
126
  - Every generation should land on a canvas. Pass `--canvas <id>` to keep a job's steps together; without it the CLI makes one canvas per working directory. `assethub canvas open <id>` shows the user what happened.
127
- - A complaint like "the mesh has extra limbs" is usually an input problem: stray lines, shadows, or inconsistent views. Clean the image (`image edit`) before switching models.
127
+ - A complaint like "the mesh has extra limbs" is usually an input problem: stray lines, shadows, or inconsistent views. Clean the image before switching models: `image generate --file <image> --prompt "remove stray lines and shadows, plain background"`, or `parts compare --preprocess-prompt <text>`.
128
128
  - Input can come from a file, a URL, stdin bytes, base64, a data URI, an OpenAI- or Anthropic-style JSON attachment (`--stdin-json`), or the clipboard. You never need to write a temporary file first.
129
129
  - Large or complex requests: `--input-json @request.json` with the schema from `api describe`.
130
130