@letterstory/cli 0.76.1 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -630,6 +630,20 @@ letterstory enhance offer <collection-id> [--accept] # our newer versio
630
630
  letterstory autopen run --article <id> --skip finish,metadata # leave passes out of one run
631
631
  ```
632
632
 
633
+ ## Org config
634
+
635
+ An org's whole configuration as one JSON bundle (`export_org_config`; docs/architecture/org-config.md): settings, primitives and data, keyed by name, live primitive files, no runs or credentials.
636
+
637
+ ```bash
638
+ letterstory org export > acme.json # the bundle — the same document MCP and the API return
639
+ letterstory org export --sections primitives.kernels --sections data.collections
640
+ letterstory org export --split acme/ # one file per record, for keeping config in git
641
+ letterstory org join acme/ > acme.json # rebuild the identical bundle from the directory
642
+ letterstory org diff acme.json # what applying the bundle would change: + add, ~ change, - only in the org
643
+ letterstory org apply acme.json --dry-run # what apply would write; then drop --dry-run to apply
644
+ letterstory org apply acme.json --prune --yes # also delete records the bundle lacks
645
+ ```
646
+
633
647
  ## Seers — event-driven signals
634
648
 
635
649
  A **seer** watches an external source (a GitHub repo, a Notion workspace, a spec URL, a
package/lib/cli.mjs CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  cmdKernel,
38
38
  cmdOnboard,
39
39
  cmdSeers,
40
+ cmdOrg,
40
41
  cmdTemplates,
41
42
  cmdCompositions,
42
43
  cmdShredder,
@@ -56,7 +57,7 @@ import {
56
57
  } from "./commands.mjs";
57
58
 
58
59
  // Keep in sync with cli/package.json.
59
- export const VERSION = "0.76.1";
60
+ export const VERSION = "0.81.0";
60
61
 
61
62
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
62
63
  // can't accidentally swallow the id as --json's value.
@@ -368,12 +369,13 @@ Offerings (context layered org → offering → collection; a layer sets only wh
368
369
 
369
370
  Ranking targets (what the org wants to be THE answer for — company-wide or on one offering):
370
371
  targets list [--offering <offering-id|company>] Targets grouped by scope, each with its active cap and usage
371
- targets add <phrase> [--framing <f>] [--offering <offering-id|company>]
372
+ targets add <phrase> [--framing <f>] [--offering <offering-id|company>] [--priority high|normal|low]
372
373
  Lands active while its scope has room, inactive after —
373
374
  adding never fails on the cap
374
375
  targets update <target-id> [--phrase <p>] [--framing <f> | --clear-framing] [--offering <offering-id|company>]
375
- [--position <n>] [--state active|inactive|achieved|retired]
376
- Moving an ACTIVE target needs room in the new scope
376
+ [--position <n>] [--state active|inactive|achieved|retired] [--priority high|normal|low|default]
377
+ Moving an ACTIVE target needs room in the new scope;
378
+ --priority: how much it counts in share of voice
377
379
  targets activate | deactivate <target-id> Switch a target on/off; activating is refused at the cap
378
380
  targets cap <offering-id|company> <n|default> Active targets allowed in that scope (preset 5; default =
379
381
  inherit the company's cap / back to the preset)
@@ -381,7 +383,7 @@ Ranking targets (what the org wants to be THE answer for — company-wide or on
381
383
 
382
384
  Arenas (adversarial clusters of research agents -> a sourced, fact-checked outline):
383
385
  arena run --topic <text> [--source <url> …] [--must-include <text> …] [--depth barebones|fleshed_out]
384
- [--gate] [--no-verify] [--provider native|exa|youcom|firecrawl] [--mention off|subtle|woven]
386
+ [--gate] [--no-verify] [--provider native|exa|youcom|context-dev] [--mention off|subtle|woven]
385
387
  [--context-file ctx.json] [--block <name> …] [--block-domain <domain> …]
386
388
  [--arena <arena-id>] [--article <uuid>] [--no-wait]
387
389
  Start a run and follow it. --article makes
@@ -715,6 +717,11 @@ Seers (event-driven signals -> drafts):
715
717
  seers export [--file <path>] Dump the org's seers as a manifest
716
718
  seers apply --file <path> [--dry-run] Idempotent, name-keyed upsert from a manifest
717
719
  (never deletes; --dry-run prints the plan)
720
+ org export [--sections <s>…] [--file <path>] The org's whole config as one JSON bundle
721
+ org export --split <dir> …as one file per record (for keeping config in git)
722
+ org join <dir> [--file <path>] Rebuild the bundle from a --split directory
723
+ org diff <file|dir> What applying a bundle would change (reads only)
724
+ org apply <file|dir> [--dry-run] [--prune --yes] Make the org match a bundle (adds/updates; --prune deletes)
718
725
 
719
726
  Content templates (compose structures seers bind via config.template_key):
720
727
  templates list Custom + built-in catalog
@@ -1011,6 +1018,7 @@ const CLIENT_COMMANDS = {
1011
1018
  kernel: cmdKernel,
1012
1019
  "phantom-onboard": cmdOnboard,
1013
1020
  seers: cmdSeers,
1021
+ org: cmdOrg,
1014
1022
  templates: cmdTemplates,
1015
1023
  compositions: cmdCompositions,
1016
1024
  shredder: cmdShredder,
@@ -64,7 +64,7 @@ function readJsonObjectFlag(flags, name) {
64
64
  const RUN_OPTIONS = {
65
65
  depth: { flag: "depth", enum: ["barebones", "fleshed_out"] },
66
66
  direction_gate: { flag: "gate", type: "bool" },
67
- provider: { flag: "provider", enum: ["native", "exa", "youcom", "firecrawl"] },
67
+ provider: { flag: "provider", enum: ["native", "exa", "youcom", "context-dev"] },
68
68
  // --no-verify turns the fact-check off; nothing sent otherwise (the default is on).
69
69
  verify: { value: (flags) => (flags["no-verify"] === true ? false : undefined) },
70
70
  mention_degree: { flag: "mention", enum: ["off", "subtle", "woven"] },
@@ -0,0 +1,222 @@
1
+ // `org` — the organization's whole configuration as one JSON bundle
2
+ // (docs/architecture/org-config.md). `org export` prints the same document every door
3
+ // returns (MCP `export_org_config`, REST, Settings' download); `--split <dir>` lays it out
4
+ // as one file per record for keeping config in git, and `org join <dir>` rebuilds the
5
+ // bundle from that directory, byte-for-byte the same JSON. `org diff <file|dir>` plans a
6
+ // bundle against the live org (MCP `diff_org_config`) and changes nothing; `org apply`
7
+ // executes that plan (`apply_org_config`), additive unless `--prune`.
8
+
9
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { CliError } from "../client.mjs";
12
+ import { argsFromFlags, flagBool, flagStr, ok, printResult, refuseUnknownFlags, requirePositional } from "./shared.mjs";
13
+
14
+ export const ARGS = {
15
+ export_org_config: {
16
+ sections: { flag: "sections", type: "list" },
17
+ },
18
+ diff_org_config: {
19
+ config: { value: (_flags, positionals) => readBundle(requirePositional(positionals, 0, "file|dir")) },
20
+ },
21
+ apply_org_config: {
22
+ config: { value: (_flags, positionals) => readBundle(requirePositional(positionals, 0, "file|dir")) },
23
+ dry_run: { flag: "dry-run", type: "bool" },
24
+ prune: { flag: "prune", type: "bool" },
25
+ },
26
+ };
27
+
28
+ export async function cmdOrg(ctx) {
29
+ const sub = ctx.positionals[0];
30
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
31
+ switch (sub) {
32
+ case "export":
33
+ return orgExport(rest);
34
+ case "join":
35
+ return orgJoin(rest);
36
+ case "diff":
37
+ return orgDiff(rest);
38
+ case "apply":
39
+ return orgApply(rest);
40
+ default:
41
+ throw new CliError(`Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply`);
42
+ }
43
+ }
44
+
45
+ const ROOT_FILE = "organization.json";
46
+
47
+ function slug(name) {
48
+ return (
49
+ String(name)
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, "-")
52
+ .replace(/^-+|-+$/g, "") || "unnamed"
53
+ );
54
+ }
55
+
56
+ function writeJson(path, value) {
57
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
58
+ }
59
+
60
+ /** One file per record under `<group>/<section>/`, every other value in its own file. */
61
+ export function splitBundle(bundle, dir) {
62
+ const { spec, ...root } = bundle;
63
+ mkdirSync(dir, { recursive: true });
64
+ writeJson(join(dir, ROOT_FILE), root);
65
+ let files = 1;
66
+ if (spec.settings) {
67
+ writeJson(join(dir, "settings.json"), spec.settings);
68
+ files++;
69
+ }
70
+ for (const group of ["primitives", "data", "tools"]) {
71
+ for (const [section, value] of Object.entries(spec[group] ?? {})) {
72
+ if (Array.isArray(value)) {
73
+ const sub = join(dir, group, section);
74
+ mkdirSync(sub, { recursive: true });
75
+ const used = new Map();
76
+ for (const record of value) {
77
+ const base = slug(record.name);
78
+ const n = (used.get(base) ?? 0) + 1;
79
+ used.set(base, n);
80
+ writeJson(join(sub, `${n === 1 ? base : `${base}-${n}`}.json`), record);
81
+ files++;
82
+ }
83
+ } else {
84
+ mkdirSync(join(dir, group), { recursive: true });
85
+ writeJson(join(dir, group, `${section}.json`), value);
86
+ files++;
87
+ }
88
+ }
89
+ }
90
+ return files;
91
+ }
92
+
93
+ export function joinBundle(dir) {
94
+ const rootPath = join(dir, ROOT_FILE);
95
+ if (!existsSync(rootPath))
96
+ throw new CliError(`${dir} has no ${ROOT_FILE} — produce one with \`org export --split\``);
97
+ const read = (p) => JSON.parse(readFileSync(p, "utf8"));
98
+ const root = read(rootPath);
99
+ const spec = {};
100
+ if (existsSync(join(dir, "settings.json"))) spec.settings = read(join(dir, "settings.json"));
101
+ for (const group of ["primitives", "data", "tools"]) {
102
+ spec[group] = {};
103
+ const gdir = join(dir, group);
104
+ if (!existsSync(gdir)) continue;
105
+ for (const entry of readdirSync(gdir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
106
+ if (entry.isDirectory()) {
107
+ spec[group][entry.name] = readdirSync(join(gdir, entry.name))
108
+ .filter((f) => f.endsWith(".json"))
109
+ .map((f) => read(join(gdir, entry.name, f)))
110
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
111
+ } else if (entry.name.endsWith(".json")) {
112
+ spec[group][entry.name.slice(0, -5)] = read(join(gdir, entry.name));
113
+ }
114
+ }
115
+ }
116
+ return { ...root, spec };
117
+ }
118
+
119
+ async function orgExport(ctx) {
120
+ const { client, flags, io } = ctx;
121
+ refuseUnknownFlags(ctx, ARGS, ["split", "file"]);
122
+ const { config } = await client.callTool("export_org_config", argsFromFlags(ARGS.export_org_config, flags));
123
+ const split = flagStr(flags.split);
124
+ const file = flagStr(flags.file);
125
+ if (split) {
126
+ const n = splitBundle(config, split);
127
+ ok(ctx, `Exported ${config.metadata.org} to ${split}/ (${n} files).`);
128
+ } else if (file) {
129
+ writeJson(file, config);
130
+ ok(ctx, `Exported ${config.metadata.org} to ${file}.`);
131
+ } else {
132
+ io.log(JSON.stringify(config, null, 2));
133
+ }
134
+ for (const w of config.metadata.warnings ?? []) io.error?.(`warning: ${w}`);
135
+ return 0;
136
+ }
137
+
138
+ async function orgJoin(ctx) {
139
+ const { flags, io } = ctx;
140
+ const dir = requirePositional(ctx.positionals, 0, "dir");
141
+ const bundle = joinBundle(dir);
142
+ const file = flagStr(flags.file);
143
+ if (file) {
144
+ writeJson(file, bundle);
145
+ ok(ctx, `Joined ${dir}/ into ${file}.`);
146
+ } else io.log(JSON.stringify(bundle, null, 2));
147
+ return 0;
148
+ }
149
+
150
+ /** A bundle file, or a `--split` directory joined back into one. */
151
+ export function readBundle(path) {
152
+ if (!existsSync(path)) throw new CliError(`${path} does not exist`);
153
+ if (statSync(path).isDirectory()) return joinBundle(path);
154
+ try {
155
+ return JSON.parse(readFileSync(path, "utf8"));
156
+ } catch (e) {
157
+ throw new CliError(`${path} is not JSON: ${e.message}`);
158
+ }
159
+ }
160
+
161
+ const SYMBOL = { add: "+", change: "~", remove: "-" };
162
+
163
+ function show(v) {
164
+ const s = JSON.stringify(v) ?? "null";
165
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
166
+ }
167
+
168
+ export function formatPlan(plan) {
169
+ const lines = [];
170
+ for (const c of plan.changes) {
171
+ lines.push(
172
+ `${SYMBOL[c.op]} ${c.section}/${c.name}${c.op === "remove" ? " (in the org only — kept unless pruning)" : ""}`
173
+ );
174
+ for (const f of c.fields) lines.push(` ${f.path || "(value)"}: ${show(f.from)} → ${show(f.to)}`);
175
+ }
176
+ const s = plan.summary;
177
+ if (plan.changes.length) {
178
+ lines.push(
179
+ "",
180
+ `${plan.org}: ${s.add} to add, ${s.change} to change, ${s.remove} only in the org, ${s.unchanged} unchanged.`
181
+ );
182
+ } else lines.push(`${plan.org}: no changes — the live config matches the bundle (${s.unchanged} unchanged).`);
183
+ for (const w of plan.warnings) lines.push(`warning: ${w}`);
184
+ return lines.join("\n");
185
+ }
186
+
187
+ async function orgDiff(ctx) {
188
+ const { client, flags, io } = ctx;
189
+ refuseUnknownFlags(ctx, ARGS);
190
+ const { plan } = await client.callTool(
191
+ "diff_org_config",
192
+ argsFromFlags(ARGS.diff_org_config, flags, ctx.positionals)
193
+ );
194
+ printResult(io, flags, plan, formatPlan);
195
+ return 0;
196
+ }
197
+
198
+ export function formatApply(result) {
199
+ const lines = [result.dryRun ? "Dry run — nothing written. Would apply:" : "Applied:"];
200
+ for (const a of result.applied)
201
+ lines.push(` ${SYMBOL[a.op]} ${a.section}/${a.name}${a.detail ? ` — ${a.detail}` : ""}`);
202
+ if (!result.applied.length) lines.push(" (nothing)");
203
+ if (result.skipped.length) lines.push("Skipped:");
204
+ for (const a of result.skipped) lines.push(` ${SYMBOL[a.op]} ${a.section}/${a.name} — ${a.reason}`);
205
+ if (result.failed.length) lines.push("Failed:");
206
+ for (const a of result.failed) lines.push(` ${SYMBOL[a.op]} ${a.section}/${a.name} — ${a.error}`);
207
+ for (const w of result.plan.warnings) lines.push(`warning: ${w}`);
208
+ return lines.join("\n");
209
+ }
210
+
211
+ async function orgApply(ctx) {
212
+ const { client, flags, io } = ctx;
213
+ refuseUnknownFlags(ctx, ARGS, ["yes"]);
214
+ if (flagBool(flags.prune) && !flagBool(flags["dry-run"]) && !flagBool(flags.yes))
215
+ throw new CliError("--prune deletes records missing from the bundle. Review with --dry-run, then add --yes.");
216
+ const { result } = await client.callTool(
217
+ "apply_org_config",
218
+ argsFromFlags(ARGS.apply_org_config, flags, ctx.positionals)
219
+ );
220
+ printResult(io, flags, result, formatApply);
221
+ return result.failed.length ? 1 : 0;
222
+ }
@@ -11,6 +11,7 @@ import { CliError } from "../client.mjs";
11
11
  import { flagStr, flagNum, printResult, argsFromFlags, refuseUnknownFlags, ok } from "./shared.mjs";
12
12
 
13
13
  const STATUSES = ["active", "inactive", "achieved", "retired"];
14
+ const PRIORITIES = ["high", "normal", "low"];
14
15
  const COMPANY = "company";
15
16
 
16
17
  // `--offering <offering-id|company>`: company = the company layer (null). Absent = not sent.
@@ -24,6 +25,20 @@ function readOffering(flags) {
24
25
  }
25
26
  const offering = { value: readOffering };
26
27
 
28
+ // `--priority high|normal|low|default`: how much the target's questions count in share of
29
+ // voice (high = 2 votes, normal = 1, low = ½); default = back to the question-type default.
30
+ function readPriority(flags) {
31
+ const v = flagStr(flags.priority);
32
+ if (v === undefined) {
33
+ if (flags.priority === true) throw new CliError("--priority takes high, normal, low or default");
34
+ return undefined;
35
+ }
36
+ if (v === "default") return null;
37
+ if (!PRIORITIES.includes(v)) throw new CliError("--priority must be one of: high, normal, low, default");
38
+ return v;
39
+ }
40
+ const priority = { value: readPriority };
41
+
27
42
  // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
28
43
  // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
29
44
  export const ARGS = {
@@ -39,6 +54,7 @@ export const ARGS = {
39
54
  },
40
55
  mention_framing: "framing",
41
56
  offering_id: offering,
57
+ priority,
42
58
  },
43
59
  update_ranking_target: {
44
60
  target_id: { positional: 0, label: "target-id", required: true },
@@ -50,6 +66,7 @@ export const ARGS = {
50
66
  status: { flag: "state", enum: STATUSES },
51
67
  offering_id: offering,
52
68
  position: { flag: "position", type: "num" },
69
+ priority,
53
70
  },
54
71
  set_ranking_target_cap: {
55
72
  offering_id: {
@@ -73,9 +90,9 @@ export const ARGS = {
73
90
 
74
91
  const USAGE = `Usage: targets <list|add|update|activate|deactivate|cap|suggest>
75
92
  targets list [--offering <offering-id|company>]
76
- targets add <phrase> [--framing <f>] [--offering <offering-id|company>]
93
+ targets add <phrase> [--framing <f>] [--offering <offering-id|company>] [--priority high|normal|low]
77
94
  targets update <target-id> [--phrase <p>] [--framing <f> | --clear-framing] [--offering <offering-id|company>]
78
- [--position <n>] [--state active|inactive|achieved|retired]
95
+ [--position <n>] [--state active|inactive|achieved|retired] [--priority high|normal|low|default]
79
96
  targets activate <target-id>
80
97
  targets deactivate <target-id>
81
98
  targets cap <offering-id|company> <n|default>
@@ -114,7 +131,8 @@ function formatScope(s) {
114
131
 
115
132
  function formatTarget(t) {
116
133
  const framing = t.mention_framing ? ` — as ${t.mention_framing}` : "";
117
- return ` ${t.id} [${t.status}] ${t.phrase}${framing}`;
134
+ const prio = t.priority ? ` (${t.priority} priority)` : "";
135
+ return ` ${t.id} [${t.status}] ${t.phrase}${framing}${prio}`;
118
136
  }
119
137
 
120
138
  // Targets grouped under their scope, each headed by its cap and usage. A target on an
@@ -151,7 +169,7 @@ async function targetsList(ctx) {
151
169
 
152
170
  async function targetsAdd(ctx) {
153
171
  const { client, flags, positionals, io } = ctx;
154
- refuseUnknownFlags(ctx, ARGS.add_ranking_target, ["offering"]);
172
+ refuseUnknownFlags(ctx, ARGS.add_ranking_target, ["offering", "priority"]);
155
173
  const result = await client.callTool(
156
174
  "add_ranking_target",
157
175
  argsFromFlags(ARGS.add_ranking_target, flags, positionals)
@@ -172,7 +190,7 @@ async function update(ctx, flags) {
172
190
  const args = argsFromFlags(ARGS.update_ranking_target, flags, positionals);
173
191
  if (Object.keys(args).length === 1) {
174
192
  throw new CliError(
175
- "Nothing to update — pass --phrase, --framing, --clear-framing, --offering, --position or --state."
193
+ "Nothing to update — pass --phrase, --framing, --clear-framing, --offering, --position, --state or --priority."
176
194
  );
177
195
  }
178
196
  const result = await client.callTool("update_ranking_target", args);
@@ -182,7 +200,7 @@ async function update(ctx, flags) {
182
200
  }
183
201
 
184
202
  async function targetsUpdate(ctx) {
185
- refuseUnknownFlags(ctx, ARGS.update_ranking_target, ["framing", "clear-framing", "offering"]);
203
+ refuseUnknownFlags(ctx, ARGS.update_ranking_target, ["framing", "clear-framing", "offering", "priority"]);
186
204
  return update(ctx, ctx.flags);
187
205
  }
188
206
 
package/lib/commands.mjs CHANGED
@@ -31,6 +31,7 @@ export * from "./commands/lede.mjs";
31
31
 
32
32
  export * from "./commands/onboard.mjs";
33
33
  export * from "./commands/seers.mjs";
34
+ export * from "./commands/org.mjs";
34
35
  export * from "./commands/shredder.mjs";
35
36
  export * from "./commands/enhance.mjs";
36
37
  export * from "./commands/landings.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.76.1",
3
+ "version": "0.81.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",