@tiny-fish/cli 0.1.7 → 0.1.8-next.71

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
@@ -130,6 +130,9 @@ tinyfish fetch content get https://agentql.com --format markdown
130
130
  # Include links and image links
131
131
  tinyfish fetch content get https://agentql.com --links --image-links
132
132
 
133
+ # Bound each URL independently
134
+ tinyfish fetch content get https://agentql.com --per-url-timeout-ms 45000
135
+
133
136
  # Human-readable output
134
137
  tinyfish fetch content get https://agentql.com --pretty
135
138
  ```
@@ -147,6 +150,33 @@ tinyfish browser session create --url https://agentql.com
147
150
  tinyfish browser session create --pretty
148
151
  ```
149
152
 
153
+ ### Vault
154
+
155
+ Connect a credential provider (1Password / Bitwarden), then have runs consume those credentials.
156
+
157
+ Provider secrets are read from the environment, never passed as flags — the same model as `TINYFISH_API_KEY`. This keeps tokens out of shell history and process listings.
158
+
159
+ ```bash
160
+ # Connect 1Password (token from env)
161
+ TINYFISH_VAULT_TOKEN=<service-account-token> \
162
+ tinyfish vault connection add --provider 1password
163
+
164
+ # Connect Bitwarden (client secret + master password from env; client ID is a flag)
165
+ TINYFISH_VAULT_CLIENT_SECRET=<secret> TINYFISH_VAULT_MASTER_PASSWORD=<password> \
166
+ tinyfish vault connection add --provider bitwarden --client-id <client-id>
167
+
168
+ # List / disconnect connections
169
+ tinyfish vault connection list
170
+ tinyfish vault connection remove <connectionId>
171
+
172
+ # List credential items. If empty right after connecting, sync first.
173
+ tinyfish vault item sync
174
+ tinyfish vault item list
175
+ # → each item has an `itemId` (e.g. cred:conn-123:Personal:item-abc123).
176
+ ```
177
+
178
+ Credential items are sourced from the connected provider — the CLI has no freeform credential create/edit (mirrors the API).
179
+
150
180
  ### Output format
151
181
 
152
182
  By default all commands output newline-delimited JSON to stdout — pipe-friendly for agents and scripts. Add `--pretty` for human-readable output.
@@ -1,6 +1,18 @@
1
+ import { InvalidArgumentError } from "commander";
1
2
  import { getApiKey } from "../lib/auth.js";
2
3
  import { fetchContentGet } from "../lib/client.js";
3
4
  import { handleApiError, out, outLine } from "../lib/output.js";
5
+ const MIN_PER_URL_TIMEOUT_MS = 1;
6
+ const MAX_PER_URL_TIMEOUT_MS = 110000;
7
+ function parsePerUrlTimeoutMs(value) {
8
+ const parsed = Number(value);
9
+ if (!Number.isInteger(parsed) ||
10
+ parsed < MIN_PER_URL_TIMEOUT_MS ||
11
+ parsed > MAX_PER_URL_TIMEOUT_MS) {
12
+ throw new InvalidArgumentError(`must be an integer between ${MIN_PER_URL_TIMEOUT_MS} and ${MAX_PER_URL_TIMEOUT_MS}`);
13
+ }
14
+ return parsed;
15
+ }
4
16
  function printPrettyFetch(response) {
5
17
  outLine(`Results: ${response.results.length}`);
6
18
  outLine(`Errors: ${response.errors.length}`);
@@ -42,6 +54,7 @@ export function registerFetch(program) {
42
54
  .option("--format <format>", "Output format: markdown, html, or json")
43
55
  .option("--links", "Include extracted links")
44
56
  .option("--image-links", "Include extracted image links")
57
+ .option("--per-url-timeout-ms <milliseconds>", "Per-URL timeout budget in milliseconds", parsePerUrlTimeoutMs)
45
58
  .option("--pretty", "Human-readable output")
46
59
  .action(async (urls, opts) => {
47
60
  const apiKey = getApiKey();
@@ -51,6 +64,9 @@ export function registerFetch(program) {
51
64
  ...(opts.format !== undefined ? { format: opts.format } : {}),
52
65
  ...(opts.links !== undefined ? { links: opts.links } : {}),
53
66
  ...(opts.imageLinks !== undefined ? { image_links: opts.imageLinks } : {}),
67
+ ...(opts.perUrlTimeoutMs !== undefined
68
+ ? { per_url_timeout_ms: opts.perUrlTimeoutMs }
69
+ : {}),
54
70
  }, apiKey);
55
71
  if (opts.pretty) {
56
72
  printPrettyFetch(response);
@@ -0,0 +1,14 @@
1
+ import { Command } from "commander";
2
+ import type { CookieRecord } from "../lib/types.js";
3
+ export declare function extractChromeCookies(domain: string): Promise<CookieRecord[]>;
4
+ export declare function extractAllChromeCookies(): Promise<CookieRecord[]>;
5
+ type TldtsParse = (host: string, opts: {
6
+ allowPrivateDomains: boolean;
7
+ }) => {
8
+ domain?: string;
9
+ };
10
+ export declare function groupByCanonicalDomain(cookies: CookieRecord[], parse: TldtsParse): Map<string, CookieRecord[]>;
11
+ export declare const MAX_COOKIES_PER_UPLOAD = 3000;
12
+ export declare function buildChunks(groups: Map<string, CookieRecord[]>): CookieRecord[][];
13
+ export declare function registerProfile(program: Command): void;
14
+ export {};
@@ -0,0 +1,315 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { createRequire } from "module";
5
+ import { getApiKey } from "../lib/auth.js";
6
+ import { profileCreate, profileUpload } from "../lib/client.js";
7
+ import { err, errLine, handleApiError, out, outLine } from "../lib/output.js";
8
+ // ── UserFacingError ───────────────────────────────────────────────────────────
9
+ // Thrown by helpers to signal a terminal error that should be printed and exit 1
10
+ // without going through handleApiError (which would print a second error line).
11
+ class UserFacingError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "UserFacingError";
15
+ }
16
+ }
17
+ // ── Helpers ───────────────────────────────────────────────────────────────────
18
+ function getChromeCookiesPath() {
19
+ const platform = process.platform;
20
+ if (platform === "darwin") {
21
+ return path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome", "Default", "Cookies");
22
+ }
23
+ if (platform === "linux") {
24
+ return path.join(os.homedir(), ".config", "google-chrome", "Default", "Cookies");
25
+ }
26
+ // Windows guard — handled in withCookiesDb
27
+ return "";
28
+ }
29
+ function validateDomain(domain) {
30
+ if (domain.includes("/") || domain.includes("://") || /\s/.test(domain) || /:\d/.test(domain)) {
31
+ err({ error: "domain must be a plain hostname (e.g. github.com)" });
32
+ process.exit(1);
33
+ }
34
+ }
35
+ // Chrome-internal timestamp (microseconds since 1601-01-01) → Unix seconds
36
+ const CHROME_EPOCH_DELTA_SECONDS = 11644473600;
37
+ function chromeTimeToUnix(chromeMicros) {
38
+ return Math.floor(chromeMicros / 1e6) - CHROME_EPOCH_DELTA_SECONDS;
39
+ }
40
+ // Sets up chrome-cookies-secure in a temp dir and calls fn; cleans up after.
41
+ async function withCookiesDb(fn) {
42
+ if (process.platform === "win32") {
43
+ throw new UserFacingError("Cookie extraction is not supported on Windows yet");
44
+ }
45
+ const cookiesPath = getChromeCookiesPath();
46
+ if (!fs.existsSync(cookiesPath)) {
47
+ throw new UserFacingError(`Chrome cookies file not found at ${cookiesPath}. Is Google Chrome installed?`);
48
+ }
49
+ // chrome-cookies-secure expects a profile directory and appends "/Cookies".
50
+ // Copy the file into a temp dir so we avoid the Chrome file lock.
51
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tinyfish-chrome-cookies-"));
52
+ try {
53
+ fs.copyFileSync(cookiesPath, path.join(tmpDir, "Cookies"));
54
+ // chrome-cookies-secure is a CommonJS module — use createRequire to load it
55
+ const require = createRequire(import.meta.url);
56
+ const chromeCookies = require("chrome-cookies-secure");
57
+ return await fn(tmpDir, chromeCookies);
58
+ }
59
+ finally {
60
+ try {
61
+ fs.rmSync(tmpDir, { recursive: true, force: true });
62
+ }
63
+ catch {
64
+ // Ignore cleanup errors
65
+ }
66
+ }
67
+ }
68
+ // Extract cookies for a single domain using an already-open cookies DB.
69
+ async function extractDomain(tmpDir, chromeCookies, domain) {
70
+ // "puppeteer" format returns an array with capitalized boolean fields and
71
+ // Chrome-internal microsecond timestamps — convert to our CookieRecord shape.
72
+ const raw = await chromeCookies.getCookiesPromised(`https://${domain}`, "puppeteer", tmpDir);
73
+ return raw.map((c) => ({
74
+ name: c.name,
75
+ value: c.value,
76
+ domain: c.domain,
77
+ path: c.path,
78
+ secure: c.Secure ?? false,
79
+ httpOnly: c.HttpOnly ?? false,
80
+ ...(c.expires && c.expires > 0 ? { expires: chromeTimeToUnix(c.expires) } : {}),
81
+ }));
82
+ }
83
+ // Exported for testing
84
+ export async function extractChromeCookies(domain) {
85
+ return withCookiesDb((tmpDir, chromeCookies) => extractDomain(tmpDir, chromeCookies, domain));
86
+ }
87
+ // Exported for testing
88
+ export async function extractAllChromeCookies() {
89
+ return withCookiesDb(async (tmpDir, chromeCookies) => {
90
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
91
+ let sqliteDb = null;
92
+ let hostKeys;
93
+ try {
94
+ sqliteDb = new DatabaseSync(path.join(tmpDir, "Cookies")); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
95
+ // Only import domains with auth-relevant cookies — skips tracking/analytics junk.
96
+ // A domain qualifies if it has any HttpOnly cookie (session/auth indicator) or
97
+ // a cookie whose name suggests authentication.
98
+ const rows = sqliteDb
99
+ .prepare(`SELECT DISTINCT host_key FROM cookies
100
+ WHERE is_httponly = 1
101
+ OR name LIKE '%session%'
102
+ OR name LIKE '%token%'
103
+ OR name LIKE '%auth%'
104
+ OR name LIKE '%sid%'
105
+ OR name LIKE '%login%'
106
+ OR name LIKE '%user%'
107
+ OR name LIKE '%account%'`)
108
+ .all();
109
+ hostKeys = rows.map((r) => r.host_key);
110
+ }
111
+ catch (e) {
112
+ throw new UserFacingError(`Failed to read Chrome cookies database: ${e instanceof Error ? e.message : String(e)}`);
113
+ }
114
+ finally {
115
+ try {
116
+ sqliteDb?.close();
117
+ }
118
+ catch {
119
+ // Ignore
120
+ }
121
+ }
122
+ // 2. Canonicalize host_keys → registrable domains (mirrors frontend/app/lib/services/domain-utils.ts)
123
+ const require = createRequire(import.meta.url);
124
+ const { parse } = require("tldts");
125
+ const registrableDomains = new Set();
126
+ for (const hk of hostKeys) {
127
+ const cleaned = hk.startsWith(".") ? hk.slice(1) : hk;
128
+ const result = parse(cleaned, { allowPrivateDomains: true });
129
+ if (result.domain)
130
+ registrableDomains.add(result.domain.toLowerCase());
131
+ }
132
+ errLine(`Scanning ${registrableDomains.size} domains from Chrome...`);
133
+ // 3. Extract per-domain and concat
134
+ const all = [];
135
+ for (const domain of registrableDomains) {
136
+ try {
137
+ const cookies = await extractDomain(tmpDir, chromeCookies, domain);
138
+ all.push(...cookies);
139
+ }
140
+ catch {
141
+ // skip domains chrome-cookies-secure can't parse (IPs, .local, etc.)
142
+ }
143
+ }
144
+ // 4. Dedup by name+domain+path
145
+ const seen = new Set();
146
+ return all.filter((c) => {
147
+ const key = `${c.name}\t${c.domain}\t${c.path}`;
148
+ if (seen.has(key))
149
+ return false;
150
+ seen.add(key);
151
+ return true;
152
+ });
153
+ });
154
+ }
155
+ // Exported for testing
156
+ export function groupByCanonicalDomain(cookies, parse) {
157
+ const groups = new Map();
158
+ for (const c of cookies) {
159
+ const cleaned = c.domain.startsWith(".") ? c.domain.slice(1) : c.domain;
160
+ const cd = parse(cleaned, { allowPrivateDomains: true }).domain?.toLowerCase() ?? cleaned;
161
+ const bucket = groups.get(cd) ?? [];
162
+ bucket.push(c);
163
+ groups.set(cd, bucket);
164
+ }
165
+ return groups;
166
+ }
167
+ // Exported for testing
168
+ export const MAX_COOKIES_PER_UPLOAD = 3000;
169
+ // Exported for testing
170
+ export function buildChunks(groups) {
171
+ const chunks = [];
172
+ let current = [];
173
+ for (const group of groups.values()) {
174
+ // A group that alone exceeds the cap is its own chunk (never split a canonical domain)
175
+ if (group.length > MAX_COOKIES_PER_UPLOAD) {
176
+ if (current.length > 0) {
177
+ chunks.push(current);
178
+ current = [];
179
+ }
180
+ chunks.push(group);
181
+ }
182
+ else if (current.length + group.length > MAX_COOKIES_PER_UPLOAD) {
183
+ chunks.push(current);
184
+ current = [...group];
185
+ }
186
+ else {
187
+ current.push(...group);
188
+ }
189
+ }
190
+ if (current.length > 0)
191
+ chunks.push(current);
192
+ return chunks;
193
+ }
194
+ // ── Pretty printers ───────────────────────────────────────────────────────────
195
+ function printPrettyProfileCreate(profile) {
196
+ outLine("Profile created");
197
+ outLine(`ID: ${profile.id}`);
198
+ outLine(`Name: ${profile.name}`);
199
+ outLine(`Created at: ${profile.created_at}`);
200
+ }
201
+ function printPrettyProfileUpload(result) {
202
+ if (result.profile_id)
203
+ outLine(`Profile ID: ${result.profile_id}`);
204
+ outLine(`Cookies uploaded: ${result.cookie_count}`);
205
+ outLine(`Domains updated: ${result.domains_updated.join(", ")}`);
206
+ }
207
+ export function registerProfile(program) {
208
+ const profileCmd = program
209
+ .command("profile")
210
+ .description("Profile commands")
211
+ .enablePositionalOptions();
212
+ profileCmd
213
+ .command("create")
214
+ .description("Create a browser profile")
215
+ .requiredOption("--name <name>", "Profile name")
216
+ .option("--pretty", "Human-readable output")
217
+ .action(async (opts) => {
218
+ const apiKey = getApiKey();
219
+ try {
220
+ const response = await profileCreate(opts.name, apiKey);
221
+ if (opts.pretty) {
222
+ printPrettyProfileCreate(response);
223
+ }
224
+ else {
225
+ out(response);
226
+ }
227
+ }
228
+ catch (error) {
229
+ handleApiError(error);
230
+ }
231
+ });
232
+ profileCmd
233
+ .command("import-cookies")
234
+ .description("Import Chrome cookies into a profile. Use --domain for a single domain or --all-domains for every domain in your Chrome profile. Exactly one must be provided.")
235
+ .option("--domain <domain>", "Domain to import cookies for (e.g. github.com)")
236
+ .option("--all-domains", "Import cookies for all registrable domains (e.g. github.com) found in your Chrome profile. IPs and non-resolvable hostnames (e.g. localhost) are skipped.")
237
+ .option("--profile-id <id>", "Profile ID to import cookies into (creates new profile if omitted)")
238
+ .option("--pretty", "Human-readable output")
239
+ .action(async (opts) => {
240
+ // Mutual-exclusivity guard — must provide exactly one of --domain / --all-domains
241
+ if (!opts.domain && !opts.allDomains) {
242
+ err({ error: "Provide exactly one of --domain or --all-domains" });
243
+ process.exit(1);
244
+ }
245
+ if (opts.domain && opts.allDomains) {
246
+ err({ error: "Provide exactly one of --domain or --all-domains" });
247
+ process.exit(1);
248
+ }
249
+ const apiKey = getApiKey();
250
+ if (opts.domain) {
251
+ validateDomain(opts.domain);
252
+ }
253
+ let profileId = opts.profileId;
254
+ try {
255
+ // Extract cookies before creating profile to avoid orphan profiles
256
+ // if extraction fails (Windows, no Chrome, no cookies).
257
+ const cookies = opts.allDomains
258
+ ? await extractAllChromeCookies()
259
+ : await extractChromeCookies(opts.domain);
260
+ if (cookies.length === 0) {
261
+ throw new UserFacingError(opts.allDomains
262
+ ? "No Chrome cookies found. Is Google Chrome installed and have you logged in to any sites?"
263
+ : `No Chrome cookies found for ${opts.domain}. Are you logged in to this site in Chrome?`);
264
+ }
265
+ if (profileId === undefined) {
266
+ const name = opts.allDomains ? "chrome-import" : opts.domain;
267
+ const created = await profileCreate(name, apiKey);
268
+ profileId = created.id;
269
+ }
270
+ let result;
271
+ if (opts.allDomains) {
272
+ // Batch upload — never split a canonical domain across chunks
273
+ const require = createRequire(import.meta.url);
274
+ const { parse } = require("tldts");
275
+ const groups = groupByCanonicalDomain(cookies, parse);
276
+ const chunks = buildChunks(groups);
277
+ let cookieCount = 0;
278
+ const domainsUpdated = [];
279
+ const domainsFailed = [];
280
+ for (const chunk of chunks) {
281
+ const r = await profileUpload(profileId, chunk, apiKey);
282
+ cookieCount += r.cookie_count;
283
+ domainsUpdated.push(...r.domains_updated);
284
+ if (r.domains_failed)
285
+ domainsFailed.push(...r.domains_failed);
286
+ }
287
+ result = {
288
+ profile_id: profileId,
289
+ cookie_count: cookieCount,
290
+ domains_updated: domainsUpdated,
291
+ ...(domainsFailed.length > 0 ? { domains_failed: domainsFailed } : {}),
292
+ };
293
+ }
294
+ else {
295
+ result = await profileUpload(profileId, cookies, apiKey);
296
+ }
297
+ if (result.domains_failed && result.domains_failed.length > 0) {
298
+ errLine(`Warning: failed to update cookies for domains: ${result.domains_failed.join(", ")}`);
299
+ }
300
+ if (opts.pretty) {
301
+ printPrettyProfileUpload(result);
302
+ }
303
+ else {
304
+ out(result);
305
+ }
306
+ }
307
+ catch (error) {
308
+ if (error instanceof UserFacingError) {
309
+ err({ error: error.message });
310
+ process.exit(1);
311
+ }
312
+ handleApiError(error);
313
+ }
314
+ });
315
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerVault(program: Command): void;
@@ -0,0 +1,177 @@
1
+ import { getApiKey } from "../lib/auth.js";
2
+ import { vaultConnect, vaultConnections, vaultDisconnect, vaultItems, vaultSync, } from "../lib/client.js";
3
+ import { err, handleApiError, out, outLine } from "../lib/output.js";
4
+ const VALID_PROVIDERS = ["1password", "bitwarden"];
5
+ // Provider secrets are read from the environment, never flags — same model as
6
+ // the API key (TINYFISH_API_KEY). This keeps tokens out of shell history and
7
+ // process listings, and needs no interactive prompt (agent-safe).
8
+ const TOKEN_ENV = "TINYFISH_VAULT_TOKEN";
9
+ const CLIENT_SECRET_ENV = "TINYFISH_VAULT_CLIENT_SECRET";
10
+ const MASTER_PASSWORD_ENV = "TINYFISH_VAULT_MASTER_PASSWORD";
11
+ function requireEnv(name, field, provider) {
12
+ const value = process.env[name];
13
+ if (!value) {
14
+ err({ error: `${provider} requires ${field} via $${name}` });
15
+ process.exit(1);
16
+ }
17
+ return value;
18
+ }
19
+ function buildConnectRequest(opts) {
20
+ if (!opts.provider || !VALID_PROVIDERS.includes(opts.provider)) {
21
+ err({ error: `--provider must be one of: ${VALID_PROVIDERS.join(", ")}` });
22
+ process.exit(1);
23
+ }
24
+ if (opts.provider === "1password") {
25
+ return { provider: "1password", token: requireEnv(TOKEN_ENV, "a service-account token", "1password") };
26
+ }
27
+ // bitwarden
28
+ if (!opts.clientId) {
29
+ err({ error: "bitwarden requires --client-id" });
30
+ process.exit(1);
31
+ }
32
+ return {
33
+ provider: "bitwarden",
34
+ clientId: opts.clientId,
35
+ clientSecret: requireEnv(CLIENT_SECRET_ENV, "a client secret", "bitwarden"),
36
+ masterPassword: requireEnv(MASTER_PASSWORD_ENV, "a master password", "bitwarden"),
37
+ ...(opts.serverUrl ? { serverUrl: opts.serverUrl } : {}),
38
+ };
39
+ }
40
+ // ── Pretty printers ───────────────────────────────────────────────────────────
41
+ function printConnections(connections) {
42
+ if (connections.length === 0) {
43
+ outLine("No vault connections.");
44
+ return;
45
+ }
46
+ for (const c of connections) {
47
+ outLine(`${c.id} ${c.provider} ${c.connectionStatus} (validated: ${c.lastValidatedAt ?? "never"})`);
48
+ }
49
+ }
50
+ function printItems(items) {
51
+ if (items.length === 0) {
52
+ outLine("No vault items. Connect a provider and run `tinyfish vault item sync`.");
53
+ return;
54
+ }
55
+ for (const item of items) {
56
+ const totp = item.hasTotp ? " [TOTP]" : "";
57
+ outLine(`${item.itemId} ${item.label} (${item.vaultName}) ${item.domains.join(", ")}${totp}`);
58
+ }
59
+ }
60
+ function printConnect(result) {
61
+ outLine("Vault connected");
62
+ outLine(`Connection ID: ${result.connectionId}`);
63
+ outLine(`Provider: ${result.provider}`);
64
+ outLine(`Items: ${result.items.length}`);
65
+ }
66
+ function printSync(result) {
67
+ const { added, updated, removed } = result.sync_summary;
68
+ outLine(`Synced ${result.items.length} items (added ${added}, updated ${updated}, removed ${removed})`);
69
+ }
70
+ // ── Command registration ──────────────────────────────────────────────────────
71
+ export function registerVault(program) {
72
+ const vaultCmd = program
73
+ .command("vault")
74
+ .description("Vault credential commands")
75
+ .enablePositionalOptions();
76
+ const connectionCmd = vaultCmd
77
+ .command("connection")
78
+ .description("Manage vault provider connections")
79
+ .enablePositionalOptions();
80
+ connectionCmd
81
+ .command("list")
82
+ .description("List connected vault providers")
83
+ .option("--pretty", "Human-readable output")
84
+ .action(async (opts) => {
85
+ const apiKey = getApiKey();
86
+ try {
87
+ const response = await vaultConnections(apiKey);
88
+ if (opts.pretty)
89
+ printConnections(response.connections);
90
+ else
91
+ out(response);
92
+ }
93
+ catch (error) {
94
+ handleApiError(error);
95
+ }
96
+ });
97
+ connectionCmd
98
+ .command("add")
99
+ .description(`Connect a vault provider. Secrets come from the environment, not flags: ` +
100
+ `1password reads $${TOKEN_ENV}; bitwarden reads $${CLIENT_SECRET_ENV} and $${MASTER_PASSWORD_ENV}.`)
101
+ .requiredOption("--provider <provider>", `Vault provider (${VALID_PROVIDERS.join("|")})`)
102
+ .option("--client-id <id>", "Bitwarden client ID (non-secret)")
103
+ .option("--server-url <url>", "Self-hosted Bitwarden server URL")
104
+ .option("--pretty", "Human-readable output")
105
+ .action(async (opts) => {
106
+ const apiKey = getApiKey();
107
+ const req = buildConnectRequest(opts);
108
+ try {
109
+ const response = await vaultConnect(req, apiKey);
110
+ if (opts.pretty)
111
+ printConnect(response);
112
+ else
113
+ out(response);
114
+ }
115
+ catch (error) {
116
+ handleApiError(error);
117
+ }
118
+ });
119
+ connectionCmd
120
+ .command("remove")
121
+ .description("Disconnect a vault provider and delete its stored credentials")
122
+ .argument("<connectionId>", "Connection ID to disconnect")
123
+ .option("--pretty", "Human-readable output")
124
+ .action(async (connectionId, opts) => {
125
+ const apiKey = getApiKey();
126
+ try {
127
+ const response = await vaultDisconnect(connectionId, apiKey);
128
+ if (opts.pretty)
129
+ outLine(`Disconnected ${response.connectionId}`);
130
+ else
131
+ out(response);
132
+ }
133
+ catch (error) {
134
+ handleApiError(error);
135
+ }
136
+ });
137
+ const itemCmd = vaultCmd
138
+ .command("item")
139
+ .description("Inspect vault credential items")
140
+ .enablePositionalOptions();
141
+ itemCmd
142
+ .command("list")
143
+ .description("List vault credential items across all connections. Each item's `itemId` " +
144
+ "is what you pass to `agent run --credential-item-ids`. If the list is empty " +
145
+ "after connecting, run `vault item sync` first.")
146
+ .option("--pretty", "Human-readable output")
147
+ .action(async (opts) => {
148
+ const apiKey = getApiKey();
149
+ try {
150
+ const response = await vaultItems(apiKey);
151
+ if (opts.pretty)
152
+ printItems(response.items);
153
+ else
154
+ out(response);
155
+ }
156
+ catch (error) {
157
+ handleApiError(error);
158
+ }
159
+ });
160
+ itemCmd
161
+ .command("sync")
162
+ .description("Re-sync credential items from connected providers")
163
+ .option("--pretty", "Human-readable output")
164
+ .action(async (opts) => {
165
+ const apiKey = getApiKey();
166
+ try {
167
+ const response = await vaultSync(apiKey);
168
+ if (opts.pretty)
169
+ printSync(response);
170
+ else
171
+ out(response);
172
+ }
173
+ catch (error) {
174
+ handleApiError(error);
175
+ }
176
+ });
177
+ }
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import { registerAuth } from "./commands/auth.js";
5
5
  import { registerBatch } from "./commands/batch.js";
6
6
  import { registerFetch } from "./commands/fetch.js";
7
7
  import { registerBrowser } from "./commands/browser.js";
8
+ import { registerProfile } from "./commands/profile.js";
9
+ import { registerVault } from "./commands/vault.js";
8
10
  import { registerRun } from "./commands/run.js";
9
11
  import { registerRuns } from "./commands/runs.js";
10
12
  import { registerConfigureClaude } from "./commands/config-claude.js";
@@ -27,6 +29,8 @@ program
27
29
  registerAuth(program);
28
30
  registerConfigureClaude(program);
29
31
  registerBrowser(program);
32
+ registerProfile(program);
33
+ registerVault(program);
30
34
  const agentCmd = program
31
35
  .command("agent")
32
36
  .description("Agent automation commands")
@@ -1,5 +1,5 @@
1
1
  import { type AgentRunAsyncResponse, type AgentRunResponse, type AgentRunWithStreamingResponse, type FetchGetContentsParams, type FetchResponse, type BrowserSession, type Run, type RunListParams, type RunListResponse, type SearchQueryParams, type SearchQueryResponse } from "@tiny-fish/sdk";
2
- import type { BatchCancelResponse, BatchGetResponse, BatchRunRequest, BatchRunResponse, CancelRunResponse, CliAgentRunParams, CliBrowserSessionCreateParams, RunStepsResponse } from "./types.js";
2
+ import type { BatchCancelResponse, BatchGetResponse, BatchRunRequest, BatchRunResponse, CancelRunResponse, CliAgentRunParams, CliBrowserSessionCreateParams, CookieRecord, ProfileCreateResponse, ProfileUploadResponse, RunStepsResponse, VaultConnectRequest, VaultConnectResponse, VaultConnectionsListResponse, VaultDisconnectResponse, VaultItemsListResponse, VaultItemsSyncResponse } from "./types.js";
3
3
  export declare function runSync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunResponse>;
4
4
  export declare function runAsync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunAsyncResponse>;
5
5
  export declare function runStream(req: CliAgentRunParams, apiKey: string, signal?: AbortSignal): AsyncGenerator<AgentRunWithStreamingResponse>;
@@ -13,3 +13,10 @@ export declare function cancelRun(runId: string, apiKey: string): Promise<Cancel
13
13
  export declare function submitBatch(req: BatchRunRequest, apiKey: string): Promise<BatchRunResponse>;
14
14
  export declare function getBatchRuns(runIds: string[], apiKey: string): Promise<BatchGetResponse>;
15
15
  export declare function cancelBatchRuns(runIds: string[], apiKey: string): Promise<BatchCancelResponse>;
16
+ export declare function profileCreate(name: string, apiKey: string): Promise<ProfileCreateResponse>;
17
+ export declare function profileUpload(profileId: string, cookies: CookieRecord[], apiKey: string): Promise<ProfileUploadResponse>;
18
+ export declare function vaultConnections(apiKey: string): Promise<VaultConnectionsListResponse>;
19
+ export declare function vaultConnect(req: VaultConnectRequest, apiKey: string): Promise<VaultConnectResponse>;
20
+ export declare function vaultDisconnect(connectionId: string, apiKey: string): Promise<VaultDisconnectResponse>;
21
+ export declare function vaultItems(apiKey: string): Promise<VaultItemsListResponse>;
22
+ export declare function vaultSync(apiKey: string): Promise<VaultItemsSyncResponse>;
@@ -1,14 +1,40 @@
1
1
  import { APIStatusError, agentRunAsyncResponseSchema, agentRunResponseSchema, agentRunWithStreamingResponseSchema, fetchResponseSchema, browserSessionSchema, searchQueryResponseSchema, TinyFish, runSchema, runStatusSchema, } from "@tiny-fish/sdk";
2
- import { BASE_URL } from "./constants.js";
2
+ import { BASE_URL, CLI_VERSION } from "./constants.js";
3
3
  import { ApiError } from "./output.js";
4
4
  import { z } from "zod";
5
5
  class TinyFishCliClient extends TinyFish {
6
6
  _buildHeaders() {
7
+ const base = super._buildHeaders();
7
8
  return {
8
- ...super._buildHeaders(),
9
+ ...base,
9
10
  "X-TF-Request-Origin": "tinyfish-cli",
11
+ "User-Agent": `${base["User-Agent"] ?? ""} @tiny-fish/cli/${CLI_VERSION}`.trim(),
10
12
  };
11
13
  }
14
+ // The SDK base client only exposes get/post/postStream; DELETE is needed for
15
+ // disconnecting a vault connection. ponytail: raw fetch, no retry/timeout —
16
+ // matches the CLI's maxRetries:0; reuse the SDK request helper if more DELETE
17
+ // routes appear.
18
+ async del(path) {
19
+ const response = await fetch(`${this.baseURL}${path}`, {
20
+ method: "DELETE",
21
+ headers: this._buildHeaders(),
22
+ });
23
+ if (!response.ok) {
24
+ let message = response.statusText;
25
+ try {
26
+ const body = (await response.json());
27
+ message = body?.error?.message ?? body?.message ?? message;
28
+ }
29
+ catch {
30
+ // Non-JSON body — keep statusText
31
+ }
32
+ throw new ApiError(response.status, message);
33
+ }
34
+ if (response.status === 204)
35
+ return undefined;
36
+ return (await response.json());
37
+ }
12
38
  }
13
39
  function createSdkClient(apiKey, timeout) {
14
40
  return new TinyFishCliClient({
@@ -35,10 +61,12 @@ const cancelRunResponseSchema = z.object({
35
61
  });
36
62
  const batchRunResponseSchema = z.object({
37
63
  run_ids: z.array(z.string()).nullable(),
38
- error: z.object({
64
+ error: z
65
+ .object({
39
66
  code: z.string(),
40
67
  message: z.string(),
41
- }).nullable(),
68
+ })
69
+ .nullable(),
42
70
  });
43
71
  const batchGetResponseSchema = z.object({
44
72
  data: z.array(runSchema),
@@ -79,7 +107,9 @@ function parseWithSchema(schema, value, message) {
79
107
  return result.data;
80
108
  }
81
109
  function normalizeStreamEvent(event) {
82
- if (typeof event !== "object" || event === null || event.type !== "COMPLETE") {
110
+ if (typeof event !== "object" ||
111
+ event === null ||
112
+ event.type !== "COMPLETE") {
83
113
  return event;
84
114
  }
85
115
  const data = event;
@@ -270,3 +300,130 @@ export async function cancelBatchRuns(runIds, apiKey) {
270
300
  rethrowSdkError(error);
271
301
  }
272
302
  }
303
+ const profileCreateResponseSchema = z.object({
304
+ id: z.string(),
305
+ name: z.string(),
306
+ created_at: z.string(),
307
+ });
308
+ const profileUploadResponseSchema = z.object({
309
+ profile_id: z.string().optional(),
310
+ cookie_count: z.number(),
311
+ domains_updated: z.array(z.string()),
312
+ domains_failed: z.array(z.string()).optional(),
313
+ });
314
+ export async function profileCreate(name, apiKey) {
315
+ try {
316
+ const response = await createSdkClient(apiKey).post("/v1/profiles", {
317
+ json: { name },
318
+ });
319
+ return parseWithSchema(profileCreateResponseSchema, response, "Invalid profile create response");
320
+ }
321
+ catch (error) {
322
+ rethrowSdkError(error);
323
+ }
324
+ }
325
+ export async function profileUpload(profileId, cookies, apiKey) {
326
+ try {
327
+ const response = await createSdkClient(apiKey).post(`/v1/profiles/${encodeURIComponent(profileId)}/upload`, { json: { cookies } });
328
+ return parseWithSchema(profileUploadResponseSchema, response, "Invalid profile upload response");
329
+ }
330
+ catch (error) {
331
+ rethrowSdkError(error);
332
+ }
333
+ }
334
+ // ── Vault ───────────────────────────────────────────────────────────────────
335
+ // /v1/vault/* returns camelCase fields (unlike the snake_case rest of the API).
336
+ const vaultProviderSchema = z.enum(["1password", "bitwarden"]);
337
+ const vaultFieldMetadataSchema = z.object({
338
+ fieldId: z.string(),
339
+ label: z.string(),
340
+ type: z.enum(["STRING", "CONCEALED", "OTP"]),
341
+ });
342
+ const vaultItemSchema = z.object({
343
+ itemId: z.string(),
344
+ connectionId: z.string().nullable(),
345
+ label: z.string(),
346
+ vaultName: z.string(),
347
+ domains: z.array(z.string()),
348
+ fieldMetadata: z.array(vaultFieldMetadataSchema),
349
+ hasTotp: z.boolean(),
350
+ });
351
+ const vaultConnectionSchema = z.object({
352
+ id: z.string(),
353
+ provider: vaultProviderSchema,
354
+ connectionStatus: z.string(),
355
+ lastValidatedAt: z.string().nullable(),
356
+ });
357
+ const vaultConnectionsListResponseSchema = z.object({
358
+ connections: z.array(vaultConnectionSchema),
359
+ });
360
+ const vaultConnectResponseSchema = z.object({
361
+ connectionId: z.string(),
362
+ connected: z.literal(true),
363
+ provider: vaultProviderSchema,
364
+ items: z.array(z.unknown()), // server returns snake_case items; CLI only uses .length
365
+ });
366
+ const vaultDisconnectResponseSchema = z.object({
367
+ disconnected: z.literal(true),
368
+ connectionId: z.string(),
369
+ });
370
+ const vaultItemsListResponseSchema = z.object({
371
+ items: z.array(vaultItemSchema),
372
+ });
373
+ const vaultItemsSyncResponseSchema = z.object({
374
+ items: z.array(z.unknown()), // server returns snake_case items; CLI only uses sync_summary
375
+ sync_summary: z.object({
376
+ added: z.number(),
377
+ updated: z.number(),
378
+ removed: z.number(),
379
+ }),
380
+ });
381
+ export async function vaultConnections(apiKey) {
382
+ try {
383
+ const response = await createSdkClient(apiKey).get("/v1/vault/connections");
384
+ return parseWithSchema(vaultConnectionsListResponseSchema, response, "Invalid vault connections response");
385
+ }
386
+ catch (error) {
387
+ rethrowSdkError(error);
388
+ }
389
+ }
390
+ export async function vaultConnect(req, apiKey) {
391
+ try {
392
+ const response = await createSdkClient(apiKey).post("/v1/vault/connections", {
393
+ json: req,
394
+ });
395
+ return parseWithSchema(vaultConnectResponseSchema, response, "Invalid vault connect response");
396
+ }
397
+ catch (error) {
398
+ rethrowSdkError(error);
399
+ }
400
+ }
401
+ export async function vaultDisconnect(connectionId, apiKey) {
402
+ try {
403
+ const response = await createSdkClient(apiKey).del(`/v1/vault/connections/${encodeURIComponent(connectionId)}`);
404
+ return parseWithSchema(vaultDisconnectResponseSchema, response, "Invalid vault disconnect response");
405
+ }
406
+ catch (error) {
407
+ rethrowSdkError(error);
408
+ }
409
+ }
410
+ export async function vaultItems(apiKey) {
411
+ try {
412
+ const response = await createSdkClient(apiKey).get("/v1/vault/items");
413
+ return parseWithSchema(vaultItemsListResponseSchema, response, "Invalid vault items response");
414
+ }
415
+ catch (error) {
416
+ rethrowSdkError(error);
417
+ }
418
+ }
419
+ export async function vaultSync(apiKey) {
420
+ try {
421
+ const response = await createSdkClient(apiKey).post("/v1/vault/items/sync", {
422
+ json: {},
423
+ });
424
+ return parseWithSchema(vaultItemsSyncResponseSchema, response, "Invalid vault sync response");
425
+ }
426
+ catch (error) {
427
+ rethrowSdkError(error);
428
+ }
429
+ }
@@ -1,3 +1,4 @@
1
+ export declare const CLI_VERSION: string;
1
2
  export declare const BASE_URL: string;
2
3
  /** URL where users can create and manage API keys */
3
4
  export declare function getDashboardUrl(source?: string): string;
@@ -1,3 +1,7 @@
1
+ import { createRequire } from "module";
2
+ const _require = createRequire(import.meta.url);
3
+ const _pkg = _require("../../package.json");
4
+ export const CLI_VERSION = _pkg.version;
1
5
  /** Base URL for the TinyFish API. Override with TINYFISH_API_URL for staging/self-hosted (must be https). */
2
6
  const apiUrlOverride = process.env["TINYFISH_API_URL"]?.trim();
3
7
  if (apiUrlOverride) {
@@ -9,6 +9,61 @@ export interface CliAgentRunParams extends AgentRunParams {
9
9
  browser_profile?: BrowserProfile;
10
10
  session_id?: string;
11
11
  }
12
+ export type VaultProvider = "1password" | "bitwarden";
13
+ export interface VaultConnection {
14
+ id: string;
15
+ provider: VaultProvider;
16
+ connectionStatus: string;
17
+ lastValidatedAt: string | null;
18
+ }
19
+ export interface VaultFieldMetadata {
20
+ fieldId: string;
21
+ label: string;
22
+ type: "STRING" | "CONCEALED" | "OTP";
23
+ }
24
+ export interface VaultItem {
25
+ itemId: string;
26
+ connectionId: string | null;
27
+ label: string;
28
+ vaultName: string;
29
+ domains: string[];
30
+ fieldMetadata: VaultFieldMetadata[];
31
+ hasTotp: boolean;
32
+ }
33
+ export type VaultConnectRequest = {
34
+ provider: "1password";
35
+ token: string;
36
+ } | {
37
+ provider: "bitwarden";
38
+ clientId: string;
39
+ clientSecret: string;
40
+ masterPassword: string;
41
+ serverUrl?: string;
42
+ };
43
+ export interface VaultConnectionsListResponse {
44
+ connections: VaultConnection[];
45
+ }
46
+ export interface VaultConnectResponse {
47
+ connectionId: string;
48
+ connected: true;
49
+ provider: VaultProvider;
50
+ items: unknown[];
51
+ }
52
+ export interface VaultDisconnectResponse {
53
+ disconnected: true;
54
+ connectionId: string;
55
+ }
56
+ export interface VaultItemsListResponse {
57
+ items: VaultItem[];
58
+ }
59
+ export interface VaultItemsSyncResponse {
60
+ items: unknown[];
61
+ sync_summary: {
62
+ added: number;
63
+ updated: number;
64
+ removed: number;
65
+ };
66
+ }
12
67
  export interface RunStep {
13
68
  id: string;
14
69
  timestamp: string;
@@ -69,3 +124,24 @@ export interface LocalBatch {
69
124
  submitted: number;
70
125
  created_at: string;
71
126
  }
127
+ export interface ProfileCreateResponse {
128
+ id: string;
129
+ name: string;
130
+ created_at: string;
131
+ }
132
+ export interface CookieRecord {
133
+ name: string;
134
+ value: string;
135
+ domain: string;
136
+ path: string;
137
+ httpOnly: boolean;
138
+ secure: boolean;
139
+ sameSite?: string;
140
+ expires?: number;
141
+ }
142
+ export interface ProfileUploadResponse {
143
+ profile_id?: string;
144
+ cookie_count: number;
145
+ domains_updated: string[];
146
+ domains_failed?: string[];
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8-next.71",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@tiny-fish/sdk": "^0.0.7",
36
+ "chrome-cookies-secure": "^3.0.2",
36
37
  "commander": "^12.0.0",
37
38
  "globals": "^17.4.0",
38
39
  "zod": "^4.3.6"
@@ -48,9 +49,11 @@
48
49
  "vitest": "^4.1.0"
49
50
  },
50
51
  "overrides": {
51
- "vite": "7.3.2",
52
+ "esbuild": "0.28.1",
53
+ "vite": "7.3.5",
52
54
  "brace-expansion": "^5.0.6",
53
- "postcss": "8.5.10"
55
+ "postcss": "8.5.10",
56
+ "tar": "^7"
54
57
  },
55
58
  "engines": {
56
59
  "node": ">=24.0.0"