@tiny-fish/cli 0.53.1-next.445 → 0.53.1-next.447

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.
@@ -4,6 +4,7 @@ import { Option } from 'commander';
4
4
  import { saveConnectConfigDir, validateKeyFormat, validatedApiKey } from '../lib/auth.js';
5
5
  import { runConnectAll } from '../lib/connect-all.js';
6
6
  import { ALL_HARNESSES } from '../lib/harness/harness-detect.js';
7
+ import { HARNESS_CHOICES, resolveHarnessName } from '../lib/harness/harness-spec.js';
7
8
  import { detectHumanInitiated } from '../lib/harness.js';
8
9
  import { emitNotice } from '../lib/notice.js';
9
10
  import { errLine } from '../lib/output.js';
@@ -76,7 +77,7 @@ async function connectManyPath(options, mcpUrl, attemptId) {
76
77
  }
77
78
  async function connectSingleClient(client, options, mcpUrl, attemptId) {
78
79
  if (!ALL_HARNESSES.includes(client)) {
79
- throw new Error('Unsupported client: ' + client + '. Supported clients: ' + ALL_HARNESSES.join(', '));
80
+ throw new Error('Unsupported client: ' + client + '. Supported clients: ' + HARNESS_CHOICES.join(', '));
80
81
  }
81
82
  const connectOptions = {
82
83
  apiKey: options.apiKey,
@@ -112,7 +113,7 @@ export function registerConnect(program) {
112
113
  program
113
114
  .command('connect')
114
115
  .description('Connect TinyFish to an AI agent')
115
- .argument('[client]', `Agent client to connect (${ALL_HARNESSES.join(', ')}); omit to pick from the agents detected on this machine`)
116
+ .argument('[client]', `Agent client to connect (${HARNESS_CHOICES.join(', ')}); omit to pick from the agents detected on this machine`)
116
117
  .option('--all', 'Detect and connect every supported harness found on this machine')
117
118
  .option('--dry-run', 'Print planned writes per harness without touching anything (--all only)')
118
119
  .option('--uninstall', 'Remove TinyFish entries written by connect --all')
@@ -144,7 +145,7 @@ export function registerConnect(program) {
144
145
  await connectManyPath(options, mcpUrl, attemptId);
145
146
  return;
146
147
  }
147
- await connectSingleClient(client, options, mcpUrl, attemptId);
148
+ await connectSingleClient(resolveHarnessName(client), options, mcpUrl, attemptId);
148
149
  // Deferred to here so the notice lands after install output, not interleaved with it.
149
150
  emitNotice();
150
151
  });
@@ -4,6 +4,7 @@ import { DOCTOR_COULD_NOT_RUN, DOCTOR_INVALID_INPUT, DOCTOR_SCHEMA_VERSION, doct
4
4
  import { applyRepairs, repairsFor } from '../lib/doctor-repairs.js';
5
5
  import { couldNotRunPayload, telemetryKey, telemetryPayload } from '../lib/doctor-telemetry.js';
6
6
  import { ALL_HARNESSES } from '../lib/harness/harness-detect.js';
7
+ import { HARNESS_CHOICES, resolveHarnessName } from '../lib/harness/harness-spec.js';
7
8
  import { err, errLine, out, outLine } from '../lib/output.js';
8
9
  import { detectRegistrations } from '../lib/registration-detect.js';
9
10
  import { sendDoctorCompleted } from '../lib/setup-telemetry.js';
@@ -63,13 +64,15 @@ export function registerDoctor(program) {
63
64
  .command('doctor')
64
65
  .description('Diagnose your TinyFish setup')
65
66
  .option('--pretty', 'Human-readable check list instead of JSON')
66
- .option('--harness <name>', `Restrict to one harness (${ALL_HARNESSES.join(', ')})`)
67
+ .option('--harness <name>', `Restrict to one harness (${HARNESS_CHOICES.join(', ')})`)
67
68
  .option('--url <mcpUrl>', 'MCP endpoint override')
68
69
  .option('--fix', 'Repair what can be repaired by re-running tinyfish connect')
69
70
  .option('--yes', 'Allow --fix to run without a terminal (unattended-safe repairs only)')
70
71
  .action(async (options) => {
72
+ if (options.harness)
73
+ options.harness = resolveHarnessName(options.harness);
71
74
  if (options.harness && !ALL_HARNESSES.includes(options.harness)) {
72
- err({ error: `Unknown --harness value: ${options.harness}`, valid: ALL_HARNESSES });
75
+ err({ error: `Unknown --harness value: ${options.harness}`, valid: HARNESS_CHOICES });
73
76
  process.exitCode = DOCTOR_INVALID_INPUT;
74
77
  return;
75
78
  }
@@ -12,6 +12,8 @@ interface ConnectEntry {
12
12
  auth_mode?: RecordedAuthMode;
13
13
  /** `connect cline --config-dir`: uninstall and doctor must find the same store. */
14
14
  config_dir?: string;
15
+ /** Claude Code Directory plugin, not `mcp add` + vendored skills. */
16
+ plugin?: true;
15
17
  }
16
18
  interface PendingConnectAttempt {
17
19
  id: string;
@@ -35,7 +37,7 @@ export declare function takePendingConnectAttempt(): string | undefined;
35
37
  export declare function writeConfig(apiKey: string): void;
36
38
  export declare function saveConfig(apiKey: string): void;
37
39
  /** `authMode` omitted rewrites the entry without one: a reconnect that degraded must not keep a stale key claim. */
38
- export declare function saveConnectContext(client: string, attemptId: string, authMode?: RecordedAuthMode): void;
40
+ export declare function saveConnectContext(client: string, attemptId: string, authMode?: RecordedAuthMode, plugin?: boolean): void;
39
41
  /** Merged after connect wrote the entry; a missing entry means the install never landed. */
40
42
  export declare function saveConnectConfigDir(client: string, configDir: string): void;
41
43
  /** Distinguishes a missing entry from an unreadable config file. */
package/dist/lib/auth.js CHANGED
@@ -60,6 +60,7 @@ function parseConnectMap(value) {
60
60
  ? { auth_mode: authMode }
61
61
  : {}),
62
62
  ...(typeof configDir === 'string' ? { config_dir: configDir } : {}),
63
+ ...(record?.plugin === true ? { plugin: true } : {}),
63
64
  };
64
65
  }
65
66
  return Object.keys(out).length > 0 ? out : undefined;
@@ -132,10 +133,12 @@ export function saveConfig(apiKey) {
132
133
  }
133
134
  }
134
135
  /** `authMode` omitted rewrites the entry without one: a reconnect that degraded must not keep a stale key claim. */
135
- export function saveConnectContext(client, attemptId, authMode) {
136
- const entry = authMode
137
- ? { attempt_id: attemptId, auth_mode: authMode }
138
- : { attempt_id: attemptId };
136
+ export function saveConnectContext(client, attemptId, authMode, plugin) {
137
+ const entry = {
138
+ attempt_id: attemptId,
139
+ ...(authMode ? { auth_mode: authMode } : {}),
140
+ ...(plugin ? { plugin: true } : {}),
141
+ };
139
142
  try {
140
143
  updateConfig((config) => ({
141
144
  ...config,
@@ -0,0 +1,19 @@
1
+ export declare const CLAUDE_PLUGIN_SERVER = "plugin:tinyfish:tinyfish";
2
+ export type ClaudePluginInstall = {
3
+ installed: true;
4
+ note?: string;
5
+ } | {
6
+ installed: false;
7
+ fallbackReason: string;
8
+ };
9
+ /** Seeds the key first; a fallback restores the prior value. */
10
+ export declare function installClaudeDirectoryPlugin(options: {
11
+ command: string;
12
+ attemptId: string;
13
+ apiKey?: string;
14
+ verbose: boolean;
15
+ }): ClaudePluginInstall;
16
+ /** Compare-only: a key the user set themselves stays. */
17
+ export declare function removeClaudeSettingsKey(): void;
18
+ /** Recorded by connect; upgrade, uninstall and the walkthrough branch on it. */
19
+ export declare function connectedViaClaudePlugin(): boolean;
@@ -0,0 +1,101 @@
1
+ import spawn from 'cross-spawn';
2
+ import { z } from 'zod';
3
+ import { CONNECT_SOURCE, loadConfig, matchesCliKey } from './auth.js';
4
+ import { captureStdio, capturedOutput, replay } from './cli-install.js';
5
+ import { throwIfInterrupted } from './connect-runtime.js';
6
+ import { CLAUDE_PLUGIN_ID, HARNESS_PROBE_TIMEOUT_MS, TINYFISH_API_KEY_VAR } from './constants.js';
7
+ import { claudeSettingsPath, readSettingsEnv, writeSettingsEnv } from './config/claude-config.js';
8
+ import { noColourEnv, parseJson } from './output.js';
9
+ export const CLAUDE_PLUGIN_SERVER = 'plugin:tinyfish:tinyfish';
10
+ const installResultSchema = z.looseObject({
11
+ outcome: z.string(),
12
+ failureCode: z.string().optional(),
13
+ });
14
+ const pluginListSchema = z.array(z.looseObject({ id: z.string(), enabled: z.boolean() }));
15
+ function claudeJson(command, args, timeout) {
16
+ const result = spawn.sync(command, args, {
17
+ ...captureStdio(false),
18
+ ...(timeout ? { timeout } : {}),
19
+ env: noColourEnv(),
20
+ });
21
+ throwIfInterrupted(result);
22
+ return result;
23
+ }
24
+ // Directory off fails the install, yet installed plugins keep updating.
25
+ function pluginEnabled(command) {
26
+ const result = claudeJson(command, ['plugin', 'list', '--json'], HARNESS_PROBE_TIMEOUT_MS);
27
+ const parsed = pluginListSchema.safeParse(parseJson(String(result.stdout ?? '')));
28
+ return parsed.success && parsed.data.some((p) => p.id === CLAUDE_PLUGIN_ID && p.enabled);
29
+ }
30
+ function runInstall(command, attemptId, verbose) {
31
+ const config = {
32
+ connect_source: CONNECT_SOURCE,
33
+ connect_client: 'claude-code',
34
+ connect_attempt_id: attemptId,
35
+ };
36
+ const configArgs = Object.entries(config).flatMap(([k, v]) => ['--config', `${k}=${v}`]);
37
+ const result = claudeJson(command, [
38
+ 'plugin',
39
+ 'install',
40
+ CLAUDE_PLUGIN_ID,
41
+ '--json',
42
+ '-y',
43
+ ...configArgs,
44
+ ]);
45
+ if (verbose)
46
+ replay(capturedOutput(result));
47
+ const parsed = installResultSchema.safeParse(parseJson(String(result.stdout ?? '').trim()));
48
+ if (parsed.success && parsed.data.outcome === 'ok')
49
+ return { installed: true };
50
+ if (pluginEnabled(command))
51
+ return { installed: true };
52
+ // Older Claude Code prints usage here, not a result line.
53
+ if (!parsed.success)
54
+ return { installed: false, fallbackReason: 'unsupported' };
55
+ // The connect-event route folds codes it cannot hold to `other`.
56
+ return { installed: false, fallbackReason: parsed.data.failureCode ?? 'unknown' };
57
+ }
58
+ /** Seeds the key first; a fallback restores the prior value. */
59
+ export function installClaudeDirectoryPlugin(options) {
60
+ const { command, attemptId, apiKey, verbose } = options;
61
+ let restore;
62
+ if (apiKey) {
63
+ try {
64
+ const previous = readSettingsEnv(TINYFISH_API_KEY_VAR);
65
+ writeSettingsEnv(TINYFISH_API_KEY_VAR, apiKey);
66
+ restore = () => writeSettingsEnv(TINYFISH_API_KEY_VAR, previous);
67
+ }
68
+ catch {
69
+ // Malformed settings.json: the header-keyed `mcp add` still works.
70
+ return { installed: false, fallbackReason: 'settings-unwritable' };
71
+ }
72
+ }
73
+ let outcome;
74
+ try {
75
+ outcome = runInstall(command, attemptId, verbose);
76
+ }
77
+ catch (error) {
78
+ restore?.();
79
+ throw error;
80
+ }
81
+ if (!outcome.installed) {
82
+ restore?.();
83
+ return outcome;
84
+ }
85
+ return apiKey
86
+ ? {
87
+ installed: true,
88
+ note: `Wrote ${TINYFISH_API_KEY_VAR} to ${claudeSettingsPath()} — Claude Code reads the key from there.`,
89
+ }
90
+ : outcome;
91
+ }
92
+ /** Compare-only: a key the user set themselves stays. */
93
+ export function removeClaudeSettingsKey() {
94
+ if (matchesCliKey(readSettingsEnv(TINYFISH_API_KEY_VAR))) {
95
+ writeSettingsEnv(TINYFISH_API_KEY_VAR, undefined);
96
+ }
97
+ }
98
+ /** Recorded by connect; upgrade, uninstall and the walkthrough branch on it. */
99
+ export function connectedViaClaudePlugin() {
100
+ return loadConfig().connect?.['claude-code']?.plugin === true;
101
+ }
@@ -4,6 +4,9 @@ export declare const TINYFISH_PERMISSION = "Bash(tinyfish:*)";
4
4
  export declare const CLAUDE_MD_MARKER = "<!-- tinyfish-configure-claude -->";
5
5
  export declare function readSettingsJson(): Record<string, unknown>;
6
6
  export declare function writeSettingsJson(settings: Record<string, unknown>): void;
7
+ export declare function readSettingsEnv(name: string): string | undefined;
8
+ /** `undefined` deletes the variable; every other setting survives. */
9
+ export declare function writeSettingsEnv(name: string, value: string | undefined): void;
7
10
  export declare function mergeSettings(existing: Record<string, unknown>): Record<string, unknown>;
8
11
  export declare function removeFromSettings(existing: Record<string, unknown>): Record<string, unknown>;
9
12
  export declare function readClaudeMd(): string;
@@ -1,9 +1,9 @@
1
1
  import * as fs from 'fs';
2
- import * as os from 'os';
3
2
  import * as path from 'path';
3
+ import { agentHome } from '../skill-paths.js';
4
4
  // ─── Path helpers ─────────────────────────────────────────────────────────────
5
5
  function claudeDir() {
6
- return path.join(os.homedir(), '.claude');
6
+ return agentHome('CLAUDE_CONFIG_DIR', '.claude');
7
7
  }
8
8
  export function claudeSettingsPath() {
9
9
  return path.join(claudeDir(), 'settings.json');
@@ -89,7 +89,34 @@ export function readSettingsJson() {
89
89
  }
90
90
  export function writeSettingsJson(settings) {
91
91
  fs.mkdirSync(claudeDir(), { recursive: true });
92
- fs.writeFileSync(claudeSettingsPath(), JSON.stringify(settings, null, 2) + '\n');
92
+ const file = claudeSettingsPath();
93
+ // Settings `env` can hold the key; `mode` only applies on create.
94
+ if (fs.existsSync(file))
95
+ fs.chmodSync(file, 0o600);
96
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
97
+ }
98
+ function settingsEnv(settings) {
99
+ const env = settings.env;
100
+ return env !== null && typeof env === 'object' && !Array.isArray(env)
101
+ ? env
102
+ : {};
103
+ }
104
+ export function readSettingsEnv(name) {
105
+ const value = settingsEnv(readSettingsJson())[name];
106
+ return typeof value === 'string' ? value : undefined;
107
+ }
108
+ /** `undefined` deletes the variable; every other setting survives. */
109
+ export function writeSettingsEnv(name, value) {
110
+ const settings = readSettingsJson();
111
+ const env = { ...settingsEnv(settings) };
112
+ if (value === undefined)
113
+ delete env[name];
114
+ else
115
+ env[name] = value;
116
+ const next = { ...settings, env };
117
+ if (Object.keys(env).length === 0)
118
+ delete next.env;
119
+ writeSettingsJson(next);
93
120
  }
94
121
  // ─── settings.json merge / remove ─────────────────────────────────────────────
95
122
  export function mergeSettings(existing) {
@@ -8,6 +8,8 @@ import { planOmpWrite, removeOmpMcpServer } from './config/omp-config.js';
8
8
  import { piMcpPath, planPiWrite, removePiMcpServer } from './config/pi-config.js';
9
9
  import { HERMES_KEY_VAR, hermesEnvPath, removeHermesKey, resolveHermesHome } from './hermes-env.js';
10
10
  import { clearHermesWebBackends } from './hermes-plugin.js';
11
+ import { removeClaudeSettingsKey } from './claude-plugin.js';
12
+ import { claudeSettingsPath } from './config/claude-config.js';
11
13
  import { HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, harnessDisplayPath, } from './harness/harness-detect.js';
12
14
  import { ALL_HARNESSES, harnessSpec } from './harness/harness-spec.js';
13
15
  import { skillRootDir } from './skill-paths.js';
@@ -156,6 +158,23 @@ function hermesKeyKeptAdvisory() {
156
158
  return (`left ${HERMES_KEY_VAR} in Hermes' .env because the entry was not removed. ` +
157
159
  'Run the command above, then re-run `tinyfish connect --all --uninstall` to clear it.');
158
160
  }
161
+ /** Every Claude Code subprocess inherits settings `env`. */
162
+ function clearClaudeSettingsKey() {
163
+ try {
164
+ removeClaudeSettingsKey();
165
+ return undefined;
166
+ }
167
+ catch (e) {
168
+ const detail = e instanceof Error ? e.message : String(e);
169
+ return `could not clear TINYFISH_API_KEY from ${claudeSettingsPath()} (${detail}); delete it from \`env\` by hand.`;
170
+ }
171
+ }
172
+ function localStateAdvisory(harness, removed) {
173
+ if (harness === 'hermes')
174
+ return removed ? clearHermesLocalState() : hermesKeyKeptAdvisory();
175
+ // Not removed: the plugin still reads the key.
176
+ return harness === 'claude-code' && removed ? clearClaudeSettingsKey() : undefined;
177
+ }
159
178
  function runRemovalCommands(detection, removal, verbose) {
160
179
  const { harness } = detection;
161
180
  let removed = false;
@@ -280,8 +299,10 @@ export function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
280
299
  // Clearing state the entry still references would break a working install.
281
300
  const removed = !result.removalFailed &&
282
301
  (result.outcome === 'uninstalled' || result.outcome === 'uninstall_noop');
283
- const hermesAdvisory = harness !== 'hermes' ? undefined : removed ? clearHermesLocalState() : hermesKeyKeptAdvisory();
284
- const advisory = [removed ? removeHarnessSkills(harness) : undefined, hermesAdvisory]
302
+ const advisory = [
303
+ removed ? removeHarnessSkills(harness) : undefined,
304
+ localStateAdvisory(harness, removed),
305
+ ]
285
306
  .filter((text) => text !== undefined)
286
307
  .join(' ');
287
308
  if (!advisory)
@@ -39,4 +39,5 @@ export interface ConnectAllOptions {
39
39
  /** The harness has never created its dir, so connecting would build one it does not own. */
40
40
  export declare function needsFirstRun(detection: HarnessDetection): boolean;
41
41
  export declare function runConnectAll(opts: ConnectAllOptions): Promise<number>;
42
+ export declare const firstTaskPrompt: (server?: string) => string;
42
43
  export declare const FIRST_TASK_PROMPT: string;
@@ -1,3 +1,4 @@
1
+ import { CLAUDE_PLUGIN_SERVER, connectedViaClaudePlugin } from './claude-plugin.js';
1
2
  import * as readline from 'node:readline/promises';
2
3
  import { harnessSpec } from './harness/harness-spec.js';
3
4
  import spawn from 'cross-spawn';
@@ -9,7 +10,7 @@ import { authGate, mapConnectError, OAUTH_SIGN_IN_TIMEOUT_MS } from './connect-a
9
10
  import { actionable, computeExitCode, reloadHint, renderSummary } from './connect-all-summary.js';
10
11
  import { planText, uninstallHarness, uninstallPlanText } from './connect-all-uninstall.js';
11
12
  import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
12
- import { KEYLESS_ONBOARDING_PROMPT } from './connect-clients.js';
13
+ import { KEYLESS_ONBOARDING_PROMPT, useServerLine } from './connect-clients.js';
13
14
  import { gateApiKey } from './connect-preflight.js';
14
15
  import { ConnectInterruptedError } from './connect-runtime.js';
15
16
  import { pickHarnesses } from './connect-picker.js';
@@ -386,13 +387,13 @@ export async function runConnectAll(opts) {
386
387
  }
387
388
  return finishRun(results, opts, isTTY);
388
389
  }
389
- export const FIRST_TASK_PROMPT = 'My TinyFish setup just finished — verify it with a real run. Use the MCP server named ' +
390
- '`tinyfish` for every TinyFish tool call; if another TinyFish server is also registered, still ' +
391
- "use `tinyfish` and do not ask me which. Then use the TinyFish search tool to find today's " +
392
- 'top Hacker News story, fetch that page with the TinyFish fetch tool, and give me a two-line ' +
390
+ export const firstTaskPrompt = (server = 'tinyfish') => `My TinyFish setup just finished — verify it with a real run. ${useServerLine(server)} ` +
391
+ "Then use the TinyFish search tool to find today's top Hacker News story," +
392
+ ' fetch that page with the TinyFish fetch tool, and give me a two-line ' +
393
393
  'summary. Show which tools you call. If a tool call comes back blocked or not approved, tell ' +
394
394
  'me which tool needs approval and how to grant it, then wait for me instead of abandoning the ' +
395
395
  'task.';
396
+ export const FIRST_TASK_PROMPT = firstTaskPrompt();
396
397
  // Launchable seeded agents, best-first — cursor-agent shares ~/.cursor/mcp.json with the IDE.
397
398
  const LAUNCHERS = [
398
399
  { harness: 'cursor', args: [FIRST_TASK_PROMPT] },
@@ -430,7 +431,12 @@ async function launchFirstTask(results) {
430
431
  outLine(`Starting ${DISPLAY_NAMES[chosen.harness]} with your first TinyFish task...`);
431
432
  const keyless = chosen.harness === 'omp' &&
432
433
  results.some((result) => result.harness === 'omp' && !result.keyAuthed);
433
- const args = keyless ? [KEYLESS_ONBOARDING_PROMPT] : chosen.args;
434
+ const pluginServer = chosen.harness === 'claude-code' && connectedViaClaudePlugin();
435
+ const args = keyless
436
+ ? [KEYLESS_ONBOARDING_PROMPT]
437
+ : pluginServer
438
+ ? [firstTaskPrompt(CLAUDE_PLUGIN_SERVER)]
439
+ : chosen.args;
434
440
  const result = spawn.sync(HARNESS_COMMANDS[chosen.harness], args, { stdio: 'inherit' });
435
441
  return !result.error;
436
442
  }
@@ -1,6 +1,7 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { type AgentClient, type SupportedCommand } from './connect-runtime.js';
3
3
  import { type HarnessSpec, type SkillAgent } from './harness/harness-spec.js';
4
+ import { installClaudeDirectoryPlugin } from './claude-plugin.js';
4
5
  export declare function openclawSkillInstallArgs(ackFlag: string | undefined): string[];
5
6
  /** `openclaw skills` has no uninstall; ClawHub removes from the global dir. */
6
7
  export declare function openclawSkillUninstall(): {
@@ -9,6 +10,8 @@ export declare function openclawSkillUninstall(): {
9
10
  /** npx fetches the package first; the 10s removal bound starves a cold cache. */
10
11
  timeoutMs: number;
11
12
  };
13
+ export declare const useServerLine: (server: string) => string;
14
+ export declare const onboardingPrompt: (server?: string) => string;
12
15
  export declare const DEFAULT_ONBOARDING_PROMPT: string;
13
16
  export declare const OPENCLAW_ONBOARDING_PROMPT: string;
14
17
  export declare const KEYLESS_ONBOARDING_PROMPT: string;
@@ -70,6 +73,13 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
70
73
  args: string[];
71
74
  label: string;
72
75
  };
76
+ pluginInstall?: PluginInstall;
77
+ }
78
+ /** Replaces `mcp add`; a fallback reason means the add runs instead. */
79
+ export interface PluginInstall {
80
+ install: typeof installClaudeDirectoryPlugin;
81
+ loginArgs: string[];
82
+ serverName: string;
73
83
  }
74
84
  /** Keyless installs use this argv; the harness signs itself in. */
75
85
  interface OauthCapableMcpClient extends BaseMcpClient {
@@ -9,6 +9,7 @@ import { HARNESS_SPECS, NATIVE_HARNESSES, harnessSpec, } from './harness/harness
9
9
  import { HERMES_KEY_VAR, captureHermesKeyRestore, hermesEnvPath, resolveHermesHome, writeHermesKey, } from './hermes-env.js';
10
10
  import { hermesConfigPath, readHermesEntry } from './config/hermes-config.js';
11
11
  import { installHermesPlugin, removeHermesMcpEntry, setHermesWebBackends, writeHermesMcpEntry, } from './hermes-plugin.js';
12
+ import { CLAUDE_PLUGIN_SERVER, installClaudeDirectoryPlugin } from './claude-plugin.js';
12
13
  const HERMES_SEED_TIMEOUT_MS = 120_000;
13
14
  const OPENCLAW_SKILL = '@tinyfish/tinyfish';
14
15
  // --force makes this an unconditional overwrite, so the same call installs and refreshes.
@@ -33,13 +34,15 @@ export function openclawSkillUninstall() {
33
34
  timeoutMs: SKILL_INSTALL_TIMEOUT_MS,
34
35
  };
35
36
  }
36
- export const DEFAULT_ONBOARDING_PROMPT = 'I just connected TinyFish. Use the MCP server named `tinyfish` for every TinyFish tool call; ' +
37
- 'if another TinyFish server is also registered, still use `tinyfish` and do not ask me which. ' +
37
+ export const useServerLine = (server) => `Use the MCP server named \`${server}\` for every TinyFish tool call; if another TinyFish ` +
38
+ `server is also registered, still use \`${server}\` and do not ask me which.`;
39
+ export const onboardingPrompt = (server = 'tinyfish') => `I just connected TinyFish. ${useServerLine(server)} ` +
38
40
  'Start by asking me what I want to look up on the web, and wait for my reply. Then call ' +
39
41
  '`guide_next_step` and follow its instructions one step at a time, asking for my input and ' +
40
42
  'waiting for my reply before each subsequent TinyFish tool call. If a tool call comes back ' +
41
43
  'blocked or not approved, tell me which tool needs approval and how to grant it, then wait for ' +
42
44
  'me instead of ending the walkthrough.';
45
+ export const DEFAULT_ONBOARDING_PROMPT = onboardingPrompt();
43
46
  export const OPENCLAW_ONBOARDING_PROMPT = 'I just installed the TinyFish skill. Guide me through it interactively: first ask what topic I ' +
44
47
  'want to search and wait for my reply before using TinyFish Search. Show me the results, ask ' +
45
48
  'which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task ' +
@@ -258,6 +261,13 @@ function specKeyAuth(spec) {
258
261
  }
259
262
  /** Descriptor behaviour the spec cannot hold; every other field is spec data. */
260
263
  const HARNESS_OVERRIDES = {
264
+ 'claude-code': {
265
+ pluginInstall: {
266
+ install: installClaudeDirectoryPlugin,
267
+ loginArgs: ['mcp', 'login', CLAUDE_PLUGIN_SERVER],
268
+ serverName: CLAUDE_PLUGIN_SERVER,
269
+ },
270
+ },
261
271
  cline: { launchWalkthrough: launchCline },
262
272
  codex: { detachedLaunch: launchCodexWalkthrough },
263
273
  hermes: {
@@ -301,6 +311,7 @@ function toNativeClient(harness) {
301
311
  extraPostInstall: overrides.extraPostInstall,
302
312
  uninstallExtra: spec.uninstallExtra,
303
313
  verifyListed: spec.verifyListed,
314
+ pluginInstall: overrides.pluginInstall,
304
315
  };
305
316
  if (spec.keyRequired) {
306
317
  if (!keyAuth)
@@ -1,6 +1,6 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validatedApiKey, } from './auth.js';
3
- import { KEYLESS_ONBOARDING_PROMPT, launchNativeMcpClient, } from './connect-clients.js';
3
+ import { KEYLESS_ONBOARDING_PROMPT, launchNativeMcpClient, onboardingPrompt, } from './connect-clients.js';
4
4
  import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
5
5
  import { ensureCliAuthenticated } from './connect-auth.js';
6
6
  import { installWebSkill } from './skill-install.js';
@@ -14,7 +14,8 @@ import { SIGN_IN_PROBES, TINYFISH_LIST_ROW } from './registration-detect.js';
14
14
  import { errLine, sanitizeLine, warnLine } from './output.js';
15
15
  import { verifyMcpAuth } from './verify.js';
16
16
  import { dotenvMarkerHead, harnessDotenvPath, writeDotenvKey } from './dotenv-file.js';
17
- import { apiBaseFromMcpUrl } from './mcp-endpoint.js';
17
+ import { apiBaseFromMcpUrl, isDefaultEndpoint } from './mcp-endpoint.js';
18
+ import { removeWebSkills } from './skill-vendor.js';
18
19
  import { finishSetupHint, requireKeylessMcp, requireSupportOrRescue, runCliInstallStep, trackPostInstallFailure, walkthroughPhase, } from './connect-steps.js';
19
20
  /** Several installs can disagree; every later spawn reuses this one. */
20
21
  function pinExecutable(client) {
@@ -243,8 +244,27 @@ function performDirectInstall(client, install, mcpUrl) {
243
244
  const detail = registered.detail ? `: ${registered.detail}` : '';
244
245
  throw new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there${detail}.`, 'invalid_config', { failureDetail: registered.tag });
245
246
  }
246
- /** Returns the seeded-credential note when a key was written into a user-owned file. */
247
+ /** Directory plugin first; on any fallback reason, the harness add. */
247
248
  function performMcpAdd(client, options, plan, attemptId) {
249
+ if (!client.pluginInstall)
250
+ return performHarnessAdd(client, options, plan, attemptId);
251
+ let pluginFallbackReason = 'custom-url';
252
+ // The plugin pins the prod endpoint; `--url` needs the add.
253
+ if (isDefaultEndpoint(options.mcpUrl)) {
254
+ errLine(`Installing the TinyFish plugin in ${client.displayName}...`);
255
+ const plugin = client.pluginInstall.install({
256
+ command: client.command,
257
+ attemptId,
258
+ apiKey: plan.keyedAdd?.key,
259
+ verbose: options.verbose ?? false,
260
+ });
261
+ if (plugin.installed)
262
+ return { note: plugin.note, plugin: true };
263
+ pluginFallbackReason = plugin.fallbackReason;
264
+ }
265
+ return { ...performHarnessAdd(client, options, plan, attemptId), pluginFallbackReason };
266
+ }
267
+ function performHarnessAdd(client, options, plan, attemptId) {
248
268
  const { keyedAdd, buildAddArgs, directInstall } = plan;
249
269
  const mcpUrl = new URL(options.mcpUrl);
250
270
  mcpUrl.searchParams.set('source', CONNECT_SOURCE);
@@ -363,6 +383,10 @@ async function completeAuthFlow(client, options, state, telemetry, ctx) {
363
383
  : AuthMode.OAuth;
364
384
  return { authMode, signInDeferred };
365
385
  }
386
+ /** A degraded harness keeps sign-in deferred on the plugin path too. */
387
+ function authLoginArgs(client, loginArgs, plugin) {
388
+ return plugin ? loginArgs && client.pluginInstall?.loginArgs : loginArgs;
389
+ }
366
390
  export async function connectNativeMcpClient(unpinned, options) {
367
391
  const client = pinExecutable(unpinned);
368
392
  const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
@@ -391,8 +415,9 @@ export async function connectNativeMcpClient(unpinned, options) {
391
415
  // A probed client authenticates inside `mcp add` too, so a failure there is either.
392
416
  state.stage =
393
417
  loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
394
- const { note: seededNote, seededHome } = performMcpAdd(client, options, plan, telemetry.attemptId);
395
- telemetry.track('checkpoint', { phase: 'registered' });
418
+ const { note: seededNote, seededHome, plugin, pluginFallbackReason, } = performMcpAdd(client, options, plan, telemetry.attemptId);
419
+ state.plugin = plugin;
420
+ telemetry.track('checkpoint', { phase: 'registered', pluginFallbackReason });
396
421
  // Only 2.x lists a variant, and only 2.x wrote the project file (PF-6613).
397
422
  const twoPointX = client.verifyListed && matchedVariant !== undefined;
398
423
  for (const stray of twoPointX ? strayProjectKeyFiles() : []) {
@@ -402,7 +427,7 @@ export async function connectNativeMcpClient(unpinned, options) {
402
427
  useKeyAuth,
403
428
  useKeyless,
404
429
  loginDegraded,
405
- loginArgs,
430
+ loginArgs: authLoginArgs(client, loginArgs, plugin),
406
431
  detachedLogin: !!client.detachedLoginVariant && matchedVariant === client.detachedLoginVariant,
407
432
  probeAuthenticated,
408
433
  seededNote,
@@ -470,13 +495,18 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
470
495
  };
471
496
  try {
472
497
  // Above every step: a Ctrl+C below must not lose a registration already reported (PF-3803).
473
- saveConnectContext(client.connectClient, telemetry.attemptId, state.authMode);
498
+ saveConnectContext(client.connectClient, telemetry.attemptId, state.authMode, state.plugin);
474
499
  if (options.cliInstall !== false) {
475
500
  await attempt('cli_install', () => runCliInstallStep(options, telemetry));
476
501
  }
477
- if (client.skillAgent) {
502
+ const { skillAgent } = client;
503
+ if (skillAgent) {
478
504
  await attempt('skill_install', () => {
479
- installWebSkill(client, { verbose: options.verbose ?? false });
505
+ // The plugin ships its own skills; vendored copies would duplicate them.
506
+ if (state.plugin)
507
+ removeWebSkills(skillAgent);
508
+ else
509
+ installWebSkill(client, { verbose: options.verbose ?? false });
480
510
  telemetry.track('checkpoint', { phase: 'skill_installed' });
481
511
  });
482
512
  }
@@ -509,7 +539,9 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
509
539
  telemetry.track('checkpoint', { phase: walkthroughPhase(outcome) });
510
540
  // Flushed pre-block; the session-long handover would strand the delivery.
511
541
  await telemetry.flush();
512
- }, state.authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
542
+ }, state.authMode === AuthMode.Keyless
543
+ ? KEYLESS_ONBOARDING_PROMPT
544
+ : onboardingPrompt(state.plugin ? client.pluginInstall?.serverName : undefined));
513
545
  });
514
546
  }
515
547
  }
@@ -62,6 +62,8 @@ export interface ConnectRunState {
62
62
  harnessDegraded?: boolean;
63
63
  /** Resolved after the probe, so callers read real key use rather than key presence. */
64
64
  authMode?: ConnectAuthMode;
65
+ /** The Directory plugin replaced `mcp add` and the vendored skills. */
66
+ plugin?: boolean;
65
67
  }
66
68
  interface ConnectStageDetail {
67
69
  failedStage?: ConnectFailureStage;
@@ -79,6 +81,8 @@ interface ConnectStageDetail {
79
81
  failureDetail?: string;
80
82
  /** pi only: without the adapter its MCP entry is dormant. */
81
83
  piAdapterState?: PiAdapterState;
84
+ /** claude-code only: why connect skipped the Directory plugin. */
85
+ pluginFallbackReason?: string;
82
86
  }
83
87
  export interface ConnectTelemetry {
84
88
  attemptId: string;
@@ -195,9 +195,9 @@ function isBelowMin(version, minVersion) {
195
195
  // Flags go unseen for reasons besides age; state facts, not causes.
196
196
  function unavailableMessage(client, version) {
197
197
  const { minVersion } = client.supportCheck;
198
- const found = version ? ` (found ${version})` : '';
199
- const required = minVersion && (isBelowMin(version, minVersion) ?? version === undefined)
200
- ? ` ${client.displayName} ${minVersion} or newer is required${found}.`
198
+ // A known version below the minimum already failed in requireMinVersion.
199
+ const required = minVersion && version === undefined
200
+ ? ` ${client.displayName} ${minVersion} or newer is required.`
201
201
  : '';
202
202
  return (`${client.supportCheck.unavailableMessage}${required}` +
203
203
  ' Set TINYFISH_DEBUG=1 and retry to print the help output TinyFish read.');
@@ -292,8 +292,8 @@ export function requireCommandSupport(client) {
292
292
  }
293
293
  // Old releases can list the flags yet mishandle them; unknown passes.
294
294
  function requireMinVersion(client, harnessVersion) {
295
- const { minVersion, updateCommand, enforceMinVersion } = client.supportCheck;
296
- if (!enforceMinVersion || !isBelowMin(harnessVersion, minVersion))
295
+ const { minVersion, updateCommand } = client.supportCheck;
296
+ if (!isBelowMin(harnessVersion, minVersion))
297
297
  return;
298
298
  const fix = updateCommand ? `Run \`${updateCommand}\`` : `Update ${client.displayName}`;
299
299
  throw new ConnectStepError(`${client.displayName} ${minVersion} or newer is required (found ${harnessVersion}). ` +
@@ -353,22 +353,24 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
353
353
  });
354
354
  }
355
355
  function eventBody(stage, detail, stageDurationMs) {
356
+ const d = detail ?? {};
356
357
  return JSON.stringify({
357
358
  attempt_id: attemptId,
358
359
  client,
359
360
  stage,
360
- failed_stage: detail?.failedStage,
361
- phase: detail?.phase,
362
- failure_reason: detail?.failureReason,
361
+ failed_stage: d.failedStage,
362
+ phase: d.phase,
363
+ failure_reason: d.failureReason,
363
364
  // Only with failed_stage: the route refine 400s the event otherwise.
364
- failure_detail: detail?.failedStage ? detail.failureDetail : undefined,
365
+ failure_detail: d.failedStage ? d.failureDetail : undefined,
365
366
  // Terminal rows only: rescue checkpoints stay wire-identical to normal ones.
366
367
  fallback_outcome: stage === 'failed' || stage === 'aborted' ? fallbackOutcome : undefined,
367
- harness_version: detail?.harnessVersion ?? harnessVersion,
368
- plugin_version: detail?.pluginVersion,
369
- auth_mode: detail?.authMode,
370
- harness_degraded: detail?.harnessDegraded,
371
- pi_adapter_state: detail?.piAdapterState,
368
+ harness_version: d.harnessVersion ?? harnessVersion,
369
+ plugin_version: d.pluginVersion,
370
+ auth_mode: d.authMode,
371
+ harness_degraded: d.harnessDegraded,
372
+ pi_adapter_state: d.piAdapterState,
373
+ plugin_fallback_reason: d.pluginFallbackReason,
372
374
  stage_duration_ms: stageDurationMs,
373
375
  runtime_platform: process.platform,
374
376
  node_version: process.version,
@@ -21,3 +21,4 @@ export declare const HARNESS_PROBE_TIMEOUT_MS = 15000;
21
21
  export declare const HERMES_COLD_START_TIMEOUT_MS = 60000;
22
22
  /** Hermes' own id for the TinyFish web plugin. */
23
23
  export declare const HERMES_PLUGIN_NAME = "tinyfish";
24
+ export declare const CLAUDE_PLUGIN_ID = "tinyfish@anthropic-plugin-directory";
@@ -66,3 +66,4 @@ export const HARNESS_PROBE_TIMEOUT_MS = 15_000;
66
66
  export const HERMES_COLD_START_TIMEOUT_MS = 60_000;
67
67
  /** Hermes' own id for the TinyFish web plugin. */
68
68
  export const HERMES_PLUGIN_NAME = 'tinyfish';
69
+ export const CLAUDE_PLUGIN_ID = 'tinyfish@anthropic-plugin-directory';
@@ -16,8 +16,6 @@ export interface HarnessSupportCheck {
16
16
  timeoutMs?: number;
17
17
  /** First release with the probed flags; bisected upstream, not derivable. */
18
18
  minVersion?: string;
19
- /** Refuse below minVersion even when the probed flags are listed. */
20
- enforceMinVersion?: true;
21
19
  /** Shown in the below-minVersion error as the one-step fix. */
22
20
  updateCommand?: string;
23
21
  unavailableMessage: string;
@@ -125,6 +123,10 @@ export declare const HARNESS_SPECS: {
125
123
  args: string[];
126
124
  label: string;
127
125
  }[];
126
+ uninstallExtra: {
127
+ args: string[];
128
+ label: string;
129
+ };
128
130
  };
129
131
  cline: {
130
132
  command: string;
@@ -229,7 +231,6 @@ export declare const HARNESS_SPECS: {
229
231
  keyAuthPattern: RegExp;
230
232
  timeoutMs: number;
231
233
  minVersion: string;
232
- enforceMinVersion: true;
233
234
  updateCommand: string;
234
235
  unavailableMessage: string;
235
236
  };
@@ -334,6 +335,8 @@ export declare const HARNESS_SPECS: {
334
335
  /** Derived from spec keys; one entry extends every union. */
335
336
  export type Harness = keyof typeof HARNESS_SPECS;
336
337
  export declare const ALL_HARNESSES: readonly Harness[];
338
+ export declare const HARNESS_CHOICES: string[];
339
+ export declare function resolveHarnessName(name: string): string;
337
340
  export declare function harnessSpec(harness: Harness): HarnessSpec;
338
341
  /** The rest of the union: every one of these must supply a connect and launch override. */
339
342
  export type NonNativeHarness = {
@@ -1,4 +1,4 @@
1
- import { HERMES_COLD_START_TIMEOUT_MS, HERMES_PLUGIN_NAME } from '../constants.js';
1
+ import { CLAUDE_PLUGIN_ID, HERMES_COLD_START_TIMEOUT_MS, HERMES_PLUGIN_NAME, } from '../constants.js';
2
2
  import { CLAUDE_CODE_DEFERRED_AUTH_NOTE, CLAUDE_CODE_KEYED_FALLBACK_NOTE, CLINE_MCP_ADD_UNAVAILABLE_MESSAGE, CODEX_DEFERRED_AUTH_NOTE, CODEX_KEYED_FALLBACK_NOTE, CODEX_MCP_URL_UNAVAILABLE_MESSAGE, GROK_HTTP_UNAVAILABLE_MESSAGE, GROK_SIGN_IN_HINT, HERMES_OAUTH_UNAVAILABLE_MESSAGE, HERMES_RESTART_NOTE, KILO_MCP_ADD_UNAVAILABLE_MESSAGE, MCP_ADD_UNAVAILABLE_MESSAGE, OPENCLAW_SKILL_UNAVAILABLE_MESSAGE, OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE, OPENCODE_MODEL_NOTE, } from './harness-messages.js';
3
3
  const HEADER_FLAG = /(?:^|\s)--header(?:[\s<=]|$)/m;
4
4
  /** One entry per harness; every per-harness list derives from it. */
@@ -35,7 +35,17 @@ export const HARNESS_SPECS = {
35
35
  args: ['mcp', 'remove', 'tinyfish', '--scope', 'local'],
36
36
  label: 'local TinyFish registration',
37
37
  },
38
+ // Cookbook plugin server is also plugin:tinyfish:tinyfish; it shadows ours.
39
+ {
40
+ args: ['plugin', 'uninstall', 'tinyfish@tinyfish-marketplace'],
41
+ label: 'TinyFish cookbook plugin',
42
+ },
38
43
  ],
44
+ // Not a removal: those run pre-add and would undo a Directory-off keep.
45
+ uninstallExtra: {
46
+ args: ['plugin', 'uninstall', CLAUDE_PLUGIN_ID],
47
+ label: 'TinyFish Directory plugin',
48
+ },
39
49
  },
40
50
  cline: {
41
51
  command: 'cline',
@@ -145,7 +155,6 @@ export const HARNESS_SPECS = {
145
155
  timeoutMs: HERMES_COLD_START_TIMEOUT_MS,
146
156
  // Older `config set` saves our JSON entry as an unusable string.
147
157
  minVersion: '0.21.2',
148
- enforceMinVersion: true,
149
158
  updateCommand: 'hermes update',
150
159
  unavailableMessage: HERMES_OAUTH_UNAVAILABLE_MESSAGE,
151
160
  },
@@ -257,6 +266,12 @@ export const HARNESS_SPECS = {
257
266
  },
258
267
  };
259
268
  export const ALL_HARNESSES = Object.keys(HARNESS_SPECS);
269
+ // Input-only: telemetry, insights and the cookbook doctor skill key on `claude-code`.
270
+ const HARNESS_ALIASES = { claude: 'claude-code' };
271
+ export const HARNESS_CHOICES = [...ALL_HARNESSES, ...Object.keys(HARNESS_ALIASES)];
272
+ export function resolveHarnessName(name) {
273
+ return Object.hasOwn(HARNESS_ALIASES, name) ? HARNESS_ALIASES[name] : name;
274
+ }
260
275
  export function harnessSpec(harness) {
261
276
  return HARNESS_SPECS[harness];
262
277
  }
@@ -31,13 +31,16 @@ function freshBundleDir() {
31
31
  export function updateWebSkill({ verbose }) {
32
32
  // One entry per `tinyfish connect <client>`, persisted by PF-3169.
33
33
  const connected = loadConfig().connect ?? {};
34
- const skillAgents = ALL_HARNESSES.filter((harness) => connected[harness])
34
+ // A plugin install ships and updates its own skills.
35
+ const skillAgents = ALL_HARNESSES.filter((h) => connected[h] && !connected[h].plugin)
35
36
  .map((harness) => harnessSpec(harness).skillAgent)
36
37
  .filter((agent) => agent !== undefined);
37
38
  const hasOpenClaw = Boolean(connected['openclaw']);
38
39
  // No recorded skill install means no target dirs; connect is the install path.
39
40
  if (skillAgents.length === 0 && !hasOpenClaw) {
40
- errLine('No TinyFish skills to refresh. Run `tinyfish connect <client>` to install them.');
41
+ if (!Object.values(connected).some((entry) => entry.plugin)) {
42
+ errLine('No TinyFish skills to refresh. Run `tinyfish connect <client>` to install them.');
43
+ }
41
44
  return false;
42
45
  }
43
46
  let refreshed = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.53.1-next.445",
3
+ "version": "0.53.1-next.447",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {