@letterstory/cli 0.177.11 → 0.177.13

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/lib/cli.mjs CHANGED
@@ -88,7 +88,7 @@ import {
88
88
  import { fetchParkedSections, withoutParked } from "./parked.mjs";
89
89
 
90
90
  // Matches cli/package.json; publish-cli.yml rewrites both to the published version.
91
- export const VERSION = "0.177.11";
91
+ export const VERSION = "0.177.13";
92
92
 
93
93
  /** The exit code for a gate refusal (CARD_REQUIRED, PAYMENT_FAILED, OUT_OF_TOKENS): 1 is a plain error. */
94
94
  export const EXIT_PAYMENT_REQUIRED = 3;
@@ -1908,6 +1908,7 @@ Repo changes (Claude Code edits your repo in a sandbox; the Letter GitHub App op
1908
1908
  repo repos [--gitlab [--search <text>] [--page n]] Repos the Letter GitHub App can reach (--gitlab: the GitLab
1909
1909
  projects the organization's connection can push to)
1910
1910
  repo connect [--gitlab] Is GitHub (or GitLab) connected; the link a person opens to connect it
1911
+ repo disconnect --installation <id> Disconnect a GitHub account (its installationId)
1911
1912
  repo branches --repo owner/name Branches a change can start from (default first)
1912
1913
  repo prs [--repo owner/name] Every open PR (whoever opened it), longest-idle first
1913
1914
  repo suggestions Fixes found and not acted on yet, and how to start each
@@ -46,6 +46,17 @@ const ARGS = {
46
46
  ask: "ask",
47
47
  // --confirms <build id>: the person's yes to the price a build stopped on.
48
48
  confirms: "confirms",
49
+ // --purchase '{"tool":"register_domain","args":{"domain":"olapdb.com"}}': priced and held for a yes, never bought.
50
+ purchase: {
51
+ value: (flags) => {
52
+ if (typeof flags.purchase !== "string") return undefined;
53
+ try {
54
+ return JSON.parse(flags.purchase);
55
+ } catch {
56
+ throw new CliError("--purchase takes JSON: {tool, args}.");
57
+ }
58
+ },
59
+ },
49
60
  // --build-it: the words are a request to build, never read as a question.
50
61
  asked_to_build: { flag: "build-it", type: "bool" },
51
62
  domain: "site",
@@ -12,7 +12,7 @@
12
12
  // letterstory deploy page <id>
13
13
 
14
14
  import { CliError } from "../client.mjs";
15
- import { argsFromFlags, flagStr, printResult, refuseUnknownFlags } from "./shared.mjs";
15
+ import { argsFromFlags, domainPrice, flagStr, printResult, refuseUnknownFlags } from "./shared.mjs";
16
16
 
17
17
  const appId = { positional: 0, label: "app-id", required: true };
18
18
 
@@ -46,6 +46,8 @@ export const ARGS = {
46
46
  writing: { flag: "writing", enum: ["on"] },
47
47
  // --no-spend: with --domain, buy nothing; the domain is one you hold, or one registered elsewhere.
48
48
  no_spend: { flag: "no-spend", type: "bool" },
49
+ // A new domain's price: read with register_domain's dry run on this key and echoed with --yes (deploy create's way).
50
+ accepted_quote: { internal: "the new domain's price read by the command (domainPrice), echoed with --yes" },
49
51
  },
50
52
  advance_deployment: { id: appId, retry: { flag: "retry", type: "bool" } },
51
53
  add_deployment_page: { id: appId },
@@ -70,19 +72,49 @@ export async function cmdDeploy(ctx) {
70
72
  case "ls":
71
73
  return deployList(rest);
72
74
  case "create": {
73
- refuseUnknownFlags(rest, ARGS.create_deployment, ["prompt"]);
75
+ refuseUnknownFlags(rest, ARGS.create_deployment, ["prompt", "yes"]);
74
76
  const args = argsFromFlags(ARGS.create_deployment, rest.flags, rest.positionals);
75
77
  if (!args.prompt)
76
78
  throw new CliError(
77
79
  'Say what to build: letterstory deploy create "a website for HVAC contractors to collect leads"'
78
80
  );
81
+ // Its domain is bought only at the price Letter shows this key, with --yes, echoed (domains buy's own way).
82
+ if (args.domain && !args.no_spend && args.domain_quote === undefined) {
83
+ const priced = await domainPrice(rest.client, rest.io, args.domain);
84
+ if (priced?.held) args.no_spend = true;
85
+ else if (priced?.quote) {
86
+ if (rest.flags.yes !== true) {
87
+ rest.io.error(
88
+ "Building it buys this domain (real money). Run again with --yes to buy it at that price."
89
+ );
90
+ return 1;
91
+ }
92
+ args.domain_quote = priced.quote;
93
+ }
94
+ }
79
95
  return deployShow(rest, await rest.client.callTool("create_deployment", args));
80
96
  }
81
97
  case "show":
82
98
  return deployShow(rest, await call(rest, "get_deployment"));
83
- case "update":
84
- refuseUnknownFlags(rest, ARGS.update_deployment, []);
85
- return deployShow(rest, await call(rest, "update_deployment"));
99
+ case "update": {
100
+ refuseUnknownFlags(rest, ARGS.update_deployment, ["yes"]);
101
+ const args = argsFromFlags(ARGS.update_deployment, rest.flags, rest.positionals);
102
+ // A new domain is bought only at the price Letter shows this key, with --yes, echoed (deploy create's way).
103
+ if (args.domain && !args.no_spend) {
104
+ const priced = await domainPrice(rest.client, rest.io, args.domain);
105
+ if (priced?.held) args.no_spend = true;
106
+ else if (priced?.quote) {
107
+ if (rest.flags.yes !== true) {
108
+ rest.io.error(
109
+ "Its new domain is bought (real money). Run again with --yes to buy it at that price."
110
+ );
111
+ return 1;
112
+ }
113
+ args.accepted_quote = priced.quote;
114
+ }
115
+ }
116
+ return deployShow(rest, await rest.client.callTool("update_deployment", args));
117
+ }
86
118
  case "pause":
87
119
  case "resume":
88
120
  case "archive":
@@ -16,7 +16,7 @@
16
16
  // plan and puts it live (spends money; domains:write)
17
17
 
18
18
  import { CliError } from "../client.mjs";
19
- import { flagCsv, requirePositional, printResult, argsFromFlags } from "./shared.mjs";
19
+ import { flagCsv, requirePositional, printResult, argsFromFlags, domainPrice } from "./shared.mjs";
20
20
 
21
21
  export const ARGS = {
22
22
  list_domains: {},
@@ -152,10 +152,16 @@ async function domainsList(ctx) {
152
152
 
153
153
  async function domainsBuy(ctx) {
154
154
  const { client, flags, io } = ctx;
155
- const result = await client.callTool(
156
- "register_domain",
157
- argsFromFlags(ARGS.register_domain, flags, ctx.positionals)
158
- );
155
+ const args = argsFromFlags(ARGS.register_domain, flags, ctx.positionals);
156
+ // A purchase goes only at a price Letter issued to this key (Mahir, 2026-11-11): --yes reads it first and buys at
157
+ // that price, echoing its quote, unless one was given (--accepted-quote).
158
+ if (flags.yes === true && !args.dry_run && args.accepted_quote === undefined && !args.no_spend) {
159
+ const priced = await domainPrice(client, io, args.domain);
160
+ // One the org holds is made its own with nothing bought; anything else at its price.
161
+ if (priced?.held) args.no_spend = true;
162
+ else if (priced?.quote) args.accepted_quote = priced.quote;
163
+ }
164
+ const result = await client.callTool("register_domain", args);
159
165
  printResult(io, flags, result, (r) => {
160
166
  const p = r.purchase;
161
167
  const on = p?.on === "org_vercel_key" ? "on your own Vercel account" : "by Letter, billed in tokens";
@@ -214,10 +220,13 @@ async function domainsPlan(ctx) {
214
220
 
215
221
  async function domainsLaunch(ctx) {
216
222
  const { client, flags, io } = ctx;
217
- const result = await client.callTool(
218
- "launch_domain_site",
219
- argsFromFlags(ARGS.launch_domain_site, flags, ctx.positionals)
220
- );
223
+ const args = argsFromFlags(ARGS.launch_domain_site, flags, ctx.positionals);
224
+ // --yes buys its domain at the price Letter shows this key, echoed (domains buy's own way): never above it.
225
+ if (args.accepted_quote === undefined) {
226
+ const priced = await domainPrice(client, io, args.domain);
227
+ if (priced?.quote) args.accepted_quote = priced.quote;
228
+ }
229
+ const result = await client.callTool("launch_domain_site", args);
221
230
  printResult(io, flags, result, (r) =>
222
231
  [
223
232
  ...(r.plan ? planLines(r.plan) : []),
@@ -61,6 +61,7 @@ import {
61
61
  pollUntil,
62
62
  readTextInput,
63
63
  argsFromFlags,
64
+ domainPrice,
64
65
  } from "./shared.mjs";
65
66
 
66
67
  const COVER_STYLES = ["editorial_image", "full_ai", "line_art"];
@@ -120,6 +121,8 @@ export const ARGS = {
120
121
  is_test: { flag: "test", type: "bool" },
121
122
  readiness_minutes: { flag: "readiness-minutes", type: "num" },
122
123
  client_email: "client-email",
124
+ // A buy_now phantom's domain price: read with register_domain's dry run on this key and echoed with --yes.
125
+ accepted_quote: { internal: "the phantom domain's price read by the command (domainPrice), echoed with --yes" },
123
126
  },
124
127
  get_domain_onboard_status: {
125
128
  job_id: "attach",
@@ -134,6 +137,8 @@ export const ARGS = {
134
137
  // flag mapping straight through: it comes from `--accept-plan <path|->` /
135
138
  // `--accept-plan-json`, or from the dry run this same command just printed.
136
139
  plan: { internal: "the reviewed plan — from --accept-plan, or the dry run just printed" },
140
+ // A buy_now launch's domain price: read with register_domain's dry run on this key and echoed with --yes.
141
+ accepted_quote: { internal: "the domain's price read by the command (domainPrice), echoed with --yes" },
137
142
  overrides: {
138
143
  object: {
139
144
  name: "name",
@@ -654,6 +659,19 @@ async function cmdOnboardDomain(ctx) {
654
659
  }
655
660
 
656
661
  const args = argsFromFlags(ARGS.onboard_domain, flags, ctx.positionals);
662
+ // Its phantom's domain is bought only at the price Letter shows this key, with --yes, echoed (domains buy's way).
663
+ if (args.phantom?.domain_mode === "buy_now" && args.phantom.site_domain) {
664
+ const priced = await domainPrice(client, ctx.io, args.phantom.site_domain);
665
+ if (priced?.quote) {
666
+ if (!flagBool(flags.yes)) {
667
+ ctx.io.error(
668
+ "Onboarding buys the phantom's domain (real money). Run again with --yes to buy it at that price."
669
+ );
670
+ return 1;
671
+ }
672
+ args.accepted_quote = priced.quote;
673
+ }
674
+ }
657
675
 
658
676
  ok(ctx, "Starting domain onboarding…");
659
677
  const started = await client.callTool("onboard_domain", args);
@@ -889,6 +907,19 @@ export async function cmdOnboard(ctx) {
889
907
  reviewedPlan = dryRun.plan;
890
908
  }
891
909
 
910
+ // A launch that buys its domain goes only at the price Letter shows this key, with --yes, echoed (domains buy's way).
911
+ let accepted_quote;
912
+ if (overrides.domain_mode === "buy_now" && overrides.site_domain) {
913
+ const priced = await domainPrice(client, io, overrides.site_domain);
914
+ if (priced?.quote) {
915
+ if (!flagBool(flags.yes)) {
916
+ io.error("Launching it buys this domain (real money). Run again with --yes to buy it at that price.");
917
+ return 1;
918
+ }
919
+ accepted_quote = priced.quote;
920
+ }
921
+ }
922
+
892
923
  // Step 3: apply — start the durable onboarding job.
893
924
  ok(ctx, "Applying — starting the onboarding job…");
894
925
  const started = await client.callTool(
@@ -898,6 +929,7 @@ export async function cmdOnboard(ctx) {
898
929
  accept: true,
899
930
  overrides: Object.keys(overrides).length ? overrides : undefined,
900
931
  plan: reviewedPlan,
932
+ accepted_quote,
901
933
  })
902
934
  );
903
935
  const jobId = started.job_id;
@@ -8,7 +8,16 @@
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
10
  import { CliError } from "../client.mjs";
11
- import { flagStr, requirePositional, printResult, ok, toolField, followJob, argsFromFlags } from "./shared.mjs";
11
+ import {
12
+ flagStr,
13
+ requirePositional,
14
+ printResult,
15
+ ok,
16
+ toolField,
17
+ followJob,
18
+ argsFromFlags,
19
+ domainPrice,
20
+ } from "./shared.mjs";
12
21
 
13
22
  function readJsonFileFlag(flags, flag) {
14
23
  const path = flagStr(flags[flag]);
@@ -130,6 +139,8 @@ export const ARGS = {
130
139
  buy_domain: {
131
140
  phantom_id: { positional: 0, label: "phantom-id", required: true },
132
141
  domain: { value: (flags, positionals) => stripScheme(requirePositional(positionals, 1, "domain")) },
142
+ // Its price: read with register_domain's dry run on this key and echoed with --yes (domains buy's way).
143
+ accepted_quote: { internal: "the domain's price read by the command (domainPrice), echoed with --yes" },
133
144
  },
134
145
  connect_domain: {
135
146
  phantom_id: { positional: 0, label: "phantom-id", required: true },
@@ -265,7 +276,14 @@ async function phantomDomain(ctx) {
265
276
  rest.io.error("Buying a domain spends real money. Re-run with --yes.");
266
277
  return 1;
267
278
  }
268
- return call(rest, "buy_domain");
279
+ {
280
+ const args = argsFromFlags(ARGS.buy_domain, rest.flags, rest.positionals);
281
+ const priced = await domainPrice(rest.client, rest.io, args.domain);
282
+ if (priced?.quote) args.accepted_quote = priced.quote;
283
+ const result = await rest.client.callTool("buy_domain", args);
284
+ printResult(rest.io, rest.flags, result);
285
+ return 0;
286
+ }
269
287
  case "connect":
270
288
  return call(rest, "connect_domain");
271
289
  default:
@@ -11,6 +11,7 @@
11
11
  // repo say → send_repo_change_message (steer a build while its agent works)
12
12
  // repo repos → list_github_app_repos repo branches → list_repo_branches
13
13
  // repo connect → connect_github (the link a person opens to connect GitHub)
14
+ // repo disconnect --installation <id> → disconnect_github_account
14
15
  // repo repos --gitlab → list_gitlab_repos repo connect --gitlab → connect_gitlab
15
16
  // repo prs → list_open_pull_requests (every open PR, whoever opened it)
16
17
  // repo install-letterprove → letterprove_open_install_pr
@@ -304,6 +305,7 @@ const ARGS = {
304
305
  },
305
306
  list_github_app_repos: {},
306
307
  connect_github: {},
308
+ disconnect_github_account: { installation_id: { flag: "installation", type: "num" } },
307
309
  list_gitlab_repos: { search: "search", page: { flag: "page", type: "num" } },
308
310
  connect_gitlab: {},
309
311
  list_repo_branches: {
@@ -406,6 +408,8 @@ export async function cmdRepo(ctx) {
406
408
  return repoRepos(rest);
407
409
  case "connect":
408
410
  return repoConnect(rest);
411
+ case "disconnect":
412
+ return repoDisconnect(rest);
409
413
  case "branches":
410
414
  return repoBranches(rest);
411
415
  case "prs":
@@ -420,7 +424,7 @@ export async function cmdRepo(ctx) {
420
424
  return repoSlackTest(rest);
421
425
  default:
422
426
  throw new CliError(
423
- `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, install-letterprove, install-visitors, sites, set-site, reset-site, secrets, set-secret, delete-secret, status, list, publish, merge, retry, revise, say, cancel, repos, branches, prs, settings, suggestions, slack-targets, slack-test`
427
+ `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, install-letterprove, install-visitors, sites, set-site, reset-site, secrets, set-secret, delete-secret, status, list, publish, merge, retry, revise, say, cancel, repos, branches, prs, settings, suggestions, slack-targets, slack-test, connect, disconnect`
424
428
  );
425
429
  }
426
430
  }
@@ -874,6 +878,20 @@ async function repoConnect(ctx) {
874
878
  return 0;
875
879
  }
876
880
 
881
+ /** `repo disconnect --installation <id>`: disconnect one GitHub account (its installationId on `repo repos --json`). */
882
+ async function repoDisconnect(ctx) {
883
+ const { client, flags, io } = ctx;
884
+ refuseUnknownFlags(ctx, {}, ["installation"]);
885
+ if (flags.installation === undefined)
886
+ throw new CliError("Pass --installation <id>: the installationId shown by `repo repos --json`.");
887
+ const result = await client.callTool(
888
+ "disconnect_github_account",
889
+ argsFromFlags(ARGS.disconnect_github_account, flags)
890
+ );
891
+ printResult(io, flags, result, (r) => r.message);
892
+ return 0;
893
+ }
894
+
877
895
  /** `repo prs [--repo owner/name]`: every open PR, longest-idle first, and which Letter opened. */
878
896
  async function repoPrs(ctx) {
879
897
  const { client, flags, io } = ctx;
@@ -385,3 +385,21 @@ export function readTextInput(flags, valueFlag, fileFlag) {
385
385
  export function readBodyInput(flags) {
386
386
  return readTextInput(flags, "body", "file");
387
387
  }
388
+
389
+ /**
390
+ * The price Letter shows for buying `domain`, read with register_domain's dry run on this key, for a command that buys
391
+ * (domains buy, domains launch, deploy create, onboard): a purchase goes only at a price Letter issued to this key,
392
+ * echoed, and never above it (Mahir, 2026-11-11). `held`: the organization has it, or takes it over: nothing is bought.
393
+ * `quote`: what to echo, the price said on stderr. Null: no purchase to price (the dispatcher says why if one is made).
394
+ */
395
+ export async function domainPrice(client, io, domain) {
396
+ if (!domain) return null;
397
+ const priced = await client.callTool("register_domain", { domain, dry_run: true });
398
+ const held = priced?.outcome === "already_owned" || priced?.outcome === "would_adopt";
399
+ // A held domain the registrar would still sell (recorded, never bought) carries a quote too: a purchase of it (a
400
+ // phantom's buy, a launch) moves money and goes only at that price (review of #6952).
401
+ if (!priced?.quote) return held ? { held: true } : null;
402
+ const usd = priced.purchase?.price_usd ?? priced.quote.purchase_usd;
403
+ io.error(`${priced.domain}: $${usd} for the first year, if bought.`);
404
+ return { ...(held ? { held: true } : {}), quote: priced.quote, price_usd: usd ?? null };
405
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.177.11",
3
+ "version": "0.177.13",
4
4
  "description": "Spin up and manage Letter phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",