@letterstory/cli 0.19.0 → 0.20.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/lib/cli.mjs CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  } from "./commands.mjs";
45
45
 
46
46
  // Keep in sync with cli/package.json.
47
- export const VERSION = "0.19.0";
47
+ export const VERSION = "0.20.0";
48
48
 
49
49
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
50
50
  // can't accidentally swallow the id as --json's value.
@@ -85,6 +85,13 @@ const BOOLEAN_FLAGS = new Set([
85
85
  "onboard",
86
86
  "test",
87
87
  "real",
88
+ // `phantom-onboard collection --enable-planner`, `strategy sitemap --posts-only`, and the
89
+ // `strategy positioning set --clear-*` switches: value-less, and each sits next to a value.
90
+ "enable-planner",
91
+ "posts-only",
92
+ "clear-topics",
93
+ "clear-stances",
94
+ "clear-avoid",
88
95
  ]);
89
96
 
90
97
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -194,7 +201,8 @@ Reviewers:
194
201
  Collections:
195
202
  collections list List your collections
196
203
  collections new --name <n> [--description <text>]
197
- collections update <id> [--name] [--description] [--cadence-target <n>] [--cadence-period week|month] [--cover-canvas-type-id <uuid>|none]
204
+ collections update <id> [--name] [--description] [--cadence-target <n>] [--cadence-period week|month]
205
+ [--cover-canvas-type-id <uuid>|none] [--seo-market <cc>]
198
206
  collections delete <id> --yes
199
207
  collections assign <article-id> <collection-id>
200
208
  collections autopen <id> [--template <key>|none] [--kernel-family <name>|org]
@@ -245,11 +253,11 @@ Connectors:
245
253
  Wire a collection to a CMS + field mapping
246
254
 
247
255
  Strategy & onboarding:
248
- strategy company get | set [--name] [--domain] [--manifesto <text>|--manifesto-file <path>]
256
+ strategy company get | set [--name] [--domain] [--market <cc>] [--manifesto <text>|--manifesto-file <path>]
249
257
  strategy positioning get | set [--topic <t> …] [--stance <s> …] [--avoid <t> …]
250
258
  [--clear-topics] [--clear-stances] [--clear-avoid]
251
259
  strategy competitors list | add <name> <domain>
252
- strategy sitemap --collection <uuid> --url <sitemap_url> [--sub <url> …] [--pattern <glob>]
260
+ strategy sitemap --collection <uuid> --url <sitemap_url> [--sub <url> …] [--pattern <glob>] [--posts-only]
253
261
  strategy topics list --collection <uuid> List the topic map's clusters/spokes
254
262
  strategy topics set --collection <uuid> (--topic <topic-id> | --suggestion <suggestion-id>)
255
263
  suggestions list <collection_id> List pending strategy suggestions + their auto-apply countdown
@@ -355,7 +363,8 @@ Propose lane (the Fleet wizard's Plan and Name screens; persists nothing, spends
355
363
 
356
364
  Collection onboarding (your OWN blog, on your org's domain — no provisioning):
357
365
  ${bin === "phantom" ? "onboard" : "phantom-onboard"} collection [--collection <uuid>] [--name "<collection name>"]
358
- [--about "the direction"] [--sitemap <url>] [--cms <provider>] [--watch]
366
+ [--about "the direction"] [--sitemap <url>] [--cms <provider>]
367
+ [--enable-planner [--cadence <n/week>] [--approval manual|review_window|autopilot]] [--watch]
359
368
  Import the existing blog from its sitemap
360
369
  (auto-discovered from the org's domain unless
361
370
  --sitemap pins one) OR from a connected CMS with
@@ -363,7 +372,12 @@ Collection onboarding (your OWN blog, on your org's domain — no provisioning):
363
372
  derive content areas + series, build the topic
364
373
  map, bind SEO demand, and stock Headlines & Queue.
365
374
  Omit --collection to create a fresh one (--name,
366
- default Blog). The Planner stays OFF (manual approval).
375
+ default Blog). The Planner stays OFF unless you
376
+ pass --enable-planner, which turns it on at the
377
+ chosen cadence and approval and fills the queue
378
+ (same activation as the app's Guided review step;
379
+ needs a user-scoped key). --approval defaults to
380
+ manual: nothing publishes unreviewed.
367
381
  ${bin === "phantom" ? "onboard" : "phantom-onboard"} sync-sitemap --collection <uuid>
368
382
  Re-check an already-onboarded blog's sitemap
369
383
  right now instead of waiting for its cadence
@@ -2,7 +2,71 @@
2
2
  // Ported from phantomstory-cli's collections.ts.
3
3
 
4
4
  import { CliError } from "../client.mjs";
5
- import { flagStr, flagNum, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
5
+ import { flagStr, printResult, ok, argsFromFlags } from "./shared.mjs";
6
+
7
+ // `--flag none|off|""` clears a nullable field; the server takes null for that.
8
+ const clearable = (flag) => ({
9
+ value: (flags) => {
10
+ const v = flagStr(flags[flag]);
11
+ if (v === undefined) return undefined;
12
+ return ["none", "off", ""].includes(v.toLowerCase()) ? null : v;
13
+ },
14
+ });
15
+
16
+ // A `--flag false` boolean (the value is the string "false"; anything else is true).
17
+ const stringBool = (flag) => ({
18
+ value: (flags) => (flags[flag] === undefined ? undefined : flags[flag] !== "false"),
19
+ });
20
+
21
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
22
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
23
+ export const ARGS = {
24
+ list_collections: {},
25
+ create_collection: {
26
+ name: { flag: "name", required: true },
27
+ description: "description",
28
+ },
29
+ update_collection: {
30
+ collection_id: { positional: 0, label: "collection-id", required: true },
31
+ name: "name",
32
+ description: "description",
33
+ cadence_target: { flag: "cadence-target", type: "num" },
34
+ cadence_period: { flag: "cadence-period", enum: ["week", "month"] },
35
+ cover_canvas_type_id: clearable("cover-canvas-type-id"),
36
+ seo_market: "seo-market",
37
+ },
38
+ delete_collection: {
39
+ collection_id: { positional: 0, label: "collection-id", required: true },
40
+ },
41
+ assign_article_to_collection: {
42
+ article_id: { positional: 0, label: "article-id", required: true },
43
+ collection_id: { positional: 1, label: "collection-id", required: true },
44
+ },
45
+ // `collections autopen <id>` — a PATCH: only the flags given are sent.
46
+ update_auto_pen_config: {
47
+ collection_id: { positional: 0, label: "collection-id", required: true },
48
+ // `--template none` clears the binding; the server takes null for that.
49
+ template_key: clearable("template"),
50
+ outline: {
51
+ object: {
52
+ depth: "outline-depth",
53
+ direction_gate: stringBool("direction-gate"),
54
+ },
55
+ },
56
+ kernel: {
57
+ object: {
58
+ family: {
59
+ value: (flags) => {
60
+ const v = flagStr(flags["kernel-family"]);
61
+ return v === undefined ? undefined : v === "org" ? null : v;
62
+ },
63
+ },
64
+ },
65
+ },
66
+ mention: { object: { degree: "mention" } },
67
+ auto_start_on_curate: stringBool("auto-start"),
68
+ },
69
+ };
6
70
 
7
71
  export async function cmdCollections(ctx) {
8
72
  const sub = ctx.positionals[0];
@@ -39,35 +103,19 @@ async function collectionsList(ctx) {
39
103
 
40
104
  async function collectionsNew(ctx) {
41
105
  const { client, flags, io } = ctx;
42
- const name = requireFlag(flags, "name");
43
- const args = compact({ name, description: flagStr(flags.description) });
106
+ const args = argsFromFlags(ARGS.create_collection, flags);
44
107
  const result = await client.callTool("create_collection", args);
45
- ok(ctx, `Created collection "${name}".`);
108
+ ok(ctx, `Created collection "${args.name}".`);
46
109
  printResult(io, flags, result);
47
110
  return 0;
48
111
  }
49
112
 
50
113
  async function collectionsUpdate(ctx) {
51
114
  const { client, positionals, flags, io } = ctx;
52
- const id = requirePositional(positionals, 0, "collection-id");
53
- const coverCanvasTypeId = flagStr(flags["cover-canvas-type-id"]);
54
- const patch = compact({
55
- collection_id: id,
56
- name: flagStr(flags.name),
57
- description: flagStr(flags.description),
58
- cadence_target: flagNum(flags["cadence-target"]),
59
- cadence_period: flagStr(flags["cadence-period"]),
60
- });
61
- if (coverCanvasTypeId !== undefined) {
62
- // compact() drops undefined, not null — set it after so "none"/"off" can clear
63
- // the field instead of being dropped. Same convention as `webhook set --url none`.
64
- patch.cover_canvas_type_id = ["none", "off", ""].includes(coverCanvasTypeId.toLowerCase())
65
- ? null
66
- : coverCanvasTypeId;
67
- }
115
+ const patch = argsFromFlags(ARGS.update_collection, flags, positionals);
68
116
  if (Object.keys(patch).length <= 1) {
69
117
  throw new CliError(
70
- "Nothing to update — pass at least one of --name/--description/--cadence-target/--cadence-period/--cover-canvas-type-id."
118
+ "Nothing to update — pass at least one of --name/--description/--cadence-target/--cadence-period/--cover-canvas-type-id/--seo-market."
71
119
  );
72
120
  }
73
121
  const result = await client.callTool("update_collection", patch);
@@ -78,12 +126,12 @@ async function collectionsUpdate(ctx) {
78
126
 
79
127
  async function collectionsDelete(ctx) {
80
128
  const { client, positionals, flags, io } = ctx;
81
- const id = requirePositional(positionals, 0, "collection-id");
129
+ const args = argsFromFlags(ARGS.delete_collection, flags, positionals);
82
130
  if (!flags.yes) {
83
- io.error(`Re-run with --yes to confirm: ${ctx.bin} collections delete ${id} --yes`);
131
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} collections delete ${args.collection_id} --yes`);
84
132
  return 1;
85
133
  }
86
- const result = await client.callTool("delete_collection", { collection_id: id });
134
+ const result = await client.callTool("delete_collection", args);
87
135
  ok(ctx, "Collection deleted.");
88
136
  printResult(io, flags, result);
89
137
  return 0;
@@ -91,12 +139,10 @@ async function collectionsDelete(ctx) {
91
139
 
92
140
  async function collectionsAssign(ctx) {
93
141
  const { client, positionals, flags, io } = ctx;
94
- const articleId = requirePositional(positionals, 0, "article-id");
95
- const collectionId = requirePositional(positionals, 1, "collection-id");
96
- const result = await client.callTool("assign_article_to_collection", {
97
- article_id: articleId,
98
- collection_id: collectionId,
99
- });
142
+ const result = await client.callTool(
143
+ "assign_article_to_collection",
144
+ argsFromFlags(ARGS.assign_article_to_collection, flags, positionals)
145
+ );
100
146
  ok(ctx, "Article assigned.");
101
147
  printResult(io, flags, result);
102
148
  return 0;
@@ -111,27 +157,7 @@ async function collectionsAssign(ctx) {
111
157
  */
112
158
  async function collectionsAutoPen(ctx) {
113
159
  const { client, positionals, flags, io } = ctx;
114
- const collectionId = requirePositional(positionals, 0, "collection-id");
115
- const patch = { collection_id: collectionId };
116
-
117
- // `--template none` clears the binding; the server takes null for that.
118
- const template = flagStr(flags.template);
119
- if (template !== undefined) patch.template_key = template === "none" ? null : template;
120
-
121
- const kernelFamily = flagStr(flags["kernel-family"]);
122
- if (kernelFamily !== undefined) patch.kernel = { family: kernelFamily === "org" ? null : kernelFamily };
123
-
124
- const mention = flagStr(flags.mention);
125
- if (mention !== undefined) patch.mention = { degree: mention };
126
-
127
- const depth = flagStr(flags["outline-depth"]);
128
- if (depth !== undefined) patch.outline = { ...(patch.outline ?? {}), depth };
129
-
130
- if (flags["direction-gate"] !== undefined) {
131
- patch.outline = { ...(patch.outline ?? {}), direction_gate: flags["direction-gate"] !== "false" };
132
- }
133
- if (flags["auto-start"] !== undefined) patch.auto_start_on_curate = flags["auto-start"] !== "false";
134
-
160
+ const patch = argsFromFlags(ARGS.update_auto_pen_config, flags, positionals);
135
161
  if (Object.keys(patch).length === 1) {
136
162
  throw new CliError(
137
163
  "Nothing to change. Pass at least one of --template, --kernel-family, --mention, --outline-depth, --direction-gate, --auto-start."
@@ -139,7 +165,7 @@ async function collectionsAutoPen(ctx) {
139
165
  }
140
166
 
141
167
  const result = await client.callTool("update_auto_pen_config", patch);
142
- ok(ctx, `Auto-Pen settings updated for ${collectionId}.`);
168
+ ok(ctx, `Auto-Pen settings updated for ${patch.collection_id}.`);
143
169
  printResult(io, flags, result);
144
170
  return 0;
145
171
  }
@@ -3,7 +3,62 @@
3
3
  // phantomstory-cli's connectors.ts.
4
4
 
5
5
  import { CliError } from "../client.mjs";
6
- import { flagStr, flagBool, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
6
+ import { printResult, ok, argsFromFlags } from "./shared.mjs";
7
+
8
+ const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
9
+ const PUBLISH_CONNECTORS = ["webflow", "framer", "google_docs", "contentful"];
10
+
11
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
12
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
13
+ export const ARGS = {
14
+ list_publish_targets: {},
15
+ publish_to_connector: {
16
+ article_id: { positional: 0, label: "article-id", required: true },
17
+ connector: { flag: "to", required: true, enum: PUBLISH_CONNECTORS },
18
+ target_id: { flag: "target", required: true },
19
+ flow_run_id: "flow-run",
20
+ // A re-publish whose external item was deleted or unpublished in the client's CMS
21
+ // is refused, and the refusal explains itself. --confirm-missing-target is the
22
+ // answer to that specific question: it re-creates / re-publishes the item there.
23
+ // Deliberately not bundled into a general --yes, and never set by default.
24
+ confirm_missing_target: { flag: "confirm-missing-target", type: "bool" },
25
+ },
26
+ get_publish_status: {
27
+ connector: { flag: "connector", required: true, enum: PUBLISH_CONNECTORS },
28
+ publish_id: { flag: "publish-id", required: true },
29
+ },
30
+ import_from_cms: {
31
+ collection_id: "collection",
32
+ provider: { flag: "provider", enum: CMS_PROVIDERS },
33
+ },
34
+ connect_cms: {
35
+ provider: { flag: "provider", required: true, enum: CMS_PROVIDERS },
36
+ // api_token is required for every provider; the rest are provider-specific.
37
+ api_token: { flag: "token", required: true },
38
+ project_url: "project-url",
39
+ auto_deploy: { flag: "auto-deploy", type: "bool", negate: "no-auto-deploy" },
40
+ project_id: "project-id",
41
+ dataset: "dataset",
42
+ project_name: "project-name",
43
+ workspace_name: "workspace",
44
+ space_id: "space-id",
45
+ environment_id: "environment",
46
+ space_name: "space-name",
47
+ default_locale: "locale",
48
+ },
49
+ disconnect_cms: {
50
+ provider: { flag: "provider", required: true, enum: CMS_PROVIDERS },
51
+ },
52
+ list_cms_connections: {},
53
+ list_cms_mappings: {
54
+ provider: { flag: "provider", required: true, enum: CMS_PROVIDERS },
55
+ },
56
+ bind_collection_cms: {
57
+ collection_id: { flag: "collection", required: true },
58
+ provider: { flag: "provider", required: true, enum: CMS_PROVIDERS },
59
+ mapping_id: "mapping",
60
+ },
61
+ };
7
62
 
8
63
  export async function cmdConnectors(ctx) {
9
64
  const sub = ctx.positionals[0];
@@ -46,31 +101,16 @@ async function connectorsList(ctx) {
46
101
 
47
102
  async function connectorsPublish(ctx) {
48
103
  const { client, positionals, flags, io } = ctx;
49
- const articleId = requirePositional(positionals, 0, "article-id");
50
- const to = requireFlag(flags, "to");
51
- const target = requireFlag(flags, "target");
52
- // A re-publish whose external item was deleted or unpublished in the client's CMS
53
- // is refused, and the refusal explains itself. --confirm-missing-target is the
54
- // answer to that specific question: it re-creates / re-publishes the item there.
55
- // Deliberately not bundled into a general --yes, and never set by default.
56
- const args = compact({
57
- article_id: articleId,
58
- connector: to,
59
- target_id: target,
60
- flow_run_id: flagStr(flags["flow-run"]),
61
- confirm_missing_target: flagBool(flags["confirm-missing-target"]) || undefined,
62
- });
104
+ const args = argsFromFlags(ARGS.publish_to_connector, flags, positionals);
63
105
  const result = await client.callTool("publish_to_connector", args);
64
- ok(ctx, `Publishing to ${to}.`);
106
+ ok(ctx, `Publishing to ${args.connector}.`);
65
107
  printResult(io, flags, result);
66
108
  return 0;
67
109
  }
68
110
 
69
111
  async function connectorsStatus(ctx) {
70
112
  const { client, flags, io } = ctx;
71
- const connector = requireFlag(flags, "connector");
72
- const publishId = requireFlag(flags, "publish-id");
73
- const result = await client.callTool("get_publish_status", { connector, publish_id: publishId });
113
+ const result = await client.callTool("get_publish_status", argsFromFlags(ARGS.get_publish_status, flags));
74
114
  printResult(io, flags, result);
75
115
  return 0;
76
116
  }
@@ -79,16 +119,14 @@ async function connectorsPull(ctx) {
79
119
  const { client, flags, io } = ctx;
80
120
  // Dual key: --collection (a CMS-bound collection), or --provider (resolve/wire),
81
121
  // or both (--provider wires an unbound --collection first). At least one required.
82
- const collectionId = flagStr(flags.collection);
83
- const provider = flagStr(flags.provider);
84
- if (!collectionId && !provider) {
122
+ const args = argsFromFlags(ARGS.import_from_cms, flags);
123
+ if (!args.collection_id && !args.provider) {
85
124
  throw new CliError("Pass --collection <id> and/or --provider <webflow|framer|sanity|contentful>");
86
125
  }
87
- const args = compact({ collection_id: collectionId, provider });
88
126
  const result = await client.callTool("import_from_cms", args);
89
127
  ok(
90
128
  ctx,
91
- `Pulled from ${result?.provider ?? provider ?? "CMS"} into collection ${result?.collection_id ?? collectionId ?? ""}.`
129
+ `Pulled from ${result?.provider ?? args.provider ?? "CMS"} into collection ${result?.collection_id ?? args.collection_id ?? ""}.`
92
130
  );
93
131
  printResult(io, flags, result);
94
132
  return 0;
@@ -96,33 +134,18 @@ async function connectorsPull(ctx) {
96
134
 
97
135
  async function connectorsConnect(ctx) {
98
136
  const { client, flags, io } = ctx;
99
- const provider = requireFlag(flags, "provider");
100
- // api_token is required for every provider; the rest are provider-specific.
101
- const args = compact({
102
- provider,
103
- api_token: requireFlag(flags, "token"),
104
- project_url: flagStr(flags["project-url"]),
105
- auto_deploy: flags["no-auto-deploy"] ? false : flagBool(flags["auto-deploy"]) || undefined,
106
- project_id: flagStr(flags["project-id"]),
107
- dataset: flagStr(flags.dataset),
108
- project_name: flagStr(flags["project-name"]),
109
- workspace_name: flagStr(flags.workspace),
110
- space_id: flagStr(flags["space-id"]),
111
- environment_id: flagStr(flags.environment),
112
- space_name: flagStr(flags["space-name"]),
113
- default_locale: flagStr(flags.locale),
114
- });
137
+ const args = argsFromFlags(ARGS.connect_cms, flags);
115
138
  const result = await client.callTool("connect_cms", args);
116
- ok(ctx, `Connected ${provider}${result?.label ? ` (${result.label})` : ""}.`);
139
+ ok(ctx, `Connected ${args.provider}${result?.label ? ` (${result.label})` : ""}.`);
117
140
  printResult(io, flags, result);
118
141
  return 0;
119
142
  }
120
143
 
121
144
  async function connectorsDisconnect(ctx) {
122
145
  const { client, flags, io } = ctx;
123
- const provider = requireFlag(flags, "provider");
124
- const result = await client.callTool("disconnect_cms", { provider });
125
- ok(ctx, `Disconnected ${provider}.`);
146
+ const args = argsFromFlags(ARGS.disconnect_cms, flags);
147
+ const result = await client.callTool("disconnect_cms", args);
148
+ ok(ctx, `Disconnected ${args.provider}.`);
126
149
  printResult(io, flags, result);
127
150
  return 0;
128
151
  }
@@ -136,19 +159,14 @@ async function connectorsConnections(ctx) {
136
159
 
137
160
  async function connectorsMappings(ctx) {
138
161
  const { client, flags, io } = ctx;
139
- const provider = requireFlag(flags, "provider");
140
- const result = await client.callTool("list_cms_mappings", { provider });
162
+ const result = await client.callTool("list_cms_mappings", argsFromFlags(ARGS.list_cms_mappings, flags));
141
163
  printResult(io, flags, result);
142
164
  return 0;
143
165
  }
144
166
 
145
167
  async function connectorsBind(ctx) {
146
168
  const { client, flags, io } = ctx;
147
- const args = compact({
148
- collection_id: requireFlag(flags, "collection"),
149
- provider: requireFlag(flags, "provider"),
150
- mapping_id: flagStr(flags.mapping),
151
- });
169
+ const args = argsFromFlags(ARGS.bind_collection_cms, flags);
152
170
  const result = await client.callTool("bind_collection_cms", args);
153
171
  ok(
154
172
  ctx,
@@ -46,18 +46,109 @@
46
46
  import { CliError } from "../client.mjs";
47
47
  import {
48
48
  flagStr,
49
- flagNum,
50
49
  flagBool,
51
50
  requirePositional,
52
51
  printResult,
52
+ refuseUnknownFlags,
53
53
  compact,
54
54
  ok,
55
55
  pollUntil,
56
56
  readTextInput,
57
+ argsFromFlags,
57
58
  } from "./shared.mjs";
58
59
 
59
60
  const COVER_STYLES = ["editorial_image", "full_ai", "line_art"];
60
61
  const DOMAIN_MODES = ["subdomain", "buy_now", "connect_later"];
62
+ const SEER_MODES = ["suggest", "skip"];
63
+ const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
64
+ const APPROVAL_MODES = ["autopilot", "review_window", "manual"];
65
+
66
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
67
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
68
+ export const ARGS = {
69
+ onboard_collection: {
70
+ collection_id: "collection",
71
+ collection_name: "name",
72
+ direction: ["about", "direction"],
73
+ sitemap_url: "sitemap",
74
+ // Source the existing blog from a connected CMS instead of a sitemap.
75
+ cms_provider: { flag: "cms", enum: CMS_PROVIDERS },
76
+ // Planner activation (#1731): the same one-call activation the app's Guided review
77
+ // step performs. Off unless asked for; needs a user-scoped key (an acting user).
78
+ enable_planner: { flag: "enable-planner", type: "bool" },
79
+ cadence_per_week: { flag: "cadence", type: "num" },
80
+ approval_mode: { flag: "approval", enum: APPROVAL_MODES },
81
+ },
82
+ get_collection_onboard_status: {
83
+ job_id: { internal: "the job id onboard_collection just returned" },
84
+ },
85
+ sync_collection_sitemap: {
86
+ collection_id: "collection",
87
+ },
88
+ onboard_domain: {
89
+ direction: ["about", "direction"],
90
+ sitemap_url: "sitemap",
91
+ cms_provider: { flag: "cms", enum: CMS_PROVIDERS },
92
+ collection_name: "name",
93
+ phantom: {
94
+ object: {
95
+ enabled: { flag: "no-phantom", type: "bool", invert: true },
96
+ posts_to_seed: { flag: "posts", type: "num" },
97
+ name: "phantom-name",
98
+ site_domain: "site-domain",
99
+ domain_mode: { flag: "domain-mode", enum: DOMAIN_MODES },
100
+ },
101
+ },
102
+ seers: { flag: "seers", enum: SEER_MODES },
103
+ visuals: { flag: "no-visuals", type: "bool", invert: true },
104
+ enhance: { flag: "no-enhance", type: "bool", invert: true },
105
+ is_test: { flag: "test", type: "bool" },
106
+ readiness_minutes: { flag: "readiness-minutes", type: "num" },
107
+ client_email: "client-email",
108
+ },
109
+ get_domain_onboard_status: {
110
+ job_id: "attach",
111
+ },
112
+ onboard_phantom: {
113
+ domain: { positional: 0, label: "domain", required: true },
114
+ // --accept gates the two-call flow below (dry run, then apply) rather than being
115
+ // forwarded: the first call never sends it, the second always sends true.
116
+ accept: { internal: "false on the dry run, true on apply — decided by the command" },
117
+ overrides: {
118
+ object: {
119
+ name: "name",
120
+ cadence_per_week: { flag: "cadence", type: "num" },
121
+ cover_canvas: { flag: "cover", enum: COVER_STYLES },
122
+ theme: "theme",
123
+ posts_to_seed: { flag: "posts", type: "num" },
124
+ // The highest-leverage override: it becomes the planner's steering prompt, so it
125
+ // shapes the topic map and every post, not just the masthead. A chosen topic
126
+ // plan (--plan-file) supplies it when no --about is given.
127
+ direction: {
128
+ value: (flags, _p, extra) =>
129
+ flagStr(flags.about) ?? flagStr(flags.direction) ?? extra.plan?.direction,
130
+ },
131
+ // The approved plan's content areas become the topic map's pillars VERBATIM.
132
+ pillars: { value: (_f, _p, extra) => extra.plan?.pillars },
133
+ site_domain: "domain",
134
+ domain_mode: { flag: "domain-mode", enum: DOMAIN_MODES },
135
+ is_test: { flag: "real", type: "bool", invert: true },
136
+ },
137
+ },
138
+ },
139
+ get_phantom_onboard_status: {
140
+ job_id: { internal: "the job id onboard_phantom just returned" },
141
+ },
142
+ propose_phantom_plan: {
143
+ client_domain: { positional: 0, label: "client-domain" },
144
+ direction: ["about", "direction"],
145
+ name: "name",
146
+ },
147
+ propose_phantom_mastheads: {
148
+ client_domain: { positional: 0, label: "client-domain", required: true },
149
+ plan: { value: (_f, _p, extra) => extra.plan },
150
+ },
151
+ };
61
152
 
62
153
  function formatPlan(plan) {
63
154
  const lines = [
@@ -191,11 +282,7 @@ function formatMastheads(r, plan) {
191
282
  // because the tool falls back to the org's own recorded domain (the key's org IS the client).
192
283
  async function cmdOnboardPropose(ctx) {
193
284
  const { client, flags, io } = ctx;
194
- const args = compact({
195
- client_domain: ctx.positionals[0],
196
- direction: flagStr(flags.about) ?? flagStr(flags.direction),
197
- name: flagStr(flags.name),
198
- });
285
+ const args = argsFromFlags(ARGS.propose_phantom_plan, flags, ctx.positionals);
199
286
  ok(ctx, "Proposing…");
200
287
  const proposal = await client.callTool("propose_phantom_plan", args);
201
288
  printResult(io, flags, proposal, formatProposal);
@@ -219,7 +306,10 @@ async function cmdOnboardMastheads(ctx) {
219
306
  );
220
307
  }
221
308
  ok(ctx, `Proposing mastheads for "${plan.title}"…`);
222
- const result = await client.callTool("propose_phantom_mastheads", { client_domain: clientDomain, plan });
309
+ const result = await client.callTool(
310
+ "propose_phantom_mastheads",
311
+ argsFromFlags(ARGS.propose_phantom_mastheads, flags, ctx.positionals, { plan })
312
+ );
223
313
  printResult(io, flags, result, (r) => formatMastheads(r, plan));
224
314
  if (result.options?.length) {
225
315
  ok(
@@ -234,35 +324,13 @@ async function cmdOnboardMastheads(ctx) {
234
324
  function overridesFromFlags(flags) {
235
325
  // A chosen topic plan pins direction + pillars; an explicit --about still wins on direction.
236
326
  const plan = readTopicPlan(flags);
237
- const cover = flagStr(flags.cover);
238
- if (cover !== undefined && !COVER_STYLES.includes(cover)) {
239
- throw new CliError(`--cover must be one of: ${COVER_STYLES.join(", ")}`);
240
- }
241
- const domainMode = flagStr(flags["domain-mode"]);
242
- if (domainMode !== undefined && !DOMAIN_MODES.includes(domainMode)) {
243
- throw new CliError(`--domain-mode must be one of: ${DOMAIN_MODES.join(", ")}`);
244
- }
245
- const siteDomain = flagStr(flags.domain);
327
+ const overrides = argsFromFlags(ARGS.onboard_phantom.overrides.object, flags, [], { plan });
246
328
  // buy_now spends real money against a 2-per-org cap; refusing the mismatch here beats
247
329
  // silently downgrading it to a subdomain launch the caller didn't ask for.
248
- if (domainMode && domainMode !== "subdomain" && !siteDomain) {
249
- throw new CliError(`--domain-mode ${domainMode} needs --domain <bare-domain>`);
250
- }
251
- return compact({
252
- name: flagStr(flags.name),
253
- cadence_per_week: flagNum(flags.cadence),
254
- cover_canvas: cover,
255
- theme: flagStr(flags.theme),
256
- posts_to_seed: flagNum(flags.posts),
257
- // The highest-leverage override: it becomes the planner's steering prompt, so it
258
- // shapes the topic map and every post, not just the masthead.
259
- direction: flagStr(flags.about) ?? flagStr(flags.direction) ?? plan?.direction,
260
- // The approved plan's content areas become the topic map's pillars VERBATIM.
261
- pillars: plan?.pillars,
262
- site_domain: siteDomain,
263
- domain_mode: domainMode,
264
- is_test: flagBool(flags["real"]) ? false : undefined,
265
- });
330
+ if (overrides.domain_mode && overrides.domain_mode !== "subdomain" && !overrides.site_domain) {
331
+ throw new CliError(`--domain-mode ${overrides.domain_mode} needs --domain <bare-domain>`);
332
+ }
333
+ return overrides;
266
334
  }
267
335
 
268
336
  // Final readback of a collection-onboarding job — the counts the engine PERSISTED.
@@ -282,7 +350,9 @@ function formatCollectionStatus(s) {
282
350
  ` series: ${r.series ?? 0}`,
283
351
  ` open topics: ${r.spokes_open ?? 0} (${r.spokes_seo_bound ?? 0} SEO-bound)`,
284
352
  ` headlines suggested: ${r.headlines_suggested ?? 0}`,
285
- ` Review them in Headlines & Queue — the Planner is configured but OFF (manual approval).`
353
+ s.enable_planner
354
+ ? ` Planner: ON — ${s.cadence_per_week ?? "?"}/week, ${s.approval_mode ?? "manual"} approval, queue filled.`
355
+ : ` Review them in Headlines & Queue — the Planner is configured but OFF (pass --enable-planner to turn it on).`
286
356
  );
287
357
  }
288
358
  return lines.join("\n");
@@ -302,14 +372,38 @@ async function cmdOnboardCollection(ctx) {
302
372
  );
303
373
  }
304
374
 
305
- const args = compact({
306
- collection_id: flagStr(flags.collection),
307
- collection_name: flagStr(flags.name),
308
- direction: flagStr(flags.about) ?? flagStr(flags.direction),
309
- sitemap_url: flagStr(flags.sitemap),
310
- // Source the existing blog from a connected CMS instead of a sitemap.
311
- cms_provider: flagStr(flags.cms),
312
- });
375
+ // READ PATHS FIRST, before anything that writes. `--status` and `--attach` mean here
376
+ // exactly what they mean on `onboard domain`: show me the job, do not start one.
377
+ // They had no branch at all, so they fell through to onboard_collection and STARTED A
378
+ // SECOND ONBOARDING — a read verb that spent money and left a duplicate collection.
379
+ const attach = flagStr(flags.attach);
380
+ if (attach) {
381
+ const s = await client.callTool("get_collection_onboard_status", { job_id: attach });
382
+ printResult(ctx.io, flags, s, formatCollectionStatus);
383
+ return 0;
384
+ }
385
+ if (flagBool(flags.status)) {
386
+ const collectionId = flagStr(flags.collection);
387
+ const s = await client.callTool(
388
+ "get_collection_onboard_status",
389
+ collectionId ? { collection_id: collectionId } : {}
390
+ );
391
+ printResult(ctx.io, flags, s, formatCollectionStatus);
392
+ return 0;
393
+ }
394
+
395
+ // Everything below WRITES. A flag we do not understand is refused rather than ignored:
396
+ // the ARGS map that builds the call is the list of what may be passed to it.
397
+ refuseUnknownFlags(ctx, ARGS.onboard_collection, ["status", "attach", "watch", "no-wait", "domain"]);
398
+
399
+ const args = argsFromFlags(ARGS.onboard_collection, flags, ctx.positionals);
400
+ // --cadence / --approval only mean something once the Planner is being turned on; a
401
+ // silently ignored flag is exactly the kind of invisible opinion this lane exists to remove.
402
+ if ((args.cadence_per_week !== undefined || args.approval_mode !== undefined) && !args.enable_planner) {
403
+ throw new CliError(
404
+ "--cadence and --approval only apply with --enable-planner (the Planner stays OFF otherwise)."
405
+ );
406
+ }
313
407
 
314
408
  ok(ctx, "Starting collection onboarding…");
315
409
  const started = await client.callTool("onboard_collection", args);
@@ -357,13 +451,13 @@ async function cmdOnboardCollection(ctx) {
357
451
  // bounded per run, so it returns synchronously.
358
452
  async function cmdOnboardSyncSitemap(ctx) {
359
453
  const { client, flags } = ctx;
360
- const collectionId = flagStr(flags.collection);
361
- if (!collectionId) {
454
+ const args = argsFromFlags(ARGS.sync_collection_sitemap, flags, ctx.positionals);
455
+ if (!args.collection_id) {
362
456
  throw new CliError("onboard sync-sitemap requires --collection <uuid>.");
363
457
  }
364
458
 
365
459
  ok(ctx, "Re-checking the sitemap…");
366
- const result = await client.callTool("sync_collection_sitemap", { collection_id: collectionId });
460
+ const result = await client.callTool("sync_collection_sitemap", args);
367
461
 
368
462
  printResult(ctx.io, flags, result, (r) =>
369
463
  [
@@ -383,9 +477,6 @@ async function cmdOnboardSyncSitemap(ctx) {
383
477
  return 0;
384
478
  }
385
479
 
386
- const SEER_MODES = ["suggest", "skip"];
387
- const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
388
-
389
480
  // Final readback of a domain-onboarding job — the counts the engine PERSISTED.
390
481
  export function formatDomainStatus(s) {
391
482
  const r = s.result ?? {};
@@ -474,35 +565,7 @@ async function cmdOnboardDomain(ctx) {
474
565
  return 0;
475
566
  }
476
567
 
477
- const seers = flagStr(flags.seers);
478
- if (seers !== undefined && !SEER_MODES.includes(seers)) {
479
- throw new CliError(`--seers must be one of: ${SEER_MODES.join(", ")}`);
480
- }
481
- const cms = flagStr(flags.cms);
482
- if (cms !== undefined && !CMS_PROVIDERS.includes(cms)) {
483
- throw new CliError(`--cms must be one of: ${CMS_PROVIDERS.join(", ")}`);
484
- }
485
- const posts = flagNum(flags.posts);
486
- const phantom = compact({
487
- enabled: flagBool(flags["no-phantom"]) ? false : undefined,
488
- posts_to_seed: posts,
489
- name: flagStr(flags["phantom-name"]),
490
- site_domain: flagStr(flags["site-domain"]),
491
- domain_mode: flagStr(flags["domain-mode"]),
492
- });
493
- const args = compact({
494
- direction: flagStr(flags.about) ?? flagStr(flags.direction),
495
- sitemap_url: flagStr(flags.sitemap),
496
- cms_provider: cms,
497
- collection_name: flagStr(flags.name),
498
- phantom: Object.keys(phantom).length ? phantom : undefined,
499
- seers,
500
- visuals: flagBool(flags["no-visuals"]) ? false : undefined,
501
- enhance: flagBool(flags["no-enhance"]) ? false : undefined,
502
- is_test: flagBool(flags.test) ? true : undefined,
503
- readiness_minutes: flagNum(flags["readiness-minutes"]),
504
- client_email: flagStr(flags["client-email"]),
505
- });
568
+ const args = argsFromFlags(ARGS.onboard_domain, flags, ctx.positionals);
506
569
 
507
570
  ok(ctx, "Starting domain onboarding…");
508
571
  const started = await client.callTool("onboard_domain", args);
@@ -53,6 +53,138 @@ export function compact(obj) {
53
53
  return out;
54
54
  }
55
55
 
56
+ // Flags every command accepts: transport, output shape, and the ones the runner
57
+ // consumes before a command ever sees them.
58
+ export const GLOBAL_FLAGS = new Set([
59
+ "url",
60
+ "key",
61
+ "staff-key",
62
+ "json",
63
+ "quiet",
64
+ "verbose",
65
+ "help",
66
+ "version",
67
+ "yes",
68
+ "no-color",
69
+ "profile",
70
+ ]);
71
+
72
+ /**
73
+ * Refuse a flag this command does not understand.
74
+ *
75
+ * On a WRITE command an unrecognised flag is not cosmetic. `phantom-onboard
76
+ * collection --status` used to fall straight through to `onboard_collection`
77
+ * and START A SECOND ONBOARDING — a read verb that spent money and left a
78
+ * duplicate collection behind — because nothing checked that `--status` meant
79
+ * anything here. Its sibling `phantom-onboard domain --status` reads, so the
80
+ * same flag meant "read" on one subcommand and "spend" on the other.
81
+ *
82
+ * `own` is the flags the command handles itself; the ARGS map supplies the
83
+ * rest, so the map that BUILDS the call is also the list of what may be passed
84
+ * to it — one source, the same principle the maps exist for.
85
+ */
86
+ export function refuseUnknownFlags(ctx, argsMap, own = []) {
87
+ const allowed = new Set([...GLOBAL_FLAGS, ...own]);
88
+ for (const source of Object.values(argsMap ?? {})) {
89
+ if (typeof source === "string") allowed.add(source);
90
+ else if (Array.isArray(source)) source.forEach((f) => allowed.add(f));
91
+ else if (source && typeof source === "object" && source.flag) allowed.add(source.flag);
92
+ }
93
+ const unknown = Object.keys(ctx.flags).filter((f) => !allowed.has(f));
94
+ if (unknown.length) {
95
+ throw new CliError(
96
+ `Unknown flag${unknown.length > 1 ? "s" : ""} for this command: ${unknown.map((f) => `--${f}`).join(", ")}. ` +
97
+ "This command writes, so it refuses rather than ignoring a flag it does not understand."
98
+ );
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------------
103
+ // ARGS maps: the one place a curated command says how each tool input key is sourced.
104
+ //
105
+ // A command module exports `ARGS = { <tool name>: { <schema key>: <source> } }` and builds
106
+ // its `callTool` payload with `argsFromFlags(ARGS[tool], flags, positionals)`. The map IS
107
+ // the implementation — not a second copy of it — so `scripts/parity-check.ts` can import it
108
+ // and compare its keys against the tool's published input schema. A tool that gains a
109
+ // parameter the command never learned then FAILS parity instead of drifting silently
110
+ // (#1731 shipped enable_planner to MCP and REST and the CLI never got the flags; nothing
111
+ // noticed, because the old check compared tool NAMES only).
112
+ //
113
+ // Source forms, one per schema key:
114
+ // "flag" a string flag, --flag <value>
115
+ // ["about", "direction"] the first of these string flags that is set
116
+ // { flag, type?, required?, enum?, invert?, negate?, positional?, label? }
117
+ // type: "str" (default) | "num" | "bool" | "list" (a repeatable flag)
118
+ // required: throw "Missing required --flag" when absent
119
+ // enum: throw "--flag must be one of: …" on any other value
120
+ // invert: (bool) a set --flag sends FALSE (e.g. --no-phantom → enabled:false)
121
+ // negate: (bool) a second flag that sends FALSE (e.g. --no-auto-deploy)
122
+ // positional: an index into positionals instead of a flag (label names it in errors)
123
+ // { object: { <sub key>: <source> } } a nested object, omitted when every sub key is absent
124
+ // { value: (flags, positionals, extra) => v }
125
+ // computed from several flags or an earlier result;
126
+ // still DECLARED, so parity counts the key as exposed
127
+ // { internal: "why" } supplied by the command from a prior tool result
128
+ // (a job id it just received), never from the user
129
+ export function argsFromFlags(spec, flags, positionals = [], extra = {}) {
130
+ const out = {};
131
+ for (const [key, source] of Object.entries(spec)) {
132
+ const v = readArgSource(key, source, flags, positionals, extra);
133
+ if (v !== undefined) out[key] = v;
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function readArgSource(key, source, flags, positionals, extra) {
139
+ if (typeof source === "string") return flagStr(flags[source]);
140
+ if (Array.isArray(source)) {
141
+ for (const name of source) {
142
+ const v = flagStr(flags[name]);
143
+ if (v !== undefined) return v;
144
+ }
145
+ return undefined;
146
+ }
147
+ if (!source || typeof source !== "object") {
148
+ throw new CliError(`ARGS.${key}: unsupported source ${JSON.stringify(source)}`);
149
+ }
150
+ if (source.internal !== undefined) return undefined;
151
+ if (typeof source.value === "function") return source.value(flags, positionals, extra);
152
+ if (source.object) {
153
+ const nested = argsFromFlags(source.object, flags, positionals, extra);
154
+ return Object.keys(nested).length ? nested : undefined;
155
+ }
156
+ if (source.positional !== undefined) {
157
+ const v = positionals[source.positional];
158
+ if (v === undefined && source.required) {
159
+ throw new CliError(`Missing required <${source.label ?? key}>`);
160
+ }
161
+ return v;
162
+ }
163
+ const raw = flags[source.flag];
164
+ let v;
165
+ switch (source.type ?? "str") {
166
+ case "num":
167
+ v = flagNum(raw);
168
+ break;
169
+ case "bool":
170
+ if (source.negate && flags[source.negate] === true) return false;
171
+ v = raw === true ? (source.invert ? false : true) : undefined;
172
+ break;
173
+ case "list": {
174
+ const list = flagList(raw);
175
+ v = list.length ? list : undefined;
176
+ break;
177
+ }
178
+ default:
179
+ v = flagStr(raw);
180
+ }
181
+ if (v === undefined && source.required) throw new CliError(`Missing required --${source.flag}`);
182
+ if (v !== undefined && source.enum && !source.enum.includes(v)) {
183
+ throw new CliError(`--${source.flag} must be one of: ${source.enum.join(", ")}`);
184
+ }
185
+ return v;
186
+ }
187
+
56
188
  export function printResult(io, flags, value, formatter) {
57
189
  if (flags.json || !formatter) {
58
190
  io.log(JSON.stringify(value, null, 2));
@@ -4,16 +4,72 @@
4
4
  // mirrored here as two exported handlers for the same reason.
5
5
 
6
6
  import { CliError } from "../client.mjs";
7
- import {
8
- flagStr,
9
- requireFlag,
10
- requirePositional,
11
- printResult,
12
- compact,
13
- flagList,
14
- ok,
15
- readBodyInput,
16
- } from "./shared.mjs";
7
+ import { flagStr, printResult, flagList, ok, readBodyInput, argsFromFlags } from "./shared.mjs";
8
+
9
+ // A list flag the API clears with an explicit empty array (which compact() keeps):
10
+ // `--clear-topics` sends [], a repeated --topic sends the list, neither sends nothing.
11
+ const clearableList = (flag, clearFlag) => ({
12
+ value: (flags) => {
13
+ if (flags[clearFlag]) return [];
14
+ const list = flagList(flags[flag]);
15
+ return list.length ? list : undefined;
16
+ },
17
+ });
18
+
19
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
20
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
21
+ export const ARGS = {
22
+ get_company_info: {},
23
+ set_company_info: {
24
+ name: "name",
25
+ domain: "domain",
26
+ // The API clears the manifesto with an empty string, which a plain flag read would
27
+ // drop — so it is set explicitly whenever the user supplied a source (text or file).
28
+ manifesto: {
29
+ value: (flags) => {
30
+ const inline = flagStr(flags.manifesto);
31
+ const file = flagStr(flags["manifesto-file"]);
32
+ if (inline === undefined && file === undefined) return undefined;
33
+ return inline ?? readBodyInput({ file }) ?? "";
34
+ },
35
+ },
36
+ market: "market",
37
+ },
38
+ get_positioning: {},
39
+ set_positioning: {
40
+ priority_topics: clearableList("topic", "clear-topics"),
41
+ stances: clearableList("stance", "clear-stances"),
42
+ avoid_topics: clearableList("avoid", "clear-avoid"),
43
+ },
44
+ list_competitors: {},
45
+ add_competitor: {
46
+ name: { positional: 0, label: "name", required: true },
47
+ domain: { positional: 1, label: "domain", required: true },
48
+ },
49
+ import_sitemap: {
50
+ collection_id: { flag: "collection", required: true },
51
+ sitemap_url: { flag: "url", required: true },
52
+ sitemap_urls: { flag: "sub", type: "list" },
53
+ url_pattern: "pattern",
54
+ posts_only: { flag: "posts-only", type: "bool" },
55
+ },
56
+ list_topics: {
57
+ collection_id: { flag: "collection", required: true },
58
+ },
59
+ set_topic: {
60
+ collection_id: { flag: "collection", required: true },
61
+ topic_id: "topic",
62
+ suggestion_id: "suggestion",
63
+ },
64
+ get_onboarding_status: {},
65
+ // Step ids are the server's to define (the registry is the authority); forwarded as given.
66
+ set_onboarding_step: {
67
+ current_step: "current",
68
+ complete_step: "complete",
69
+ skip_step: "skip",
70
+ status: "status",
71
+ },
72
+ };
17
73
 
18
74
  export async function cmdStrategy(ctx) {
19
75
  const sub = ctx.positionals[0];
@@ -60,16 +116,11 @@ async function companyGet(ctx) {
60
116
 
61
117
  async function companySet(ctx) {
62
118
  const { client, flags, io } = ctx;
63
- const args = compact({ name: flagStr(flags.name), domain: flagStr(flags.domain) });
64
- // The API clears the manifesto with an empty string, which compact() would drop —
65
- // so set it explicitly whenever the user supplied a source (text or file).
66
- const manifesto = flagStr(flags.manifesto);
67
- const manifestoFile = flagStr(flags["manifesto-file"]);
68
- if (manifesto !== undefined || manifestoFile !== undefined) {
69
- args.manifesto = manifesto ?? readBodyInput({ file: manifestoFile }) ?? "";
70
- }
119
+ const args = argsFromFlags(ARGS.set_company_info, flags);
71
120
  if (Object.keys(args).length === 0) {
72
- throw new CliError('Nothing to set — pass --name/--domain/--manifesto (empty --manifesto "" clears it).');
121
+ throw new CliError(
122
+ 'Nothing to set — pass --name/--domain/--manifesto/--market (empty --manifesto "" clears it).'
123
+ );
73
124
  }
74
125
  const result = await client.callTool("set_company_info", args);
75
126
  ok(ctx, "Company profile updated.");
@@ -101,15 +152,7 @@ async function positioningGet(ctx) {
101
152
 
102
153
  async function positioningSet(ctx) {
103
154
  const { client, flags, io } = ctx;
104
- const topics = flagList(flags.topic);
105
- const stances = flagList(flags.stance);
106
- const avoid = flagList(flags.avoid);
107
- // The API clears a list with an explicit empty array (which compact() keeps).
108
- const args = compact({
109
- priority_topics: flags["clear-topics"] ? [] : topics.length ? topics : undefined,
110
- stances: flags["clear-stances"] ? [] : stances.length ? stances : undefined,
111
- avoid_topics: flags["clear-avoid"] ? [] : avoid.length ? avoid : undefined,
112
- });
155
+ const args = argsFromFlags(ARGS.set_positioning, flags);
113
156
  if (Object.keys(args).length === 0) {
114
157
  throw new CliError("Pass at least one --topic/--stance/--avoid (or a --clear-* flag).");
115
158
  }
@@ -144,10 +187,9 @@ async function competitorsList(ctx) {
144
187
 
145
188
  async function competitorsAdd(ctx) {
146
189
  const { client, positionals, flags, io } = ctx;
147
- const name = requirePositional(positionals, 0, "name");
148
- const domain = requirePositional(positionals, 1, "domain");
149
- const result = await client.callTool("add_competitor", { name, domain });
150
- ok(ctx, `Added competitor ${name}.`);
190
+ const args = argsFromFlags(ARGS.add_competitor, flags, positionals);
191
+ const result = await client.callTool("add_competitor", args);
192
+ ok(ctx, `Added competitor ${args.name}.`);
151
193
  printResult(io, flags, result);
152
194
  return 0;
153
195
  }
@@ -156,15 +198,7 @@ async function competitorsAdd(ctx) {
156
198
 
157
199
  async function strategySitemap(ctx) {
158
200
  const { client, flags, io } = ctx;
159
- const collection = requireFlag(flags, "collection");
160
- const url = requireFlag(flags, "url");
161
- const sub = flagList(flags.sub);
162
- const args = compact({
163
- collection_id: collection,
164
- sitemap_url: url,
165
- sitemap_urls: sub.length ? sub : undefined,
166
- url_pattern: flagStr(flags.pattern),
167
- });
201
+ const args = argsFromFlags(ARGS.import_sitemap, flags);
168
202
  const result = await client.callTool("import_sitemap", args);
169
203
  ok(ctx, "Sitemap import started.");
170
204
  printResult(io, flags, result);
@@ -189,21 +223,17 @@ async function strategyTopics(ctx) {
189
223
 
190
224
  async function topicsList(ctx) {
191
225
  const { client, flags, io } = ctx;
192
- const collection = requireFlag(flags, "collection");
193
- const result = await client.callTool("list_topics", { collection_id: collection });
226
+ const result = await client.callTool("list_topics", argsFromFlags(ARGS.list_topics, flags));
194
227
  printResult(io, flags, result);
195
228
  return 0;
196
229
  }
197
230
 
198
231
  async function topicsSet(ctx) {
199
232
  const { client, flags, io } = ctx;
200
- const collection = requireFlag(flags, "collection");
201
- const topicId = flagStr(flags.topic);
202
- const suggestionId = flagStr(flags.suggestion);
203
- if ((topicId === undefined) === (suggestionId === undefined)) {
233
+ const args = argsFromFlags(ARGS.set_topic, flags);
234
+ if ((args.topic_id === undefined) === (args.suggestion_id === undefined)) {
204
235
  throw new CliError("Pass exactly one of --topic <topic-id> or --suggestion <suggestion-id>.");
205
236
  }
206
- const args = compact({ collection_id: collection, topic_id: topicId, suggestion_id: suggestionId });
207
237
  const result = await client.callTool("set_topic", args);
208
238
  ok(ctx, "Topic queued.");
209
239
  printResult(io, flags, result);
@@ -234,12 +264,7 @@ async function onboardingStatus(ctx) {
234
264
 
235
265
  async function onboardingStep(ctx) {
236
266
  const { client, flags, io } = ctx;
237
- const args = compact({
238
- current_step: flagStr(flags.current),
239
- complete_step: flagStr(flags.complete),
240
- skip_step: flagStr(flags.skip),
241
- status: flagStr(flags.status),
242
- });
267
+ const args = argsFromFlags(ARGS.set_onboarding_step, flags);
243
268
  if (Object.keys(args).length === 0) {
244
269
  throw new CliError("Pass --current/--complete/--skip/--status.");
245
270
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",