@amdahl/cli 0.4.0 → 0.4.1

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
@@ -44,9 +44,8 @@ Full steps: https://amdahl.ai/connect
44
44
  ## Install
45
45
 
46
46
  Needs Node.js 22 or later. Node.js 20 reached end of life in April 2026 and
47
- gets no security fixes. `amdahl` still starts on it, but npm warns when it
48
- installs. On a Node older than 20, `amdahl` stops before it does anything,
49
- names the version it found, and links the Node.js download page.
47
+ gets no security fixes. On a Node older than 22, `amdahl` stops before it does
48
+ anything, names the version it found, and links the Node.js download page.
50
49
 
51
50
  ```sh
52
51
  # Run once, with no install
@@ -372,7 +371,7 @@ amdahl open [--no-browser]
372
371
  amdahl feedback <run-id> --evidence did_it_myself|reported_to_me|inferred
373
372
  [--outcome used|used_with_edits|not_used|unsure] [--sent-text <file>|-] [--comment <text>]
374
373
  amdahl sent <run-id> --provider <provider> --channel <channel> --sent-as returned|edited
375
- (--provider-send-id <id> | --recipient <email|url> | --recipient-key-hash <sha256>)
374
+ [--provider-send-id <id>] [--recipient <email|url> | --recipient-key-hash <sha256>]
376
375
  [--sent-at <time>] [--provider-campaign-id <id>] [--sequence-step <n>]
377
376
  [--provider-variant-id <id>] [--provider-lead-id <id>] [--thread-id <id>]
378
377
  [--sent-text <file>|-]
@@ -399,9 +398,20 @@ amdahl search "What objections came up in lost deals last quarter?"
399
398
  (`POST /search/query`, with `answer: true`). It prints a short answer, with
400
399
  each citation said in plain words, and the rows it came from under `From:`,
401
400
  at most five. With no written answer it prints up to ten rows. Each row is
402
- one line (its title, who and when) and up to three lines of its text, in 80
403
- columns. `--limit <n>` asks for at most `n` rows, and `--json` prints the
404
- server's whole answer, every row included.
401
+ one line (its title, who and when) and up to three lines of its quote (the
402
+ row's `content_preview`, or its text), in 80 columns. `--limit <n>` asks for
403
+ at most `n` rows, and `--json` prints the server's whole answer, every row
404
+ included.
405
+
406
+ When the server found rows but wrote no answer over them (`not_enough_data`),
407
+ the CLI says so plainly before the rows: "Amdahl couldn't answer that from
408
+ these rows. Try rephrasing the question, or run `amdahl connections` to see
409
+ whether your data is synced." The rows follow under `Rows it read:`. A
410
+ failed answer says it found rows but could not write an answer.
411
+
412
+ The CLI sends the question as you typed it. A window such as "last month" is
413
+ read by the server from the words (the previous calendar month, in UTC); the
414
+ request has no separate field for a time window.
405
415
 
406
416
  Research is something search starts, never a command of its own. Search
407
417
  hands a statistical or long question to a research job by itself, and
@@ -422,11 +432,11 @@ theme blocks print as plain text (an `<unverified>` figure is marked
422
432
  type this version does not know: both point to `--json`, where the whole block
423
433
  is. Starting research needs
424
434
  `conversations:write` and reading it back `conversations:read`. A CLI
425
- sign-in has neither, so the server runs the search instead and the CLI adds
426
- one line:
435
+ sign-in has neither, so the server runs the search instead. The CLI says so
436
+ first, before any row:
427
437
 
428
438
  ```
429
- A deeper answer needs a Customer agent key: amdahl keys create --name research --preset agent
439
+ Research needs a Customer agent key: amdahl keys create --name research --preset agent. Here is the search answer instead.
430
440
  ```
431
441
 
432
442
  A workspace with nothing connected answers with `setup_needed`: the CLI
@@ -459,7 +469,10 @@ console's add page,
459
469
  `<console>/<workspace>/settings/connections?add=1&connector=hubspot`. The
460
470
  `connector` only fills the search box on that page: you pick HubSpot there and
461
471
  sign in to it. With no connector it opens the add page with an empty search
462
- box. The CLI never creates a connection itself. An unknown connector exits 2
472
+ box. If that connector is already connected and healthy, the CLI says so
473
+ first ("HubSpot is already connected and healthy (last sync 2026-11-11).")
474
+ and prints the add page instead of opening it; one that needs attention gets
475
+ its own console page. The CLI never creates a connection itself. An unknown connector exits 2
463
476
  and lists the ones you can connect; one that is not offered yet exits 1 and
464
477
  says it is coming soon. It opens the browser only for a person at a terminal:
465
478
  stdin and stdout are terminals, `AMDAHL_NO_PROMPT=1` is not set, no agent runs
@@ -494,18 +507,23 @@ The list is a CSV file with a header row, or a JSONL file (`.jsonl` or
494
507
  `.ndjson`) with one batch item, a JSON object, per line. A CSV has a
495
508
  `message` column and may have `item_id`, `channel`, `evidence`,
496
509
  `on_unsupported`, `facts` and `rules`; facts and rules take one per line
497
- inside the cell. Any other column is refused by name before anything is
498
- sent, so a misspelled column is never dropped without a word (use JSONL for
499
- any other field a single optimize takes). The optimize flags (`--channel`,
510
+ inside the cell. Any other column (a CRM export has many) is not sent: one
511
+ line names it, and it is copied to the results file in its place. Only a
512
+ missing `message` column, or a column Amdahl reads that appears twice (a
513
+ doubled `message`, say), is refused. A JSONL item takes any field a single
514
+ optimize takes, and the server refuses a key it does not know. The optimize flags (`--channel`,
500
515
  `--evidence`, `--on-unsupported`, `--tries`, `--context`) apply to every
501
516
  item that does not set its own; a row's facts and a `--context` file's rules
502
517
  and voice examples are both sent.
503
518
 
504
- The whole file is read and checked first. A header the CLI does not read, or a
505
- missing `message` column, says which headers it expects. Progress goes to
506
- stderr, in place at a terminal. When the batch completes, the results go next
507
- to the list: `drafts.csv` gets `drafts.optimized.csv` (every input column,
508
- then `optimized_message`, `status`, `what_changed`, `run_id` and `error`), and
519
+ The whole file is read and checked first. A missing `message` column says
520
+ which columns it reads. Progress goes to stderr, in place at a terminal: a
521
+ queued batch says it is queued, and each line after that says where it stands
522
+ once. When the batch completes, the results go next to the list: `drafts.csv`
523
+ gets `drafts.optimized.csv` (every input column in its original order, then
524
+ `optimized_message`, `status`, `summary`, `run_id` and `error`; a column of
525
+ yours with one of those names keeps it, and Amdahl's takes `amdahl_` in
526
+ front), and
509
527
  `x.jsonl` gets `x.optimized.jsonl` (one batch item per line, its `result`
510
528
  what a single optimize returns). With `--json`, the batch and every item go
511
529
  to stdout and no file is written. A failed item exits 1. A refused list
@@ -513,7 +531,10 @@ names the line of the first item the server refused.
513
531
 
514
532
  A spreadsheet runs a cell that starts with `=`, `+`, `-` or `@` as a formula,
515
533
  so in the results CSV such a cell (and one that starts with a tab or a
516
- carriage return) starts with a `'`. The CLI does not replace a results file
534
+ carriage return) starts with a `'`. That includes the cells copied through
535
+ from your own columns: a phone number such as `+1 555 0100` or a negative
536
+ number gets the `'` too, and you delete it to use the value. The `'` is the
537
+ guard, so it stays. The CLI does not replace a results file
517
538
  that already exists: it exits 2 before it queues anything, and `--force`
518
539
  replaces the file.
519
540
 
@@ -527,7 +548,7 @@ here. `--no-wait` with `--batch` exits 2: it only queues a new list.
527
548
 
528
549
  ```sh
529
550
  amdahl feedback <run-id> --evidence did_it_myself --outcome used_with_edits --sent-text sent.txt
530
- amdahl sent <run-id> --provider gmail --channel email --sent-as edited --provider-send-id "<msg-id@mail.gmail.com>"
551
+ amdahl sent <run-id> --provider gmail --channel email --sent-as edited --sent-text sent.txt --provider-send-id "<msg-id@mail.gmail.com>"
531
552
  ```
532
553
 
533
554
  `feedback` says what happened to the message one optimize run returned
@@ -543,16 +564,23 @@ stores two.
543
564
  (`POST /messages/report_send`). A report from a CLI sign-in is stored as
544
565
  `reported_via: cli`. The flags are the report's fields: `--provider`
545
566
  (`lemlist`, `instantly`, `outreach`, `salesloft`, `hubspot`, `salesforce`,
546
- `gmail`, `heyreach`, `apollo`, `linkedin_manual`, `other`), `--channel`
547
- (`email`, `linkedin_invite`, `linkedin_message`, `linkedin_inmail`),
567
+ `gmail`, `heyreach`, `apollo`, `linkedin_manual`, `other`; the server's
568
+ list, so use `other` for a sender that is not on it), `--channel` (`email`,
569
+ `linkedin_invite`, `linkedin_message`, `linkedin_inmail`; `linkedin` is
570
+ accepted and means `linkedin_message`, as in `amdahl optimize --channel`),
548
571
  `--sent-as` (`returned` or `edited`) and `--sent-at` (a time with a time
549
- zone; the current time when left out), then the optional ids.
550
-
551
- The server stores a report only when it can tell which send it is, so one of
552
- `--provider-send-id`, `--recipient` (an email address, or a LinkedIn profile
553
- URL for a LinkedIn channel) or `--recipient-key-hash` (a SHA-256) is required.
554
- A campaign, a lead and a step are not enough. Without one, the CLI exits 2
555
- before it sends anything. The id is the provider's own: for gmail, the
572
+ zone; the current time when left out), then the optional ids. `--sent-as
573
+ edited` needs `--sent-text`, the message as you sent it (Amdahl keeps only a
574
+ hash of it), and the file must not be empty. That is the CLI's rule, not the
575
+ server's. A run id given twice, or two run ids, exits 2.
576
+
577
+ The server stores a report only when it can tell which send it is, so you give
578
+ at least one of `--provider-send-id` and a recipient: `--recipient` (an email
579
+ address, or a LinkedIn profile URL for a LinkedIn channel) or
580
+ `--recipient-key-hash` (a SHA-256), never both. A send id with a recipient is
581
+ fine, and the server then counts a report with and one without the id as one
582
+ send. A campaign, a lead and a step are not enough. Without a send id or a
583
+ recipient, the CLI exits 2 before it sends anything. The id is the provider's own: for gmail, the
556
584
  `Message-ID` header with its angle brackets; never an email address (give that
557
585
  as `--recipient`; Amdahl stores only a hash of it). The command reports one
558
586
  send, so give one run id. To report many at once, call
@@ -580,6 +608,39 @@ lines; `--json` never carries them.
580
608
  a connection that needs attention, its console page; a used-up quota, the
581
609
  day it resets; a role too low, ask a workspace admin.
582
610
  - Bare `amdahl`, signed in, with every tool set up: what to try here.
611
+ - After an optimize (one file, a pasted message, or a list) a line says what
612
+ to do once the message is sent: two lines, each a command to paste, with
613
+ the run's own id and the flags it needs as placeholders (`amdahl sent
614
+ <run-id> --provider <provider> --channel <channel> --sent-as returned
615
+ --provider-send-id <id>`, then `amdahl feedback <run-id> --evidence
616
+ did_it_myself --outcome used`). A list prints the first run id of its
617
+ results. Replace each `<placeholder>` before you run it.
618
+ - `amdahl optimize` no longer prints the server's `what_changed` line. It is
619
+ the same sentence on every run. It is still in `--json`.
620
+
621
+ ## Help and errors
622
+
623
+ `amdahl <command> --help` (also `-h`, or `amdahl help <command>`) prints that
624
+ command's usage and nothing else: its flags, what it prints, and its exit
625
+ codes where they matter. `amdahl keys --help` lists the `keys` commands. For
626
+ `optimize --json`, the help says where the message is: `.result.message`, and
627
+ the run id is `.result.run_id`.
628
+
629
+ Three failures print a plain sentence each. Only the sentence changed from
630
+ 0.4.0: the `--json` error `code` is still the server's (or `http_<status>`) and
631
+ its `hint` is the same, because scripts and agents branch on them.
632
+
633
+ - A credential that lacks a scope says what it cannot do and what to use: `This
634
+ credential cannot optimize. Use a Customer agent key, or run \`amdahl
635
+ login\` again.` (exit 4). It never prints the server's `Missing required
636
+ scope(s)`.
637
+ - A key the server will not take (revoked or expired): `This key isn't valid
638
+ (revoked or expired).` and the hint, `Run amdahl login, or check
639
+ AMDAHL_KEY.` (exit 3). An OAuth access token in `AMDAHL_ACCESS_TOKEN` says
640
+ `This token isn't valid (revoked or expired).` A saved sign-in the server
641
+ will not take: `Your sign-in is no longer valid.`
642
+ - A file that is not there: `Can't find <file>.` (exit 2), for a draft, a
643
+ list, `--context` and `--sent-text`.
583
644
 
584
645
  ## Skills
585
646
 
package/bin/amdahl.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // bin/amdahl.js
3
3
  //
4
4
  // The `amdahl` command. It checks the Node.js version before it loads the
5
- // CLI: dist/amdahl.js is built for Node 20, and an older Node fails to parse
5
+ // CLI: dist/amdahl.js is built for Node 22, and an older Node fails to parse
6
6
  // it or crashes in it, while npx only warns about `engines`. So this file
7
7
  // uses no syntax newer than Node 14 (no `??=`, no top-level await, no class
8
8
  // fields), and an old Node can run it far enough to say what to do.
@@ -10,9 +10,9 @@
10
10
  var version = process.versions.node
11
11
  var major = parseInt(version.split('.')[0], 10)
12
12
 
13
- if (!(major >= 20)) {
13
+ if (!(major >= 22)) {
14
14
  process.stderr.write(
15
- 'amdahl needs Node.js 20 or later. This computer has Node.js ' +
15
+ 'amdahl needs Node.js 22 or later. This computer has Node.js ' +
16
16
  version +
17
17
  '.\nInstall a newer one from https://nodejs.org/en/download, then run the same command again.\n'
18
18
  )
package/dist/amdahl.js CHANGED
@@ -7921,6 +7921,13 @@ var ALIASES = {
7921
7921
  "auth status": "whoami"
7922
7922
  };
7923
7923
  var TWO_WORD = /* @__PURE__ */ new Set(["auth", "keys", "workspace", "skills"]);
7924
+ function helpWords(argv) {
7925
+ try {
7926
+ return parseArgs({ args: argv, options: allOptions(), allowPositionals: true, strict: false }).positionals;
7927
+ } catch {
7928
+ return [];
7929
+ }
7930
+ }
7924
7931
  function allOptions() {
7925
7932
  const merged = { ...GLOBAL_OPTIONS };
7926
7933
  for (const spec of Object.values(COMMAND_OPTIONS)) Object.assign(merged, spec);
@@ -8022,12 +8029,16 @@ Commands:
8022
8029
  optimize [<file>... | -] [--channel email|linkedin]
8023
8030
  [--evidence off|workspace] [--on-unsupported flag|remove]
8024
8031
  [--tries] [--context <file.json>]
8032
+ --json prints {ok, file, result}: the message is .result.message
8033
+ and the run id is .result.run_id.
8025
8034
  optimize --list <file.csv|file.jsonl> [--no-wait] [--batch <id>] [--force]
8026
8035
  [the optimize flags above, for every message]
8027
- A CSV has a message column, and may have item_id, channel,
8028
- evidence, on_unsupported, facts and rules. --batch <id> picks up
8029
- a batch queued earlier, with the same list. --force replaces an
8030
- existing results file.
8036
+ A CSV needs a message column, and may have item_id, channel,
8037
+ evidence, on_unsupported, facts and rules. Any other column is
8038
+ copied to the results as it is. --batch <id> picks up a batch
8039
+ queued earlier, with the same list. --force replaces an existing
8040
+ results file. In the results CSV, a cell that starts with =, +, -
8041
+ or @ (a phone number, say) gets a ' in front: it is no formula.
8031
8042
  search "<question>" [--research] [--limit <n>]
8032
8043
  --research ask for a research job: a deeper, cited answer (needs
8033
8044
  a Customer agent key, amdahl keys create --preset
@@ -8041,12 +8052,19 @@ Commands:
8041
8052
  What happened to a message
8042
8053
  sent <run-id> --provider <provider> --channel <channel>
8043
8054
  --sent-as returned|edited [--sent-at <time>]
8044
- (--provider-send-id <id> | --recipient <email|url> |
8045
- --recipient-key-hash <sha256>)
8055
+ [--provider-send-id <id>]
8056
+ [--recipient <email|url> | --recipient-key-hash <sha256>]
8046
8057
  [--provider-campaign-id <id>] [--sequence-step <n>]
8047
8058
  [--provider-variant-id <id>] [--provider-lead-id <id>]
8048
8059
  [--thread-id <id>] [--sent-text <file> | -]
8049
- One send per command. It needs one of the three in brackets.
8060
+ One send per command. Give --provider-send-id, a recipient, or
8061
+ both (a recipient is --recipient or --recipient-key-hash, not both).
8062
+ --provider: lemlist, instantly, outreach, salesloft, hubspot,
8063
+ salesforce, gmail, heyreach, apollo, linkedin_manual,
8064
+ other (any sender not listed)
8065
+ --channel: email, linkedin (= linkedin_message), linkedin_invite,
8066
+ linkedin_message, linkedin_inmail
8067
+ --sent-as edited needs --sent-text.
8050
8068
  Report that a message went out
8051
8069
  keys create --name <name> [--preset read-only|agent|internal|admin]
8052
8070
  [--expires 30d|90d|365d] [--no-browser]
@@ -8068,6 +8086,7 @@ Commands:
8068
8086
  (Cancel or Install anyway); -y, --json or no terminal
8069
8087
  stops (exit 9)
8070
8088
  help Show this help
8089
+ Run \`amdahl <command> --help\` for one command's usage.
8071
8090
 
8072
8091
  Global options:
8073
8092
  --profile <name> Use a saved profile (default: AMDAHL_PROFILE, then the
@@ -8085,7 +8104,7 @@ Global options:
8085
8104
  `;
8086
8105
 
8087
8106
  // src/version.ts
8088
- var VERSION = "0.4.0";
8107
+ var VERSION = "0.4.1";
8089
8108
 
8090
8109
  // src/api.ts
8091
8110
  var CLIENT_HEADER = {
@@ -8093,18 +8112,20 @@ var CLIENT_HEADER = {
8093
8112
  "User-Agent": `amdahl-cli/${VERSION}`
8094
8113
  };
8095
8114
  var AmdahlApiError = class extends Error {
8096
- constructor(message2, statusCode, path, responseBody, errorCode) {
8115
+ constructor(message2, statusCode, path, responseBody, errorCode, credentialSource) {
8097
8116
  super(message2);
8098
8117
  this.statusCode = statusCode;
8099
8118
  this.path = path;
8100
8119
  this.responseBody = responseBody;
8101
8120
  this.errorCode = errorCode;
8121
+ this.credentialSource = credentialSource;
8102
8122
  this.name = "AmdahlApiError";
8103
8123
  }
8104
8124
  statusCode;
8105
8125
  path;
8106
8126
  responseBody;
8107
8127
  errorCode;
8128
+ credentialSource;
8108
8129
  };
8109
8130
  function extractToolFailure(json) {
8110
8131
  if (!json || typeof json !== "object" || Array.isArray(json)) return null;
@@ -8176,7 +8197,7 @@ function assertUuid(name, value) {
8176
8197
  }
8177
8198
  }
8178
8199
  function createAmdahlClient(config) {
8179
- const { baseUrl, token } = config;
8200
+ const { baseUrl, token, source } = config;
8180
8201
  async function request(path, options = {}) {
8181
8202
  const { method = "GET", body, headers = {} } = options;
8182
8203
  const allHeaders = { ...headers, ...CLIENT_HEADER, Authorization: `Bearer ${token}` };
@@ -8189,12 +8210,12 @@ function createAmdahlClient(config) {
8189
8210
  if (!response.ok) {
8190
8211
  const errorBody = await response.text().catch(() => "");
8191
8212
  const { message: message2, code } = parseErrorBody(errorBody, response.status);
8192
- throw new AmdahlApiError(message2, response.status, path, errorBody, code);
8213
+ throw new AmdahlApiError(message2, response.status, path, errorBody, code, source);
8193
8214
  }
8194
8215
  const json = await response.json();
8195
8216
  const toolFailure = extractToolFailure(json);
8196
8217
  if (toolFailure) {
8197
- throw new AmdahlApiError(toolFailure.message, response.status, path, JSON.stringify(json), toolFailure.code);
8218
+ throw new AmdahlApiError(toolFailure.message, response.status, path, JSON.stringify(json), toolFailure.code, source);
8198
8219
  }
8199
8220
  if (json && typeof json === "object" && !Array.isArray(json) && "data" in json && Object.keys(json).length === 1) {
8200
8221
  return json.data;
@@ -8804,7 +8825,7 @@ async function profileCredential(ctx) {
8804
8825
  return { source: "profile", token: refreshed.access_token, profile: profileName };
8805
8826
  }
8806
8827
  function apiClient(ctx, credential) {
8807
- return createAmdahlClient({ baseUrl: ctx.apiUrl, token: credential.token });
8828
+ return createAmdahlClient({ baseUrl: ctx.apiUrl, token: credential.token, source: credential.source });
8808
8829
  }
8809
8830
  function errorDetails(body) {
8810
8831
  if (!body) return {};
@@ -8816,6 +8837,28 @@ function errorDetails(body) {
8816
8837
  return {};
8817
8838
  }
8818
8839
  }
8840
+ var CALL_VERBS = [
8841
+ ["/messages/optimize/batch", "optimize a list"],
8842
+ ["/messages/optimize/runs/", "record feedback"],
8843
+ ["/messages/optimize", "optimize"],
8844
+ ["/messages/report_send", "report sends"],
8845
+ ["/research/", "read research"],
8846
+ ["/search/query", "search"],
8847
+ ["/connections", "read connections"],
8848
+ ["/setup/status", "read the setup status"]
8849
+ ];
8850
+ function plainMessage(err) {
8851
+ if (err.statusCode === 401 && err.credentialSource) {
8852
+ if (err.credentialSource === "profile") return "Your sign-in is no longer valid.";
8853
+ const noun = err.credentialSource === "env:AMDAHL_ACCESS_TOKEN" ? "token" : "key";
8854
+ return `This ${noun} isn't valid (revoked or expired).`;
8855
+ }
8856
+ if (err.errorCode === "missing_scope" || /^Missing required scope/i.test(err.message)) {
8857
+ const verb = CALL_VERBS.find(([prefix2]) => err.path.includes(prefix2))?.[1] ?? "do this";
8858
+ return `This credential cannot ${verb}. Use a Customer agent key, or run \`${amdahlCommand("login")}\` again.`;
8859
+ }
8860
+ return null;
8861
+ }
8819
8862
  function toCliError(err) {
8820
8863
  if (err instanceof CliError) return err;
8821
8864
  if (err instanceof PromptCancelled) return new CliError("cancelled", "Stopped. Nothing changed.", EXIT.cancelled);
@@ -8824,7 +8867,7 @@ function toCliError(err) {
8824
8867
  const details = errorDetails(err.responseBody);
8825
8868
  const retry = details.retry_after_seconds;
8826
8869
  const hint = exit === EXIT.unauthenticated ? `Run \`${amdahlCommand("login")}\`, or check AMDAHL_KEY.` : typeof retry === "number" ? `Try again in ${retry} seconds.` : void 0;
8827
- return new CliError(err.errorCode ?? `http_${err.statusCode}`, err.message, exit, hint);
8870
+ return new CliError(err.errorCode ?? `http_${err.statusCode}`, plainMessage(err) ?? err.message, exit, hint);
8828
8871
  }
8829
8872
  const message2 = err instanceof Error ? err.message : String(err);
8830
8873
  return new CliError("network_error", `Could not reach the Amdahl API: ${message2}`, EXIT.network, NETWORK_HINT);
@@ -9436,6 +9479,14 @@ function day(iso) {
9436
9479
  const ms = Date.parse(iso);
9437
9480
  return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : "";
9438
9481
  }
9482
+ function hasFactsOrRules(context) {
9483
+ const c = record(context);
9484
+ const some = (list2) => Array.isArray(list2) && list2.some((entry) => typeof entry === "string" ? entry.trim() !== "" : !!record(entry));
9485
+ return !!c && (some(c.facts) || some(c.rules));
9486
+ }
9487
+ function truthfulSummary(summary, context) {
9488
+ return /facts and rules/i.test(summary) && !hasFactsOrRules(context) ? "" : summary;
9489
+ }
9439
9490
  function count(n, one, many = `${one}s`) {
9440
9491
  return `${n} ${n === 1 ? one : many}`;
9441
9492
  }
@@ -9536,7 +9587,10 @@ async function connect(ctx, args) {
9536
9587
  }
9537
9588
  }
9538
9589
  const slug = await workspaceSlug(ctx, credential);
9539
- const url = connectUrl(consoleUrlFor(ctx.io, ctx.apiUrl), slug, entry?.type);
9590
+ const consoleUrl = consoleUrlFor(ctx.io, ctx.apiUrl);
9591
+ const url = connectUrl(consoleUrl, slug, entry?.type);
9592
+ const existing = entry ? await existingConnection(client, entry.type) : null;
9593
+ if (entry && existing) return alreadyConnected(ctx, entry, existing, url, connectionUrl(consoleUrl, slug, field(existing.id)));
9540
9594
  const opened = await openOrPrint(ctx, url);
9541
9595
  if (ctx.out.json) {
9542
9596
  ctx.out.result({ ok: true, url, opened, connector: entry?.type ?? null });
@@ -9550,6 +9604,33 @@ async function connect(ctx, args) {
9550
9604
  ctx.out.result({}, url);
9551
9605
  return EXIT.ok;
9552
9606
  }
9607
+ async function existingConnection(client, type) {
9608
+ try {
9609
+ const body = await client.request(`${PLATFORM2}/connections`);
9610
+ const same = records(Array.isArray(body) ? body : record(body)?.connections).filter((c) => field(c.connector_type) === type || field(record(c.connector)?.type) === type);
9611
+ return same.find((c) => FINE.has(healthOf(c))) ?? same[0] ?? null;
9612
+ } catch {
9613
+ return null;
9614
+ }
9615
+ }
9616
+ function alreadyConnected(ctx, entry, existing, addUrl, pageUrl) {
9617
+ const health = healthOf(existing);
9618
+ const healthy = FINE.has(health);
9619
+ const synced = day(existing.last_synced_at);
9620
+ if (ctx.out.json) {
9621
+ ctx.out.result({ ok: true, url: addUrl, opened: false, connector: entry.type, connected: { id: field(existing.id), health, healthy, url: pageUrl } });
9622
+ return EXIT.ok;
9623
+ }
9624
+ if (healthy) {
9625
+ ctx.out.info(`${entry.name} is already connected and healthy${synced ? ` (last sync ${synced})` : ""}.`);
9626
+ ctx.out.info("To add another one, open this link:");
9627
+ ctx.out.result({}, addUrl);
9628
+ return EXIT.ok;
9629
+ }
9630
+ ctx.out.info(`${entry.name} is already connected, but its health is "${HEALTH[health] ?? health}".`);
9631
+ ctx.out.info(`${health === "needs_reauth" ? "Sign in to it again" : "Fix it"}: ${pageUrl}`);
9632
+ return EXIT.ok;
9633
+ }
9553
9634
  function connectionRow(c) {
9554
9635
  const connector = record(c.connector);
9555
9636
  const type = field(connector?.name) || field(c.connector_type);
@@ -9602,13 +9683,27 @@ async function fixLine(ctx, credential, attention, slug) {
9602
9683
  return `Fix them: ${connectionsUrl(consoleUrl, ws)}`;
9603
9684
  }
9604
9685
 
9605
- // src/commands/feedback.ts
9686
+ // src/files.ts
9606
9687
  import { readFileSync as readFileSync4 } from "fs";
9688
+ function readTextFile(path) {
9689
+ try {
9690
+ return readFileSync4(path, "utf8");
9691
+ } catch (err) {
9692
+ const code = err?.code;
9693
+ if (code === "ENOENT") throw usageError(`Can't find ${path}.`);
9694
+ if (code === "EISDIR") throw usageError(`${path} is a folder, not a file.`);
9695
+ if (code === "EACCES" || code === "EPERM") throw usageError(`Can't read ${path}: permission denied.`);
9696
+ throw usageError(`Can't read ${path}: ${err instanceof Error ? err.message : String(err)}`);
9697
+ }
9698
+ }
9699
+
9700
+ // src/commands/feedback.ts
9607
9701
  var PLATFORM3 = "/api/platform/v1";
9608
9702
  var FEEDBACK_OUTCOMES = ["used", "used_with_edits", "not_used", "unsure"];
9609
9703
  var FEEDBACK_EVIDENCE = ["did_it_myself", "reported_to_me", "inferred"];
9610
9704
  var SEND_PROVIDERS = ["lemlist", "instantly", "outreach", "salesloft", "hubspot", "salesforce", "gmail", "heyreach", "apollo", "linkedin_manual", "other"];
9611
9705
  var SEND_CHANNELS = ["email", "linkedin_invite", "linkedin_message", "linkedin_inmail"];
9706
+ var CHANNEL_ALIASES = { linkedin: "linkedin_message" };
9612
9707
  var SENT_AS = ["returned", "edited"];
9613
9708
  var MAX_MESSAGE_CHARS = 1e4;
9614
9709
  var MAX_COMMENT_CHARS = 2e3;
@@ -9633,11 +9728,7 @@ function text(flag, value, max) {
9633
9728
  return value;
9634
9729
  }
9635
9730
  async function readText(ctx, path) {
9636
- try {
9637
- return path === "-" ? await ctx.io.readStdin() : readFileSync4(path, "utf8");
9638
- } catch (err) {
9639
- throw usageError(`Could not read --sent-text ${path}: ${err instanceof Error ? err.message : String(err)}`);
9640
- }
9731
+ return path === "-" ? await ctx.io.readStdin() : readTextFile(path);
9641
9732
  }
9642
9733
  async function feedback(ctx, args) {
9643
9734
  const usage = `Usage: ${amdahlCommand("feedback <run-id> --evidence did_it_myself [--outcome used] [--sent-text <file>] [--comment <text>]")}`;
@@ -9675,21 +9766,28 @@ var PROVIDER_IDS = {
9675
9766
  "provider-lead-id": ["provider_lead_id", 256],
9676
9767
  "thread-id": ["thread_id", 512]
9677
9768
  };
9678
- var SEND_KEYS = ["--provider-send-id", "--recipient", "--recipient-key-hash"];
9679
9769
  async function sent(ctx, args) {
9680
9770
  const { flags } = ctx;
9681
9771
  const usage = `Usage: ${amdahlCommand("sent <run-id> --provider <provider> --channel <channel> --sent-as returned|edited --provider-send-id <id>")}`;
9682
9772
  const [rawId, ...rest] = args;
9683
9773
  if (!rawId) throw usageError("Give the run id of the message that was sent.", usage);
9774
+ if (rest.length > 0 && rest.every((r) => r.toLowerCase() === rawId.toLowerCase())) {
9775
+ throw usageError(`${rawId} is there twice.`, "Give each run id once. The command reports one send.");
9776
+ }
9684
9777
  if (rest.length > 0) {
9685
9778
  throw usageError("Report one send at a time: give one run id.", "Each send needs its own --provider-send-id or --recipient, so run it once for each. The API takes up to 100 at once (POST /messages/report_send).");
9686
9779
  }
9687
9780
  const id = runId(rawId, usage);
9688
- const provider = oneOf("provider", str(flags.provider), SEND_PROVIDERS);
9689
- const channel = oneOf("channel", str(flags.channel), SEND_CHANNELS);
9781
+ const rawProvider = str(flags.provider);
9782
+ if (rawProvider !== void 0 && !SEND_PROVIDERS.includes(rawProvider)) {
9783
+ throw usageError(`--provider must be one of: ${SEND_PROVIDERS.join(", ")}.`, `"${clip(rawProvider, 40)}" is not on the list. For a sender that is not, use --provider other.`);
9784
+ }
9785
+ const provider = rawProvider;
9786
+ const rawChannel = str(flags.channel);
9787
+ const channel = rawChannel === void 0 ? void 0 : CHANNEL_ALIASES[rawChannel] ?? oneOf("channel", rawChannel, [...SEND_CHANNELS, ...Object.keys(CHANNEL_ALIASES)]);
9690
9788
  const sentAs = oneOf("sent-as", str(flags["sent-as"]), SENT_AS);
9691
9789
  if (!provider) throw usageError(`Say where it went out with --provider: ${SEND_PROVIDERS.join(", ")}.`, usage);
9692
- if (!channel) throw usageError(`Say what was sent with --channel: ${SEND_CHANNELS.join(", ")}.`, usage);
9790
+ if (!channel) throw usageError(`Say what was sent with --channel: ${[...SEND_CHANNELS, ...Object.keys(CHANNEL_ALIASES)].join(", ")}.`, usage);
9693
9791
  if (!sentAs) throw usageError("Say whether it was sent as Amdahl returned it with --sent-as: returned, edited.", usage);
9694
9792
  const sentAt = str(flags["sent-at"]) ?? new Date(ctx.io.now()).toISOString();
9695
9793
  if (!TIME_WITH_ZONE.test(sentAt) || !Number.isFinite(Date.parse(sentAt))) {
@@ -9726,8 +9824,14 @@ async function sent(ctx, args) {
9726
9824
  }
9727
9825
  const sentPath = str(flags["sent-text"]);
9728
9826
  if (sentPath !== void 0) item.sent_text = text("sent-text", await readText(ctx, sentPath), MAX_MESSAGE_CHARS);
9827
+ if (sentAs === "edited" && sentPath === void 0) {
9828
+ throw usageError("--sent-as edited needs --sent-text <file>: the message as you sent it.", "Amdahl keeps only a hash of the text. Use - to read it from standard input.");
9829
+ }
9830
+ if (sentAs === "edited" && !(typeof item.sent_text === "string" && item.sent_text.trim())) {
9831
+ throw usageError(`${sentPath === "-" ? "Standard input" : `--sent-text ${sentPath}`} is empty. An edited send needs the message as you sent it.`);
9832
+ }
9729
9833
  if (!sendId?.trim() && !recipient?.trim() && !hash?.trim()) {
9730
- throw usageError(`Say which send this is with one of ${SEND_KEYS.join(", ")}.`, "Amdahl matches a report to a send by its provider's own id, or by the recipient (stored only as a hash).");
9834
+ throw usageError("Say which send this is: give --provider-send-id, a recipient (--recipient or --recipient-key-hash), or both.", "Amdahl matches a report to a send by its provider's own id, by the recipient (stored only as a hash), or both.");
9731
9835
  }
9732
9836
  const credential = await resolveCredential(ctx);
9733
9837
  const result = await call(
@@ -10247,11 +10351,18 @@ async function keysRevoke(ctx, args) {
10247
10351
  return EXIT.ok;
10248
10352
  }
10249
10353
 
10250
- // src/commands/optimize.ts
10251
- import { readFileSync as readFileSync7 } from "fs";
10354
+ // src/next-steps.ts
10355
+ function sentLines(runId2, lead = "Sent it?") {
10356
+ const id = field(runId2);
10357
+ if (!id) return [];
10358
+ return [
10359
+ `${lead} ${amdahlCommand("sent")} ${id} --provider <provider> --channel <channel> --sent-as returned --provider-send-id <id>`,
10360
+ `Or say how it went: ${amdahlCommand("feedback")} ${id} --evidence did_it_myself --outcome used`
10361
+ ];
10362
+ }
10252
10363
 
10253
10364
  // src/commands/batch.ts
10254
- import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
10365
+ import { existsSync as existsSync5, writeFileSync as writeFileSync4 } from "fs";
10255
10366
  import { basename as basename2, dirname as dirname4, extname, join as join7 } from "path";
10256
10367
  var PLATFORM4 = "/api/platform/v1";
10257
10368
  var BATCH_WAIT_MS = 15e3;
@@ -10329,12 +10440,15 @@ function csvList(path, text3) {
10329
10440
  const [header, ...body] = rows;
10330
10441
  if (!header) throw usageError(`${path} is empty.`);
10331
10442
  const names = header.map((h) => h.trim().toLowerCase());
10332
- const unknown = names.find((n) => !CSV_COLUMNS.includes(n));
10333
- const headers = `The first row must name the columns, from: ${CSV_COLUMNS.join(", ")} (message is required). For any other field, use a JSONL file.`;
10334
- if (unknown !== void 0) {
10335
- throw usageError(`${path} has a column Amdahl doesn't read: "${clip(unknown, 40)}".`, headers);
10443
+ const twice = CSV_COLUMNS.find((c) => names.indexOf(c) !== names.lastIndexOf(c));
10444
+ if (twice !== void 0) throw usageError(`${path} has the column "${twice}" twice.`);
10445
+ if (!names.includes("message")) {
10446
+ throw usageError(
10447
+ `${path} needs a "message" column.`,
10448
+ `The first row must name the columns. Required: message. Read: ${CSV_COLUMNS.filter((c) => c !== "message").join(", ")}. Any other column is copied to the results as it is.`
10449
+ );
10336
10450
  }
10337
- if (!names.includes("message")) throw usageError(`${path} needs a "message" column.`, headers);
10451
+ const ignored = header.map((h, i) => ({ name: h.trim(), known: CSV_COLUMNS.includes(names[i] ?? "") })).filter((c) => !c.known && c.name).map((c) => clip(c.name, 30));
10338
10452
  const items = body.map((row, i) => {
10339
10453
  if (row.length !== names.length) {
10340
10454
  throw usageError(`Line ${lines[i + 1]} of ${path} has ${count(row.length, "cell")}; the header has ${names.length}.`);
@@ -10343,14 +10457,14 @@ function csvList(path, text3) {
10343
10457
  const context = {};
10344
10458
  names.forEach((name, j) => {
10345
10459
  const value = row[j] ?? "";
10346
- if (!value.trim()) return;
10460
+ if (!CSV_COLUMNS.includes(name) || !value.trim()) return;
10347
10461
  if (name === "facts" || name === "rules") context[name] = perLine(value);
10348
10462
  else item[name] = name === "message" ? value : value.trim();
10349
10463
  });
10350
10464
  if (Object.keys(context).length > 0) item.context = context;
10351
10465
  return item;
10352
10466
  });
10353
- return { path, kind: "csv", items, header, rows: body, lines: lines.slice(1) };
10467
+ return { path, kind: "csv", items, header, rows: body, ignored, lines: lines.slice(1) };
10354
10468
  }
10355
10469
  function jsonlList(path, text3) {
10356
10470
  const items = [];
@@ -10371,12 +10485,7 @@ function jsonlList(path, text3) {
10371
10485
  return { path, kind: "jsonl", items, lines };
10372
10486
  }
10373
10487
  function readList(path) {
10374
- let text3;
10375
- try {
10376
- text3 = readFileSync6(path, "utf8");
10377
- } catch (err) {
10378
- throw usageError(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
10379
- }
10488
+ const text3 = readTextFile(path);
10380
10489
  const ext = extname(path).toLowerCase();
10381
10490
  const jsonl = ext === ".jsonl" || ext === ".ndjson" || ext !== ".csv" && text3.trimStart().startsWith("{");
10382
10491
  const list2 = jsonl ? jsonlList(path, text3) : csvList(path, text3);
@@ -10398,6 +10507,10 @@ function withDefaults(items, defaults) {
10398
10507
  return merged;
10399
10508
  });
10400
10509
  }
10510
+ function progressKey(batch) {
10511
+ const c = record(batch.counts) ?? {};
10512
+ return ["total", "queued", "running", "done", "failed"].map((k) => typeof c[k] === "number" ? c[k] : 0).join("/");
10513
+ }
10401
10514
  function progressLine(batch) {
10402
10515
  const c = record(batch.counts) ?? {};
10403
10516
  const n = (k) => typeof c[k] === "number" ? c[k] : 0;
@@ -10409,7 +10522,7 @@ function progressLine(batch) {
10409
10522
  const left = seconds === null || finished === total ? "" : seconds < 90 ? ", about a minute left" : `, about ${Math.round(seconds / 60)} min left`;
10410
10523
  if (finished === 0 && n("running") === 0) {
10411
10524
  const ahead = typeof batch.queue_position === "number" && batch.queue_position > 0 ? ` behind ${count(batch.queue_position, "item")}` : "";
10412
- return `Queued${ahead}: 0 of ${total} done${left}`;
10525
+ return `Queued${ahead}${left}`;
10413
10526
  }
10414
10527
  return `${n("done")} of ${total} done${failed}${left}`;
10415
10528
  }
@@ -10417,12 +10530,14 @@ async function waitForBatch(ctx, credential, id, first2) {
10417
10530
  const client = apiClient(ctx, credential);
10418
10531
  const tty = ctx.io.stderrIsTTY && !ctx.out.json;
10419
10532
  let last = "";
10533
+ let lastKey = "";
10420
10534
  const show = (batch2) => {
10421
10535
  const line = progressLine(batch2);
10422
10536
  if (line === last) return;
10423
10537
  last = line;
10424
10538
  if (tty) ctx.io.stderr(`\r\x1B[2K${line}`);
10425
- else ctx.out.info(line);
10539
+ else if (progressKey(batch2) !== lastKey) ctx.out.info(line);
10540
+ lastKey = progressKey(batch2);
10426
10541
  };
10427
10542
  let batch = first2;
10428
10543
  for (; ; ) {
@@ -10454,13 +10569,22 @@ async function allItems(ctx, credential, id) {
10454
10569
  function neutralise(cell2) {
10455
10570
  return /^[=+\-@\t\r]/.test(cell2) ? `'${cell2}` : cell2;
10456
10571
  }
10457
- var RESULT_COLUMNS = ["optimized_message", "status", "what_changed", "run_id", "error"];
10458
- function resultCells(item) {
10572
+ var RESULT_COLUMNS = ["optimized_message", "status", "summary", "run_id", "error"];
10573
+ function resultColumnNames(header) {
10574
+ const taken = new Set(header.map((h) => h.trim().toLowerCase()));
10575
+ return RESULT_COLUMNS.map((name) => {
10576
+ let candidate = name;
10577
+ while (taken.has(candidate)) candidate = `amdahl_${candidate}`;
10578
+ taken.add(candidate);
10579
+ return candidate;
10580
+ });
10581
+ }
10582
+ function resultCells(item, context) {
10459
10583
  const result = record(item?.result);
10460
10584
  if (!item || !result) return ["", field(item?.status) || "missing", "", "", ""];
10461
10585
  if (result.ok === false) return ["", "failed", "", field(result.run_id), `${field(result.reason)}: ${field(result.detail)}`.replace(/: $/, "")];
10462
10586
  const message2 = typeof result.message === "string" ? result.message : "";
10463
- return [message2, result.unchanged === true ? "unchanged" : "done", field(result.what_changed), field(result.run_id), ""];
10587
+ return [message2, result.unchanged === true ? "unchanged" : "done", truthfulSummary(field(result.summary), context), field(result.run_id), ""];
10464
10588
  }
10465
10589
  function resultsFile(list2, id, cwd) {
10466
10590
  if (list2) return resultsPath(list2.path, list2.kind);
@@ -10469,12 +10593,12 @@ function resultsFile(list2, id, cwd) {
10469
10593
  function assertFree(path, force) {
10470
10594
  if (!force && existsSync5(path)) throw usageError(`${path} already exists.`, "Move it, or add --force to replace it.");
10471
10595
  }
10472
- function writeResults(list2, id, items, cwd, force) {
10596
+ function writeResults(list2, id, items, cwd, force, contexts) {
10473
10597
  const byIndex = new Map(items.map((i) => [i.index, i]));
10474
10598
  const path = resultsFile(list2, id, cwd);
10475
10599
  const flag = force ? "w" : "wx";
10476
10600
  if (list2?.kind === "csv" && list2.header && list2.rows) {
10477
- const rows = [[...list2.header, ...RESULT_COLUMNS], ...list2.rows.map((row, i) => [...row, ...resultCells(byIndex.get(i))])];
10601
+ const rows = [[...list2.header, ...resultColumnNames(list2.header)], ...list2.rows.map((row, i) => [...row, ...resultCells(byIndex.get(i), contexts[i])])];
10478
10602
  writeFileSync4(path, toCsv(rows.map((row) => row.map(neutralise))), { flag });
10479
10603
  return path;
10480
10604
  }
@@ -10500,6 +10624,11 @@ async function optimizeList(ctx, defaults) {
10500
10624
  throw usageError("--batch needs a batch id: a UUID like the one `amdahl optimize --list` printed.");
10501
10625
  }
10502
10626
  const list2 = listPath ? readList(listPath) : null;
10627
+ if (list2?.ignored?.length) {
10628
+ const shown = list2.ignored.slice(0, 6).join(", ");
10629
+ const more = list2.ignored.length > 6 ? ` and ${list2.ignored.length - 6} more` : "";
10630
+ out.info(`Not sent, and copied to the results as they are: ${count(list2.ignored.length, "column")} Amdahl does not read (${shown}${more}).`);
10631
+ }
10503
10632
  if (!resume && list2 && list2.items.length > MAX_BATCH_ITEMS) {
10504
10633
  throw usageError(`A batch takes at most ${MAX_BATCH_ITEMS} messages, and ${list2.path} has ${list2.items.length}.`, "Split the list into files of 500 or fewer, and run each one.");
10505
10634
  }
@@ -10546,10 +10675,13 @@ Get the results: ${pickUp}`);
10546
10675
  out.result({ ...batch, ok: failed === 0, items, next_cursor: null });
10547
10676
  return failed === 0 ? EXIT.ok : EXIT.general;
10548
10677
  }
10549
- const path = writeResults(list2, id, items, ctx.io.cwd, force);
10678
+ const contexts = list2 ? withDefaults(list2.items, defaults).map((item) => item.context) : [];
10679
+ const path = writeResults(list2, id, items, ctx.io.cwd, force, contexts);
10550
10680
  const done = items.length - failed;
10551
10681
  out.info(`${done} of ${count(items.length, "message")} optimized${failed ? `, ${failed} failed` : ""}.`);
10552
10682
  out.result({}, `Results: ${path}`);
10683
+ const runId2 = items.map((i) => field(record(i.result)?.run_id)).find(Boolean);
10684
+ for (const line of sentLines(runId2, "Sent one?")) out.info(line);
10553
10685
  return failed === 0 ? EXIT.ok : EXIT.general;
10554
10686
  }
10555
10687
 
@@ -10565,11 +10697,12 @@ function oneOf2(flag, value, allowed) {
10565
10697
  return value;
10566
10698
  }
10567
10699
  function readContext(path) {
10700
+ const text3 = readTextFile(path);
10568
10701
  let parsed;
10569
10702
  try {
10570
- parsed = JSON.parse(readFileSync7(path, "utf8"));
10703
+ parsed = JSON.parse(text3);
10571
10704
  } catch (err) {
10572
- throw usageError(`Could not read --context ${path}: ${err instanceof Error ? err.message : String(err)}`);
10705
+ throw usageError(`--context ${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
10573
10706
  }
10574
10707
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
10575
10708
  throw usageError("--context must be a JSON object with rules, voice_examples and facts.");
@@ -10688,7 +10821,6 @@ function whatAmdahlDid(result) {
10688
10821
  const more = whys.length > 1 ? ` (${plural(whys.length - 1, "more reason")} in --json)` : "";
10689
10822
  const leftOut = omitted.length ? `Left out ${plural(omitted.length, "thing")}${whys[0] ? `: ${sentence(whys[0])}` : "."}${more}` : "";
10690
10823
  const lines = [
10691
- { rank: 2, line: sentence(text2(result.what_changed)) },
10692
10824
  { rank: 5, line: checked },
10693
10825
  { rank: 3, line: removed },
10694
10826
  { rank: 6, line: usedLine },
@@ -10800,7 +10932,7 @@ ${nextTimeLine(ctx)}`);
10800
10932
  out.result({}, String(result.message ?? ""));
10801
10933
  const did = whatAmdahlDid(result);
10802
10934
  const connect2 = tipsOn(ctx) && (saysNoData(result) || await noData) ? [noDataLine()] : [];
10803
- out.info([...did, ...feedbackLines(ctx, result), ...connect2, nextTimeLine(ctx)].join("\n"));
10935
+ out.info([...did, ...feedbackLines(ctx, result), ...sentLines(result.run_id), ...connect2, nextTimeLine(ctx)].join("\n"));
10804
10936
  return EXIT.ok;
10805
10937
  }
10806
10938
  async function optimize(ctx, args) {
@@ -10826,12 +10958,7 @@ async function optimize(ctx, args) {
10826
10958
  const base = requestOptions(ctx);
10827
10959
  const inputs = [];
10828
10960
  for (const file of files) {
10829
- let message2;
10830
- try {
10831
- message2 = file === "-" ? await io.readStdin() : readFileSync7(file, "utf8");
10832
- } catch (err) {
10833
- throw usageError(`Could not read ${file}: ${err instanceof Error ? err.message : String(err)}`);
10834
- }
10961
+ const message2 = file === "-" ? await io.readStdin() : readTextFile(file);
10835
10962
  if (!message2.trim()) throw usageError(`${file === "-" ? "Stdin" : file} is empty.`);
10836
10963
  inputs.push({ file, message: message2 });
10837
10964
  }
@@ -10851,9 +10978,9 @@ async function optimize(ctx, args) {
10851
10978
  if (!out.json) {
10852
10979
  if (inputs.length > 1) out.info(`== ${file} ==`);
10853
10980
  if (result.unchanged === true) out.info("No rewrite beat your draft; it is returned unchanged.");
10854
- else if (typeof result.summary === "string") out.info(result.summary);
10981
+ else if (typeof result.summary === "string" && truthfulSummary(result.summary, base.context)) out.info(truthfulSummary(result.summary, base.context));
10855
10982
  out.result({}, String(result.message ?? ""));
10856
- const lines = [...whatAmdahlDid(result), ...feedbackLines(ctx, result)];
10983
+ const lines = [...whatAmdahlDid(result), ...feedbackLines(ctx, result), ...sentLines(result.run_id)];
10857
10984
  if (lines.length) out.info(lines.join("\n"));
10858
10985
  }
10859
10986
  }
@@ -11005,13 +11132,16 @@ var RESEARCH_TIMEOUT_MS = 15 * 6e4;
11005
11132
  var ROWS_UNDER_ANSWER = 5;
11006
11133
  var ROWS_ALONE = 10;
11007
11134
  function researchKeyLine() {
11008
- return `A deeper answer needs a Customer agent key: ${amdahlCommand("keys create --name research --preset agent")}`;
11135
+ return `Research needs a Customer agent key: ${amdahlCommand("keys create --name research --preset agent")}`;
11136
+ }
11137
+ function researchKeyFirst() {
11138
+ return `${researchKeyLine()}. Here is the search answer instead.`;
11009
11139
  }
11010
11140
  var SETTLED = /* @__PURE__ */ new Set(["complete", "failed", "canceled", "needs_input"]);
11011
11141
  var TITLE_FIELDS = ["title", "call_title", "meeting_title", "subject", "deal_name", "account_name", "company_name", "name"];
11012
11142
  var DATE_FIELDS = ["occurred_at", "started_at", "date", "call_date", "call_timestamp", "close_date", "created_at", "updated_at"];
11013
- var TEXT_FIELDS = ["text", "quote", "snippet", "excerpt", "content", "summary", "body", "description"];
11014
- var WHO_FIELDS = ["speaker", "speaker_name", "account_name", "company_name", "owner_name", "deal_stage_label"];
11143
+ var TEXT_FIELDS = ["content_preview", "text", "quote", "snippet", "excerpt", "content", "summary", "body", "description"];
11144
+ var WHO_FIELDS = ["speaker", "speaker_name", "account_name", "company_name", "owner_name", "deal_stage_label", "speaker_type"];
11015
11145
  var AMOUNT_FIELDS = ["deal_amount", "amount"];
11016
11146
  function first(row, names) {
11017
11147
  for (const name of names) {
@@ -11039,15 +11169,24 @@ function rowLines(row, n) {
11039
11169
  function rowsOf(result) {
11040
11170
  return records(result.results);
11041
11171
  }
11172
+ function noAnswerLines(status2, rows) {
11173
+ if (rows === 0) return [];
11174
+ if (status2 === "not_enough_data") {
11175
+ return [...wrap(`Amdahl couldn't answer that from these rows. Try rephrasing the question, or run ${amdahlCommand("connections")} to see whether your data is synced.`), ""];
11176
+ }
11177
+ if (status2 === "failed") return [...wrap(`Amdahl found rows but couldn't write an answer this time. Here are the rows. Try again, or rephrase the question.`), ""];
11178
+ return [];
11179
+ }
11042
11180
  function searchLines(result) {
11043
11181
  const lines = [];
11044
11182
  const rows = rowsOf(result);
11045
11183
  const answer = record(result.answer);
11046
11184
  const answered = answer?.status === "answered" && paragraphs(answer.text);
11047
11185
  if (answered) lines.push(...wrap(plainMarkdown(answered)), "");
11186
+ else lines.push(...noAnswerLines(field(answer?.status), rows.length));
11048
11187
  const shown = rows.slice(0, answered ? ROWS_UNDER_ANSWER : ROWS_ALONE);
11049
11188
  if (shown.length > 0) {
11050
- if (answered) lines.push("From:");
11189
+ if (answered || lines.length > 0) lines.push(answered ? "From:" : "Rows it read:");
11051
11190
  shown.forEach((row, i) => lines.push(...rowLines(row, i + 1)));
11052
11191
  if (rows.length > shown.length) lines.push(`And ${count(rows.length - shown.length, "more row")} (all of them: add --json).`);
11053
11192
  } else if (!answered) {
@@ -11126,13 +11265,12 @@ async function search(ctx, args) {
11126
11265
  out.result({}, [...wrap(field(setup2.message) || "This workspace has no data connected yet."), connectLine()].join("\n"));
11127
11266
  return EXIT.ok;
11128
11267
  }
11129
- const lines = searchLines(result);
11130
- out.result({}, lines.join("\n"));
11131
- if (refusedForScope(notes)) out.info(researchKeyLine());
11268
+ if (refusedForScope(notes)) out.info(wrap(researchKeyFirst()).join("\n"));
11132
11269
  else {
11133
11270
  const refused = notes.find((n) => /ran search instead/i.test(n));
11134
11271
  if (refused) out.info(wrap(refused).join("\n"));
11135
11272
  }
11273
+ out.result({}, searchLines(result).join("\n"));
11136
11274
  return EXIT.ok;
11137
11275
  }
11138
11276
  function printResearch(ctx, search2, job) {
@@ -12112,6 +12250,262 @@ async function workspaceUse(ctx, args) {
12112
12250
  return EXIT.ok;
12113
12251
  }
12114
12252
 
12253
+ // src/help.ts
12254
+ var GLOBAL = `
12255
+ Global options: --profile, --api-url, --api-key, --json, --no-color, --verbose.
12256
+ Run \`amdahl --help\` for what each does.
12257
+ `;
12258
+ var HELP = {
12259
+ setup: `Usage: amdahl [setup] [--no-browser]
12260
+
12261
+ The guided setup. In a terminal it asks where Amdahl should work: Claude Code,
12262
+ Cursor, Codex or this terminal. Run by an agent, or with no terminal, it sets
12263
+ up that agent (or every client it finds) and asks nothing.
12264
+
12265
+ --no-browser Print the sign-in link instead of opening a browser.
12266
+ `,
12267
+ login: `Usage: amdahl login [--workspace <slug>] [--no-browser] [--port <n>]
12268
+
12269
+ Sign in through the browser with your console account. Also: amdahl auth login.
12270
+
12271
+ --workspace <slug> Sign in to this workspace.
12272
+ --no-browser Print the sign-in link instead of opening a browser.
12273
+ --port <n> Listen for the browser's answer on this local port.
12274
+ `,
12275
+ logout: `Usage: amdahl logout [--all]
12276
+
12277
+ Sign out and forget the sign-in on this machine. Also: amdahl auth logout.
12278
+
12279
+ --all Sign out of every saved profile, not just the current one.
12280
+ `,
12281
+ whoami: `Usage: amdahl whoami [--show-token]
12282
+
12283
+ Show who you are: the account, workspace, role and which credential won. Also:
12284
+ amdahl auth status.
12285
+
12286
+ --show-token Print the full access token, not its first characters.
12287
+ `,
12288
+ "auth token": `Usage: amdahl auth token
12289
+
12290
+ Print a fresh access token from your sign-in. It needs your own sign-in: a key
12291
+ in AMDAHL_KEY or --api-key wins over it, and then this exits 4.
12292
+ `,
12293
+ status: `Usage: amdahl status
12294
+
12295
+ Can this workspace optimize, and what blocks it. It shows your role, the
12296
+ monthly optimize quota and any connection that needs attention. Each blocked
12297
+ item names its fix: the command, the console page, or who to ask.
12298
+
12299
+ With --json it prints the server's setup status as one object.
12300
+ `,
12301
+ optimize: `Usage: amdahl optimize [<file>... | -] [--channel email|linkedin]
12302
+ [--evidence off|workspace] [--on-unsupported flag|remove]
12303
+ [--tries] [--context <file.json>]
12304
+ amdahl optimize --list <file.csv|file.jsonl> [--no-wait] [--force]
12305
+ amdahl optimize --batch <id> [--list <file>] [--force]
12306
+
12307
+ Rewrite an outbound email or LinkedIn message. Each file is one message; "-"
12308
+ reads standard input. With no file, at a terminal, paste the message.
12309
+
12310
+ --channel email|linkedin What the message is. Left out, it is inferred.
12311
+ --evidence off|workspace "workspace" also evaluates each claim against
12312
+ your customers' own conversations. Slower.
12313
+ --on-unsupported flag|remove What to do with a claim nothing backs.
12314
+ --tries Also show every version the run scored.
12315
+ --context <file.json> Your rules, voice_examples and facts, as JSON.
12316
+ --list <file> Rewrite a whole list in one batch: a CSV with a
12317
+ message column (it may also have item_id,
12318
+ channel, evidence, on_unsupported, facts and
12319
+ rules; any other column is copied to the
12320
+ results) or a JSONL file, one item per line.
12321
+ Up to 500 messages. The results go next to the
12322
+ list: drafts.csv gets drafts.optimized.csv.
12323
+ --no-wait With --list: queue the batch and print its id.
12324
+ --batch <id> Pick up a batch queued earlier. With --list, the
12325
+ same list; the results go next to it.
12326
+ --force Replace a results file that already exists.
12327
+
12328
+ In the results CSV, a cell that starts with =, +, - or @ gets a ' in front, so
12329
+ a spreadsheet does not run it as a formula. That includes a phone number such
12330
+ as +1 555 0100 or a negative number you copied through. Delete the ' to use it.
12331
+
12332
+ Output: the rewrite goes to stdout. What Amdahl did, and the run id, go to
12333
+ stderr. With --json it prints {"ok", "file", "result"}: the rewritten message
12334
+ is .result.message and the run id is .result.run_id. Several files give an
12335
+ array of those. A list prints the batch, and each item's result, with --json.
12336
+ Exit 1 when a message could not be rewritten.
12337
+ ${GLOBAL}`,
12338
+ search: `Usage: amdahl search "<question>" [--research] [--limit <n>]
12339
+
12340
+ Ask a question about your customers, calls and deals. It prints a short answer
12341
+ with the rows it came from.
12342
+
12343
+ --research Start a research job: a deeper, cited answer. It needs a
12344
+ Customer agent key (amdahl keys create --preset agent). With a
12345
+ sign-in you get the search answer and a line that says so.
12346
+ --limit <n> Ask for at most n rows.
12347
+
12348
+ With --json it prints the server's whole answer, every row included.
12349
+ ${GLOBAL}`,
12350
+ connections: `Usage: amdahl connections [--all]
12351
+
12352
+ List the sources this workspace has connected, with each one's health and last
12353
+ sync. A source that needs attention gets a link to its console page.
12354
+
12355
+ --all Also list disconnected and never-synced sources.
12356
+ `,
12357
+ connect: `Usage: amdahl connect [<connector>] [--no-browser]
12358
+
12359
+ Open the console's add page. The connector only fills its search box: you pick
12360
+ it there and sign in to that tool. If that connector is already connected and
12361
+ healthy, this says so and prints the page instead of opening it. The CLI never
12362
+ creates a connection itself.
12363
+
12364
+ --no-browser Print the link instead of opening a browser. It also prints
12365
+ under --json, for an agent, in CI, and when stdin or stdout
12366
+ is not a terminal.
12367
+ `,
12368
+ open: `Usage: amdahl open [--no-browser]
12369
+
12370
+ Open this workspace in the console.
12371
+
12372
+ --no-browser Print the link instead of opening a browser.
12373
+ `,
12374
+ feedback: `Usage: amdahl feedback <run-id> --evidence did_it_myself|reported_to_me|inferred
12375
+ [--outcome used|used_with_edits|not_used|unsure]
12376
+ [--sent-text <file> | -] [--comment <text>]
12377
+
12378
+ Say what happened to the message an optimize run returned. The run id is the
12379
+ one \`amdahl optimize\` printed. Amdahl learns your preferences from an edit,
12380
+ unless the evidence is "inferred". Each call stores one event.
12381
+
12382
+ --evidence <how> How you know. Required.
12383
+ --outcome <what> What happened to it.
12384
+ --sent-text <file> The text as sent (a file, or - for standard input).
12385
+ --comment <text> Your own words about it.
12386
+
12387
+ Give at least one of --outcome, --sent-text and --comment.
12388
+ ${GLOBAL}`,
12389
+ sent: `Usage: amdahl sent <run-id> --provider <provider> --channel <channel>
12390
+ --sent-as returned|edited [--sent-at <time>]
12391
+ [--provider-send-id <id>]
12392
+ [--recipient <email|url> | --recipient-key-hash <sha256>]
12393
+ [--provider-campaign-id <id>] [--sequence-step <n>]
12394
+ [--provider-variant-id <id>] [--provider-lead-id <id>]
12395
+ [--thread-id <id>] [--sent-text <file> | -]
12396
+
12397
+ Report that the system that sent a message sent it. One send per command; the
12398
+ API takes up to 100 (POST /messages/report_send).
12399
+
12400
+ --provider <name> ${SEND_PROVIDERS.slice(0, 4).join(", ")},
12401
+ ${SEND_PROVIDERS.slice(4, 8).join(", ")},
12402
+ ${SEND_PROVIDERS.slice(8).join(", ")}.
12403
+ A sender not on the list: use "other".
12404
+ --channel <kind> email, linkedin (the same as linkedin_message),
12405
+ linkedin_invite, linkedin_message or linkedin_inmail.
12406
+ --sent-as <how> returned (as Amdahl returned it) or edited. An edit needs
12407
+ --sent-text: the message as you sent it.
12408
+ --sent-at <time> A time with a zone, like 2026-11-12T09:30:00Z. Default:
12409
+ now.
12410
+ --provider-send-id The provider's own id for the send. For gmail, the
12411
+ Message-ID header with its angle brackets.
12412
+ --recipient An email address, or a LinkedIn profile URL for a LinkedIn
12413
+ channel. Amdahl stores only a hash of it.
12414
+ --recipient-key-hash A SHA-256 you made, instead of --recipient (not both).
12415
+ --provider-campaign-id, --sequence-step, --provider-variant-id,
12416
+ --provider-lead-id, --thread-id The provider's ids, when you have them.
12417
+ --sent-text <file> The text as sent (a file, or - for standard input). Only
12418
+ a hash of it is kept.
12419
+
12420
+ Give at least one of --provider-send-id and a recipient (--recipient or
12421
+ --recipient-key-hash, not both). A send id with a recipient is fine: Amdahl
12422
+ matches a report to a send by the id, the recipient, or both.
12423
+ ${GLOBAL}`,
12424
+ "keys create": `Usage: amdahl keys create --name <name>
12425
+ [--preset read-only|agent|internal|admin]
12426
+ [--expires 30d|90d|365d] [--no-browser]
12427
+
12428
+ Make an API key for a server or CI. You approve it in the console, then the key
12429
+ is printed once. It needs your own sign-in.
12430
+
12431
+ --name <name> What to call the key. Required.
12432
+ --preset <kind> read-only (default), agent (reads, optimize and research),
12433
+ internal or admin (workspace admins only).
12434
+ --expires <days> 30d, 90d (default) or 365d.
12435
+ --no-browser Print the approval link instead of opening a browser.
12436
+ `,
12437
+ "keys list": `Usage: amdahl keys list
12438
+
12439
+ List the workspace's keys, without their secrets. It needs your own sign-in.
12440
+ `,
12441
+ "keys revoke": `Usage: amdahl keys revoke <id|prefix> [--yes] [--no-browser]
12442
+
12443
+ Revoke a key. You type the key's name, then approve in the console. It needs
12444
+ your own sign-in.
12445
+
12446
+ --yes Skip the question that asks you to type the name.
12447
+ --no-browser Print the approval link instead of opening a browser.
12448
+ `,
12449
+ "workspace list": `Usage: amdahl workspace list
12450
+
12451
+ List your saved sign-ins (profiles). One sign-in is one workspace.
12452
+ `,
12453
+ "workspace use": `Usage: amdahl workspace use <profile>
12454
+
12455
+ Make a saved profile the default.
12456
+ `,
12457
+ install: `Usage: amdahl install claude-code|codex|cursor [--print] [--force]
12458
+
12459
+ Add the Amdahl MCP server and the Amdahl skill to one AI client. It writes no
12460
+ key or token: the client signs in itself the first time it connects.
12461
+
12462
+ --print Show what it would change, and change nothing.
12463
+ --force Replace a skill file you have edited.
12464
+ `,
12465
+ "skills add": `Usage: amdahl skills add <publisher>/<skill> [-y] [-g] [--agents] [--force]
12466
+ [--accept-warning] [--site <origin>]
12467
+
12468
+ Add a skill from https://amdahl.ai/skills to this project. It needs no sign-in.
12469
+ In a terminal it asks where to install it.
12470
+
12471
+ -y, --yes Add it to this project without asking.
12472
+ -g, --global Add it for every project (~/.claude/skills).
12473
+ --agents Also write .agents/skills, for Codex, Cursor and others.
12474
+ --force Replace a different copy that is already there.
12475
+ --accept-warning Install a skill amdahl.ai's security review flagged,
12476
+ after printing the warning. Without it, -y, --json or no
12477
+ terminal stops (exit 9).
12478
+ --site <origin> Read from another origin (https, or http for localhost).
12479
+ `,
12480
+ help: `Usage: amdahl help [<command>]
12481
+
12482
+ Show the help for one command, or all of them. Also: amdahl <command> --help.
12483
+ `
12484
+ };
12485
+ var GROUPS = {
12486
+ keys: ["keys create", "keys list", "keys revoke"],
12487
+ auth: ["login", "logout", "whoami", "auth token"],
12488
+ workspace: ["workspace list", "workspace use"],
12489
+ skills: ["skills add"]
12490
+ };
12491
+ var ALIASES2 = { "auth login": "login", "auth logout": "logout", "auth status": "whoami" };
12492
+ var HELP_COMMANDS = Object.keys(HELP);
12493
+ function commandHelp(words) {
12494
+ const key = ALIASES2[words.join(" ")] ?? words.join(" ");
12495
+ const text3 = HELP[key];
12496
+ if (text3) return text3;
12497
+ const group = words.length === 1 ? GROUPS[words[0] ?? ""] : void 0;
12498
+ if (!group) return null;
12499
+ const lines = group.map((name) => ` ${amdahlCommand(name)}`);
12500
+ return `Usage: amdahl ${words[0]} <command>
12501
+
12502
+ Commands:
12503
+ ${lines.join("\n")}
12504
+
12505
+ Run \`amdahl ${words[0]} <command> --help\` for one command.
12506
+ `;
12507
+ }
12508
+
12115
12509
  // src/main.ts
12116
12510
  var COMMANDS = {
12117
12511
  login: (ctx) => login(ctx),
@@ -12134,13 +12528,25 @@ var COMMANDS = {
12134
12528
  install,
12135
12529
  "skills add": skillsAdd
12136
12530
  };
12531
+ function helpFor(words) {
12532
+ return commandHelp(words.slice(0, 2)) ?? commandHelp(words.slice(0, 1)) ?? USAGE;
12533
+ }
12137
12534
  var NO_ARGS = /* @__PURE__ */ new Set(["setup", "login", "logout", "whoami", "auth token", "status", "connections", "open", "keys create", "keys list", "workspace list"]);
12138
12535
  async function runCli(argv, io) {
12139
12536
  setCommandName(io);
12140
12537
  const json = argv.includes("--json");
12141
12538
  let out = createOutput(io, json, argv.includes("--no-color"));
12142
12539
  try {
12143
- const parsed = parseCommandLine(argv);
12540
+ let parsed;
12541
+ try {
12542
+ parsed = parseCommandLine(argv);
12543
+ } catch (err) {
12544
+ if (argv.includes("--help") || argv.includes("-h")) {
12545
+ io.stdout(helpFor(helpWords(argv)));
12546
+ return EXIT.ok;
12547
+ }
12548
+ throw err;
12549
+ }
12144
12550
  out = createOutput(io, parsed.flags.json === true, parsed.flags["no-color"] === true);
12145
12551
  if (parsed.flags.version) {
12146
12552
  io.stdout(`${VERSION}
@@ -12148,7 +12554,7 @@ async function runCli(argv, io) {
12148
12554
  return EXIT.ok;
12149
12555
  }
12150
12556
  if (parsed.flags.help || parsed.command === "help") {
12151
- io.stdout(USAGE);
12557
+ io.stdout(helpFor(parsed.command === "help" ? parsed.args : parsed.command?.split(" ") ?? []));
12152
12558
  return EXIT.ok;
12153
12559
  }
12154
12560
  if (NO_ARGS.has(parsed.command ?? "") && parsed.args.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amdahl/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "The amdahl command line: sign in, check setup, optimize messages, manage API keys, connect MCP clients.",
6
6
  "license": "Apache-2.0",