@tiny-fish/cli 0.42.0 → 0.42.1-next.336

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.
@@ -23,14 +23,16 @@ export interface NativeConnectOptions {
23
23
  /** Targeted connects only: --all keeps its per-harness failed row. */
24
24
  fallbackWhenMissing?: boolean;
25
25
  }
26
- /** Skill install only: no key handoff, sign-in wait, or post-install callback to honour. */
27
- export declare function connectOpenClaw(options: Omit<NativeConnectOptions, 'keyAuthOnly' | 'authTimeoutMs' | 'onPostInstallFailed'>): Promise<void>;
26
+ /** Skill install only: no key handoff or post-install callback to honour. */
27
+ export declare function connectOpenClaw(options: Omit<NativeConnectOptions, 'keyAuthOnly' | 'onPostInstallFailed'>): Promise<void>;
28
28
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
29
29
  export declare function connectCursor(options: {
30
30
  apiKey?: string;
31
31
  mcpUrl: string;
32
32
  attemptId?: string;
33
33
  verbose?: boolean;
34
+ authTimeoutMs?: number;
35
+ onPostInstallFailed?: () => void;
34
36
  }): Promise<void>;
35
37
  export declare function launchAgent(client: AgentClient): Promise<void>;
36
38
  export declare function connectHarness(harness: AgentClient, options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
@@ -451,7 +451,7 @@ function trackPostInstallFailure(displayName, state, telemetry, error) {
451
451
  errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
452
452
  `(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
453
453
  }
454
- /** Skill install only: no key handoff, sign-in wait, or post-install callback to honour. */
454
+ /** Skill install only: no key handoff or post-install callback to honour. */
455
455
  export async function connectOpenClaw(options) {
456
456
  const telemetry = createConnectTelemetry(options.mcpUrl, 'openclaw', options);
457
457
  // Skill install, no MCP server — never degraded, and false keeps it inside a `= false` filter.
@@ -483,7 +483,10 @@ export async function connectOpenClaw(options) {
483
483
  telemetry.track('checkpoint', { phase: 'skill_installed' });
484
484
  // Stores the key before probing, so a passed --api-key never reaches an interactive login.
485
485
  state.stage = 'authentication';
486
- ensureCliAuthenticated('openclaw', options.apiKey);
486
+ // A set timeout means headless; never shell an interactive login.
487
+ ensureCliAuthenticated('openclaw', options.apiKey, {
488
+ interactiveLogin: options.authTimeoutMs === undefined,
489
+ });
487
490
  telemetry.track('checkpoint', { phase: 'authenticated' });
488
491
  saveConnectContext('openclaw', telemetry.attemptId);
489
492
  // For openclaw the skill install and auth ARE functional; only the walkthrough is cosmetic.
@@ -586,6 +589,28 @@ const CONFIG_FILE_HARNESSES = {
586
589
  },
587
590
  },
588
591
  };
592
+ /** Cosmetic and post-settle: a skill failure must not fail the connection. */
593
+ function installSkillPostSettle(spec, displayName, state, telemetry, options) {
594
+ if (!spec.skillTarget.skillAgent)
595
+ return false;
596
+ state.stage = 'skill_install';
597
+ try {
598
+ installWebSkill(spec.skillTarget, { verbose: options.verbose ?? false });
599
+ telemetry.track('checkpoint', { phase: 'skill_installed' });
600
+ return false;
601
+ }
602
+ catch (error) {
603
+ trackPostInstallFailure(displayName, state, telemetry, error);
604
+ return true;
605
+ }
606
+ }
607
+ /** Prints last: the outro must own the final line. */
608
+ function reportPostInstallFailure(harness, failed, onPostInstallFailed) {
609
+ if (!failed)
610
+ return;
611
+ errLine(finishSetupHint(harness));
612
+ onPostInstallFailed?.();
613
+ }
589
614
  /** Writes the harness's MCP config file itself; no `mcp add` exists. */
590
615
  async function connectConfigFileHarness(harness, options) {
591
616
  const spec = CONFIG_FILE_HARNESSES[harness];
@@ -630,12 +655,12 @@ async function connectConfigFileHarness(harness, options) {
630
655
  await runGuarded(state, telemetry, async () => {
631
656
  telemetry.track('started');
632
657
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
633
- state.stage = 'skill_install';
634
- installWebSkill(spec.skillTarget, { verbose: options.verbose ?? false });
635
- telemetry.track('checkpoint', { phase: 'skill_installed' });
636
658
  // Auth first: a key lets the config carry an X-API-Key header, removing the sign-in hand-back.
637
659
  state.stage = 'authentication';
638
- ensureCliAuthenticated(harness, options.apiKey);
660
+ // A set timeout means headless; never shell an interactive login.
661
+ ensureCliAuthenticated(harness, options.apiKey, {
662
+ interactiveLogin: options.authTimeoutMs === undefined,
663
+ });
639
664
  telemetry.track('checkpoint', { phase: 'authenticated' });
640
665
  const resolvedKey = validatedApiKey(options.apiKey);
641
666
  // ensureCliAuthenticated leaves a key stored, and `auth status` exits 0 on a malformed one.
@@ -662,6 +687,8 @@ async function connectConfigFileHarness(harness, options) {
662
687
  }
663
688
  telemetry.track('checkpoint', { phase: 'registered' });
664
689
  saveConnectContext(harness, telemetry.attemptId);
690
+ settle(state, telemetry, 'completed', { authMode: resolvedKey ? AuthMode.ApiKey : 'deferred' });
691
+ const postInstallFailed = installSkillPostSettle(spec, displayName, state, telemetry, options);
665
692
  if (resolvedKey) {
666
693
  // Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
667
694
  const verify = await verifyMcpAuth(resolvedKey);
@@ -681,7 +708,7 @@ async function connectConfigFileHarness(harness, options) {
681
708
  const note = spec.postConnectNote?.();
682
709
  if (note)
683
710
  errLine(note);
684
- settle(state, telemetry, 'completed', { authMode: resolvedKey ? AuthMode.ApiKey : 'deferred' });
711
+ reportPostInstallFailure(harness, postInstallFailed, options.onPostInstallFailed);
685
712
  });
686
713
  }
687
714
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
@@ -118,10 +118,10 @@ export async function runUpgradeCommand(verbose = false, scope = 'both') {
118
118
  export function registerUpgrade(program) {
119
119
  program
120
120
  .command('upgrade')
121
- .description('Update the TinyFish CLI and the use-tinyfish skill to the latest versions')
122
- .option('--verbose', 'Print the full npm and skills output instead of only on failure')
121
+ .description('Update the TinyFish CLI and refresh its bundled use-tinyfish skill')
122
+ .option('--verbose', 'Print the full npm output instead of only on failure')
123
123
  .option('--cli-only', 'Update only the TinyFish CLI, leaving the skill untouched')
124
- .option('--skill-only', 'Refresh only the use-tinyfish skill, leaving the CLI untouched')
124
+ .option('--skill-only', "Rewrite the skill from the installed CLI's bundle, skipping npm")
125
125
  .action(async (options) => {
126
126
  if (options.cliOnly && options.skillOnly) {
127
127
  throw new Error('Pass either --cli-only or --skill-only, not both.');
@@ -1,11 +1,9 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { connectHarness } from '../commands/connect.js';
3
- import { ALL_HARNESSES, Registered } from './harness-detect.js';
4
- import { harnessSpec } from './harness-spec.js';
3
+ import { OAUTH_SIGN_IN_TIMEOUT_MS } from './connect-all-auth.js';
4
+ import { Registered } from './harness-detect.js';
5
5
  import { detectHumanInitiated } from './harness.js';
6
6
  import { errLine } from './output.js';
7
- // Config-file repairs are local writes; the rest need browser sign-in.
8
- const UNATTENDED_SAFE = new Set(ALL_HARNESSES.filter((harness) => harnessSpec(harness).cliWritesConfig));
9
7
  export function repairsFor(checks, statuses) {
10
8
  const repairs = [];
11
9
  // A revoked-but-well-formed key passes the credential check and fails the call; both need login.
@@ -38,8 +36,8 @@ export function repairsFor(checks, statuses) {
38
36
  action: 'connect',
39
37
  harness: status.harness,
40
38
  command: `tinyfish connect ${status.harness}`,
41
- // A dead well-formed key passes the format check.
42
- unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
39
+ // Every harness takes a key, so a live one is the only gate.
40
+ unattended_safe: authCallPassed,
43
41
  });
44
42
  }
45
43
  // connect hermes reinstalls the plugin; a second row would double it.
@@ -50,7 +48,7 @@ export function repairsFor(checks, statuses) {
50
48
  action: 'connect',
51
49
  harness: 'hermes',
52
50
  command: 'tinyfish connect hermes',
53
- unattended_safe: false,
51
+ unattended_safe: authCallPassed,
54
52
  });
55
53
  }
56
54
  return repairs;
@@ -72,11 +70,20 @@ function runAuthLogin() {
72
70
  }
73
71
  }
74
72
  /** Dispatches on `action`, never on a null harness: that conflation made the credential fix a no-op. */
75
- async function runRepair(repair, mcpUrl) {
73
+ async function runRepair(repair, options, unattended) {
76
74
  if (repair.action === 'auth-login')
77
75
  return runAuthLogin();
78
- if (repair.harness)
79
- await connectHarness(repair.harness, { mcpUrl, launch: false });
76
+ if (!repair.harness)
77
+ return;
78
+ await connectHarness(repair.harness, {
79
+ mcpUrl: options.mcpUrl,
80
+ launch: false,
81
+ // Unattended: refuse a keyless install, bound the spawns.
82
+ keyAuthOnly: unattended || undefined,
83
+ authTimeoutMs: unattended ? OAUTH_SIGN_IN_TIMEOUT_MS : undefined,
84
+ // Doctor is the CLI; reinstalling it to fix a harness is circular.
85
+ cliInstall: unattended ? false : undefined,
86
+ });
80
87
  }
81
88
  export async function applyRepairs(repairs, options) {
82
89
  const interactive = detectHumanInitiated();
@@ -94,12 +101,14 @@ export async function applyRepairs(repairs, options) {
94
101
  outcomes.push({
95
102
  repair,
96
103
  status: 'skipped',
97
- reason: 'needs a browser sign-in, which has no unattended path',
104
+ reason: repair.action === 'auth-login'
105
+ ? 'needs a browser sign-in, which has no unattended path'
106
+ : 'credential missing or failing; run tinyfish auth login',
98
107
  });
99
108
  continue;
100
109
  }
101
110
  try {
102
- await runRepair(repair, options.mcpUrl);
111
+ await runRepair(repair, options, !interactive);
103
112
  outcomes.push({ repair, status: 'repaired' });
104
113
  }
105
114
  catch (e) {
@@ -1,4 +1,4 @@
1
- /** The `skills` CLI's own agent names, not ours. */
1
+ /** Our skill-path slugs, inherited from the `skills` CLI era. */
2
2
  export type SkillAgent = 'claude-code' | 'codex' | 'command-code' | 'cursor' | 'hermes-agent' | 'opencode' | 'pi';
3
3
  export interface HarnessSupportCheck {
4
4
  args: string[];
@@ -11,5 +11,7 @@ export type InstallRoot = {
11
11
  */
12
12
  export declare function resolveInstallRoot(packageRoot: string, platform: typeof process.platform, onDisk?: (candidate: string) => boolean): InstallRoot | null;
13
13
  export declare function installRoot(): InstallRoot | null;
14
+ /** This package's own dir under a prefix's node_modules. */
15
+ export declare function installedPackageDir(nodeModules: string): string;
14
16
  /** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
15
17
  export declare function readInstalledVersion(nodeModules: string): string | null;
@@ -37,10 +37,14 @@ export function installRoot() {
37
37
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
38
38
  return resolveInstallRoot(packageRoot, process.platform);
39
39
  }
40
+ /** This package's own dir under a prefix's node_modules. */
41
+ export function installedPackageDir(nodeModules) {
42
+ return path.join(nodeModules, ...TINYFISH_CLI_PACKAGE.split('/'));
43
+ }
40
44
  /** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
41
45
  export function readInstalledVersion(nodeModules) {
42
46
  try {
43
- const manifest = fs.readFileSync(path.join(nodeModules, ...TINYFISH_CLI_PACKAGE.split('/'), 'package.json'), 'utf8');
47
+ const manifest = fs.readFileSync(path.join(installedPackageDir(nodeModules), 'package.json'), 'utf8');
44
48
  return JSON.parse(manifest).version ?? null;
45
49
  }
46
50
  catch {
@@ -1,18 +1,8 @@
1
1
  import { type InstallOptions } from './cli-install.js';
2
2
  import { type SkillAgent } from './connect-clients.js';
3
- export declare const SKILLS_CLI_PACKAGE = "skills@1.5.15";
4
3
  export declare function installWebSkill(client: {
5
4
  skillAgent?: SkillAgent;
6
5
  displayName: string;
7
6
  }, { verbose }: Pick<InstallOptions, 'verbose'>): void;
8
- /**
9
- * Refresh the skill we installed, for the harnesses we installed it in.
10
- *
11
- * Reinstalling beats `skills update`: it reports failure through the exit code instead of
12
- * only in prose, it repairs a copy whose lock entry lost its hash, and it cannot spread the
13
- * skill to agents the user never connected (`update` re-adds with no --agent, which targets
14
- * every agent on the machine).
15
- *
16
- * Returns whether a skill was actually rewritten.
17
- */
7
+ /** Rewrite the vendored skill where connect recorded installs; true iff bytes changed. */
18
8
  export declare function updateWebSkill({ verbose }: InstallOptions): boolean;
@@ -2,55 +2,13 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import spawn from 'cross-spawn';
4
4
  import { loadConfig } from './auth.js';
5
- import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
5
+ import { captureStdio, capturedOutput, replay } from './cli-install.js';
6
6
  import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS, OPENCLAW, openclawSkillInstallArgs, } from './connect-clients.js';
7
7
  import { ConnectInterruptedError, ConnectStepError, probeSupportVariant, throwIfInterrupted, } from './connect-runtime.js';
8
- import { CLI_AGENT_IDENTITY } from './constants.js';
8
+ import { WEB_SKILL_NAME } from './constants.js';
9
+ import { installedPackageDir, installRoot } from './install-root.js';
9
10
  import { errLine } from './output.js';
10
- import { skillTargetDir } from './skill-paths.js';
11
11
  import { clearSkillLockEntry, writeWebSkill } from './skill-vendor.js';
12
- // Supports Hermes without node:util.styleText, so the installer still runs on Node 20.11.
13
- export const SKILLS_CLI_PACKAGE = 'skills@1.5.15';
14
- const TINYFISH_WEB_SKILL_SOURCE = 'tinyfish-io/tinyfish-cookbook';
15
- const TINYFISH_WEB_SKILL = 'use-tinyfish';
16
- /** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
17
- function skillAddArgs(skillAgents) {
18
- return [
19
- '-y',
20
- SKILLS_CLI_PACKAGE,
21
- 'add',
22
- TINYFISH_WEB_SKILL_SOURCE,
23
- '--skill',
24
- TINYFISH_WEB_SKILL,
25
- '--global',
26
- '--agent',
27
- ...skillAgents,
28
- '--yes',
29
- ];
30
- }
31
- // npx resolves from PATH, which a global npm install may not have exported yet.
32
- function skillSpawnEnv() {
33
- return {
34
- ...process.env,
35
- // `skills` gates its logo art on agent detection.
36
- AI_AGENT: CLI_AGENT_IDENTITY,
37
- PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ''}`,
38
- };
39
- }
40
- // The `skills` update flow exits 0 even when it fails, so status alone proves nothing.
41
- // Safe to match on because SKILLS_CLI_PACKAGE is pinned.
42
- const SKILL_UPDATE_FAILURE_PATTERN = /Failed to (?:update|check|fetch)/;
43
- const SKILL_NOT_INSTALLED_PATTERN = /No installed skills found matching/;
44
- // Nothing was rewritten, so nothing needs an agent restart.
45
- const SKILL_ALREADY_CURRENT_PATTERN = /All global skills are up to date/;
46
- // A lock entry with no recorded hash is untrackable, so `skills` reports it as skipped rather
47
- // than failed. Left undetected that reads as "up to date" while nothing was refreshed.
48
- const SKILL_UNCHECKABLE_PATTERN = /cannot be checked automatically/;
49
- /** `add` exits 0 on per-agent failure, so the file it should have written is the verdict. */
50
- function skillOnDisk(agent) {
51
- // SKILL.md, not the dir: `skills` mkdirs before it copies, so a failed copy leaves one.
52
- return fs.existsSync(path.join(skillTargetDir(agent), 'SKILL.md'));
53
- }
54
12
  export function installWebSkill(client, { verbose }) {
55
13
  if (!client.skillAgent)
56
14
  return;
@@ -58,16 +16,16 @@ export function installWebSkill(client, { verbose }) {
58
16
  writeWebSkill([client.skillAgent]);
59
17
  clearSkillLockEntry({ verbose });
60
18
  }
61
- /**
62
- * Refresh the skill we installed, for the harnesses we installed it in.
63
- *
64
- * Reinstalling beats `skills update`: it reports failure through the exit code instead of
65
- * only in prose, it repairs a copy whose lock entry lost its hash, and it cannot spread the
66
- * skill to agents the user never connected (`update` re-adds with no --agent, which targets
67
- * every agent on the machine).
68
- *
69
- * Returns whether a skill was actually rewritten.
70
- */
19
+ // The running process predates the install it just ran, so its own bundle is stale.
20
+ function freshBundleDir() {
21
+ const root = installRoot();
22
+ if (!root)
23
+ return undefined;
24
+ const dir = path.join(installedPackageDir(root.nodeModules), 'skill', WEB_SKILL_NAME);
25
+ // A failed or pre-bundle install has none; fall back to our own copy.
26
+ return fs.existsSync(path.join(dir, 'SKILL.md')) ? dir : undefined;
27
+ }
28
+ /** Rewrite the vendored skill where connect recorded installs; true iff bytes changed. */
71
29
  export function updateWebSkill({ verbose }) {
72
30
  // One entry per `tinyfish connect <client>`, persisted by PF-3169.
73
31
  const connected = loadConfig().connect ?? {};
@@ -77,16 +35,17 @@ export function updateWebSkill({ verbose }) {
77
35
  ...(connected['cursor'] ? [CURSOR_SKILL_TARGET.skillAgent] : []),
78
36
  ].filter((agent) => agent !== undefined);
79
37
  const hasOpenClaw = Boolean(connected['openclaw']);
80
- // Installs predating PF-3169 recorded nothing; ask `skills` to refresh what it tracks. A
81
- // skill-less recorded install (Grok) may still have one from before, so refresh anyway
82
- // — but there having been none is then expected, not an upgrade failure.
38
+ // No recorded skill install means no target dirs; connect is the install path.
83
39
  if (skillAgents.length === 0 && !hasOpenClaw) {
84
- return refreshWebSkillByHash(verbose, Object.keys(connected).length > 0);
40
+ errLine('No use-tinyfish skill to refresh. Run `tinyfish connect <client>` to install it.');
41
+ return false;
85
42
  }
86
43
  let refreshed = false;
87
44
  if (skillAgents.length > 0) {
88
- reinstallWebSkill(skillAgents, verbose);
89
- refreshed = true;
45
+ if (verbose)
46
+ errLine(`Refreshing the TinyFish web skill for ${skillAgents.join(', ')}...`);
47
+ refreshed = writeWebSkill(skillAgents, { sourceDir: freshBundleDir() });
48
+ clearSkillLockEntry({ verbose });
90
49
  }
91
50
  // OpenClaw keeps its skill under its own CLI, so it needs its own refresh.
92
51
  if (hasOpenClaw)
@@ -131,59 +90,3 @@ function reinstallOpenClawSkill(verbose) {
131
90
  }
132
91
  return true;
133
92
  }
134
- function reinstallWebSkill(skillAgents, verbose) {
135
- if (verbose)
136
- errLine(`Refreshing the TinyFish web skill for ${skillAgents.join(', ')}...`);
137
- const result = spawn.sync('npx', skillAddArgs(skillAgents), {
138
- encoding: 'utf8',
139
- env: skillSpawnEnv(),
140
- maxBuffer: STEP_MAX_BUFFER,
141
- timeout: SKILL_INSTALL_TIMEOUT_MS,
142
- });
143
- const output = capturedOutput(result);
144
- // Quiet capture stays (#4434); the on-disk check replaced prose matching.
145
- const addFailed = Boolean(result.error) || result.status !== 0;
146
- const missing = addFailed ? undefined : skillAgents.find((agent) => !skillOnDisk(agent));
147
- const failed = addFailed || missing !== undefined;
148
- if (failed)
149
- throwIfInterrupted(result);
150
- if (verbose || failed)
151
- replay(output);
152
- if (failed) {
153
- // Upgrade telemetry carries no detail, so the agent has to ride the message.
154
- const scope = missing ? ` for ${missing}` : '';
155
- throw new Error(`Could not refresh the TinyFish web skill${scope}`, { cause: result.error });
156
- }
157
- }
158
- /** Fallback when no skill-bearing harness is recorded: refresh whatever `skills` tracks. */
159
- function refreshWebSkillByHash(verbose, untrackedIsExpected = false) {
160
- if (verbose)
161
- errLine('Refreshing the TinyFish web skill...');
162
- const result = spawn.sync('npx', ['-y', SKILLS_CLI_PACKAGE, 'update', TINYFISH_WEB_SKILL, '--global', '--yes'], {
163
- encoding: 'utf8',
164
- env: skillSpawnEnv(),
165
- maxBuffer: STEP_MAX_BUFFER,
166
- timeout: SKILL_INSTALL_TIMEOUT_MS,
167
- });
168
- // Piped so prose-only failures can be matched; skills exits 0 anyway.
169
- const output = capturedOutput(result);
170
- const unchecked = SKILL_UNCHECKABLE_PATTERN.test(output);
171
- const failed = Boolean(result.error) || result.status !== 0 || SKILL_UPDATE_FAILURE_PATTERN.test(output);
172
- if (failed)
173
- throwIfInterrupted(result);
174
- if (verbose || failed || unchecked)
175
- replay(output);
176
- if (failed)
177
- throw new Error('Could not refresh the TinyFish web skill', { cause: result.error });
178
- if (unchecked) {
179
- errLine('Run `tinyfish connect <client>` to reinstall the skill and restore update tracking.');
180
- if (untrackedIsExpected)
181
- return false;
182
- throw new Error('The TinyFish web skill cannot be checked for updates');
183
- }
184
- if (SKILL_NOT_INSTALLED_PATTERN.test(output)) {
185
- errLine('No use-tinyfish skill installed. Run `tinyfish connect <client>` to add it.');
186
- return false;
187
- }
188
- return !SKILL_ALREADY_CURRENT_PATTERN.test(output);
189
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.42.0",
3
+ "version": "0.42.1-next.336",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {