@trawlme/cli 2.3.0 → 3.0.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
@@ -18,6 +18,15 @@ Requires Node.js >= 20.
18
18
  npm install -g @trawlme/cli
19
19
  ```
20
20
 
21
+ Already installed? Update the CLI itself with:
22
+
23
+ ```bash
24
+ trawl upgrade # install the latest version (old → new)
25
+ trawl upgrade --check # only report if an update is available (exit 1 if so)
26
+ ```
27
+
28
+ (`trawl upgrade` updates the CLI on your machine — distinct from `trawl scraps update <id>`, which updates a *scrap*.)
29
+
21
30
  ## Authentication
22
31
 
23
32
  Three methods:
@@ -46,8 +55,6 @@ trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted ru
46
55
  trawl history <id> [--json] [-n <limit>] List past runs for a scrap (newest first)
47
56
  trawl run-info <hid> [--json] Show details of a single run
48
57
  trawl trigger <id> [--watch] [--wait] [--json] Launch a scrap as a background worker (returns immediately)
49
- trawl update <id> [-t <title>] [-u <url>] [-r <request>] [-d <description>] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [--autofix|--no-autofix] [-p <json>|--params-file <path>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--force-tier <tier0|tier1|tier2|tier3|tier4>] [--json]
50
- Update an existing scrap
51
58
  trawl whoami [--json] Show the authenticated user's identity
52
59
  trawl ping [--json] Health/version handshake against the Trawl API
53
60
  ```
@@ -56,7 +63,7 @@ trawl ping [--json] Health/version handshake against
56
63
 
57
64
  `create`/`whoami`/`ping` are fully non-interactive — all three read auth only from `TRAWL_TOKEN`/the stored login token, never prompt. `trawl create` runs the AI wizard server-side (`POST /api/ai/wizard`): generate scrap code from `--prompt` via LLM, persist the scrap, trigger its FIRST run, and auto-fix on failure (default on — `--no-autofix` disables it, sending `autoFix:false`). On a successful first run (human mode) it prints a small **data sample** (item count + first-item fields + one truncated value) as proof of value — best-effort, silent if the sample can't be fetched — and points `Next step` at `trawl data <id>` (the data), with `trawl get <id>` as the secondary detail view. `--json` skips the sample fetch and prints the raw wizard payload verbatim. `success` is an honest outcome of that first run, not "did the HTTP call succeed" — a failed first run is still a 200 response (the scrap was still created; auto-fix, when enabled, retries in the background), and the CLI exits 1 in that case (both human and `--json` modes) even though `--json` always prints the raw payload verbatim. The call legitimately takes 30–250s+ server-side (AI generation + a real run), same long-run timeout as `run`/`data --fresh`/`trigger --wait` below. `trawl whoami`/`trawl ping` mirror the MCP `trawl_whoami`/`trawl_health_ping` tools as closely as the REST surface allows (`GET /api/users/me` / `GET /api/health`) — `ping`'s `--json` payload is admin-enriched (version/uptime/db) and just `{"status":"ok"}` for anyone else.
58
65
 
59
- > **`create` is NOT idempotent, and every wizard-created scrap runs on a DAILY cron by default.** A client-side timeout (exit `5`, a `NetworkError`) does not mean the wizard failed server-side — scrap creation + the first run keep going after the CLI gives up waiting, so the scrap may already exist. Run `trawl list` and look for a matching URL/title **before** retrying — a blind retry creates a DUPLICATE scrap and burns AI-generation quota a second time for the same goal. Separately, the scrap the wizard creates is scheduled to re-run every day at 07:00 UTC (`cron: "0 7 * * *"`, hardcoded server-side, unrelated to `--no-autofix`) — each of those recurring runs consumes execute quota like any other run. Review the generated scrap, then change or disable the schedule with `trawl update <id> --cron <expr>` (or `--no-cron` to disable it). Because the call can legitimately run 250s+, also confirm `TRAWL_TIMEOUT` isn't set to something tighter than `create` needs — the env var always wins over `create`'s own 300s default (see [Environment variables](#environment-variables)), so a value set for another purpose (e.g. a tight CI smoke-test budget) silently clamps `create` too; unset it or raise it before running `create`.
66
+ > **`create` is NOT idempotent, and every wizard-created scrap runs on a DAILY cron by default.** A client-side timeout (exit `5`, a `NetworkError`) does not mean the wizard failed server-side — scrap creation + the first run keep going after the CLI gives up waiting, so the scrap may already exist. Run `trawl list` and look for a matching URL/title **before** retrying — a blind retry creates a DUPLICATE scrap and burns AI-generation quota a second time for the same goal. Separately, the scrap the wizard creates is scheduled to re-run every day at 07:00 UTC (`cron: "0 7 * * *"`, hardcoded server-side, unrelated to `--no-autofix`) — each of those recurring runs consumes execute quota like any other run. Review the generated scrap, then change or disable the schedule with `trawl scraps update <id> --cron <expr>` (or `--no-cron` to disable it). Because the call can legitimately run 250s+, also confirm `TRAWL_TIMEOUT` isn't set to something tighter than `create` needs — the env var always wins over `create`'s own 300s default (see [Environment variables](#environment-variables)), so a value set for another purpose (e.g. a tight CI smoke-test budget) silently clamps `create` too; unset it or raise it before running `create`.
60
67
 
61
68
  - `list` has a short alias, `ls` (matches `trawl --help`'s `list|ls`).
62
69
  - `history` lists past runs (newest first); `run-info <hid>` shows details of a single run from that history.
@@ -70,6 +77,7 @@ trawl ping [--json] Health/version handshake against
70
77
 
71
78
  ```
72
79
  trawl scraps create -t <title> [-u <url>] [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--json]
80
+ trawl scraps update <id> [-t <title>] [-u <url>] [-r <request>] [-d <description>] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [--autofix|--no-autofix] [-p <json>|--params-file <path>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--force-tier <tier0|tier1|tier2|tier3|tier4>] [--json]
73
81
  trawl scraps watch <id> [--json]
74
82
  trawl scraps doctor <id> [--json] [--autofix]
75
83
  trawl scraps autofix <id> [--json]
@@ -134,6 +142,14 @@ trawl telemetry off [--json] Disable usage telemetry
134
142
  trawl telemetry status [--json] Show current state, telemetry ID, and opt-out instructions
135
143
  ```
136
144
 
145
+ ### Upgrade the CLI
146
+
147
+ ```
148
+ trawl upgrade [--check] [--json] Update @trawlme/cli to the latest published version
149
+ ```
150
+
151
+ Compares the running version against the npm registry and runs `npm install -g @trawlme/cli@latest` when a newer one exists. `--check` reports without installing (exit `1` when an update is available). A permission error on a system-owned global prefix is surfaced with the manual command.
152
+
137
153
  ## Telemetry
138
154
 
139
155
  `@trawlme/cli` collects anonymous usage data to help us improve the CLI based on real usage patterns. Telemetry is enabled by default.
@@ -30,8 +30,7 @@ export interface WizardResponse {
30
30
  * 'UTC'` — unconditionally, regardless of `--prompt`/`--no-autofix`.
31
31
  * Present on the scrap object returned here (the wizard controller
32
32
  * passes the created scrap straight through, no stripping) — change or
33
- * disable it with `trawl update <id> --cron <expr>` / `--no-cron` (#120
34
- * — `update` is now a promoted top-level verb, same as `get`/`run`/…).
33
+ * disable it with `trawl scraps update <id> --cron <expr>` / `--no-cron`.
35
34
  */
36
35
  cron?: string | null;
37
36
  cronTimezone?: string;
@@ -5,6 +5,7 @@ import { api, LONG_RUN_TIMEOUT_MS, NetworkError } from '../lib/api.js';
5
5
  import { json } from '../lib/format.js';
6
6
  import { requireUrl, requireString } from '../lib/validate.js';
7
7
  import { UsageError } from '../lib/errors.js';
8
+ import { renderPinch, pinchEnabled } from '../lib/pinch.js';
8
9
  /** Best-effort, honest first-run summary — never claims a background retry
9
10
  * happened when auto-fix was disabled for this call, and never claims a
10
11
  * scrap was persisted when the response carries none (#114-F3 — a hard
@@ -129,6 +130,14 @@ export const create = new Command('create')
129
130
  data = await call();
130
131
  }
131
132
  else {
133
+ // #122 — Pinch shows up front, working, while the server-side wizard
134
+ // runs (legitimately 30-250s+, see LONG_RUN_TIMEOUT_MS above). Belt-
135
+ // and-suspenders `!opts.json` alongside pinchEnabled(): we're already
136
+ // inside the non-`--json` branch, but the check is kept explicit here
137
+ // too so stdout purity under `--json` (#106-F2/#121) never depends on
138
+ // this code staying inside that branch.
139
+ if (!opts.json && pinchEnabled())
140
+ console.log(renderPinch('thinking'));
132
141
  try {
133
142
  data = await spin(call, {
134
143
  text: `Creating a scrap from ${url}…`,
@@ -192,6 +201,13 @@ export const create = new Command('create')
192
201
  console.log(chalk.yellow(` Note: `) +
193
202
  `Auto-fix is retrying in the background — do NOT re-run create; poll ${pollTarget}.`);
194
203
  }
204
+ // #122 — Pinch reacts to the HONEST first-run outcome (same signal the
205
+ // ✓/✗ line above already renders): celebrates a real success, looks
206
+ // confused on a genuine failure. Belt-and-suspenders `!opts.json`
207
+ // alongside pinchEnabled() — see the 'thinking' print above for why.
208
+ if (!opts.json && pinchEnabled()) {
209
+ console.log(data.success ? renderPinch('celebrating') : renderPinch('confused'));
210
+ }
195
211
  }
196
212
  // Honest exit code alongside the honest payload — a --json caller gets
197
213
  // the raw body regardless (never wrapped/altered), but a script checking
@@ -140,7 +140,6 @@ export declare function pollRunProgress(id: string, before: BeforeRunState | und
140
140
  }): Promise<void>;
141
141
  export declare function attachListCommand(parent: Command, attachOpts?: AttachOptions): Command;
142
142
  export declare function attachGetCommand(parent: Command, attachOpts?: AttachOptions): Command;
143
- export declare function attachUpdateCommand(parent: Command, attachOpts?: AttachOptions): Command;
144
143
  export declare function attachRunCommand(parent: Command, attachOpts?: AttachOptions): Command;
145
144
  export declare function attachDataCommand(parent: Command, attachOpts?: AttachOptions): Command;
146
145
  export declare function attachHistoryCommand(parent: Command, attachOpts?: AttachOptions): Command;
@@ -583,132 +583,129 @@ scraps
583
583
  if (refused)
584
584
  process.exitCode = 1;
585
585
  });
586
- // update — promoted to a top-level verb (#108/#120)
587
- export function attachUpdateCommand(parent, attachOpts = {}) {
588
- return parent
589
- .command('update <id>', attachOpts)
590
- .description('Update an existing scrap')
591
- .option('-t, --title <title>', 'New title')
592
- .option('-u, --url <url>', 'New target URL')
593
- .option('-r, --request <request>', 'New request')
594
- .option('-d, --description <text>', 'New description')
595
- .option('--cron <expression>', 'Cron expression (empty string to disable)')
596
- .option('--no-cron', 'Disable cron (set to null)')
597
- .option('--alert <email>', 'Failure alert email (empty string to clear)')
598
- .option('--no-alert', 'Disable failure alert email (set to null)')
599
- .option('--autofix', 'Enable AI Fix (auto-recovery on selector breakage)')
600
- .option('--no-autofix', 'Disable AI Fix')
601
- .option('-p, --params <json>', 'Runtime params as JSON array of objects (e.g. \'[{"TRAWL.paramName":"value"}]\')')
602
- .option('--params-file <path>', 'Runtime params from a JSON file')
603
- .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
604
- .option('--force-tier <tier>', `Raise the proxy-tier ceiling PAST the auto-cap (${VALID_TIERS.join('|')}) — history-gated: may be refused or cost more`)
605
- .option('--json', 'Output as JSON')
606
- .action(async (id, opts) => {
607
- validateObjectId(id);
608
- if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
609
- usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
610
- return;
586
+ // update
587
+ scraps
588
+ .command('update <id>')
589
+ .description('Update an existing scrap')
590
+ .option('-t, --title <title>', 'New title')
591
+ .option('-u, --url <url>', 'New target URL')
592
+ .option('-r, --request <request>', 'New request')
593
+ .option('-d, --description <text>', 'New description')
594
+ .option('--cron <expression>', 'Cron expression (empty string to disable)')
595
+ .option('--no-cron', 'Disable cron (set to null)')
596
+ .option('--alert <email>', 'Failure alert email (empty string to clear)')
597
+ .option('--no-alert', 'Disable failure alert email (set to null)')
598
+ .option('--autofix', 'Enable AI Fix (auto-recovery on selector breakage)')
599
+ .option('--no-autofix', 'Disable AI Fix')
600
+ .option('-p, --params <json>', 'Runtime params as JSON array of objects (e.g. \'[{"TRAWL.paramName":"value"}]\')')
601
+ .option('--params-file <path>', 'Runtime params from a JSON file')
602
+ .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
603
+ .option('--force-tier <tier>', `Raise the proxy-tier ceiling PAST the auto-cap (${VALID_TIERS.join('|')}) — history-gated: may be refused or cost more`)
604
+ .option('--json', 'Output as JSON')
605
+ .action(async (id, opts) => {
606
+ validateObjectId(id);
607
+ if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
608
+ usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
609
+ return;
610
+ }
611
+ if (opts.forceTier !== undefined && !VALID_TIERS.includes(opts.forceTier)) {
612
+ usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
613
+ return;
614
+ }
615
+ const body = {};
616
+ if (opts.title !== undefined)
617
+ body.title = opts.title;
618
+ if (opts.url !== undefined)
619
+ body.url = opts.url;
620
+ if (opts.request !== undefined)
621
+ body.request = opts.request;
622
+ if (opts.description !== undefined)
623
+ body.description = opts.description;
624
+ if (opts.cron === false)
625
+ body.cron = null;
626
+ else if (typeof opts.cron === 'string')
627
+ body.cron = opts.cron === '' ? null : opts.cron;
628
+ if (opts.alert === false)
629
+ body.alert = null;
630
+ else if (typeof opts.alert === 'string')
631
+ body.alert = opts.alert === '' ? null : opts.alert;
632
+ if (opts.autofix === true)
633
+ body.autoFix = true;
634
+ else if (opts.autofix === false)
635
+ body.autoFix = false;
636
+ if (opts.params !== undefined || opts.paramsFile !== undefined) {
637
+ let raw;
638
+ if (opts.paramsFile) {
639
+ const { readFileSync } = await import('fs');
640
+ raw = readFileSync(opts.paramsFile, 'utf8');
611
641
  }
612
- if (opts.forceTier !== undefined && !VALID_TIERS.includes(opts.forceTier)) {
613
- usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
614
- return;
642
+ else {
643
+ raw = opts.params;
615
644
  }
616
- const body = {};
617
- if (opts.title !== undefined)
618
- body.title = opts.title;
619
- if (opts.url !== undefined)
620
- body.url = opts.url;
621
- if (opts.request !== undefined)
622
- body.request = opts.request;
623
- if (opts.description !== undefined)
624
- body.description = opts.description;
625
- if (opts.cron === false)
626
- body.cron = null;
627
- else if (typeof opts.cron === 'string')
628
- body.cron = opts.cron === '' ? null : opts.cron;
629
- if (opts.alert === false)
630
- body.alert = null;
631
- else if (typeof opts.alert === 'string')
632
- body.alert = opts.alert === '' ? null : opts.alert;
633
- if (opts.autofix === true)
634
- body.autoFix = true;
635
- else if (opts.autofix === false)
636
- body.autoFix = false;
637
- if (opts.params !== undefined || opts.paramsFile !== undefined) {
638
- let raw;
639
- if (opts.paramsFile) {
640
- const { readFileSync } = await import('fs');
641
- raw = readFileSync(opts.paramsFile, 'utf8');
642
- }
643
- else {
644
- raw = opts.params;
645
- }
646
- let parsed;
647
- try {
648
- parsed = JSON.parse(raw);
649
- }
650
- catch (e) {
651
- usageError(`Invalid JSON for --params: ${e.message}`, { json: opts.json });
652
- return;
653
- }
654
- if (!Array.isArray(parsed)) {
655
- usageError('--params must be a JSON array of objects', { json: opts.json });
656
- return;
657
- }
658
- body.params = parsed;
645
+ let parsed;
646
+ try {
647
+ parsed = JSON.parse(raw);
659
648
  }
660
- if (opts.tier !== undefined)
661
- body.proxyTier = opts.tier;
662
- if (opts.forceTier !== undefined) {
663
- // Raise the ceiling; also start the run at that tier unless --tier says otherwise.
664
- body.proxyMaxTier = opts.forceTier;
665
- if (opts.tier === undefined)
666
- body.proxyTier = opts.forceTier;
649
+ catch (e) {
650
+ usageError(`Invalid JSON for --params: ${e.message}`, { json: opts.json });
651
+ return;
667
652
  }
668
- if (Object.keys(body).length === 0) {
669
- if (opts.json) {
670
- process.exitCode = reportError(new UsageError('Nothing to update. Provide at least one option.'), { json: true });
671
- return;
672
- }
673
- console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
653
+ if (!Array.isArray(parsed)) {
654
+ usageError('--params must be a JSON array of objects', { json: opts.json });
674
655
  return;
675
656
  }
676
- const data = await spin(() => api.put(`/api/scraps/${id}`, body), {
677
- text: 'Updating scrap…',
678
- successText: (d) => `Scrap updated: ${chalk.bold(d._id)}`,
679
- });
680
- // #1559 / #86 findings 4b/5 — surface the effective tier + clamp/refuse
681
- // reason (fixes the silent-clamp: the server may persist a lower tier
682
- // than requested), and NEVER echo the requested value as applied when
683
- // the server doesn't confirm it (old-server fallback below).
684
- const tierWasRequested = opts.tier !== undefined || opts.forceTier !== undefined;
685
- warnIfUnconfirmedTier(data, tierWasRequested, id);
686
- const refused = Boolean(data._tierOverride?.refused);
657
+ body.params = parsed;
658
+ }
659
+ if (opts.tier !== undefined)
660
+ body.proxyTier = opts.tier;
661
+ if (opts.forceTier !== undefined) {
662
+ // Raise the ceiling; also start the run at that tier unless --tier says otherwise.
663
+ body.proxyMaxTier = opts.forceTier;
664
+ if (opts.tier === undefined)
665
+ body.proxyTier = opts.forceTier;
666
+ }
667
+ if (Object.keys(body).length === 0) {
687
668
  if (opts.json) {
688
- if (refused) {
689
- process.exitCode = reportTierRefusal(data, true);
690
- return;
691
- }
692
- json(withTierUnconfirmed(data, tierWasRequested));
669
+ process.exitCode = reportError(new UsageError('Nothing to update. Provide at least one option.'), { json: true });
693
670
  return;
694
671
  }
695
- const shown = data;
696
- for (const key of Object.keys(body)) {
697
- // Tier keys are rendered exclusively by renderTierOverrideHuman /
698
- // warnIfUnconfirmedTier above never echo them here, whether or not
699
- // _tierOverride came back (an old-server echo of the REQUESTED value
700
- // is exactly the silent-clamp lie #1559 fixed).
701
- if (key === 'proxyTier' || key === 'proxyMaxTier')
702
- continue;
703
- const src = key in shown ? shown[key] : body[key];
704
- console.log(chalk.dim(` ${key}: `) + String(src ?? '—'));
705
- }
706
- renderTierOverrideHuman(data);
707
- if (refused)
708
- process.exitCode = 1;
672
+ console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
673
+ return;
674
+ }
675
+ const data = await spin(() => api.put(`/api/scraps/${id}`, body), {
676
+ text: 'Updating scrap…',
677
+ successText: (d) => `Scrap updated: ${chalk.bold(d._id)}`,
709
678
  });
710
- }
711
- attachUpdateCommand(scraps, { hidden: true });
679
+ // #1559 / #86 findings 4b/5 — surface the effective tier + clamp/refuse
680
+ // reason (fixes the silent-clamp: the server may persist a lower tier
681
+ // than requested), and NEVER echo the requested value as applied when
682
+ // the server doesn't confirm it (old-server fallback below).
683
+ const tierWasRequested = opts.tier !== undefined || opts.forceTier !== undefined;
684
+ warnIfUnconfirmedTier(data, tierWasRequested, id);
685
+ const refused = Boolean(data._tierOverride?.refused);
686
+ if (opts.json) {
687
+ if (refused) {
688
+ process.exitCode = reportTierRefusal(data, true);
689
+ return;
690
+ }
691
+ json(withTierUnconfirmed(data, tierWasRequested));
692
+ return;
693
+ }
694
+ const shown = data;
695
+ for (const key of Object.keys(body)) {
696
+ // Tier keys are rendered exclusively by renderTierOverrideHuman /
697
+ // warnIfUnconfirmedTier above — never echo them here, whether or not
698
+ // _tierOverride came back (an old-server echo of the REQUESTED value
699
+ // is exactly the silent-clamp lie #1559 fixed).
700
+ if (key === 'proxyTier' || key === 'proxyMaxTier')
701
+ continue;
702
+ const src = key in shown ? shown[key] : body[key];
703
+ console.log(chalk.dim(` ${key}: `) + String(src ?? '—'));
704
+ }
705
+ renderTierOverrideHuman(data);
706
+ if (refused)
707
+ process.exitCode = 1;
708
+ });
712
709
  // run — promoted to a top-level verb (#108)
713
710
  export function attachRunCommand(parent, attachOpts = {}) {
714
711
  return parent
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const upgrade: Command;
@@ -0,0 +1,84 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname, join } from 'node:path';
8
+ import { json } from '../lib/format.js';
9
+ const execFileP = promisify(execFile);
10
+ const PKG_NAME = '@trawlme/cli';
11
+ /** The version currently RUNNING — read from this package's own package.json.
12
+ * (At publish time semantic-release stamps the real version into the tarball's
13
+ * package.json, so at runtime this is the installed version; the repo's
14
+ * checked-in value is vestigial.) */
15
+ function currentVersion() {
16
+ const here = dirname(fileURLToPath(import.meta.url)); // dist/commands
17
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
18
+ return pkg.version;
19
+ }
20
+ /** Latest published version from the npm registry. Throws with a friendly
21
+ * message when npm is missing or the registry is unreachable. */
22
+ async function latestVersion() {
23
+ try {
24
+ const { stdout } = await execFileP('npm', ['view', PKG_NAME, 'version'], { timeout: 30_000 });
25
+ return stdout.trim();
26
+ }
27
+ catch (err) {
28
+ const e = err;
29
+ if (e.code === 'ENOENT')
30
+ throw new Error('npm was not found on PATH — install Node.js/npm, then run: npm install -g @trawlme/cli@latest');
31
+ throw new Error(`could not reach the npm registry to check for updates (${e.message})`);
32
+ }
33
+ }
34
+ export const upgrade = new Command('upgrade')
35
+ .description('Update the trawl CLI itself to the latest published version')
36
+ .option('--check', 'Only report whether an update is available — do not install')
37
+ .option('--json', 'Output as JSON')
38
+ .action(async (opts) => {
39
+ const current = currentVersion();
40
+ const latest = await latestVersion();
41
+ const upToDate = current === latest;
42
+ if (upToDate) {
43
+ if (opts.json)
44
+ return json({ package: PKG_NAME, current, latest, upToDate: true, upgraded: false });
45
+ console.log(`${chalk.green('✓')} Already on the latest version (${chalk.bold(current)}).`);
46
+ return;
47
+ }
48
+ if (opts.check) {
49
+ if (opts.json)
50
+ return json({ package: PKG_NAME, current, latest, upToDate: false, upgraded: false });
51
+ console.log(`${chalk.yellow('↑')} Update available: ${chalk.bold(current)} → ${chalk.bold(latest)}. Run ${chalk.cyan('trawl upgrade')} to install.`);
52
+ // Honest exit code so a script can gate on "is an update available".
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ // Install the latest globally. This shells out to the same npm the user
57
+ // installed the CLI with; a permission error (EACCES on a system-owned
58
+ // global prefix) is surfaced with the manual command rather than swallowed.
59
+ if (!opts.json)
60
+ console.log(chalk.dim(`Upgrading ${PKG_NAME}: ${current} → ${latest}…`));
61
+ try {
62
+ await execFileP('npm', ['install', '-g', `${PKG_NAME}@latest`], { timeout: 180_000 });
63
+ }
64
+ catch (err) {
65
+ const e = err;
66
+ const hint = e.code === 'ENOENT'
67
+ ? 'npm was not found on PATH.'
68
+ : /EACCES|permission/i.test(String(e.stderr ?? e.message))
69
+ ? 'permission denied on the global npm prefix — retry with sudo, or use a Node version manager.'
70
+ : (e.stderr?.trim() || e.message);
71
+ if (opts.json) {
72
+ console.log(JSON.stringify({ error: { message: `upgrade failed: ${hint}`, kind: 'upgrade_failed' } }));
73
+ }
74
+ else {
75
+ console.error(chalk.red(`✗ Upgrade failed: ${hint}`));
76
+ console.error(chalk.dim(` Install manually: npm install -g ${PKG_NAME}@latest`));
77
+ }
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ if (opts.json)
82
+ return json({ package: PKG_NAME, current, latest, upToDate: false, upgraded: true });
83
+ console.log(`${chalk.green('✓')} Upgraded ${PKG_NAME}: ${chalk.bold(current)} → ${chalk.bold(latest)}.`);
84
+ });
package/dist/index.js CHANGED
@@ -4,10 +4,11 @@ import { readFileSync, realpathSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { login, logout } from './commands/login.js';
7
- import { scraps, attachListCommand, attachGetCommand, attachRunCommand, attachDataCommand, attachHistoryCommand, attachRunInfoCommand, attachTriggerCommand, attachUpdateCommand, } from './commands/scraps.js';
7
+ import { scraps, attachListCommand, attachGetCommand, attachRunCommand, attachDataCommand, attachHistoryCommand, attachRunInfoCommand, attachTriggerCommand, } from './commands/scraps.js';
8
8
  import { skills } from './commands/skills.js';
9
9
  import { telemetry } from './commands/telemetry.js';
10
10
  import { token } from './commands/token.js';
11
+ import { upgrade } from './commands/upgrade.js';
11
12
  import { create } from './commands/create.js';
12
13
  import { whoami } from './commands/whoami.js';
13
14
  import { ping } from './commands/ping.js';
@@ -108,14 +109,11 @@ export function createProgram() {
108
109
  attachHistoryCommand(program);
109
110
  attachRunInfoCommand(program);
110
111
  attachTriggerCommand(program);
111
- attachUpdateCommand(program);
112
112
  program.addCommand(whoami);
113
113
  program.addCommand(ping);
114
114
  // Management (#108) — human/CI surface, grouped below. `scraps` still
115
- // holds every pre-#108 management command (create/delete/banner/
116
- // watch/account.*/session.*/doctor/autofix/snapshot) exactly as before
117
- // `update` is now a promoted core verb (#120), still reachable (hidden)
118
- // under `scraps` for backward compatibility.
115
+ // holds every pre-#108 management command (create/update/delete/banner/
116
+ // watch/account.*/session.*/doctor/autofix/snapshot) exactly as before.
119
117
  program.commandsGroup(MANAGEMENT_GROUP);
120
118
  program.addCommand(scraps);
121
119
  program.addCommand(skills);
@@ -123,6 +121,7 @@ export function createProgram() {
123
121
  program.addCommand(logout);
124
122
  program.addCommand(token);
125
123
  program.addCommand(telemetry);
124
+ program.addCommand(upgrade);
126
125
  return program;
127
126
  }
128
127
  /**
@@ -3,15 +3,21 @@
3
3
  * art. Distinct from Clawd's 8-bit lane: Pinch is drawn with full 24-bit
4
4
  * (`\x1b[38;2;r;g;bm` / `\x1b[48;2;r;g;bm`) color blocks, not a fixed palette.
5
5
  *
6
- * The 12×9 cube-grid (AVGRID proto v11.4, locked 2026-07-16) is packed two
7
- * grid rows into one terminal row: the upper row's color becomes the
8
- * half-block's foreground, the lower row's becomes its background, using the
9
- * upper-half-block glyph '▀' (or '▄' when only the lower half is filled). A
10
- * 9-row grid therefore renders in 5 terminal rows, 12 columns wide. '.' cells
11
- * are transparent — no color escape is emitted for that half, so the
12
- * terminal's own background shows through.
6
+ * The 14×9 cube-grid is packed two grid rows into one terminal row: the
7
+ * upper row's color becomes the half-block's foreground, the lower row's
8
+ * becomes its background, using the upper-half-block glyph '▀' (or '▄' when
9
+ * only the lower half is filled). A 9-row grid therefore renders in 5
10
+ * terminal rows, 14 columns wide. '.' cells are transparent — no color
11
+ * escape is emitted for that half, so the terminal's own background shows
12
+ * through.
13
13
  *
14
- * See comes-io/trawl_cli#94.
14
+ * Grids + palette are mirrored from trawl_vue
15
+ * `src/modules/trawl/assets/mascot/_src/pinch.model.mjs` (AVCOLORS +
16
+ * AVATAR_GRIDS, rev r6f) — no cross-repo import (cli is a standalone npm
17
+ * package). Claws are 2×2 'O' blocks at cols 0-1 / 12-13, fully outside the
18
+ * body silhouette (on the sides), per r6f.
19
+ *
20
+ * See comes-io/trawl_cli#94, comes-io/trawl_cli#122.
15
21
  */
16
22
  export type PinchState = 'wave' | 'thinking' | 'celebrating' | 'confused';
17
23
  /**
@@ -19,8 +25,8 @@ export type PinchState = 'wave' | 'thinking' | 'celebrating' | 'confused';
19
25
  * one-line caption. Pure — never touches process.env/stdout; callers must
20
26
  * gate on `pinchEnabled()` before printing the result.
21
27
  *
22
- * `frame` only affects 'thinking' (2-frame spinner alternation); every other
23
- * state ignores it.
28
+ * `frame` is accepted for signature stability but currently unused — every
29
+ * state's grid is static (see `gridForState`).
24
30
  */
25
31
  export declare function renderPinch(state: PinchState, frame?: number): string;
26
32
  /**
package/dist/lib/pinch.js CHANGED
@@ -3,76 +3,98 @@
3
3
  * art. Distinct from Clawd's 8-bit lane: Pinch is drawn with full 24-bit
4
4
  * (`\x1b[38;2;r;g;bm` / `\x1b[48;2;r;g;bm`) color blocks, not a fixed palette.
5
5
  *
6
- * The 12×9 cube-grid (AVGRID proto v11.4, locked 2026-07-16) is packed two
7
- * grid rows into one terminal row: the upper row's color becomes the
8
- * half-block's foreground, the lower row's becomes its background, using the
9
- * upper-half-block glyph '▀' (or '▄' when only the lower half is filled). A
10
- * 9-row grid therefore renders in 5 terminal rows, 12 columns wide. '.' cells
11
- * are transparent — no color escape is emitted for that half, so the
12
- * terminal's own background shows through.
6
+ * The 14×9 cube-grid is packed two grid rows into one terminal row: the
7
+ * upper row's color becomes the half-block's foreground, the lower row's
8
+ * becomes its background, using the upper-half-block glyph '▀' (or '▄' when
9
+ * only the lower half is filled). A 9-row grid therefore renders in 5
10
+ * terminal rows, 14 columns wide. '.' cells are transparent — no color
11
+ * escape is emitted for that half, so the terminal's own background shows
12
+ * through.
13
13
  *
14
- * See comes-io/trawl_cli#94.
14
+ * Grids + palette are mirrored from trawl_vue
15
+ * `src/modules/trawl/assets/mascot/_src/pinch.model.mjs` (AVCOLORS +
16
+ * AVATAR_GRIDS, rev r6f) — no cross-repo import (cli is a standalone npm
17
+ * package). Claws are 2×2 'O' blocks at cols 0-1 / 12-13, fully outside the
18
+ * body silhouette (on the sides), per r6f.
19
+ *
20
+ * See comes-io/trawl_cli#94, comes-io/trawl_cli#122.
15
21
  */
16
- /** Grid-char → RGB. 'C' (cyan) is a "thinking" blip, not present in BASE_GRID. */
22
+ /** Grid-char → RGB, ported from AVCOLORS (pinch.model.mjs, rev r6f). */
17
23
  const PALETTE = {
18
- B: [0x3b, 0x82, 0xf6], // blue — shell
19
- O: [0xee, 0x90, 0x18], // orange — antennae / claws
24
+ B: [0x29, 0x79, 0xff], // blue — shell
25
+ O: [0xfb, 0x92, 0x3c], // orange — antennae / claws
20
26
  W: [0xff, 0xff, 0xff], // white — eye whites
21
- K: [0x12, 0x30, 0x33], // navy — pupils / mouth
22
- P: [0xf9, 0xa8, 0xd4], // pink — claw tips
23
- C: [0x08, 0x91, 0xb2], // cyan — thinking-spinner antenna blip
27
+ K: [0x12, 0x30, 0x33], // navy — pupils / mouth / blush-adjacent mouth corner
28
+ P: [0xf4, 0x72, 0xb6], // pink — blush
24
29
  };
25
30
  const TRANSPARENT = '.';
26
- /** Base 12×9 grid — Pinch waving (also the 'wave' state, unmodified). */
27
- const BASE_GRID = [
28
- '...O....O...',
29
- '...O....O...',
30
- '.BBBBBBBBBB.',
31
- '.BWWBBBBWWB.',
32
- '.BWKBBBBKWB.',
33
- '.PBBBBBBBBP.',
34
- '.BBBKBBKBBB.',
35
- '.BBBBKKBBBB.',
36
- 'OO........OO',
37
- ];
31
+ /**
32
+ * Explicit per-state 14×9 grids, ported verbatim from trawl_vue's
33
+ * AVATAR_GRIDS (pinch.model.mjs, rev r6f) — no derivation/mutation from a
34
+ * shared base, so each state stays a straight, auditable copy of its source
35
+ * grid. CLI state → source grid: wave→wave, thinking→working (a.k.a.
36
+ * AVGRID/idle), celebrating→celebrating, confused→confused.
37
+ */
38
+ const GRIDS = {
39
+ wave: [
40
+ '....O....O....',
41
+ '....O....O....',
42
+ '..BBBBBBBBBB..',
43
+ '..BWWBBBBWWBOO',
44
+ '..BWKBBBBKWBOO',
45
+ '..BBBBBBBBBB..',
46
+ 'OOBBPKBBKPBB..',
47
+ 'OOBBBBKKBBBB..',
48
+ '..............',
49
+ ],
50
+ // 'thinking' maps to the source's 'working' grid (identical to 'idle').
51
+ // The frame param is accepted for signature compatibility but ignored — a
52
+ // static working frame rather than an animated blip (#122, simplified;
53
+ // the source has no per-frame "thinking" animation to port).
54
+ thinking: [
55
+ '....O....O....',
56
+ '....O....O....',
57
+ '..BBBBBBBBBB..',
58
+ '..BWWBBBBWWB..',
59
+ '..BWKBBBBKWB..',
60
+ '..BBBBBBBBBB..',
61
+ 'OOBBPKBBKPBBOO',
62
+ 'OOBBBBKKBBBBOO',
63
+ '..............',
64
+ ],
65
+ celebrating: [
66
+ '....O....O....',
67
+ '....O....O....',
68
+ '..BBBBBBBBBB..',
69
+ 'OOBWWBBBBWWBOO',
70
+ 'OOBWKBBBBKWBOO',
71
+ '..BBBBBBBBBB..',
72
+ '..BBPKKKKPBB..',
73
+ '..BBBKKKKBBB..',
74
+ '..............',
75
+ ],
76
+ confused: [
77
+ '..............',
78
+ '...O......O...',
79
+ '..BBBBBBBBBB..',
80
+ '..BWWBBBBWWB..',
81
+ '..BWKBBBBKWB..',
82
+ '..BBBBBBBBBB..',
83
+ 'OOBBPBBBBPBBOO',
84
+ 'OOBBBBKKBBBBOO',
85
+ '..............',
86
+ ],
87
+ };
38
88
  const CAPTIONS = {
39
89
  wave: 'Pinch says hi.',
40
90
  thinking: 'Pinch is thinking…',
41
91
  celebrating: 'Pinch is celebrating!',
42
92
  confused: 'Pinch looks confused.',
43
93
  };
44
- /** Replace the chars at `indices` in `row` with `ch` never mutates `row`. */
45
- function setCells(row, indices, ch) {
46
- const chars = row.split('');
47
- for (const i of indices)
48
- chars[i] = ch;
49
- return chars.join('');
50
- }
51
- /** Derive the per-state grid from BASE_GRID (which is never mutated). */
52
- function gridForState(state, frame) {
53
- const rows = [...BASE_GRID];
54
- switch (state) {
55
- case 'thinking':
56
- // Odd frames: both antenna tips (row 0, cols 3 & 8) blip cyan — a
57
- // 2-frame spinner alternation with no layout shift.
58
- if (frame % 2 === 1) {
59
- rows[0] = setCells(rows[0], [3, 8], 'C');
60
- }
61
- return rows;
62
- case 'celebrating':
63
- // Fists up: the row-8 corner claws move up to row 7's corners; row 8's
64
- // corners go transparent (arms raised, no longer at the sides).
65
- rows[7] = setCells(rows[7], [0, 11], 'O');
66
- rows[8] = setCells(rows[8], [0, 1, 10, 11], TRANSPARENT);
67
- return rows;
68
- case 'confused':
69
- // Pupils removed (row 4, cols 3 & 8) — blank white eyes.
70
- rows[4] = setCells(rows[4], [3, 8], 'W');
71
- return rows;
72
- case 'wave':
73
- default:
74
- return rows;
75
- }
94
+ /** Look up the grid for `state`. `frame` is accepted (signature stability
95
+ * for `renderPinch`) but unused — every current state's grid is static. */
96
+ function gridForState(state, _frame) {
97
+ return GRIDS[state];
76
98
  }
77
99
  const RESET = '\x1b[0m';
78
100
  const fgCode = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
@@ -117,15 +139,17 @@ function renderGrid(rows) {
117
139
  * one-line caption. Pure — never touches process.env/stdout; callers must
118
140
  * gate on `pinchEnabled()` before printing the result.
119
141
  *
120
- * `frame` only affects 'thinking' (2-frame spinner alternation); every other
121
- * state ignores it.
142
+ * `frame` is accepted for signature stability but currently unused — every
143
+ * state's grid is static (see `gridForState`).
122
144
  */
123
145
  export function renderPinch(state, frame = 0) {
124
146
  const grid = gridForState(state, frame);
125
147
  const lines = renderGrid(grid);
126
148
  if (state === 'confused') {
127
149
  // "beside the art" — a bold '?' to the right of the eye row (grid rows
128
- // 4-5 pack into terminal line index 2).
150
+ // 4-5 pack into terminal line index 2, unchanged by the r6f grid — the
151
+ // antenna/shell/eye/claw row layout stayed the same, only width + claw
152
+ // placement changed).
129
153
  lines[2] = `${lines[2]} \x1b[1m?${RESET}`;
130
154
  }
131
155
  return [...lines, CAPTIONS[state]].join('\n');
@@ -20,7 +20,7 @@ agent should never need them.)
20
20
 
21
21
  ## Core commands (agent + human)
22
22
 
23
- These eleven commands are the CLI's agent+human surface — `--json` is
23
+ These ten commands are the CLI's agent+human surface — `--json` is
24
24
  first-class on every one, and none of them ever blocks on a prompt (see
25
25
  [Non-interactive contract](#non-interactive-contract) below):
26
26
 
@@ -34,8 +34,6 @@ trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted ru
34
34
  trawl history <id> [--json] [-n <limit>] List past runs for a scrap
35
35
  trawl run-info <hid> [--json] Show details of a single run
36
36
  trawl trigger <id> [--watch] [--wait] [--json] Launch a scrap as a background worker
37
- trawl update <id> [-t <title>] [-u <url>] [-r <request>] [-d <description>] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [--autofix|--no-autofix] [-p <json>|--params-file <path>] [--tier <t>] [--force-tier <t>] [--json]
38
- Update an existing scrap
39
37
  trawl whoami [--json] Show the authenticated user's identity
40
38
  trawl ping [--json] Health/version handshake against the Trawl API
41
39
  ```
@@ -61,7 +59,7 @@ default.
61
59
  > scheduled to re-run every day at 07:00 UTC by default
62
60
  > (`cron: "0 7 * * *"`, unrelated to `--no-autofix`) — each recurring run
63
61
  > consumes execute quota. Disable or change it once you've reviewed the
64
- > scrap: `trawl update <id> --no-cron` (or `--cron <expr>`). Finally,
62
+ > scrap: `trawl scraps update <id> --no-cron` (or `--cron <expr>`). Finally,
65
63
  > if `TRAWL_TIMEOUT` is set globally for a tighter budget than 300s, it
66
64
  > clamps `create`'s ceiling too (env always wins) — unset it or raise it
67
65
  > before calling `create`.
@@ -71,10 +69,9 @@ default.
71
69
  > a hidden alias. Prefer the bare top-level form above; it's what
72
70
  > `trawl --help` now shows.
73
71
 
74
- For the full flag reference (tier overrides on `scraps create` (management)
75
- and the core `update` verb above note the core `create` verb has no
76
- `--tier` of its own, unlike `scraps create`, the `--watch` polling mechanics,
77
- retention/regression semantics on `data`, …)
72
+ For the full flag reference (tier overrides on `scraps create`/`scraps
73
+ update` the core `create` verb above has no `--tier` of its own, the
74
+ `--watch` polling mechanics, retention/regression semantics on `data`, …)
78
75
  see the README's [Core commands](../README.md#core-commands-agent--human) section
79
76
  — this doc intentionally stays minimal.
80
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "2.3.0",
3
+ "version": "3.0.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {