@tiny-fish/cli 0.30.2-next.259 → 0.30.2-next.265

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.
@@ -78,227 +78,229 @@ function sleep(ms, signal) {
78
78
  signal.addEventListener('abort', onAbort, { once: true });
79
79
  });
80
80
  }
81
- export function registerRuns(runCmd) {
82
- // ── run list ───────────────────────────────────────────────────────────────
83
- runCmd
84
- .command('list')
85
- .description('List your automation runs')
86
- .option('--status <status>', 'Filter by status (PENDING|RUNNING|COMPLETED|FAILED|CANCELLED)')
87
- .option('--limit <n>', 'Max number of runs to return (default: 20)')
88
- .option('--cursor <cursor>', 'Pagination cursor from a previous response')
89
- .option('--pretty', 'Human-readable output')
90
- .action(async (opts) => {
91
- // Validate --status
92
- if (opts.status != null &&
93
- !VALID_STATUSES.includes(opts.status)) {
81
+ async function runsListAction(opts) {
82
+ // Validate --status
83
+ if (opts.status != null &&
84
+ !VALID_STATUSES.includes(opts.status)) {
85
+ err({
86
+ error: `Invalid --status "${opts.status}". Must be one of: ${VALID_STATUSES.join(', ')}`,
87
+ });
88
+ process.exit(1);
89
+ }
90
+ // Validate --limit
91
+ const MAX_LIMIT = 100;
92
+ let limit;
93
+ if (opts.limit != null) {
94
+ limit = parseInt(opts.limit, 10);
95
+ if (!isFinite(limit) || limit <= 0) {
96
+ err({ error: `Invalid --limit "${opts.limit}". Must be a positive integer.` });
97
+ process.exit(1);
98
+ }
99
+ if (limit > MAX_LIMIT) {
100
+ err({ error: `--limit cannot exceed ${MAX_LIMIT}.` });
101
+ process.exit(1);
102
+ }
103
+ }
104
+ const apiKey = getApiKey();
105
+ const filter = {
106
+ status: opts.status,
107
+ limit,
108
+ cursor: opts.cursor,
109
+ };
110
+ try {
111
+ const body = await listRuns(filter, apiKey);
112
+ if (opts.pretty) {
113
+ if (body.data.length === 0) {
114
+ outLine('No runs found.');
115
+ return;
116
+ }
117
+ outLine(`${'RUN ID'.padEnd(36)} ${'STATUS'.padEnd(10)} ${'CREATED AT'.padEnd(24)} GOAL`);
118
+ outLine(`${'-'.repeat(36)} ${'-'.repeat(10)} ${'-'.repeat(24)} ${'-'.repeat(30)}`);
119
+ for (const run of body.data) {
120
+ outLine(`${run.run_id.padEnd(36)} ${run.status.padEnd(10)} ${run.created_at.padEnd(24)} ${run.goal}`);
121
+ }
122
+ if (body.pagination.has_more) {
123
+ outLine(`\nNext cursor: ${body.pagination.next_cursor}`);
124
+ }
125
+ }
126
+ else {
127
+ out(body);
128
+ }
129
+ }
130
+ catch (e) {
131
+ handleApiError(e);
132
+ }
133
+ }
134
+ async function runsGetAction(runId, opts) {
135
+ const apiKey = getApiKey();
136
+ try {
137
+ const run = await getRun(runId, apiKey);
138
+ if (opts.pretty) {
139
+ outLine(`Run ID: ${run.run_id}`);
140
+ outLine(`Status: ${run.status}`);
141
+ outLine(`Goal: ${run.goal}`);
142
+ outLine(`Created: ${run.created_at}`);
143
+ if (run.started_at)
144
+ outLine(`Started: ${run.started_at}`);
145
+ if (run.finished_at)
146
+ outLine(`Finished: ${run.finished_at}`);
147
+ outLine(`Steps: ${run.num_of_steps}`);
148
+ if (run.result !== null && run.result !== undefined) {
149
+ outLine(`\nResult:\n${JSON.stringify(run.result, null, 2)}`);
150
+ }
151
+ if (run.error)
152
+ outLine(`\nError: ${run.error.message}`);
153
+ }
154
+ else {
155
+ out(run);
156
+ }
157
+ }
158
+ catch (e) {
159
+ handleApiError(e);
160
+ }
161
+ }
162
+ async function runsStepsAction(runId, opts) {
163
+ const apiKey = getApiKey();
164
+ try {
165
+ const result = await getRunSteps(runId, apiKey);
166
+ if (opts.pretty) {
167
+ if (result.steps.length === 0) {
168
+ outLine('No steps found.');
169
+ return;
170
+ }
171
+ outLine(`Run ID: ${result.run_id}`);
172
+ for (const [index, step] of result.steps.entries()) {
173
+ outLine(formatStep(step, index));
174
+ outLine(` ${step.timestamp}`);
175
+ }
176
+ }
177
+ else {
178
+ out(result);
179
+ }
180
+ }
181
+ catch (e) {
182
+ handleApiError(e);
183
+ }
184
+ }
185
+ async function runsWatchAction(runId, opts) {
186
+ const apiKey = getApiKey();
187
+ const timeoutMs = parsePositiveInt(opts.timeout, '--timeout', DEFAULT_WATCH_TIMEOUT_MS);
188
+ const intervalMs = parsePositiveInt(opts.interval, '--interval', DEFAULT_WATCH_INTERVAL_MS, MIN_WATCH_INTERVAL_MS);
189
+ const controller = new AbortController();
190
+ const onSigint = () => {
191
+ controller.abort();
192
+ process.exit(130);
193
+ };
194
+ process.once('SIGINT', onSigint);
195
+ const deadline = Date.now() + timeoutMs;
196
+ const seen = new Set();
197
+ try {
198
+ const initial = await withDeadline(getRun(runId, apiKey), deadline, controller.signal);
199
+ if (TERMINAL_STATUSES.includes(initial.status)) {
94
200
  err({
95
- error: `Invalid --status "${opts.status}". Must be one of: ${VALID_STATUSES.join(', ')}`,
201
+ error: `Run ${runId} is already ${initial.status}; nothing to watch. Use 'tinyfish agent run steps ${runId}' for the trace.`,
96
202
  });
97
203
  process.exit(1);
98
204
  }
99
- // Validate --limit
100
- const MAX_LIMIT = 100;
101
- let limit;
102
- if (opts.limit != null) {
103
- limit = parseInt(opts.limit, 10);
104
- if (!isFinite(limit) || limit <= 0) {
105
- err({ error: `Invalid --limit "${opts.limit}". Must be a positive integer.` });
106
- process.exit(1);
205
+ while (!controller.signal.aborted) {
206
+ // One API call per tick — the steps endpoint also returns the run's current status.
207
+ const tick = await withDeadline(getRunSteps(runId, apiKey), deadline, controller.signal);
208
+ let nextIndex = seen.size;
209
+ for (const step of tick.steps) {
210
+ if (seen.has(step.id))
211
+ continue;
212
+ seen.add(step.id);
213
+ if (opts.pretty) {
214
+ outLine(formatStep(step, nextIndex));
215
+ outLine(` ${step.timestamp}`);
216
+ }
217
+ else {
218
+ out(step);
219
+ }
220
+ nextIndex += 1;
221
+ }
222
+ if (TERMINAL_STATUSES.includes(tick.status)) {
223
+ if (opts.pretty)
224
+ outLine(`Run ${tick.status.toLowerCase()}.`);
225
+ return;
107
226
  }
108
- if (limit > MAX_LIMIT) {
109
- err({ error: `--limit cannot exceed ${MAX_LIMIT}.` });
227
+ if (Date.now() + intervalMs > deadline) {
228
+ err({ error: `Watch timed out after ${timeoutMs}ms (run still ${tick.status}).` });
110
229
  process.exit(1);
111
230
  }
231
+ try {
232
+ await sleep(intervalMs, controller.signal);
233
+ }
234
+ catch {
235
+ return; // aborted
236
+ }
112
237
  }
113
- const apiKey = getApiKey();
114
- const filter = {
115
- status: opts.status,
116
- limit,
117
- cursor: opts.cursor,
118
- };
119
- try {
120
- const body = await listRuns(filter, apiKey);
121
- if (opts.pretty) {
122
- if (body.data.length === 0) {
123
- outLine('No runs found.');
124
- return;
125
- }
126
- outLine(`${'RUN ID'.padEnd(36)} ${'STATUS'.padEnd(10)} ${'CREATED AT'.padEnd(24)} GOAL`);
127
- outLine(`${'-'.repeat(36)} ${'-'.repeat(10)} ${'-'.repeat(24)} ${'-'.repeat(30)}`);
128
- for (const run of body.data) {
129
- outLine(`${run.run_id.padEnd(36)} ${run.status.padEnd(10)} ${run.created_at.padEnd(24)} ${run.goal}`);
130
- }
131
- if (body.pagination.has_more) {
132
- outLine(`\nNext cursor: ${body.pagination.next_cursor}`);
133
- }
238
+ }
239
+ catch (e) {
240
+ if (e instanceof Error && e.message === 'watch deadline exceeded') {
241
+ err({ error: `Watch timed out after ${timeoutMs}ms.` });
242
+ process.exit(1);
243
+ }
244
+ if (e instanceof Error && e.message === 'watch aborted') {
245
+ return; // SIGINT handler already triggered process.exit(130)
246
+ }
247
+ handleApiError(e);
248
+ }
249
+ finally {
250
+ process.off('SIGINT', onSigint);
251
+ }
252
+ }
253
+ async function runsCancelAction(runId, opts) {
254
+ const apiKey = getApiKey();
255
+ try {
256
+ const result = await cancelRun(runId, apiKey);
257
+ if (opts.pretty) {
258
+ if (result.status === RunStatus.CANCELLED) {
259
+ outLine(result.message
260
+ ? `Run ${result.run_id} cancelled (${result.message}).`
261
+ : `Run ${result.run_id} cancelled.`);
134
262
  }
135
263
  else {
136
- out(body);
264
+ outLine(`Run ${result.run_id} already finished (${result.status}).`);
137
265
  }
138
266
  }
139
- catch (e) {
140
- handleApiError(e);
267
+ else {
268
+ out(result);
141
269
  }
142
- });
143
- // ── run get ────────────────────────────────────────────────────────────────
270
+ }
271
+ catch (e) {
272
+ handleApiError(e);
273
+ }
274
+ }
275
+ export function registerRuns(runCmd) {
276
+ runCmd
277
+ .command('list')
278
+ .description('List your automation runs')
279
+ .option('--status <status>', 'Filter by status (PENDING|RUNNING|COMPLETED|FAILED|CANCELLED)')
280
+ .option('--limit <n>', 'Max number of runs to return (default: 20)')
281
+ .option('--cursor <cursor>', 'Pagination cursor from a previous response')
282
+ .option('--pretty', 'Human-readable output')
283
+ .action(runsListAction);
144
284
  runCmd
145
285
  .command('get <run_id>')
146
286
  .description('Get a run by ID')
147
287
  .option('--pretty', 'Human-readable output')
148
- .action(async (runId, opts) => {
149
- const apiKey = getApiKey();
150
- try {
151
- const run = await getRun(runId, apiKey);
152
- if (opts.pretty) {
153
- outLine(`Run ID: ${run.run_id}`);
154
- outLine(`Status: ${run.status}`);
155
- outLine(`Goal: ${run.goal}`);
156
- outLine(`Created: ${run.created_at}`);
157
- if (run.started_at)
158
- outLine(`Started: ${run.started_at}`);
159
- if (run.finished_at)
160
- outLine(`Finished: ${run.finished_at}`);
161
- outLine(`Steps: ${run.num_of_steps}`);
162
- if (run.result !== null && run.result !== undefined) {
163
- outLine(`\nResult:\n${JSON.stringify(run.result, null, 2)}`);
164
- }
165
- if (run.error)
166
- outLine(`\nError: ${run.error.message}`);
167
- }
168
- else {
169
- out(run);
170
- }
171
- }
172
- catch (e) {
173
- handleApiError(e);
174
- }
175
- });
288
+ .action(runsGetAction);
176
289
  runCmd
177
290
  .command('steps <run_id>')
178
291
  .description('Print the step-by-step trace for a run')
179
292
  .option('--pretty', 'Human-readable output')
180
- .action(async (runId, opts) => {
181
- const apiKey = getApiKey();
182
- try {
183
- const result = await getRunSteps(runId, apiKey);
184
- if (opts.pretty) {
185
- if (result.steps.length === 0) {
186
- outLine('No steps found.');
187
- return;
188
- }
189
- outLine(`Run ID: ${result.run_id}`);
190
- for (const [index, step] of result.steps.entries()) {
191
- outLine(formatStep(step, index));
192
- outLine(` ${step.timestamp}`);
193
- }
194
- }
195
- else {
196
- out(result);
197
- }
198
- }
199
- catch (e) {
200
- handleApiError(e);
201
- }
202
- });
293
+ .action(runsStepsAction);
203
294
  runCmd
204
295
  .command('watch <run_id>')
205
296
  .description('Poll a run and print new steps as they arrive (exits when run reaches a terminal status)')
206
297
  .option('--pretty', 'Human-readable output')
207
298
  .option('--timeout <ms>', `Stop polling after this many milliseconds (default: ${DEFAULT_WATCH_TIMEOUT_MS})`)
208
299
  .option('--interval <ms>', `Poll interval (default: ${DEFAULT_WATCH_INTERVAL_MS}, min: ${MIN_WATCH_INTERVAL_MS})`)
209
- .action(async (runId, opts) => {
210
- const apiKey = getApiKey();
211
- const timeoutMs = parsePositiveInt(opts.timeout, '--timeout', DEFAULT_WATCH_TIMEOUT_MS);
212
- const intervalMs = parsePositiveInt(opts.interval, '--interval', DEFAULT_WATCH_INTERVAL_MS, MIN_WATCH_INTERVAL_MS);
213
- const controller = new AbortController();
214
- const onSigint = () => {
215
- controller.abort();
216
- process.exit(130);
217
- };
218
- process.once('SIGINT', onSigint);
219
- const deadline = Date.now() + timeoutMs;
220
- const seen = new Set();
221
- try {
222
- const initial = await withDeadline(getRun(runId, apiKey), deadline, controller.signal);
223
- if (TERMINAL_STATUSES.includes(initial.status)) {
224
- err({
225
- error: `Run ${runId} is already ${initial.status}; nothing to watch. Use 'tinyfish agent run steps ${runId}' for the trace.`,
226
- });
227
- process.exit(1);
228
- }
229
- while (!controller.signal.aborted) {
230
- // One API call per tick — the steps endpoint also returns the run's current status.
231
- const tick = await withDeadline(getRunSteps(runId, apiKey), deadline, controller.signal);
232
- let nextIndex = seen.size;
233
- for (const step of tick.steps) {
234
- if (seen.has(step.id))
235
- continue;
236
- seen.add(step.id);
237
- if (opts.pretty) {
238
- outLine(formatStep(step, nextIndex));
239
- outLine(` ${step.timestamp}`);
240
- }
241
- else {
242
- out(step);
243
- }
244
- nextIndex += 1;
245
- }
246
- if (TERMINAL_STATUSES.includes(tick.status)) {
247
- if (opts.pretty)
248
- outLine(`Run ${tick.status.toLowerCase()}.`);
249
- return;
250
- }
251
- if (Date.now() + intervalMs > deadline) {
252
- err({ error: `Watch timed out after ${timeoutMs}ms (run still ${tick.status}).` });
253
- process.exit(1);
254
- }
255
- try {
256
- await sleep(intervalMs, controller.signal);
257
- }
258
- catch {
259
- return; // aborted
260
- }
261
- }
262
- }
263
- catch (e) {
264
- if (e instanceof Error && e.message === 'watch deadline exceeded') {
265
- err({ error: `Watch timed out after ${timeoutMs}ms.` });
266
- process.exit(1);
267
- }
268
- if (e instanceof Error && e.message === 'watch aborted') {
269
- return; // SIGINT handler already triggered process.exit(130)
270
- }
271
- handleApiError(e);
272
- }
273
- finally {
274
- process.off('SIGINT', onSigint);
275
- }
276
- });
277
- // ── run cancel ────────────────────────────────────────────────────────────
300
+ .action(runsWatchAction);
278
301
  runCmd
279
302
  .command('cancel <run_id>')
280
303
  .description('Cancel a run by ID')
281
304
  .option('--pretty', 'Human-readable output')
282
- .action(async (runId, opts) => {
283
- const apiKey = getApiKey();
284
- try {
285
- const result = await cancelRun(runId, apiKey);
286
- if (opts.pretty) {
287
- if (result.status === RunStatus.CANCELLED) {
288
- outLine(result.message
289
- ? `Run ${result.run_id} cancelled (${result.message}).`
290
- : `Run ${result.run_id} cancelled.`);
291
- }
292
- else {
293
- outLine(`Run ${result.run_id} already finished (${result.status}).`);
294
- }
295
- }
296
- else {
297
- out(result);
298
- }
299
- }
300
- catch (e) {
301
- handleApiError(e);
302
- }
303
- });
305
+ .action(runsCancelAction);
304
306
  }
package/dist/lib/auth.js CHANGED
@@ -179,6 +179,20 @@ export function clearConnectContext(client) {
179
179
  errLine(`Warning: could not clear connect context: ${e instanceof Error ? e.message : String(e)}`);
180
180
  }
181
181
  }
182
+ function resolveProfilePath(shell, platform) {
183
+ const home = os.homedir();
184
+ const macBashProfile = ['.bash_profile', '.bash_login', '.profile']
185
+ .map((name) => path.join(home, name)) // nosemgrep: path-join-resolve-traversal -- fixed filenames under os.homedir()
186
+ .find((candidate) => fs.existsSync(candidate)) ?? path.join(home, '.bash_profile');
187
+ if (shell === 'zsh')
188
+ return path.join(home, '.zshrc');
189
+ if (shell === 'bash') {
190
+ return platform === 'darwin' ? macBashProfile : path.join(home, '.bashrc');
191
+ }
192
+ if (shell === 'fish')
193
+ return path.join(home, '.config', 'fish', 'config.fish');
194
+ return path.join(home, '.profile');
195
+ }
182
196
  export function persistApiKeyToEnvironment(apiKey, options = {}) {
183
197
  const platform = options.platform ?? process.platform;
184
198
  if (platform === 'win32') {
@@ -190,21 +204,9 @@ export function persistApiKeyToEnvironment(apiKey, options = {}) {
190
204
  }
191
205
  return;
192
206
  }
193
- const home = os.homedir();
194
207
  const shell = path.basename(options.shell ?? process.env['SHELL'] ?? '');
195
208
  const fish = shell === 'fish';
196
- const macBashProfile = ['.bash_profile', '.bash_login', '.profile']
197
- .map((name) => path.join(home, name)) // nosemgrep: path-join-resolve-traversal -- fixed filenames under os.homedir()
198
- .find((candidate) => fs.existsSync(candidate)) ?? path.join(home, '.bash_profile');
199
- const profile = shell === 'zsh'
200
- ? path.join(home, '.zshrc')
201
- : shell === 'bash'
202
- ? platform === 'darwin'
203
- ? macBashProfile
204
- : path.join(home, '.bashrc')
205
- : fish
206
- ? path.join(home, '.config', 'fish', 'config.fish')
207
- : path.join(home, '.profile');
209
+ const profile = resolveProfilePath(shell, platform);
208
210
  const quotedKey = `'${apiKey.replaceAll("'", "'\\''")}'`;
209
211
  const prefix = fish ? `set -gx ${TINYFISH_API_KEY_VAR} ` : `export ${TINYFISH_API_KEY_VAR}=`;
210
212
  const assignment = `${prefix}${quotedKey}`;
@@ -12,6 +12,7 @@ export type HoistedCliInstall = {
12
12
  durationMs: number;
13
13
  } | {
14
14
  ok: false;
15
+ durationMs: number;
15
16
  error: Error;
16
17
  };
17
18
  /** Every harness reads the outcome; only the first take reports it. */
@@ -19,9 +20,7 @@ export type TakeHoistedCliInstall = () => {
19
20
  outcome: HoistedCliInstall;
20
21
  firstTake: boolean;
21
22
  };
22
- /** Carried failure an earlier harness already reported. */
23
- export declare class HoistedFailureReportedError extends Error {
24
- }
23
+ export { HoistedFailureReportedError } from './connect-runtime.js';
25
24
  export declare function captureStdio(verbose: boolean): {
26
25
  stdio: "inherit";
27
26
  timeout: number;
@@ -7,9 +7,7 @@ export const SKILL_INSTALL_TIMEOUT_MS = 120_000;
7
7
  const TINYFISH_CLI_INSTALL_SPEC = `${TINYFISH_CLI_PACKAGE}@latest`;
8
8
  // spawnSync caps piped output at 1 MiB by default.
9
9
  export const STEP_MAX_BUFFER = 10 * 1024 * 1024;
10
- /** Carried failure an earlier harness already reported. */
11
- export class HoistedFailureReportedError extends Error {
12
- }
10
+ export { HoistedFailureReportedError } from './connect-runtime.js';
13
11
  export function captureStdio(verbose) {
14
12
  return verbose
15
13
  ? { stdio: 'inherit', timeout: SKILL_INSTALL_TIMEOUT_MS }
@@ -7,6 +7,7 @@ import { HARNESS_CAPABILITIES, NATIVE_BY_HARNESS, openclawSkillUninstall, } from
7
7
  import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
8
8
  import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, finishSetupCommand, spawnRemoval, } from './connect-runtime.js';
9
9
  import { pickHarnesses } from './connect-picker.js';
10
+ import { HERMES_KEY_VAR, hermesEnvPath, removeHermesKey, resolveHermesHome } from './hermes-env.js';
10
11
  import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, RELOAD_ACTION, } from './harness-detect.js';
11
12
  import { detectHumanInitiated } from './harness.js';
12
13
  import { emitNotice } from './notice.js';
@@ -84,7 +85,11 @@ function runHoistedCliInstall(verbose) {
84
85
  catch (error) {
85
86
  if (error instanceof ConnectInterruptedError)
86
87
  return undefined;
87
- return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
88
+ return {
89
+ ok: false,
90
+ durationMs: Math.max(0, Date.now() - startedAt),
91
+ error: error instanceof Error ? error : new Error(String(error)),
92
+ };
88
93
  }
89
94
  }
90
95
  async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
@@ -100,6 +105,10 @@ async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
100
105
  function planText(harness, mcpUrl, apiKey) {
101
106
  if (harness === 'cursor')
102
107
  return planCursorWrite(mcpUrl, apiKey);
108
+ // Only the keyed path seeds the .env; a keyless Hermes install writes nothing.
109
+ if (harness === 'hermes' && apiKey) {
110
+ return `would run \`tinyfish connect hermes\` (harness-owned MCP write; the CLI writes ${HERMES_KEY_VAR} to Hermes' .env)`;
111
+ }
103
112
  return `would run \`tinyfish connect ${harness}\` (harness-owned MCP write; no local file touched by the CLI)`;
104
113
  }
105
114
  function uninstallPlanText(harness) {
@@ -109,7 +118,9 @@ function uninstallPlanText(harness) {
109
118
  if (!uninstallRuns(harness)) {
110
119
  return `would print \`${uninstallPointer(harness)}\` (no removal command exists)`;
111
120
  }
112
- return `would run \`${uninstallPointer(harness)}\``;
121
+ // Hermes alone leaves a seeded key behind; `mcp remove` never clears it.
122
+ const envNote = harness === 'hermes' ? ` and clear ${HERMES_KEY_VAR} from Hermes' .env` : '';
123
+ return `would run \`${uninstallPointer(harness)}\`${envNote}`;
113
124
  }
114
125
  /** Undefined: no harness-CLI removal exists (opencode). */
115
126
  function uninstallRuns(harness) {
@@ -132,7 +143,46 @@ function uninstallPointer(harness) {
132
143
  const joiner = process.platform === 'win32' ? ' && ' : ' ; ';
133
144
  return removal.runs.map((args) => `${removal.command} ${args.join(' ')}`).join(joiner);
134
145
  }
146
+ /** `mcp remove` leaves this behind; a stale value gets silently reused. */
147
+ function clearHermesEnvKey() {
148
+ const home = resolveHermesHome();
149
+ const retry = 're-run `tinyfish connect --all --uninstall`';
150
+ if (!home) {
151
+ return (`could not locate Hermes' home, so ${HERMES_KEY_VAR} may still be set in its .env. ` +
152
+ `A stale key is reused by the next install: delete that line, or ${retry}.`);
153
+ }
154
+ try {
155
+ removeHermesKey(home);
156
+ return undefined;
157
+ }
158
+ catch (e) {
159
+ const detail = e instanceof Error ? e.message : String(e);
160
+ return (`could not clear ${HERMES_KEY_VAR} from ${hermesEnvPath(home)} (${detail}). ` +
161
+ `A stale key is reused by the next install: delete that line, or ${retry}.`);
162
+ }
163
+ }
164
+ /** Kept on purpose: the entry survives and still interpolates this variable. */
165
+ function hermesKeyKeptAdvisory() {
166
+ return (`left ${HERMES_KEY_VAR} in Hermes' .env because the entry was not removed. ` +
167
+ 'Run the command above, then re-run `tinyfish connect --all --uninstall` to clear it.');
168
+ }
135
169
  function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
170
+ const result = removeHarnessRegistration(harness, configPath, verbose, binaryOnPath);
171
+ if (harness !== 'hermes')
172
+ return result;
173
+ // Clearing a key the entry still references would break a working install.
174
+ const removed = !result.removalFailed &&
175
+ (result.outcome === 'uninstalled' || result.outcome === 'uninstall_noop');
176
+ const advisory = removed ? clearHermesEnvKey() : hermesKeyKeptAdvisory();
177
+ if (!advisory)
178
+ return result;
179
+ // Appended, never replacing: an advisory must not hide the removal's message.
180
+ return {
181
+ ...result,
182
+ errorMessage: result.errorMessage ? `${result.errorMessage} ${advisory}` : advisory,
183
+ };
184
+ }
185
+ function removeHarnessRegistration(harness, configPath, verbose, binaryOnPath) {
136
186
  const detection = { harness, detected: true, configPath, installed: false };
137
187
  if (harness !== 'cursor') {
138
188
  const removal = uninstallRuns(harness);
@@ -165,6 +215,7 @@ function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
165
215
  return {
166
216
  ...detection,
167
217
  outcome: 'uninstalled',
218
+ removalFailed: true,
168
219
  errorMessage: `could not run \`${removal.command} ${args.join(' ')}\` (${result.error.message}) — nothing removed.`,
169
220
  fixCommand: uninstallPointer(harness),
170
221
  };
@@ -209,6 +260,7 @@ function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
209
260
  return {
210
261
  ...detection,
211
262
  outcome: 'uninstalled',
263
+ removalFailed: true,
212
264
  errorMessage: `could not update Cursor's mcp.json (${result.error}) — nothing removed.`,
213
265
  fixCommand: 'Fix ~/.cursor/mcp.json by hand, then re-run: tinyfish connect --all --uninstall',
214
266
  };
@@ -377,9 +429,12 @@ function summaryLine(result, uninstall) {
377
429
  return `… ${name} — no tinyfish entry found, nothing removed`;
378
430
  }
379
431
  if (result.outcome === 'uninstalled') {
380
- return result.errorMessage
381
- ? `✗ ${name} — ${result.errorMessage} Fix: ${result.fixCommand}`
382
- : `✓ ${name} — removed`;
432
+ if (!result.errorMessage)
433
+ return `✓ ${name} — removed`;
434
+ // The removal itself worked; the note is advice, not a failed removal.
435
+ if (!result.removalFailed)
436
+ return `⚠ ${name} — removed, but ${result.errorMessage}`;
437
+ return `✗ ${name} — ${result.errorMessage} Fix: ${result.fixCommand}`;
383
438
  }
384
439
  if (result.outcome === 'interrupted') {
385
440
  return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
@@ -457,7 +512,7 @@ function computeExitCode(results) {
457
512
  if (results.some((r) => r.outcome === 'interrupted'))
458
513
  return 130;
459
514
  // A removal that could not run is a failure, not a silent success.
460
- if (results.some((r) => r.outcome === 'uninstalled' && r.errorMessage))
515
+ if (results.some((r) => r.removalFailed))
461
516
  return 1;
462
517
  const detected = results.filter(actionable);
463
518
  if (detected.length === 0)
@@ -18,6 +18,22 @@ export declare const CURSOR_SKILL_TARGET: {
18
18
  readonly skillAgent: "cursor";
19
19
  readonly displayName: "Cursor";
20
20
  };
21
+ /** The harness reads the key from its own store. */
22
+ export interface SeededInstall {
23
+ /** Pins the add child at the home the key landed in. */
24
+ env: typeof process.env;
25
+ /** Answers prompts `mcp add` still asks once the key is seeded. */
26
+ stdinInput: string;
27
+ /** Names the file it wrote, never the value. */
28
+ note: string;
29
+ /** Undoes the seed, carrying the value it replaced. */
30
+ rollback: () => void;
31
+ /** `mcp add` exits 0 even having saved nothing; the code lies. */
32
+ confirmRegistered: () => {
33
+ ok: boolean;
34
+ detail?: string;
35
+ };
36
+ }
21
37
  export interface NativeMcpClient extends SupportedCommand {
22
38
  connectClient: Exclude<AgentClient, 'openclaw'>;
23
39
  /** Omitted where the `skills` CLI has no agent for this harness (Grok ships its own plugin). */
@@ -29,6 +45,8 @@ export interface NativeMcpClient extends SupportedCommand {
29
45
  /** Which variable the client reads the key from. */
30
46
  envVar?: string;
31
47
  addArgs: (mcpUrl: string, apiKey: string) => string[];
48
+ /** Writes into the harness's store, so connect verifies the key first. */
49
+ seedKey?: (apiKey: string) => SeededInstall;
32
50
  };
33
51
  /** Shown when a keyless install leaves sign-in to the harness. */
34
52
  signInHint?: string;
@@ -61,6 +79,11 @@ export declare const CLAUDE_CODE: NativeMcpClient;
61
79
  export declare function openExternalUrl(url: string): ReturnType<typeof spawn.sync>;
62
80
  export declare const CODEX: NativeMcpClient;
63
81
  export declare const GROK: NativeMcpClient;
82
+ /** `mcp add` exits 0 after saving nothing, or a disabled entry. */
83
+ export declare function hermesRegistrationEnabled(home: string): {
84
+ ok: boolean;
85
+ detail?: string;
86
+ };
64
87
  export declare const HERMES: NativeMcpClient;
65
88
  export declare const OPENCODE: NativeMcpClient;
66
89
  /** Every native MCP client. Derive from this, so a new harness cannot be missed. */