@letterstory/cli 0.35.0 → 0.36.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
@@ -245,6 +245,34 @@ letterstory onboarding status
245
245
  letterstory onboarding step --complete connect_domain
246
246
  ```
247
247
 
248
+ ### Offerings — one org, many products
249
+
250
+ Context is layered **org → offering → collection**. An offering is anything the org would
251
+ name when recommending it ("use Lettershred"). Each layer stores only what it overrides: an
252
+ omitted field inherits, and `--reset <field>` puts one back to inheriting. Stances, avoid
253
+ topics and blocked mentions **add** across layers (a collection's stances lead in prompts);
254
+ priority topics (`--topic`) **replace** the inherited list, so a product never picks up its
255
+ siblings' focus.
256
+
257
+ ```bash
258
+ letterstory offerings list
259
+ letterstory offerings create lettershred --name Lettershred --link https://lettershred.com \
260
+ --one-liner "Removes AI watermarks from text" --stance "Detection is an arms race" --topic "AI watermarks"
261
+ letterstory offerings update <offering-id> --reset priority_topics # inherit the org's focus again
262
+ letterstory offerings competitor <offering-id> --competitor <competitor-domain-id> --stance avoid
263
+
264
+ # A collection publishes as its primary offering and may mention the others it lists.
265
+ letterstory offerings set-collection <collection-id> --primary <offering-id> --also <other-id>:0.5
266
+ letterstory offerings set-collection <collection-id> --none # the org itself again
267
+
268
+ # Why is this collection writing what it's writing? (every field + the layer that set it)
269
+ letterstory offerings collection <collection-id>
270
+ letterstory offerings collection-context <collection-id> --stance "…" --avoid "pricing" --framing "…"
271
+
272
+ # The planner picks an offering per topic; override one pick (or --offering none to clear).
273
+ letterstory offerings topic <topic-id> --offering <offering-id>
274
+ ```
275
+
248
276
  ### One-shot domain onboarding
249
277
 
250
278
  A bare company domain in, a fully-configured organization out — as ONE durable job that
@@ -494,6 +522,22 @@ letterstory templates update <template-id> --angle "…" # unset fields keep t
494
522
  letterstory templates delete <template-id> --yes # bound seers fall back to defaults
495
523
  ```
496
524
 
525
+ Primitive compositions (a named bundle of kernel/shredder/template/seer references —
526
+ `docs/formats/compositions.md`; not a primitive, no endpoint or run. Needs
527
+ `composition:read` / `composition:write`):
528
+
529
+ ```bash
530
+ letterstory compositions list
531
+ letterstory compositions create --name "Regulation-watch site kit" \
532
+ --content '{"kernel_family":"Cedar","shred":true,"template_key":"tmpl_howto"}' \
533
+ --phantom '{"theme":"gazette"}' \
534
+ --seer '{"name":"AI regulation watch","provider":"regulation_change","mode":"auto","config":{"query":"AI regulation"}}'
535
+ letterstory compositions resolve comp_9f1a2b3c4d5e # preview, read-only — costs nothing
536
+ letterstory compositions update <composition-id> --clear-seer
537
+ letterstory compositions delete <composition-id> --yes # non-destructive to phantoms already built from it
538
+ letterstory phantom create --name "EU AI Act Weekly" --composition '{"key":"comp_9f1a2b3c4d5e"}'
539
+ ```
540
+
497
541
  ## Cover images
498
542
 
499
543
  Which image model draws a post's cover is a CLI decision, not a UI-only one. `variants`
package/lib/cli.mjs CHANGED
@@ -37,15 +37,17 @@ import {
37
37
  cmdOnboard,
38
38
  cmdSeers,
39
39
  cmdTemplates,
40
+ cmdCompositions,
40
41
  cmdShredder,
41
42
  cmdLede,
42
43
  cmdLandings,
43
44
  cmdAdmin,
44
45
  cmdFeedback,
46
+ cmdOfferings,
45
47
  } from "./commands.mjs";
46
48
 
47
49
  // Keep in sync with cli/package.json.
48
- export const VERSION = "0.35.0";
50
+ export const VERSION = "0.36.1";
49
51
 
50
52
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
51
53
  // can't accidentally swallow the id as --json's value.
@@ -99,6 +101,8 @@ const BOOLEAN_FLAGS = new Set([
99
101
  "clear-topics",
100
102
  "clear-stances",
101
103
  "clear-avoid",
104
+ // `offerings set-collection <id> --none` (the org itself is the subject again).
105
+ "none",
102
106
  ]);
103
107
 
104
108
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -214,7 +218,7 @@ Content:
214
218
  posts unpublish <article-id> Unpublish an article
215
219
  posts delete --id <uuid> [--id <uuid> ...] Permanently delete article(s)
216
220
  published list [--limit <n>] [--collection <uuid>] [--format html|md]
217
- published show (--id <uuid> | --slug <slug>) [--format html|md]
221
+ published show (--id <uuid> | --slug <slug>) [--collection <id>] [--format html|md]
218
222
 
219
223
  Reviewers:
220
224
  reviewers standing list List the org's standing reviewers
@@ -290,6 +294,33 @@ Strategy & onboarding:
290
294
  directives propose <collection_id> <what you want> Read one instruction back as concrete levers (applies nothing)
291
295
  directives apply <directive_id> --effect <id>... Commit the levers you kept (or --reason to decline them all)
292
296
  directives off <directive_id> --reason <why> Stop a live instruction; the reason is read back by the next proposal
297
+
298
+ Offerings (context layered org → offering → collection; a layer sets only what it overrides):
299
+ offerings list The org's offerings (things you'd name when recommending them)
300
+ offerings show <offering-id> One offering + its competitor overrides
301
+ offerings create <slug> [--name <n>] [--alias <a>…] [--link <url>] [--manifesto <text>|--manifesto-file <path>]
302
+ [--one-liner <s>] [--is <fact>…] [--is-not <fact>…] [--descriptor <d>…] [--framing <f>]
303
+ [--stance <s>…] [--topic <t>…] [--avoid <t>…] [--block <name>…] [--brand-profile <slug>]
304
+ [--position <n>] [--detach <field>…] Omitted = inherit the org. Stances/avoid/block ADD to the
305
+ org's; --topic (priority topics) REPLACES them. --detach
306
+ stops inheriting a field (e.g. --detach stances = only
307
+ this offering's own; --detach manifesto = none).
308
+ offerings update <offering-id> [same flags] [--slug <s>] [--reset <field>…]
309
+ --reset puts a field back to inheriting. The identity
310
+ flags (--one-liner/--is/--is-not/--descriptor) replace
311
+ the whole identity.
312
+ offerings delete <offering-id> --yes Collections that published as it fall back to the org
313
+ offerings competitor <offering-id> (--competitor <competitor-domain-id> | --add-domain <d> --add-name <n>)
314
+ --stance inherit|mention|avoid This offering's stance on one competitor
315
+ offerings collection <collection-id> Why it writes what it writes: resolved context, the layer
316
+ each field came from, and its mention set
317
+ offerings collection-context <collection-id> [--stance <s>…] [--topic <t>…] [--avoid <t>…] [--block <name>…]
318
+ [--framing <f>] [--detach <field>…] [--reset <field>…]
319
+ Edit the collection's own layer (its stances lead)
320
+ offerings set-collection <collection-id> (--primary <offering-id> [--also <offering-id>[:weight]…] | --none)
321
+ What it may promote; the primary is what it publishes as
322
+ offerings topic <topic-id> --offering <offering-id|none>
323
+ Override which offering one planner topic promotes
293
324
  probes list [--collection <uuid>] [--active-only] [--kind mention|citation|page]
294
325
  probes keep | pause | resume <probe_id> Keep = protect an AI probe from rotation (no pause)
295
326
  probes edit <probe_id> <new question text> Fix/sharpen a generated probe
@@ -534,6 +565,26 @@ Content templates (compose structures seers bind via config.template_key):
534
565
  for coverage in your own words and lets the engine pick the shape.
535
566
  templates delete <template-id> --yes
536
567
 
568
+ Primitive compositions (named bundles of kernel/shredder/template/seer; not a primitive —
569
+ no endpoint, no run. Applied on phantom create --composition '{"key":"..."}'):
570
+ compositions list Org's compositions, by key
571
+ compositions show <composition-id>
572
+ compositions create --name <n> [--description <text>]
573
+ [--content '<json>'|--content-file <path>] The collection's own content
574
+ section (kernel_family, shred,
575
+ shredder_endpoint_id, template_key,
576
+ mention_degree, cover, visuals, verify).
577
+ [--phantom '<json>'|--phantom-file <path>] Phantom-hosting defaults ({theme}).
578
+ [--seer '<json>'|--seer-file <path>] Optional — the same shape
579
+ 'seers create' takes, minus
580
+ collection/org (filled in once
581
+ the phantom's collection exists).
582
+ compositions update <composition-id> [--name] [--description]
583
+ [--content ...] [--phantom ...] [--seer '<json>'|--seer-file <path>|--clear-seer]
584
+ compositions delete <composition-id> --yes
585
+ compositions resolve <composition-key> Preview what create_phantom would
586
+ merge in — read-only, costs nothing.
587
+
537
588
  Shredder (diversify content produced outside Letterstory; structure preserved):
538
589
  shredder list Your shredder endpoints + run counts
539
590
  shredder create --name <n> New endpoint (returns a callable public id)
@@ -653,6 +704,7 @@ const CLIENT_COMMANDS = {
653
704
  connectors: cmdConnectors,
654
705
  strategy: cmdStrategy,
655
706
  directives: cmdDirectives,
707
+ offerings: cmdOfferings,
656
708
  probes: cmdProbes,
657
709
  suggestions: cmdSuggestions,
658
710
  onboarding: cmdOnboarding,
@@ -665,6 +717,7 @@ const CLIENT_COMMANDS = {
665
717
  "phantom-onboard": cmdOnboard,
666
718
  seers: cmdSeers,
667
719
  templates: cmdTemplates,
720
+ compositions: cmdCompositions,
668
721
  shredder: cmdShredder,
669
722
  lede: cmdLede,
670
723
  ledes: cmdLede,
@@ -0,0 +1,193 @@
1
+ // `compositions` — Primitive Compositions, the FORMAT that bundles a kernel, a shredder, a
2
+ // content template and a seer into one named, reusable reference (docs/formats/compositions.md).
3
+ //
4
+ // Not a primitive: no endpoint, no run, no balance. What it has instead of a door is a
5
+ // RESOLUTION — `compositions resolve <key>` — and one consuming door, `phantom create
6
+ // --composition '{"key":"..."}'` (see phantom.mjs), which resolves it exactly once and merges
7
+ // it in. Authoring (list/show/create/update/delete) is CLI/REST parity work only; the mechanism
8
+ // itself shipped with the format.
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { CliError } from "../client.mjs";
12
+ import { flagStr, requirePositional, printResult, ok, argsFromFlags } from "./shared.mjs";
13
+
14
+ function readJsonFileFlag(flags, flag) {
15
+ const path = flagStr(flags[flag]);
16
+ if (path === undefined) return undefined;
17
+ let text;
18
+ try {
19
+ text = readFileSync(path, "utf8");
20
+ } catch (error) {
21
+ throw new CliError(
22
+ `Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
23
+ );
24
+ }
25
+ try {
26
+ return JSON.parse(text);
27
+ } catch {
28
+ throw new CliError(`--${flag} ${path} is not valid JSON.`);
29
+ }
30
+ }
31
+
32
+ function readJsonObjectFlag(flags, name) {
33
+ const inline = flagStr(flags[name]);
34
+ if (inline !== undefined) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(inline);
38
+ } catch (err) {
39
+ throw new CliError(`--${name} is not valid JSON: ${err.message}`);
40
+ }
41
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
42
+ throw new CliError(`--${name} must be a JSON object`);
43
+ return parsed;
44
+ }
45
+ return readJsonFileFlag(flags, `${name}-file`);
46
+ }
47
+
48
+ // `--seer '<json>'`/`--seer-file <path>` sets it, `--clear-seer` sends null (the composition
49
+ // stands up no seer), neither leaves it unchanged — same idiom as seers.mjs's clearableValue.
50
+ function seerFromFlags(flags) {
51
+ if (flags["clear-seer"] === true) return null;
52
+ return readJsonObjectFlag(flags, "seer");
53
+ }
54
+
55
+ /**
56
+ * How this command sources its tool inputs — see `argsFromFlags` in shared.mjs and
57
+ * `scripts/parity-check.ts`. `content`/`phantom`/`seer` are each a JSON object (the collection
58
+ * content section, `{theme}`, and a create_seer-shaped spec, respectively) — the same
59
+ * `--flag '<json>'`/`--flag-file <path>` idiom `phantom create --composition` already uses,
60
+ * not decomposed field-by-field: they're bundles authored once and read back, not edited a
61
+ * field at a time from the CLI.
62
+ */
63
+ export const ARGS = {
64
+ list_compositions: {},
65
+ get_composition: {
66
+ composition_id: { positional: 0, label: "composition-id", required: true },
67
+ },
68
+ resolve_composition: {
69
+ key: { positional: 0, label: "composition-key", required: true },
70
+ },
71
+ create_composition: {
72
+ name: { flag: "name", required: true },
73
+ description: "description",
74
+ content: { value: (flags) => readJsonObjectFlag(flags, "content") },
75
+ phantom: { value: (flags) => readJsonObjectFlag(flags, "phantom") },
76
+ seer: { value: (flags) => readJsonObjectFlag(flags, "seer") },
77
+ },
78
+ update_composition: {
79
+ composition_id: { positional: 0, label: "composition-id", required: true },
80
+ name: "name",
81
+ description: "description",
82
+ content: { value: (flags) => readJsonObjectFlag(flags, "content") },
83
+ phantom: { value: (flags) => readJsonObjectFlag(flags, "phantom") },
84
+ seer: { value: (flags) => seerFromFlags(flags) },
85
+ },
86
+ delete_composition: {
87
+ composition_id: { positional: 0, label: "composition-id", required: true },
88
+ },
89
+ };
90
+
91
+ export async function cmdCompositions(ctx) {
92
+ const sub = ctx.positionals[0];
93
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
94
+ switch (sub) {
95
+ case "list":
96
+ case "ls":
97
+ return compositionsList(rest);
98
+ case "show":
99
+ case "get":
100
+ return compositionsShow(rest);
101
+ case "create":
102
+ case "new":
103
+ return compositionsCreate(rest);
104
+ case "update":
105
+ return compositionsUpdate(rest);
106
+ case "delete":
107
+ case "rm":
108
+ return compositionsDelete(rest);
109
+ case "resolve":
110
+ return compositionsResolve(rest);
111
+ default:
112
+ throw new CliError(
113
+ `Unknown compositions subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete, resolve`
114
+ );
115
+ }
116
+ }
117
+
118
+ async function compositionsList(ctx) {
119
+ const { client, flags, io } = ctx;
120
+ const result = await client.callTool("list_compositions", {});
121
+ printResult(io, flags, result, (r) =>
122
+ (r.compositions ?? []).length
123
+ ? (r.compositions ?? [])
124
+ .map((c) => ` ${c.key} ${c.name}${c.seer ? " (+seer)" : ""} — id ${c.id}`)
125
+ .join("\n")
126
+ : "No compositions yet."
127
+ );
128
+ return 0;
129
+ }
130
+
131
+ async function compositionsShow(ctx) {
132
+ const { client, positionals, flags, io } = ctx;
133
+ requirePositional(positionals, 0, "composition-id");
134
+ const result = await client.callTool("get_composition", argsFromFlags(ARGS.get_composition, flags, positionals));
135
+ printResult(io, flags, result);
136
+ return 0;
137
+ }
138
+
139
+ async function compositionsCreate(ctx) {
140
+ const { client, flags, io } = ctx;
141
+ const args = argsFromFlags(ARGS.create_composition, flags, []);
142
+ if (!args.name) throw new CliError("Missing required --name");
143
+ const result = await client.callTool("create_composition", args);
144
+ ok(ctx, `Composition created — key ${result.composition?.key}.`);
145
+ printResult(io, flags, result);
146
+ return 0;
147
+ }
148
+
149
+ async function compositionsUpdate(ctx) {
150
+ const { client, positionals, flags, io } = ctx;
151
+ requirePositional(positionals, 0, "composition-id");
152
+ const result = await client.callTool(
153
+ "update_composition",
154
+ argsFromFlags(ARGS.update_composition, flags, positionals)
155
+ );
156
+ ok(ctx, "Composition updated.");
157
+ printResult(io, flags, result);
158
+ return 0;
159
+ }
160
+
161
+ async function compositionsDelete(ctx) {
162
+ const { client, positionals, flags, io } = ctx;
163
+ requirePositional(positionals, 0, "composition-id");
164
+ if (!flags.yes) {
165
+ throw new CliError(
166
+ "Non-destructive to phantoms already built from it (their resolved_from_composition becomes a name pointing at nothing). Re-run with --yes to delete."
167
+ );
168
+ }
169
+ const result = await client.callTool(
170
+ "delete_composition",
171
+ argsFromFlags(ARGS.delete_composition, flags, positionals)
172
+ );
173
+ ok(ctx, "Composition deleted.");
174
+ printResult(io, flags, result);
175
+ return 0;
176
+ }
177
+
178
+ /**
179
+ * `compositions resolve <key>` — turn a composition into the sections `phantom create
180
+ * --composition` would merge in. Read-only, costs nothing. Preview a bundle, or build the
181
+ * sections yourself for a door that doesn't take `composition` directly yet (`collection
182
+ * config` / `seers create`).
183
+ */
184
+ async function compositionsResolve(ctx) {
185
+ const { client, positionals, flags, io } = ctx;
186
+ requirePositional(positionals, 0, "composition-key");
187
+ const result = await client.callTool(
188
+ "resolve_composition",
189
+ argsFromFlags(ARGS.resolve_composition, flags, positionals)
190
+ );
191
+ printResult(io, flags, result);
192
+ return 0;
193
+ }
@@ -0,0 +1,423 @@
1
+ // `offerings` — the org context hierarchy: org → offering → collection.
2
+ //
3
+ // An offering is anything the org would NAME when recommending it ("use Lettershred").
4
+ // Each layer stores only what it overrides: an omitted field inherits, and
5
+ // `--reset <field>` sends null (or an empty list), which puts the field back to
6
+ // inheriting. Stances, avoid topics and blocked mentions ADD across layers; priority
7
+ // topics REPLACE. Thin wrappers over the MCP tools, which call the same store functions
8
+ // the app's /api/offerings routes do.
9
+
10
+ import { CliError } from "../client.mjs";
11
+ import {
12
+ flagStr,
13
+ flagNum,
14
+ flagList,
15
+ requirePositional,
16
+ printResult,
17
+ argsFromFlags,
18
+ refuseUnknownFlags,
19
+ readTextInput,
20
+ ok,
21
+ } from "./shared.mjs";
22
+
23
+ // What `--reset <field>` sends for each field: null where the schema reads null as
24
+ // "inherit", an empty list where the field is additive (no additions = inherit).
25
+ const OFFERING_RESETS = {
26
+ name: null,
27
+ aliases: null,
28
+ url: null,
29
+ manifesto: null,
30
+ identity: null,
31
+ default_framing: null,
32
+ priority_topics: null,
33
+ brand_profile_slug: null,
34
+ stances: [],
35
+ avoid_topics: [],
36
+ blocked_mentions: [],
37
+ detached_fields: [],
38
+ };
39
+ const COLLECTION_RESETS = {
40
+ priority_topics: null,
41
+ default_framing: null,
42
+ stances: [],
43
+ avoid_topics: [],
44
+ blocked_mentions: [],
45
+ detached_fields: [],
46
+ };
47
+
48
+ function resets(flags, allowed) {
49
+ const fields = flagList(flags.reset);
50
+ const unknown = fields.filter((f) => !(f in allowed));
51
+ if (unknown.length) {
52
+ throw new CliError(`--reset takes one of: ${Object.keys(allowed).join(", ")} (got ${unknown.join(", ")})`);
53
+ }
54
+ return new Set(fields);
55
+ }
56
+
57
+ const list = (flag) => (flags) => {
58
+ const v = flagList(flags[flag]);
59
+ return v.length ? v : undefined;
60
+ };
61
+ const str = (flag) => (flags) => flagStr(flags[flag]);
62
+
63
+ // A field that `--reset <key>` can put back to inheriting. `read` is how the value is
64
+ // taken from flags otherwise.
65
+ function resettable(key, read, table) {
66
+ return {
67
+ value: (flags, positionals) => (flagList(flags.reset).includes(key) ? table[key] : read(flags, positionals)),
68
+ };
69
+ }
70
+
71
+ // identity is one field (nearest layer wins as a whole), so its four flags build one
72
+ // object — and sending any of them replaces the whole identity.
73
+ function readIdentity(flags) {
74
+ const out = {};
75
+ const oneLiner = flagStr(flags["one-liner"]);
76
+ if (oneLiner !== undefined) out.one_liner = oneLiner;
77
+ for (const [key, flag] of [
78
+ ["is", "is"],
79
+ ["is_not", "is-not"],
80
+ ["descriptors", "descriptor"],
81
+ ]) {
82
+ const v = flagList(flags[flag]);
83
+ if (v.length) out[key] = v;
84
+ }
85
+ return Object.keys(out).length ? out : undefined;
86
+ }
87
+
88
+ const offeringFields = (table) => ({
89
+ name: resettable("name", str("name"), table),
90
+ aliases: resettable("aliases", list("alias"), table),
91
+ url: resettable("url", str("link"), table),
92
+ manifesto: resettable("manifesto", (flags) => readTextInput(flags, "manifesto", "manifesto-file"), table),
93
+ identity: resettable("identity", readIdentity, table),
94
+ default_framing: resettable("default_framing", str("framing"), table),
95
+ stances: resettable("stances", list("stance"), table),
96
+ priority_topics: resettable("priority_topics", list("topic"), table),
97
+ avoid_topics: resettable("avoid_topics", list("avoid"), table),
98
+ blocked_mentions: resettable("blocked_mentions", list("block"), table),
99
+ brand_profile_slug: resettable("brand_profile_slug", str("brand-profile"), table),
100
+ // `--detach stances` = stop inheriting that field from the org (only this offering's
101
+ // own value, or none). `--reset detached_fields` = inherit everything again.
102
+ detached_fields: resettable("detached_fields", list("detach"), table),
103
+ position: { flag: "position", type: "num" },
104
+ });
105
+
106
+ // Flags the `value` sources above read (argsFromFlags can't list them for
107
+ // refuseUnknownFlags, since a computed source names no single flag).
108
+ const OFFERING_FIELD_FLAGS = [
109
+ "name",
110
+ "alias",
111
+ "link",
112
+ "manifesto",
113
+ "manifesto-file",
114
+ "one-liner",
115
+ "is",
116
+ "is-not",
117
+ "descriptor",
118
+ "framing",
119
+ "stance",
120
+ "topic",
121
+ "avoid",
122
+ "block",
123
+ "brand-profile",
124
+ "detach",
125
+ "reset",
126
+ ];
127
+
128
+ // `--primary <id>` plus any `--also <id>[:weight]`; `--none` = the org itself.
129
+ function readMentionSet(flags) {
130
+ if (flags.none === true) return [];
131
+ const primary = flagStr(flags.primary);
132
+ if (!primary) return undefined;
133
+ const also = flagList(flags.also).map((entry) => {
134
+ const [id, weight] = entry.split(":");
135
+ const out = { offering_id: id, is_primary: false };
136
+ if (weight !== undefined) out.weight = flagNum(weight);
137
+ return out;
138
+ });
139
+ return [{ offering_id: primary, is_primary: true }, ...also];
140
+ }
141
+
142
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
143
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
144
+ export const ARGS = {
145
+ list_offerings: {},
146
+ get_offering: {
147
+ offering_id: { positional: 0, label: "offering-id", required: true },
148
+ },
149
+ create_offering: {
150
+ slug: { positional: 0, label: "slug", required: true },
151
+ // Nothing to reset on create: an omitted field already inherits.
152
+ ...offeringFields({}),
153
+ },
154
+ update_offering: {
155
+ offering_id: { positional: 0, label: "offering-id", required: true },
156
+ slug: "slug",
157
+ ...offeringFields(OFFERING_RESETS),
158
+ },
159
+ delete_offering: {
160
+ offering_id: { positional: 0, label: "offering-id", required: true },
161
+ },
162
+ set_offering_competitor: {
163
+ offering_id: { positional: 0, label: "offering-id", required: true },
164
+ competitor_domain_id: "competitor",
165
+ add: { object: { domain: "add-domain", name: "add-name" } },
166
+ mention_stance: { flag: "stance", required: true, enum: ["inherit", "mention", "avoid"] },
167
+ },
168
+ get_collection_context: {
169
+ collection_id: { positional: 0, label: "collection-id", required: true },
170
+ },
171
+ set_collection_context: {
172
+ collection_id: { positional: 0, label: "collection-id", required: true },
173
+ stances: resettable("stances", list("stance"), COLLECTION_RESETS),
174
+ priority_topics: resettable("priority_topics", list("topic"), COLLECTION_RESETS),
175
+ avoid_topics: resettable("avoid_topics", list("avoid"), COLLECTION_RESETS),
176
+ blocked_mentions: resettable("blocked_mentions", list("block"), COLLECTION_RESETS),
177
+ default_framing: resettable("default_framing", str("framing"), COLLECTION_RESETS),
178
+ detached_fields: resettable("detached_fields", list("detach"), COLLECTION_RESETS),
179
+ },
180
+ set_collection_offerings: {
181
+ collection_id: { positional: 0, label: "collection-id", required: true },
182
+ offerings: { value: readMentionSet },
183
+ },
184
+ set_topic_offering: {
185
+ topic_id: { positional: 0, label: "topic-id", required: true },
186
+ offering_id: {
187
+ value: (flags) => {
188
+ const v = flagStr(flags.offering);
189
+ if (v === undefined) throw new CliError("Missing required --offering <offering-id|none>");
190
+ return v === "none" ? null : v;
191
+ },
192
+ },
193
+ },
194
+ };
195
+
196
+ const USAGE = `Usage: offerings <list|show|create|update|delete|competitor|collection|collection-context|set-collection|topic>
197
+ offerings list
198
+ offerings show <offering-id>
199
+ offerings create <slug> [--name <n>] [--alias <a>…] [--link <url>] [--manifesto <text>|--manifesto-file <path>]
200
+ [--one-liner <s>] [--is <fact>…] [--is-not <fact>…] [--descriptor <d>…] [--framing <f>]
201
+ [--stance <s>…] [--topic <t>…] [--avoid <t>…] [--block <name>…] [--brand-profile <slug>] [--position <n>]
202
+ offerings update <offering-id> [same flags] [--slug <s>] [--reset <field>…]
203
+ offerings delete <offering-id> --yes
204
+ offerings competitor <offering-id> (--competitor <competitor-domain-id> | --add-domain <d> --add-name <n>)
205
+ --stance inherit|mention|avoid
206
+ offerings collection <collection-id>
207
+ offerings collection-context <collection-id> [--stance <s>…] [--topic <t>…] [--avoid <t>…] [--block <name>…]
208
+ [--framing <f>] [--reset <field>…]
209
+ offerings set-collection <collection-id> (--primary <offering-id> [--also <offering-id>[:weight]…] | --none)
210
+ offerings topic <topic-id> --offering <offering-id|none>`;
211
+
212
+ export async function cmdOfferings(ctx) {
213
+ const sub = ctx.positionals[0];
214
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
215
+ switch (sub) {
216
+ case "list":
217
+ case "ls":
218
+ return offeringsList(rest);
219
+ case "show":
220
+ case "get":
221
+ return offeringsShow(rest);
222
+ case "create":
223
+ return offeringsCreate(rest);
224
+ case "update":
225
+ return offeringsUpdate(rest);
226
+ case "delete":
227
+ return offeringsDelete(rest);
228
+ case "competitor":
229
+ return offeringsCompetitor(rest);
230
+ case "collection":
231
+ return offeringsCollection(rest);
232
+ case "collection-context":
233
+ return offeringsCollectionContext(rest);
234
+ case "set-collection":
235
+ return offeringsSetCollection(rest);
236
+ case "topic":
237
+ return offeringsTopic(rest);
238
+ default:
239
+ throw new CliError(USAGE);
240
+ }
241
+ }
242
+
243
+ const label = (o) => o.name ?? `${o.slug} (org name)`;
244
+
245
+ function formatOffering(o) {
246
+ const lines = [`${o.id} ${o.slug} ${label(o)}`];
247
+ const set = (k, v) =>
248
+ v !== null && v !== undefined && (!Array.isArray(v) || v.length) && lines.push(` ${k}: ${v}`);
249
+ set("url", o.url);
250
+ set("framing", o.default_framing);
251
+ set("one-liner", o.identity?.one_liner);
252
+ set("stances (+org)", o.stances?.join("; "));
253
+ set("priority topics (replaces org)", o.priority_topics?.join("; "));
254
+ set("avoid (+org)", o.avoid_topics?.join("; "));
255
+ set("never mention (+org)", o.blocked_mentions?.join("; "));
256
+ return lines.join("\n");
257
+ }
258
+
259
+ async function offeringsList(ctx) {
260
+ const { client, flags, io } = ctx;
261
+ const result = await client.callTool("list_offerings", argsFromFlags(ARGS.list_offerings, flags));
262
+ printResult(io, flags, result, (r) => {
263
+ const all = r.offerings ?? [];
264
+ if (!all.length) return "No offerings — the org itself is the subject everywhere.";
265
+ return all.map(formatOffering).join("\n\n");
266
+ });
267
+ return 0;
268
+ }
269
+
270
+ async function offeringsShow(ctx) {
271
+ const { client, flags, positionals, io } = ctx;
272
+ const result = await client.callTool("get_offering", argsFromFlags(ARGS.get_offering, flags, positionals));
273
+ printResult(io, flags, result, (r) => {
274
+ const lines = [formatOffering(r.offering)];
275
+ for (const c of r.competitors ?? []) {
276
+ lines.push(
277
+ ` competitor ${c.name ?? c.competitor_domain_id}: ${c.mention_stance}${c.added_here ? " (only here)" : ""}`
278
+ );
279
+ }
280
+ return lines.join("\n");
281
+ });
282
+ return 0;
283
+ }
284
+
285
+ async function offeringsCreate(ctx) {
286
+ const { client, flags, positionals, io } = ctx;
287
+ refuseUnknownFlags(
288
+ ctx,
289
+ ARGS.create_offering,
290
+ OFFERING_FIELD_FLAGS.filter((f) => f !== "reset")
291
+ );
292
+ const result = await client.callTool("create_offering", argsFromFlags(ARGS.create_offering, flags, positionals));
293
+ ok(ctx, `Created ${result.offering?.slug} (${result.offering?.id}).`);
294
+ printResult(io, flags, result, (r) => formatOffering(r.offering));
295
+ return 0;
296
+ }
297
+
298
+ async function offeringsUpdate(ctx) {
299
+ const { client, flags, positionals, io } = ctx;
300
+ refuseUnknownFlags(ctx, ARGS.update_offering, OFFERING_FIELD_FLAGS);
301
+ resets(flags, OFFERING_RESETS);
302
+ const args = argsFromFlags(ARGS.update_offering, flags, positionals);
303
+ if (Object.keys(args).length === 1) throw new CliError("Nothing to update — pass a field flag or --reset <field>.");
304
+ const result = await client.callTool("update_offering", args);
305
+ ok(ctx, `Updated ${result.offering?.slug}.`);
306
+ printResult(io, flags, result, (r) => formatOffering(r.offering));
307
+ return 0;
308
+ }
309
+
310
+ async function offeringsDelete(ctx) {
311
+ const { client, flags, positionals, io } = ctx;
312
+ const id = requirePositional(positionals, 0, "offering-id");
313
+ if (!flags.yes) {
314
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} offerings delete ${id} --yes`);
315
+ return 1;
316
+ }
317
+ await client.callTool("delete_offering", argsFromFlags(ARGS.delete_offering, flags, positionals));
318
+ ok(ctx, "Offering deleted. Collections that published as it now fall back to the org.");
319
+ return 0;
320
+ }
321
+
322
+ async function offeringsCompetitor(ctx) {
323
+ const { client, flags, positionals, io } = ctx;
324
+ // `add`'s nested flags aren't visible to refuseUnknownFlags (it reads top-level flags only).
325
+ refuseUnknownFlags(ctx, ARGS.set_offering_competitor, ["add-domain", "add-name"]);
326
+ const result = await client.callTool(
327
+ "set_offering_competitor",
328
+ argsFromFlags(ARGS.set_offering_competitor, flags, positionals)
329
+ );
330
+ ok(ctx, `Competitor ${result.competitor_domain_id}: ${result.mention_stance} for this offering.`);
331
+ if (flags.json) printResult(io, flags, result);
332
+ return 0;
333
+ }
334
+
335
+ function formatContext(c) {
336
+ const r = c.resolved;
337
+ const from = (field) => ` [${r.origin?.[field] ?? "org"}]`;
338
+ const lines = [
339
+ r.offering
340
+ ? `Subject: ${r.offering.name ?? r.offering.slug} (offering ${r.offering.id})`
341
+ : "Subject: the org itself",
342
+ `name: ${r.name ?? "—"}${from("name")}`,
343
+ ];
344
+ if (r.default_framing) lines.push(`framing: ${r.default_framing}${from("default_framing")}`);
345
+ if (r.stances.length) {
346
+ lines.push("stances (most targeted first):");
347
+ for (const s of r.stances) lines.push(` - ${s.text} [${s.origin}]`);
348
+ }
349
+ if (r.priority_topics.length)
350
+ lines.push(`priority topics${from("priority_topics")}: ${r.priority_topics.join("; ")}`);
351
+ if (r.avoid_topics.length) lines.push(`avoid: ${r.avoid_topics.join("; ")}`);
352
+ if (r.blocked_mentions.length) lines.push(`never mention: ${r.blocked_mentions.join("; ")}`);
353
+ lines.push("", "Mention set:");
354
+ if (!c.offerings.length) lines.push(" (none — the org is the subject)");
355
+ for (const m of c.offerings) {
356
+ lines.push(
357
+ ` ${m.is_primary ? "*" : " "} ${m.offering_id} ${m.name ?? m.slug}${m.is_primary ? "" : ` weight ${m.weight}`}`
358
+ );
359
+ }
360
+ return lines.join("\n");
361
+ }
362
+
363
+ async function offeringsCollection(ctx) {
364
+ const { client, flags, positionals, io } = ctx;
365
+ const result = await client.callTool(
366
+ "get_collection_context",
367
+ argsFromFlags(ARGS.get_collection_context, flags, positionals)
368
+ );
369
+ printResult(io, flags, result, (r) => formatContext(r.context));
370
+ return 0;
371
+ }
372
+
373
+ async function offeringsCollectionContext(ctx) {
374
+ const { client, flags, positionals, io } = ctx;
375
+ refuseUnknownFlags(ctx, ARGS.set_collection_context, [
376
+ "stance",
377
+ "topic",
378
+ "avoid",
379
+ "block",
380
+ "framing",
381
+ "detach",
382
+ "reset",
383
+ ]);
384
+ resets(flags, COLLECTION_RESETS);
385
+ const args = argsFromFlags(ARGS.set_collection_context, flags, positionals);
386
+ if (Object.keys(args).length === 1) throw new CliError("Nothing to update — pass a field flag or --reset <field>.");
387
+ const result = await client.callTool("set_collection_context", args);
388
+ ok(ctx, "Collection context saved.");
389
+ printResult(io, flags, result, (r) => formatContext(r.context));
390
+ return 0;
391
+ }
392
+
393
+ async function offeringsSetCollection(ctx) {
394
+ const { client, flags, positionals, io } = ctx;
395
+ refuseUnknownFlags(ctx, ARGS.set_collection_offerings, ["primary", "also", "none"]);
396
+ const args = argsFromFlags(ARGS.set_collection_offerings, flags, positionals);
397
+ if (!args.offerings) {
398
+ throw new CliError(
399
+ "Pass --primary <offering-id> (plus any --also <offering-id>), or --none for the org itself."
400
+ );
401
+ }
402
+ const result = await client.callTool("set_collection_offerings", args);
403
+ ok(ctx, args.offerings.length ? "Mention set saved." : "Cleared — the org is this collection's subject again.");
404
+ if (flags.json) printResult(io, flags, result);
405
+ return 0;
406
+ }
407
+
408
+ async function offeringsTopic(ctx) {
409
+ const { client, flags, positionals } = ctx;
410
+ refuseUnknownFlags(ctx, ARGS.set_topic_offering, ["offering"]);
411
+ const result = await client.callTool(
412
+ "set_topic_offering",
413
+ argsFromFlags(ARGS.set_topic_offering, flags, positionals)
414
+ );
415
+ ok(
416
+ ctx,
417
+ result.mention_offering_id
418
+ ? `Topic now promotes ${result.mention_offering_id}.`
419
+ : "Override cleared — the topic falls back to the collection's primary offering."
420
+ );
421
+ if (flags.json) printResult(ctx.io, flags, result);
422
+ return 0;
423
+ }
@@ -141,7 +141,14 @@ async function publishedShow(ctx) {
141
141
  const id = flagStr(flags.id);
142
142
  const slug = flagStr(flags.slug);
143
143
  if (id === undefined && slug === undefined) throw new CliError("Pass --id or --slug.");
144
- const args = compact({ article_id: id, slug, format: flagStr(flags.format) });
144
+ // --collection scopes a slug lookup to one collection (slugs are only unique per
145
+ // collection once an org runs several blogs).
146
+ const args = compact({
147
+ article_id: id,
148
+ slug,
149
+ collection_id: flagStr(flags.collection),
150
+ format: flagStr(flags.format),
151
+ });
145
152
  const result = await client.callTool("get_published", args);
146
153
  printResult(io, flags, result);
147
154
  return 0;
package/lib/commands.mjs CHANGED
@@ -23,6 +23,7 @@ export * from "./commands/insights.mjs";
23
23
  export * from "./commands/covers.mjs";
24
24
  export * from "./commands/arena.mjs";
25
25
  export * from "./commands/templates.mjs";
26
+ export * from "./commands/compositions.mjs";
26
27
  export * from "./commands/autopen.mjs";
27
28
  export * from "./commands/kernel.mjs";
28
29
  export * from "./commands/lede.mjs";
@@ -31,5 +32,6 @@ export * from "./commands/onboard.mjs";
31
32
  export * from "./commands/seers.mjs";
32
33
  export * from "./commands/shredder.mjs";
33
34
  export * from "./commands/landings.mjs";
35
+ export * from "./commands/offerings.mjs";
34
36
  export * from "./commands/admin.mjs";
35
37
  export * from "./commands/feedback.mjs";
package/lib/documents.mjs CHANGED
@@ -59,6 +59,8 @@ export const DOCUMENT_FLAG_PAIRS = [
59
59
  ["body", "file"],
60
60
  ["brief", "brief-file"],
61
61
  ["text", "text-file"],
62
+ // An offering's (or the company's) manifesto is prose — a PDF or a .docx is fair game.
63
+ ["manifesto", "manifesto-file"],
62
64
  ];
63
65
 
64
66
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",