@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.
- package/README.md +67 -24
- package/dist/agentSetup.d.ts +35 -40
- package/dist/agentSetup.d.ts.map +1 -1
- package/dist/agentSetup.js +72 -113
- package/dist/hooks/command.d.ts +1 -0
- package/dist/hooks/command.d.ts.map +1 -1
- package/dist/hooks/command.js +2 -1
- package/dist/hooks/save.d.ts +14 -0
- package/dist/hooks/save.d.ts.map +1 -1
- package/dist/hooks/save.js +20 -0
- package/dist/hooks/types.d.ts +2 -0
- package/dist/hooks/types.d.ts.map +1 -1
- package/dist/hooks/upload.d.ts +6 -0
- package/dist/hooks/upload.d.ts.map +1 -1
- package/dist/hooks/upload.js +16 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +156 -30
- package/dist/setupCommand.d.ts +53 -2
- package/dist/setupCommand.d.ts.map +1 -1
- package/dist/setupCommand.js +344 -131
- package/dist/updateNotice.d.ts +71 -0
- package/dist/updateNotice.d.ts.map +1 -0
- package/dist/updateNotice.js +189 -0
- package/package.json +1 -1
- package/skills/assethub/SKILL.md +20 -3
package/dist/setupCommand.js
CHANGED
|
@@ -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 {
|
|
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',
|
|
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
|
|
118
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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 = (
|
|
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 =
|
|
181
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
218
|
-
throw new Error('Cancelled before changing any
|
|
219
|
-
//
|
|
220
|
-
|
|
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 (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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',
|
|
247
|
-
|
|
248
|
-
|
|
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(
|
|
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:
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
//
|
|
309
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
:
|
|
411
|
-
?
|
|
412
|
-
|
|
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:
|
|
694
|
+
home: cliHome(),
|
|
496
695
|
cwd: process.cwd(),
|
|
497
|
-
codexHome: process.env.CODEX_HOME?.trim() || join(
|
|
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
|
|
513
|
-
await
|
|
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) => {
|