@letterstory/cli 0.26.0 → 0.27.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/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.26.0";
47
+ export const VERSION = "0.27.1";
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.
@@ -80,6 +80,21 @@ const RUN_OPTIONS = {
80
80
  * shared.mjs and `scripts/parity-check.ts`, which compares these keys against each tool's
81
81
  * published input schema.
82
82
  */
83
+ /** `--var Name=Value` (repeatable) → `{ Name: "Value" }` for a template's {Variables}. */
84
+ function templateVariablesFromFlags(flags) {
85
+ const raw = flags.var;
86
+ if (raw === undefined) return undefined;
87
+ const list = Array.isArray(raw) ? raw : [raw];
88
+ const out = {};
89
+ for (const entry of list) {
90
+ const text = String(entry);
91
+ const eq = text.indexOf("=");
92
+ if (eq <= 0) throw new CliError(`--var must be Name=Value (got "${text}").`);
93
+ out[text.slice(0, eq).trim()] = text.slice(eq + 1).trim();
94
+ }
95
+ return Object.keys(out).length ? out : undefined;
96
+ }
97
+
83
98
  export const ARGS = {
84
99
  run_arena: {
85
100
  arena_id: { flag: "arena" },
@@ -87,6 +102,19 @@ export const ARGS = {
87
102
  source_urls: { flag: "source", type: "list" },
88
103
  must_include: { flag: "must-include", type: "list" },
89
104
  shape: { value: (flags) => readJsonObjectFlag(flags, "shape") },
105
+ // A TEMPLATE INSTEAD OF LISTING THE SHAPE BY HAND. Resolving one fills both
106
+ // `must_include` and `shape`; the `constraints` it returns come back on the run, and
107
+ // passing them to `kernel write --template-constraints` is what stops the same template
108
+ // being resolved twice into two different sets of section labels.
109
+ template: {
110
+ object: {
111
+ key: { flag: "template" },
112
+ blocks: { value: (flags) => readJsonFileFlag(flags, "template-blocks") },
113
+ title: { flag: "title" },
114
+ variables: { value: (flags) => templateVariablesFromFlags(flags) },
115
+ constraints: { value: (flags) => readJsonFileFlag(flags, "template-constraints") },
116
+ },
117
+ },
90
118
  existing_content: { value: (flags) => flagStr(flags["existing-content"]) },
91
119
  article_id: { flag: "article" },
92
120
  ...RUN_OPTIONS,
@@ -69,6 +69,21 @@ function readTextFileFlag(flags, flag) {
69
69
  * The rest of this module predates the field-level ratchet and is covered by the
70
70
  * frozen UNMAPPED_TOOLS list. New commands declare; that list only shrinks.
71
71
  */
72
+ /** `--var Name=Value` (repeatable) → `{ Name: "Value" }` for a template's {Variables}. */
73
+ function variablesFromFlags(flags) {
74
+ const raw = flags.var;
75
+ if (raw === undefined) return undefined;
76
+ const list = Array.isArray(raw) ? raw : [raw];
77
+ const out = {};
78
+ for (const entry of list) {
79
+ const text = String(entry);
80
+ const eq = text.indexOf("=");
81
+ if (eq <= 0) throw new CliError(`--var must be Name=Value (got "${text}").`);
82
+ out[text.slice(0, eq).trim()] = text.slice(eq + 1).trim();
83
+ }
84
+ return Object.keys(out).length ? out : undefined;
85
+ }
86
+
72
87
  export const ARGS = {
73
88
  // The article-bound door: submit a kernel run INTO an article you already have.
74
89
  submit_kernel_run: {
@@ -77,6 +92,12 @@ export const ARGS = {
77
92
  // A brief overrides the article's own content as the instruction; --brief-file reads
78
93
  // it from a path. Omit both to write from the article as it stands.
79
94
  brief: { value: (flags) => flagStr(flags.brief) ?? readBodyInput({ file: flagStr(flags["brief-file"]) }) },
95
+ // How to read --brief: auto (default) treats a short topic as instruction and a pasted
96
+ // draft/article as source material to decompose first. Same flag name as run_kernel's.
97
+ content_kind: {
98
+ flag: "read-as",
99
+ enum: ["auto", "instruction", "document", "article", "outline", "research", "notes"],
100
+ },
80
101
  },
81
102
  // The standalone door. Two subcommands share this one tool: `write` and `polish` both
82
103
  // send `input`, `polish` forcing content_kind to "document" — the map is one, so a field
@@ -109,6 +130,22 @@ export const ARGS = {
109
130
  // field a template resolves to, sent by a caller who has their own.
110
131
  skeleton: { value: (flags) => readJsonFileFlag(flags, "skeleton-file") },
111
132
  brief_json: { value: (flags) => readJsonFileFlag(flags, "brief-json-file") },
133
+ // A TEMPLATE INSTEAD OF HAND-BUILDING THOSE. `--template <key>` resolves one of your
134
+ // templates against the material you sent and fills `skeleton`, `target_word_count` and
135
+ // `brief_json` for this call — and the finished run comes back ASSEMBLED rather than as
136
+ // the scaffolding the kernel writes. `--template-constraints <path>` passes back the
137
+ // answer `templates resolve` already gave, so the same template is not resolved twice
138
+ // (which would relabel the sections the outline planned). `--var Name=Value` fills a
139
+ // {Variable} the template declares.
140
+ template: {
141
+ object: {
142
+ key: { flag: "template" },
143
+ blocks: { value: (flags) => readJsonFileFlag(flags, "template-blocks") },
144
+ title: { flag: "title" },
145
+ variables: { value: (flags) => variablesFromFlags(flags) },
146
+ constraints: { value: (flags) => readJsonFileFlag(flags, "template-constraints") },
147
+ },
148
+ },
112
149
  max_iterations: { flag: "max-iterations", type: "num" },
113
150
  kernel_version: { flag: "kernel-version", type: "num" },
114
151
  // In-loop shred tuning. Only sent when a flag is set; ignored when no shred runs.
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { readFileSync, writeFileSync } from "node:fs";
16
16
  import { CliError } from "../client.mjs";
17
- import { flagStr, flagBool, requirePositional, printResult, ok, argsFromFlags } from "./shared.mjs";
17
+ import { flagStr, flagBool, printResult, ok, argsFromFlags } from "./shared.mjs";
18
18
 
19
19
  // A single-value flag the API clears with an explicit `null` (same idiom as
20
20
  // strategy.mjs's clearableList): `--clear-collection` sends null, `--collection <uuid>`
@@ -721,123 +721,3 @@ async function seersApply(ctx) {
721
721
  }
722
722
  return 0;
723
723
  }
724
-
725
- // --- Templates ---------------------------------------------------------------------
726
-
727
- export async function cmdTemplates(ctx) {
728
- const sub = ctx.positionals[0];
729
- const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
730
- switch (sub) {
731
- case "list":
732
- case "ls":
733
- return templatesList(rest);
734
- case "show":
735
- case "get":
736
- return templatesShow(rest);
737
- case "create":
738
- case "new":
739
- return templatesCreate(rest);
740
- case "update":
741
- return templatesUpdate(rest);
742
- case "delete":
743
- case "rm":
744
- return templatesDelete(rest);
745
- default:
746
- throw new CliError(
747
- `Unknown templates subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete`
748
- );
749
- }
750
- }
751
-
752
- async function templatesList(ctx) {
753
- const { client, flags, io } = ctx;
754
- const result = await client.callTool("list_templates", {});
755
- printResult(io, flags, result, (r) => {
756
- const custom = (r.templates ?? []).map((t) => ` ${t.key} ${t.name} — ${t.angle} (id ${t.id})`);
757
- const builtIn = (r.built_ins ?? []).map((t) => ` ${t.key} ${t.name}`);
758
- return [
759
- custom.length ? "Custom templates:" : "No custom templates yet.",
760
- ...custom,
761
- "",
762
- "Built-in templates (read-only):",
763
- ...builtIn,
764
- ].join("\n");
765
- });
766
- return 0;
767
- }
768
-
769
- async function templatesShow(ctx) {
770
- const { client, positionals, flags, io } = ctx;
771
- const templateId = requirePositional(positionals, 0, "template-id");
772
- const result = await client.callTool("get_template", { template_id: templateId });
773
- printResult(io, flags, result);
774
- return 0;
775
- }
776
-
777
- function templateBlocksFromFlags(flags) {
778
- const inline = flagStr(flags.blocks);
779
- const file = flagStr(flags["blocks-file"]);
780
- if (inline === undefined && file === undefined) return undefined;
781
- const raw = inline !== undefined ? inline : readFileOrThrow(file, "--blocks-file");
782
- let parsed;
783
- try {
784
- parsed = JSON.parse(raw);
785
- } catch (err) {
786
- throw new CliError(`--blocks is not valid JSON: ${err.message}`);
787
- }
788
- if (!Array.isArray(parsed)) throw new CliError("--blocks must be a JSON array of block objects");
789
- return parsed;
790
- }
791
-
792
- async function templatesCreate(ctx) {
793
- const { client, flags, io } = ctx;
794
- const name = flagStr(flags.name);
795
- const angle = flagStr(flags.angle);
796
- const blocks = templateBlocksFromFlags(flags);
797
- if (!name) throw new CliError("Missing required --name");
798
- // `--angle` is OPTIONAL, and omitting it is the better answer. It was required here while
799
- // the server schema had already made it optional and the app had dropped the field: a
800
- // template constrains a post's SHAPE and never says what it is about, so an angle belongs
801
- // to the post — which already has one, from its research direction.
802
- if (!blocks) throw new CliError("Missing required --blocks '<json>' or --blocks-file <path>");
803
- const result = await client.callTool("create_template", { name, blocks, ...(angle ? { angle } : {}) });
804
- ok(ctx, `Template created — key ${result.template?.key}.`);
805
- printResult(io, flags, result);
806
- return 0;
807
- }
808
-
809
- async function templatesUpdate(ctx) {
810
- const { client, positionals, flags, io } = ctx;
811
- const templateId = requirePositional(positionals, 0, "template-id");
812
- const name = flagStr(flags.name);
813
- const angle = flagStr(flags.angle);
814
- const blocks = templateBlocksFromFlags(flags);
815
- // update_template replaces the whole template; fetch current values for any
816
- // field the caller didn't pass so a partial edit doesn't blank the rest.
817
- let current;
818
- if (!name || !angle || !blocks) {
819
- const existing = await client.callTool("get_template", { template_id: templateId });
820
- current = existing.template;
821
- }
822
- const result = await client.callTool("update_template", {
823
- template_id: templateId,
824
- name: name ?? current.name,
825
- angle: angle ?? current.angle,
826
- blocks: blocks ?? current.blocks,
827
- });
828
- ok(ctx, "Template updated.");
829
- printResult(io, flags, result);
830
- return 0;
831
- }
832
-
833
- async function templatesDelete(ctx) {
834
- const { client, positionals, flags, io } = ctx;
835
- const templateId = requirePositional(positionals, 0, "template-id");
836
- if (!flagBool(flags.yes)) {
837
- throw new CliError("Seers bound to this template fall back to defaults. Re-run with --yes to delete.");
838
- }
839
- const result = await client.callTool("delete_template", { template_id: templateId });
840
- ok(ctx, "Template deleted.");
841
- printResult(io, flags, result);
842
- return 0;
843
- }
@@ -0,0 +1,267 @@
1
+ // `templates` — the Templates FORMAT: authoring it, and the two operations that make it
2
+ // composable.
3
+ //
4
+ // MOVED OUT OF `seers.mjs` (2026-09-18) for the same reason `src/lib/templates/` moved out of
5
+ // `src/lib/seers/`: a template stopped being seer-owned when collections, series and per-article
6
+ // overrides started binding them, and two of the primitives that consume one have nothing to do
7
+ // with seers at all.
8
+ //
9
+ // A template is not a primitive: no endpoint, no run, no balance, nothing to poll. It is a SHAPE
10
+ // two primitives consume — what sections an article has, in what order, in what form. What it has
11
+ // instead of a door is a RESOLUTION:
12
+ //
13
+ // templates resolve <key> the SHAPE: feeds `arena run --template`
14
+ // templates resolve <key> --file material.md the PLAN too: skeleton, budget and brief for `kernel write`
15
+ // templates assemble --spans s.json --file a.md a templated run's output, turned back into the article
16
+ //
17
+ // Resolve ONCE and pass `--constraints` back on every later call. Resolving twice is a model call
18
+ // twice and two different sets of section labels — and the writer writes each section under its
19
+ // label while assembly matches output back by that label, so a second resolution files sections
20
+ // under headings the outline never planned.
21
+ //
22
+ // For a single write with no research, skip all of this: `kernel write --template <key>` resolves
23
+ // through the same function and returns the article already assembled.
24
+
25
+ import { readFileSync } from "node:fs";
26
+ import { CliError } from "../client.mjs";
27
+ import { flagStr, flagBool, requirePositional, printResult, ok, argsFromFlags, readBodyInput } from "./shared.mjs";
28
+
29
+ /** `--<flag> <path>` → the file's parsed JSON, or undefined when the flag is not set. */
30
+ function readJsonFileFlag(flags, flag) {
31
+ const path = flagStr(flags[flag]);
32
+ if (path === undefined) return undefined;
33
+ let text;
34
+ try {
35
+ text = readFileSync(path, "utf8");
36
+ } catch (error) {
37
+ throw new CliError(
38
+ `Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
39
+ );
40
+ }
41
+ try {
42
+ return JSON.parse(text);
43
+ } catch {
44
+ throw new CliError(`--${flag} ${path} is not valid JSON.`);
45
+ }
46
+ }
47
+
48
+ /** Read a file, or throw a CLI error naming the flag that asked for it. */
49
+ function readFileOrThrow(path, flag) {
50
+ try {
51
+ return readFileSync(path, "utf8");
52
+ } catch (error) {
53
+ throw new CliError(`Could not read ${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ }
56
+
57
+ /** `--var Name=Value` (repeatable) → `{ Name: "Value" }`. */
58
+ function variablesFromFlags(flags) {
59
+ const raw = flags.var;
60
+ if (raw === undefined) return undefined;
61
+ const list = Array.isArray(raw) ? raw : [raw];
62
+ const out = {};
63
+ for (const entry of list) {
64
+ const text = String(entry);
65
+ const eq = text.indexOf("=");
66
+ if (eq <= 0) throw new CliError(`--var must be Name=Value (got "${text}").`);
67
+ out[text.slice(0, eq).trim()] = text.slice(eq + 1).trim();
68
+ }
69
+ return Object.keys(out).length ? out : undefined;
70
+ }
71
+
72
+ /**
73
+ * How the RESOLVE and ASSEMBLE commands source their arguments — see `argsFromFlags` in
74
+ * shared.mjs, and `scripts/parity-check.ts`, which compares these keys against each tool's
75
+ * published input schema. The CRUD commands below predate the field-level ratchet and are
76
+ * covered by the frozen `UNMAPPED_TOOLS` list, which only shrinks.
77
+ */
78
+ export const ARGS = {
79
+ resolve_template: {
80
+ key: { positional: 0, label: "template-key" },
81
+ blocks: { value: (flags) => readJsonFileFlag(flags, "blocks-file") },
82
+ title: "title",
83
+ material: { value: (flags) => readBodyInput(flags) },
84
+ variables: { value: variablesFromFlags },
85
+ constraints: { value: (flags) => readJsonFileFlag(flags, "constraints") },
86
+ // The brand a {Brand}-style variable names. Omitted = this org's own; an org with no
87
+ // positioning simply resolves no brand variable rather than failing.
88
+ brand: {
89
+ object: {
90
+ name: { flag: "brand" },
91
+ one_liner: { flag: "brand-one-liner" },
92
+ },
93
+ },
94
+ },
95
+ // Declared rather than re-frozen. These two were on the `UNMAPPED_TOOLS` list while they lived
96
+ // in `seers.mjs`; moving the file is not a reason to re-freeze them, and they are one
97
+ // positional each.
98
+ get_template: {
99
+ template_id: { positional: 0, label: "template-id", required: true },
100
+ },
101
+ delete_template: {
102
+ template_id: { positional: 0, label: "template-id", required: true },
103
+ },
104
+ assemble_template: {
105
+ spans: { value: (flags) => readJsonFileFlag(flags, "spans") },
106
+ article: { value: (flags) => readBodyInput(flags) },
107
+ },
108
+ };
109
+
110
+ export async function cmdTemplates(ctx) {
111
+ const sub = ctx.positionals[0];
112
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
113
+ switch (sub) {
114
+ case "list":
115
+ case "ls":
116
+ return templatesList(rest);
117
+ case "show":
118
+ case "get":
119
+ return templatesShow(rest);
120
+ case "create":
121
+ case "new":
122
+ return templatesCreate(rest);
123
+ case "update":
124
+ return templatesUpdate(rest);
125
+ case "delete":
126
+ case "rm":
127
+ return templatesDelete(rest);
128
+ case "resolve":
129
+ return templatesResolve(rest);
130
+ case "assemble":
131
+ return templatesAssemble(rest);
132
+ default:
133
+ throw new CliError(
134
+ `Unknown templates subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete, resolve, assemble`
135
+ );
136
+ }
137
+ }
138
+
139
+ async function templatesList(ctx) {
140
+ const { client, flags, io } = ctx;
141
+ const result = await client.callTool("list_templates", {});
142
+ printResult(io, flags, result, (r) => {
143
+ const custom = (r.templates ?? []).map((t) => ` ${t.key} ${t.name} — ${t.angle} (id ${t.id})`);
144
+ const builtIn = (r.built_ins ?? []).map((t) => ` ${t.key} ${t.name}`);
145
+ return [
146
+ custom.length ? "Custom templates:" : "No custom templates yet.",
147
+ ...custom,
148
+ "",
149
+ "Built-in templates (read-only):",
150
+ ...builtIn,
151
+ ].join("\n");
152
+ });
153
+ return 0;
154
+ }
155
+
156
+ async function templatesShow(ctx) {
157
+ const { client, positionals, flags, io } = ctx;
158
+ requirePositional(positionals, 0, "template-id");
159
+ const result = await client.callTool("get_template", argsFromFlags(ARGS.get_template, flags, positionals));
160
+ printResult(io, flags, result);
161
+ return 0;
162
+ }
163
+
164
+ function templateBlocksFromFlags(flags) {
165
+ const inline = flagStr(flags.blocks);
166
+ const file = flagStr(flags["blocks-file"]);
167
+ if (inline === undefined && file === undefined) return undefined;
168
+ const raw = inline !== undefined ? inline : readFileOrThrow(file, "--blocks-file");
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(raw);
172
+ } catch (err) {
173
+ throw new CliError(`--blocks is not valid JSON: ${err.message}`);
174
+ }
175
+ if (!Array.isArray(parsed)) throw new CliError("--blocks must be a JSON array of block objects");
176
+ return parsed;
177
+ }
178
+
179
+ async function templatesCreate(ctx) {
180
+ const { client, flags, io } = ctx;
181
+ const name = flagStr(flags.name);
182
+ const angle = flagStr(flags.angle);
183
+ const blocks = templateBlocksFromFlags(flags);
184
+ if (!name) throw new CliError("Missing required --name");
185
+ // `--angle` is OPTIONAL, and omitting it is the better answer. It was required here while
186
+ // the server schema had already made it optional and the app had dropped the field: a
187
+ // template constrains a post's SHAPE and never says what it is about, so an angle belongs
188
+ // to the post — which already has one, from its research direction.
189
+ if (!blocks) throw new CliError("Missing required --blocks '<json>' or --blocks-file <path>");
190
+ const result = await client.callTool("create_template", { name, blocks, ...(angle ? { angle } : {}) });
191
+ ok(ctx, `Template created — key ${result.template?.key}.`);
192
+ printResult(io, flags, result);
193
+ return 0;
194
+ }
195
+
196
+ async function templatesUpdate(ctx) {
197
+ const { client, positionals, flags, io } = ctx;
198
+ const templateId = requirePositional(positionals, 0, "template-id");
199
+ const name = flagStr(flags.name);
200
+ const angle = flagStr(flags.angle);
201
+ const blocks = templateBlocksFromFlags(flags);
202
+ // update_template replaces the whole template; fetch current values for any
203
+ // field the caller didn't pass so a partial edit doesn't blank the rest.
204
+ let current;
205
+ if (!name || !angle || !blocks) {
206
+ const existing = await client.callTool("get_template", { template_id: templateId });
207
+ current = existing.template;
208
+ }
209
+ const result = await client.callTool("update_template", {
210
+ template_id: templateId,
211
+ name: name ?? current.name,
212
+ angle: angle ?? current.angle,
213
+ blocks: blocks ?? current.blocks,
214
+ });
215
+ ok(ctx, "Template updated.");
216
+ printResult(io, flags, result);
217
+ return 0;
218
+ }
219
+
220
+ async function templatesDelete(ctx) {
221
+ const { client, positionals, flags, io } = ctx;
222
+ requirePositional(positionals, 0, "template-id");
223
+ if (!flagBool(flags.yes)) {
224
+ throw new CliError("Seers bound to this template fall back to defaults. Re-run with --yes to delete.");
225
+ }
226
+ const result = await client.callTool("delete_template", argsFromFlags(ARGS.delete_template, flags, positionals));
227
+ ok(ctx, "Template deleted.");
228
+ printResult(io, flags, result);
229
+ return 0;
230
+ }
231
+
232
+ /**
233
+ * `templates resolve <key> [--title t] [--body/--file material] [--var K=V] [--constraints c.json]`
234
+ *
235
+ * THE ONE PLACE A TEMPLATE BECOMES CONSTRAINTS. Without material: the SHAPE — `must_include`,
236
+ * `gather_hints`, `form_guidance`, which is what `arena run` takes. With it: the PLAN as well —
237
+ * `skeleton`, `target_word_count`, `brief`, `brief_json`, `spans`, which is what `kernel write`
238
+ * takes. Pass `--constraints` back on the second call so nothing is re-decided.
239
+ */
240
+ async function templatesResolve(ctx) {
241
+ const { client, positionals, flags, io } = ctx;
242
+ const args = argsFromFlags(ARGS.resolve_template, flags, positionals);
243
+ if (args.key === undefined && args.blocks === undefined) {
244
+ throw new CliError(
245
+ "Name the template: `templates resolve <template-key>`, or --blocks-file <path> for an inline one."
246
+ );
247
+ }
248
+ const result = await client.callTool("resolve_template", args);
249
+ printResult(io, flags, result);
250
+ return 0;
251
+ }
252
+
253
+ /** `templates assemble --spans spans.json --file article.md` — the way back. */
254
+ async function templatesAssemble(ctx) {
255
+ const { client, positionals, flags, io } = ctx;
256
+ const args = argsFromFlags(ARGS.assemble_template, flags, positionals);
257
+ if (args.spans === undefined) {
258
+ throw new CliError("--spans <path> is required: the spans `templates resolve` returned.");
259
+ }
260
+ if (args.article === undefined) {
261
+ throw new CliError("Provide the run's output with --body <text>, --file <path>, or --file - (stdin).");
262
+ }
263
+ const result = await client.callTool("assemble_template", args);
264
+ // The article itself, so `> post.html` works.
265
+ printResult(io, flags, result, (r) => r?.article ?? "");
266
+ return 0;
267
+ }
package/lib/commands.mjs CHANGED
@@ -20,6 +20,7 @@ export * from "./commands/series.mjs";
20
20
  export * from "./commands/insights.mjs";
21
21
  export * from "./commands/covers.mjs";
22
22
  export * from "./commands/arena.mjs";
23
+ export * from "./commands/templates.mjs";
23
24
  export * from "./commands/autopen.mjs";
24
25
  export * from "./commands/kernel.mjs";
25
26
  export * from "./commands/phantom-job.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",