@tiny-fish/cli 0.12.1-next.139 → 0.12.1-next.143

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 CHANGED
@@ -263,7 +263,7 @@ tinyfish agent run "..." --url https://example.com --use-vault \
263
263
 
264
264
  By default all commands output newline-delimited JSON to stdout — pipe-friendly for agents and scripts. Add `--pretty` for human-readable output.
265
265
 
266
- Errors are JSON to stderr with exit code 1. Ctrl+C during a streaming run cancels it automatically.
266
+ Errors are JSON to stderr with exit code 1. Ctrl+C during a streaming run cancels it automatically. The server may also attach an advisory upgrade notice to stderr: a plain line in a terminal, and JSON (`{"notice":…,"level":…}`) in non-interactive use. The JSON message is composed locally from the notice level, never from server-sent prose. It never changes stdout or the exit code.
267
267
 
268
268
  `--output-schema` and `--output-schema-file` are mutually exclusive. Both inputs must parse to a top-level JSON object. The CLI only validates that outer shape locally; the API performs full schema validation and may return errors such as `INVALID_INPUT` or feature-flag access failures.
269
269
 
@@ -6,10 +6,10 @@ import { CLI_VERSION } from "../lib/constants.js";
6
6
  import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig } from "../lib/cursor-config.js";
7
7
  import { runConnectAll } from "../lib/connect-all.js";
8
8
  import { detectHumanInitiated } from "../lib/harness.js";
9
+ import { captureNotice, emitNotice } from "../lib/notice.js";
9
10
  import { errLine } from "../lib/output.js";
10
11
  import { telemetryDisabled, telemetryHeaders } from "../lib/setup-telemetry.js";
11
12
  import { verifyMcpAuth } from "../lib/verify.js";
12
- import { recordInstalledVersion } from "../lib/version-marker.js";
13
13
  export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
14
14
  const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
15
15
  const SKILL_INSTALL_TIMEOUT_MS = 120_000;
@@ -254,6 +254,8 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
254
254
  redirect: "error",
255
255
  signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS),
256
256
  });
257
+ // Before the ok branch so an error response still yields a notice.
258
+ captureNotice(response.headers);
257
259
  if (response.ok)
258
260
  return;
259
261
  if (response.status < 500) {
@@ -591,7 +593,6 @@ async function connectNativeMcpClient(client, options) {
591
593
  // Persist once here (skill installed + authenticated) so both success paths, launch
592
594
  // or not, record the install; buildConnectHeaders reads it on subsequent requests.
593
595
  saveConnectContext(client.connectClient, telemetry.attemptId);
594
- recordInstalledVersion();
595
596
  errLine(UPGRADE_HINT);
596
597
  if (!options.launch) {
597
598
  errLine(`TinyFish is connected. Open ${client.displayName} to start using it.`);
@@ -662,7 +663,6 @@ export async function connectOpenClaw(options) {
662
663
  }
663
664
  telemetry.track("checkpoint", { phase: "authenticated" });
664
665
  saveConnectContext("openclaw", telemetry.attemptId);
665
- recordInstalledVersion();
666
666
  errLine(UPGRADE_HINT);
667
667
  if (!options.launch) {
668
668
  errLine("TinyFish is connected. Open OpenClaw to start using it.");
@@ -706,7 +706,6 @@ export async function connectCursor(options) {
706
706
  }
707
707
  telemetry.track("checkpoint", { phase: "registered" });
708
708
  saveConnectContext("cursor", telemetry.attemptId);
709
- recordInstalledVersion();
710
709
  if (resolvedKey) {
711
710
  // Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
712
711
  const verify = await verifyMcpAuth(resolvedKey);
@@ -841,5 +840,7 @@ export function registerConnect(program) {
841
840
  client +
842
841
  ". Supported clients: claude-code, codex, cursor, hermes, openclaw");
843
842
  }
843
+ // Deferred to here so the notice lands after install output, not interleaved with it.
844
+ emitNotice();
844
845
  });
845
846
  }
@@ -4,11 +4,11 @@ import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectO
4
4
  import { loadConfig, validateKeyFormat } from "./auth.js";
5
5
  import { detectInstalledHarnesses, FIVE_HARNESSES, } from "./harness-detect.js";
6
6
  import { detectHumanInitiated } from "./harness.js";
7
+ import { emitNotice } from "./notice.js";
7
8
  import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.js";
8
9
  import { errLine, outLine } from "./output.js";
9
10
  import { sendSetupCompleted } from "./setup-telemetry.js";
10
11
  import { verifyMcpAuth, verifyMcpHealth } from "./verify.js";
11
- import { printUpdateNoticeIfDrifted, recordInstalledVersion } from "./version-marker.js";
12
12
  const DISPLAY_NAMES = {
13
13
  "claude-code": "Claude Code",
14
14
  codex: "Codex",
@@ -153,7 +153,6 @@ async function processHarness(detection, opts, prompt) {
153
153
  if (!interruptedBefore && process.exitCode === 130) {
154
154
  return { ...base, outcome: "interrupted", fixCommand: `tinyfish connect ${harness}` };
155
155
  }
156
- recordInstalledVersion();
157
156
  const verify = await verifyHarness(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
158
157
  return {
159
158
  ...base,
@@ -292,7 +291,8 @@ export async function runConnectAll(opts) {
292
291
  verify_ok: r.verifyOk ?? null,
293
292
  }));
294
293
  await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, resolvedApiKey(opts.apiKey));
295
- await printUpdateNoticeIfDrifted();
294
+ // After the summary and before the launch, which is where the user is reading.
295
+ emitNotice();
296
296
  }
297
297
  const exitCode = computeExitCode(results);
298
298
  const interrupted = results.some((r) => r.outcome === "interrupted");
@@ -0,0 +1,8 @@
1
+ /** Test-only: the capture slot and the once-per-invocation latch are module state. */
2
+ export declare function resetNoticeState(): void;
3
+ /** Records the first notice seen; printing is deferred so it lands at the end of a command. */
4
+ export declare function captureNotice(headers: Headers): void;
5
+ /** Advisory only, never changes stdout, the exit code, or control flow. */
6
+ export declare function emitNotice(): void;
7
+ /** For callers where the response is the end of the command, such as one-shot API calls. */
8
+ export declare function surfaceNotice(headers: Headers): void;
@@ -0,0 +1,53 @@
1
+ // Surfaces the advisory X-TF-Notice header the server attaches to outdated clients (PF-3301).
2
+ import { CLI_VERSION } from "./constants.js";
3
+ import { detectHumanInitiated } from "./harness.js";
4
+ import { err, errLine, sanitizeLine } from "./output.js";
5
+ const NOTICE_HEADER = "x-tf-notice";
6
+ const LEVEL_HEADER = "x-tf-notice-level";
7
+ const MAX_LENGTH = 300;
8
+ const VALID_LEVELS = new Set(["required", "recommended"]);
9
+ let captured;
10
+ let printed = false;
11
+ /** Test-only: the capture slot and the once-per-invocation latch are module state. */
12
+ export function resetNoticeState() {
13
+ captured = undefined;
14
+ printed = false;
15
+ }
16
+ /** Records the first notice seen; printing is deferred so it lands at the end of a command. */
17
+ export function captureNotice(headers) {
18
+ if (captured)
19
+ return;
20
+ if (typeof headers?.get !== "function")
21
+ return;
22
+ const message = sanitizeLine(headers.get(NOTICE_HEADER) ?? "")
23
+ .trim()
24
+ .slice(0, MAX_LENGTH);
25
+ if (!message)
26
+ return;
27
+ const rawLevel = headers.get(LEVEL_HEADER)?.trim().toLowerCase();
28
+ // Spread rather than `level: undefined`, which exactOptionalPropertyTypes rejects.
29
+ captured = { message, ...(rawLevel && VALID_LEVELS.has(rawLevel) ? { level: rawLevel } : {}) };
30
+ }
31
+ /** Built here, not by the server, so nothing it sends can reach an agent's context as instructions. */
32
+ function agentMessage(level) {
33
+ return level === "required"
34
+ ? `This TinyFish CLI (${CLI_VERSION}) is below the required minimum. Run: tinyfish upgrade`
35
+ : `A TinyFish CLI update is available (you have ${CLI_VERSION}). Run: tinyfish upgrade`;
36
+ }
37
+ /** Advisory only, never changes stdout, the exit code, or control flow. */
38
+ export function emitNotice() {
39
+ if (printed || !captured)
40
+ return;
41
+ printed = true;
42
+ const { message, level } = captured;
43
+ if (detectHumanInitiated()) {
44
+ errLine(message);
45
+ return;
46
+ }
47
+ err({ notice: agentMessage(level), ...(level ? { level } : {}) });
48
+ }
49
+ /** For callers where the response is the end of the command, such as one-shot API calls. */
50
+ export function surfaceNotice(headers) {
51
+ captureNotice(headers);
52
+ emitNotice();
53
+ }
@@ -4,6 +4,8 @@ export declare class ApiError extends Error {
4
4
  constructor(status: number, message: string, code?: string | undefined);
5
5
  }
6
6
  export declare function out(data: unknown): void;
7
+ /** The only writer for untrusted text: strips ANSI and C0/C1 controls. */
8
+ export declare function sanitizeLine(text: string): string;
7
9
  export declare function outLine(line: string): void;
8
10
  export declare function errLine(line: string): void;
9
11
  export declare function err(data: unknown): void;
@@ -12,20 +12,21 @@ export class ApiError extends Error {
12
12
  export function out(data) {
13
13
  process.stdout.write(JSON.stringify(data) + "\n");
14
14
  }
15
- function safe(text) {
15
+ /** The only writer for untrusted text: strips ANSI and C0/C1 controls. */
16
+ export function sanitizeLine(text) {
16
17
  // eslint-disable-next-line no-control-regex
17
18
  return stripVTControlCharacters(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
18
19
  }
19
20
  export function outLine(line) {
20
- process.stdout.write(safe(line) + "\n");
21
+ process.stdout.write(sanitizeLine(line) + "\n");
21
22
  }
22
23
  export function errLine(line) {
23
- process.stderr.write(safe(line) + "\n");
24
+ process.stderr.write(sanitizeLine(line) + "\n");
24
25
  }
25
26
  export function err(data) {
26
27
  const payload = typeof data === "string" ? { error: data } : data;
27
28
  const serialized = JSON.stringify(payload) ?? "undefined";
28
- process.stderr.write(safe(serialized) + "\n");
29
+ process.stderr.write(sanitizeLine(serialized) + "\n");
29
30
  }
30
31
  export function handleApiError(e) {
31
32
  if (e instanceof ApiError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.12.1-next.139",
3
+ "version": "0.12.1-next.143",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "prepublishOnly": "npm run build"
33
33
  },
34
34
  "dependencies": {
35
- "@tiny-fish/sdk": "^0.2.0",
35
+ "@tiny-fish/sdk": "^0.3.0",
36
36
  "chrome-cookies-secure": "^3.0.2",
37
37
  "commander": "^12.0.0",
38
38
  "cross-spawn": "^7.0.6",
@@ -56,6 +56,7 @@
56
56
  "brace-expansion": "^5.0.9",
57
57
  "ip-address": ">=10.3.1",
58
58
  "postcss": ">=8.5.23",
59
+ "nanoid": "^3.3.17",
59
60
  "tar": "^7.5.21"
60
61
  },
61
62
  "engines": {
@@ -1,10 +0,0 @@
1
- /** Install-time, offline-safe; best-effort — never fails a finished install. */
2
- export declare function recordInstalledVersion(): void;
3
- interface UpdateCheckResult {
4
- updateAvailable: boolean;
5
- latest?: string;
6
- }
7
- /** Throttled npm-currency check; never auto-mutates the install. */
8
- export declare function checkForCliUpdate(now?: number): Promise<UpdateCheckResult>;
9
- export declare function printUpdateNoticeIfDrifted(): Promise<void>;
10
- export {};
@@ -1,109 +0,0 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import { z } from "zod";
4
- import { configDir } from "./auth.js";
5
- import { CLI_VERSION } from "./constants.js";
6
- import { errLine } from "./output.js";
7
- import { telemetryDisabled } from "./setup-telemetry.js";
8
- const MARKER_FILE = "skill-version-marker.json";
9
- const THROTTLE_MS = 24 * 60 * 60 * 1000;
10
- const CHECK_TIMEOUT_MS = 5_000;
11
- // npm is the only currency signal with a publisher behind it: the cookbook repo has no
12
- // releases and no release automation, so a tag check would report "up to date" forever.
13
- const REGISTRY_URL = "https://registry.npmjs.org/@tiny-fish/cli/latest";
14
- const markerSchema = z.object({
15
- last_checked_at: z.number().optional(),
16
- last_known_latest: z.string().optional(),
17
- });
18
- function markerPath() {
19
- return path.join(configDir(), MARKER_FILE);
20
- }
21
- function readMarker() {
22
- try {
23
- const parsed = markerSchema.safeParse(JSON.parse(fs.readFileSync(markerPath(), "utf8")));
24
- return parsed.success ? parsed.data : undefined;
25
- }
26
- catch {
27
- return undefined;
28
- }
29
- }
30
- function writeMarker(state) {
31
- fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
32
- const tmpPath = `${markerPath()}.tmp-${process.pid}`;
33
- fs.writeFileSync(tmpPath, JSON.stringify(state), { mode: 0o600 });
34
- fs.renameSync(tmpPath, markerPath());
35
- }
36
- /** Install-time, offline-safe; best-effort — never fails a finished install. */
37
- export function recordInstalledVersion() {
38
- try {
39
- writeMarker({ ...readMarker() });
40
- }
41
- catch {
42
- // marker is a nice-to-have
43
- }
44
- }
45
- const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/;
46
- /** Strictly newer; false on unparsable input. */
47
- function isNewer(current, latest) {
48
- const c = SEMVER.exec(current);
49
- const l = SEMVER.exec(latest);
50
- if (!c || !l)
51
- return false;
52
- for (let i = 1; i <= 3; i++) {
53
- const cn = Number(c[i]);
54
- const ln = Number(l[i]);
55
- if (ln !== cn)
56
- return ln > cn;
57
- }
58
- return false;
59
- }
60
- /** Throttled npm-currency check; never auto-mutates the install. */
61
- export async function checkForCliUpdate(now = Date.now()) {
62
- const marker = readMarker();
63
- // No marker means the CLI never installed anything — nothing to nag about.
64
- if (!marker || telemetryDisabled())
65
- return { updateAvailable: false };
66
- const cached = () => ({
67
- updateAvailable: marker.last_known_latest !== undefined && isNewer(CLI_VERSION, marker.last_known_latest),
68
- ...(marker.last_known_latest !== undefined ? { latest: marker.last_known_latest } : {}),
69
- });
70
- // Time-based only — failed fetch must not stall every invocation.
71
- if (marker.last_checked_at !== undefined && now - marker.last_checked_at < THROTTLE_MS) {
72
- return cached();
73
- }
74
- let latest;
75
- try {
76
- const response = await fetch(REGISTRY_URL, {
77
- headers: { accept: "application/json" },
78
- signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
79
- });
80
- if (response.ok) {
81
- const body = (await response.json());
82
- if (typeof body.version === "string")
83
- latest = body.version;
84
- }
85
- }
86
- catch {
87
- // Unreachable registry — don't fail setup, don't erase the cache.
88
- }
89
- try {
90
- writeMarker({
91
- ...marker,
92
- last_checked_at: now,
93
- last_known_latest: latest ?? marker.last_known_latest,
94
- });
95
- }
96
- catch {
97
- // best-effort
98
- }
99
- // Fall back to the cache so consecutive invocations can't disagree.
100
- if (!latest)
101
- return cached();
102
- return { updateAvailable: isNewer(CLI_VERSION, latest), latest };
103
- }
104
- export async function printUpdateNoticeIfDrifted() {
105
- const { updateAvailable } = await checkForCliUpdate();
106
- if (updateAvailable) {
107
- errLine("a newer TinyFish CLI is available — re-run `npx @tiny-fish/cli connect --all`");
108
- }
109
- }