@trawlme/cli 1.11.0 → 1.13.0

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
@@ -71,12 +71,54 @@ Skills auto-update silently when you upgrade the CLI — no need to re-install m
71
71
 
72
72
  You can also install skills standalone (without the CLI): `npx @trawlme/skills install`.
73
73
 
74
+ ### Telemetry
75
+
76
+ ```
77
+ trawl telemetry on Enable usage telemetry (default)
78
+ trawl telemetry off Disable usage telemetry
79
+ trawl telemetry status Show current state, telemetry ID, and opt-out instructions
80
+ ```
81
+
82
+ ## Telemetry
83
+
84
+ `@trawlme/cli` collects anonymous usage data to help us improve the CLI based on real usage patterns. Telemetry is enabled by default.
85
+
86
+ **What we collect:**
87
+ - Command name (e.g. `scraps list`)
88
+ - CLI version
89
+ - Node.js version
90
+ - Platform (e.g. `darwin`, `linux`, `win32`)
91
+ - Command duration (`duration_ms`)
92
+ - Exit code (`0` = success, `1` = error)
93
+ - Error name if the command failed (e.g. `ApiError`) — never the error message or stack
94
+ - An opaque, randomly-generated telemetry ID (`cli_<uuid>`) stored locally in your config file
95
+
96
+ **What we do NOT collect:**
97
+ - Email address or any account identifier
98
+ - Scrap content, scrap URLs, or request bodies
99
+ - Org names or any business data
100
+ - IP address (PostHog EU Cloud anonymises it)
101
+ - Any personally identifiable information (PII)
102
+
103
+ **How to opt out:**
104
+
105
+ ```bash
106
+ # Permanent opt-out (written to config file)
107
+ trawl telemetry off
108
+
109
+ # Session-level opt-out (env var, takes precedence)
110
+ TRAWL_TELEMETRY=0 trawl scraps list
111
+ ```
112
+
113
+ **Why:** usage data helps us prioritise CLI features and catch silent errors before users report them.
114
+
74
115
  ## Environment variables
75
116
 
76
- | Variable | Description |
77
- |---------------|------------------------------------------------------|
78
- | `TRAWL_TOKEN` | JWT token — bypasses login prompt, useful for CI/CD |
79
- | `TRAWL_API_URL` | Override the default API URL (`https://api.trawl.me`) |
117
+ | Variable | Description |
118
+ |--------------------|-------------------------------------------------------------------|
119
+ | `TRAWL_TOKEN` | JWT token — bypasses login prompt, useful for CI/CD |
120
+ | `TRAWL_API_URL` | Override the default API URL (`https://api.trawl.me`) |
121
+ | `TRAWL_TELEMETRY` | Set to `0` to disable telemetry for the current session |
80
122
 
81
123
  ## API documentation
82
124
 
@@ -91,6 +91,7 @@ scraps
91
91
  console.log(chalk.dim(` Last run: `) + lastRun(data));
92
92
  console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
93
93
  });
94
+ const VALID_TIERS = ['tier0', 'tier1', 'tier2', 'tier3', 'tier4'];
94
95
  // create
95
96
  scraps
96
97
  .command('create')
@@ -99,13 +100,20 @@ scraps
99
100
  .option('-u, --url <url>', 'Target URL')
100
101
  .option('-r, --request <request>', 'Request/query')
101
102
  .option('-d, --description <text>', 'Scrap description')
103
+ .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
102
104
  .action(async (opts) => {
105
+ if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
106
+ console.log(chalk.red(`✗ Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`));
107
+ process.exitCode = 1;
108
+ return;
109
+ }
103
110
  const spinner = ora('Creating scrap…').start();
104
111
  const data = await api.post('/api/scraps', {
105
112
  title: opts.title,
106
113
  ...(opts.url && { url: opts.url }),
107
114
  request: opts.request || '',
108
115
  ...(opts.description !== undefined && { description: opts.description }),
116
+ ...(opts.tier !== undefined && { proxyTier: opts.tier }),
109
117
  });
110
118
  spinner.succeed(`Scrap created: ${chalk.bold(data._id)}`);
111
119
  console.log(chalk.dim(` Title: ${data.title}`));
@@ -126,8 +134,14 @@ scraps
126
134
  .option('--no-autofix', 'Disable AI Fix')
127
135
  .option('-p, --params <json>', 'Runtime params as JSON array of objects (e.g. \'[{"TRAWL.paramName":"value"}]\')')
128
136
  .option('--params-file <path>', 'Runtime params from a JSON file')
137
+ .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
129
138
  .action(async (id, opts) => {
130
139
  validateObjectId(id);
140
+ if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
141
+ console.log(chalk.red(`✗ Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`));
142
+ process.exitCode = 1;
143
+ return;
144
+ }
131
145
  const body = {};
132
146
  if (opts.title !== undefined)
133
147
  body.title = opts.title;
@@ -174,6 +188,8 @@ scraps
174
188
  }
175
189
  body.params = parsed;
176
190
  }
191
+ if (opts.tier !== undefined)
192
+ body.proxyTier = opts.tier;
177
193
  if (Object.keys(body).length === 0) {
178
194
  console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
179
195
  return;
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const telemetry: Command;
@@ -0,0 +1,43 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import config from '../lib/config.js';
4
+ import { initPostHog } from '../lib/posthog.js';
5
+ export const telemetry = new Command('telemetry').description('Manage CLI telemetry (usage data collection)');
6
+ telemetry
7
+ .command('on')
8
+ .description('Enable telemetry (default)')
9
+ .action(() => {
10
+ config.set('telemetry', true);
11
+ // Ensure a telemetryUserId is created on opt-in
12
+ initPostHog();
13
+ console.log(chalk.green('✓ Telemetry enabled'));
14
+ console.log(chalk.dim(' Anonymous usage data will be collected. Run `trawl telemetry status` to view.'));
15
+ });
16
+ telemetry
17
+ .command('off')
18
+ .description('Disable telemetry')
19
+ .action(() => {
20
+ config.set('telemetry', false);
21
+ console.log(chalk.green('✓ Telemetry disabled'));
22
+ console.log(chalk.dim(' No data will be sent. Your telemetry ID is kept for if you re-enable later.'));
23
+ });
24
+ telemetry
25
+ .command('status')
26
+ .description('Show current telemetry state')
27
+ .action(() => {
28
+ const enabled = config.get('telemetry') !== false;
29
+ const userId = config.get('telemetryUserId') || '(not yet generated)';
30
+ const envOverride = process.env['TRAWL_TELEMETRY'] === '0';
31
+ console.log(chalk.bold('Telemetry status'));
32
+ console.log(chalk.dim(' State: ') +
33
+ (envOverride
34
+ ? chalk.yellow('disabled (TRAWL_TELEMETRY=0 env var)')
35
+ : enabled
36
+ ? chalk.green('enabled')
37
+ : chalk.yellow('disabled')));
38
+ console.log(chalk.dim(' Telemetry ID: ') + userId);
39
+ console.log('');
40
+ console.log(chalk.dim(' To opt out:'));
41
+ console.log(chalk.dim(' trawl telemetry off'));
42
+ console.log(chalk.dim(' TRAWL_TELEMETRY=0 (env var, disables for this session)'));
43
+ });
package/dist/index.js CHANGED
@@ -7,8 +7,11 @@ import { dirname, join } from 'node:path';
7
7
  import { login, logout } from './commands/login.js';
8
8
  import { scraps } from './commands/scraps.js';
9
9
  import { skills } from './commands/skills.js';
10
+ import { telemetry } from './commands/telemetry.js';
10
11
  import { autoUpdateInstalledSkills } from './lib/skills.js';
12
+ import { initPostHog, captureCommand, shutdown } from './lib/posthog.js';
11
13
  autoUpdateInstalledSkills();
14
+ initPostHog();
12
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
16
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
14
17
  const program = new Command()
@@ -16,11 +19,32 @@ const program = new Command()
16
19
  .description('Trawl CLI — manage scraps from the terminal')
17
20
  .version(pkg.version)
18
21
  .option('--debug', 'Show full error stack traces');
22
+ // Track start times per command instance for duration measurement
23
+ const startTimes = new WeakMap();
24
+ program.hook('preAction', (thisCommand, actionCommand) => {
25
+ startTimes.set(actionCommand, Date.now());
26
+ });
27
+ program.hook('postAction', (thisCommand, actionCommand) => {
28
+ const start = startTimes.get(actionCommand);
29
+ if (start !== undefined) {
30
+ const name = actionCommand.parent
31
+ ? `${actionCommand.parent.name()} ${actionCommand.name()}`
32
+ : actionCommand.name();
33
+ void captureCommand(name, { duration_ms: Date.now() - start, exit_code: 0 });
34
+ }
35
+ });
19
36
  program.addCommand(login);
20
37
  program.addCommand(logout);
21
38
  program.addCommand(scraps);
22
39
  program.addCommand(skills);
40
+ program.addCommand(telemetry);
41
+ process.on('exit', () => {
42
+ void shutdown();
43
+ });
23
44
  program.parseAsync().catch((err) => {
45
+ // Capture error telemetry (best-effort: command name from process.argv)
46
+ const name = process.argv.slice(2).filter((a) => !a.startsWith('-')).join(' ') || 'unknown';
47
+ void captureCommand(name, { exit_code: 1, error: err.name });
24
48
  const { debug } = program.opts();
25
49
  if (debug || process.env['DEBUG']) {
26
50
  console.error(err);
package/dist/lib/api.js CHANGED
@@ -1,4 +1,10 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, resolve } from 'node:path';
1
4
  import config from './config.js';
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
7
+ const USER_AGENT = `@trawlme/cli/${pkg.version}`;
2
8
  class ApiError extends Error {
3
9
  status;
4
10
  constructor(status, message) {
@@ -59,6 +65,7 @@ async function request(path, options = {}) {
59
65
  ...options,
60
66
  headers: {
61
67
  'Content-Type': 'application/json',
68
+ 'User-Agent': USER_AGENT,
62
69
  ...options.headers,
63
70
  Cookie: `TOKEN=${token}`,
64
71
  },
@@ -89,6 +96,7 @@ async function upload(path, formData) {
89
96
  method: 'POST',
90
97
  body: formData,
91
98
  headers: {
99
+ 'User-Agent': USER_AGENT,
92
100
  Cookie: `TOKEN=${token}`,
93
101
  },
94
102
  });
@@ -112,7 +120,7 @@ async function publicPost(path, body) {
112
120
  const url = `${config.get('apiUrl')}${path}`;
113
121
  const res = await fetch(url, {
114
122
  method: 'POST',
115
- headers: { 'Content-Type': 'application/json' },
123
+ headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
116
124
  body: body ? JSON.stringify(body) : undefined,
117
125
  });
118
126
  await throwIfError(res, true);
@@ -146,6 +154,7 @@ export const api = {
146
154
  const res = await fetch(url, {
147
155
  headers: {
148
156
  Accept: 'text/event-stream',
157
+ 'User-Agent': USER_AGENT,
149
158
  Cookie: `TOKEN=${token}`,
150
159
  },
151
160
  });
@@ -2,6 +2,8 @@ import Conf from 'conf';
2
2
  interface TrawlConfig {
3
3
  apiUrl: string;
4
4
  token: string;
5
+ telemetry: boolean;
6
+ telemetryUserId: string;
5
7
  }
6
8
  declare const config: Conf<TrawlConfig>;
7
9
  export default config;
@@ -4,6 +4,8 @@ const config = new Conf({
4
4
  defaults: {
5
5
  apiUrl: 'https://api.trawl.me',
6
6
  token: '',
7
+ telemetry: true,
8
+ telemetryUserId: '',
7
9
  },
8
10
  });
9
11
  export default config;
@@ -0,0 +1,5 @@
1
+ export declare const initPostHog: () => void;
2
+ export declare const captureCommand: (command: string, props?: Record<string, unknown>) => void;
3
+ export declare const shutdown: () => Promise<void>;
4
+ /** Reset singleton state — for testing only */
5
+ export declare const _resetForTesting: () => void;
@@ -0,0 +1,52 @@
1
+ import { PostHog } from 'posthog-node';
2
+ import os from 'node:os';
3
+ import crypto from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, resolve } from 'node:path';
7
+ import config from './config.js';
8
+ // public-by-design — cf memory feedback_public_keys_in_config.md
9
+ const POSTHOG_KEY = 'phc_pGgSLivbq8rDZzXC9trWWJG7sBjNghXeTJjETzBcnEDv';
10
+ const POSTHOG_HOST = 'https://eu.i.posthog.com';
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
13
+ let client = null;
14
+ let distinctId = null;
15
+ export const initPostHog = () => {
16
+ if (config.get('telemetry') === false || process.env['TRAWL_TELEMETRY'] === '0')
17
+ return;
18
+ if (!config.get('telemetryUserId')) {
19
+ config.set('telemetryUserId', `cli_${crypto.randomUUID()}`);
20
+ }
21
+ distinctId = config.get('telemetryUserId');
22
+ client = new PostHog(POSTHOG_KEY, {
23
+ host: POSTHOG_HOST,
24
+ flushAt: 1,
25
+ flushInterval: 0,
26
+ });
27
+ };
28
+ export const captureCommand = (command, props = {}) => {
29
+ if (!client || !distinctId)
30
+ return;
31
+ client.capture({
32
+ distinctId,
33
+ event: 'cli.command.run',
34
+ properties: {
35
+ command,
36
+ cli_version: pkg.version,
37
+ node_version: process.version,
38
+ platform: os.platform(),
39
+ source: 'cli',
40
+ ...props,
41
+ },
42
+ });
43
+ };
44
+ export const shutdown = async () => {
45
+ if (client)
46
+ await client.shutdown();
47
+ };
48
+ /** Reset singleton state — for testing only */
49
+ export const _resetForTesting = () => {
50
+ client = null;
51
+ distinctId = null;
52
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,6 +45,7 @@
45
45
  "commander": "^14.0.3",
46
46
  "conf": "^15.1.0",
47
47
  "ora": "^9.3.0",
48
+ "posthog-node": "^4.0.0",
48
49
  "strip-ansi": "^7.2.0"
49
50
  },
50
51
  "devDependencies": {