@letterstory/cli 0.39.0 → 0.39.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
@@ -47,10 +47,11 @@ import {
47
47
  cmdAdmin,
48
48
  cmdFeedback,
49
49
  cmdOfferings,
50
+ cmdTargets,
50
51
  } from "./commands.mjs";
51
52
 
52
53
  // Keep in sync with cli/package.json.
53
- export const VERSION = "0.39.0";
54
+ export const VERSION = "0.39.1";
54
55
 
55
56
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
56
57
  // can't accidentally swallow the id as --json's value.
@@ -116,6 +117,8 @@ const BOOLEAN_FLAGS = new Set([
116
117
  "none",
117
118
  // `prospects import|source --one-per-company`.
118
119
  "one-per-company",
120
+ // `targets update <id> --clear-framing`.
121
+ "clear-framing",
119
122
  ]);
120
123
 
121
124
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -344,6 +347,19 @@ Offerings (context layered org → offering → collection; a layer sets only wh
344
347
  series create --name <n> --description <d> [--coverage a,b,c] [--collection <uuid>] [--template <key>]
345
348
  series backfill <series-id> [--collection <uuid>] Reconstruct a series from existing posts
346
349
 
350
+ Ranking targets (what the org wants to be THE answer for — company-wide or on one offering):
351
+ targets list [--offering <offering-id|company>] Targets grouped by scope, each with its active cap and usage
352
+ targets add <phrase> [--framing <f>] [--offering <offering-id|company>]
353
+ Lands active while its scope has room, inactive after —
354
+ adding never fails on the cap
355
+ targets update <target-id> [--phrase <p>] [--framing <f> | --clear-framing] [--offering <offering-id|company>]
356
+ [--position <n>] [--state active|inactive|achieved|retired]
357
+ Moving an ACTIVE target needs room in the new scope
358
+ targets activate | deactivate <target-id> Switch a target on/off; activating is refused at the cap
359
+ targets cap <offering-id|company> <n|default> Active targets allowed in that scope (preset 5; default =
360
+ inherit the company's cap / back to the preset)
361
+ targets suggest [--offering <offering-id|company>] Evidence-informed suggestions (nothing saved)
362
+
347
363
  Arenas (adversarial clusters of research agents -> a sourced, fact-checked outline):
348
364
  arena run --topic <text> [--source <url> …] [--must-include <text> …] [--depth barebones|fleshed_out]
349
365
  [--gate] [--no-verify] [--provider native|exa|youcom|firecrawl] [--mention off|subtle|woven]
@@ -740,7 +756,8 @@ Prospects (who you write to):
740
756
  [--city] [--country] [--years] [--notes] [--custom key=value]...
741
757
 
742
758
  Mail:
743
- mail connections Connected mailboxes, Google connect + Vercel key status
759
+ mail connections Connected mailboxes, Google connect, Vercel key + mailing address status
760
+ mail postal-address "<address>" Set the org's CAN-SPAM mailing address (email footers)
744
761
 
745
762
  Insights:
746
763
  insights site [--period 14d|30d|90d] [--collection <uuid>]
@@ -806,6 +823,7 @@ const CLIENT_COMMANDS = {
806
823
  strategy: cmdStrategy,
807
824
  directives: cmdDirectives,
808
825
  offerings: cmdOfferings,
826
+ targets: cmdTargets,
809
827
  probes: cmdProbes,
810
828
  suggestions: cmdSuggestions,
811
829
  onboarding: cmdOnboarding,
@@ -167,6 +167,9 @@ export const ARGS = {
167
167
  custom: { value: readCustom },
168
168
  },
169
169
  get_mail_connections: {},
170
+ set_mail_postal_address: {
171
+ mail_postal_address: { positional: 0, label: "address", required: true },
172
+ },
170
173
  };
171
174
 
172
175
  // Flags the computed `value` sources read (refuseUnknownFlags can't see them).
@@ -318,7 +321,9 @@ export async function cmdMail(ctx) {
318
321
  case "connections":
319
322
  case "status":
320
323
  return run(rest, "get_mail_connections");
324
+ case "postal-address":
325
+ return run(rest, "set_mail_postal_address", "Mailing address saved.");
321
326
  default:
322
- throw new CliError(`Unknown mail subcommand: ${sub ?? "(none)"}. Try: connections`);
327
+ throw new CliError(`Unknown mail subcommand: ${sub ?? "(none)"}. Try: connections, postal-address`);
323
328
  }
324
329
  }
@@ -0,0 +1,223 @@
1
+ // `targets` — ranking targets: what the org wants to be THE answer for.
2
+ //
3
+ // A target belongs to the company (company-wide) or to one offering. Each scope — the
4
+ // company layer and every offering — holds its own ACTIVE targets up to its cap (a preset
5
+ // of 5, adjustable per scope with `targets cap`); inactive targets are never capped, so
6
+ // `add` never fails on the cap: it lands inactive when the scope is full. Thin wrappers
7
+ // over the MCP tools, which call the same store functions the app's /api/ranking-targets
8
+ // routes and staff Fleet do.
9
+
10
+ import { CliError } from "../client.mjs";
11
+ import { flagStr, flagNum, printResult, argsFromFlags, refuseUnknownFlags, ok } from "./shared.mjs";
12
+
13
+ const STATUSES = ["active", "inactive", "achieved", "retired"];
14
+ const COMPANY = "company";
15
+
16
+ // `--offering <offering-id|company>`: company = the company layer (null). Absent = not sent.
17
+ function readOffering(flags) {
18
+ const v = flagStr(flags.offering);
19
+ if (v === undefined) {
20
+ if (flags.offering === true) throw new CliError("--offering takes an offering id, or `company`");
21
+ return undefined;
22
+ }
23
+ return v === COMPANY ? null : v;
24
+ }
25
+ const offering = { value: readOffering };
26
+
27
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
28
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
29
+ export const ARGS = {
30
+ list_ranking_targets: { offering_id: offering },
31
+ add_ranking_target: {
32
+ // The phrase is every positional, so `targets add best payroll software` needs no quotes.
33
+ phrase: {
34
+ value: (_flags, positionals) => {
35
+ const phrase = positionals.join(" ").trim();
36
+ if (!phrase) throw new CliError("Missing required <phrase>");
37
+ return phrase;
38
+ },
39
+ },
40
+ mention_framing: "framing",
41
+ offering_id: offering,
42
+ },
43
+ update_ranking_target: {
44
+ target_id: { positional: 0, label: "target-id", required: true },
45
+ phrase: "phrase",
46
+ mention_framing: {
47
+ value: (flags) => (flags["clear-framing"] === true ? null : flagStr(flags.framing)),
48
+ },
49
+ // `activate` / `deactivate` set this for you.
50
+ status: { flag: "state", enum: STATUSES },
51
+ offering_id: offering,
52
+ position: { flag: "position", type: "num" },
53
+ },
54
+ set_ranking_target_cap: {
55
+ offering_id: {
56
+ value: (_flags, positionals) => {
57
+ const scope = positionals[0];
58
+ if (scope === undefined) throw new CliError("Missing required <offering-id|company>");
59
+ return scope === COMPANY ? null : scope;
60
+ },
61
+ },
62
+ // `default` = back to inheriting (an offering → the company's cap; the company → the preset).
63
+ cap: {
64
+ value: (_flags, positionals) => {
65
+ const raw = positionals[1];
66
+ if (raw === undefined) throw new CliError("Missing required <n|default>");
67
+ return raw === "default" ? null : flagNum(raw);
68
+ },
69
+ },
70
+ },
71
+ suggest_ranking_targets: { offering_id: offering },
72
+ };
73
+
74
+ const USAGE = `Usage: targets <list|add|update|activate|deactivate|cap|suggest>
75
+ targets list [--offering <offering-id|company>]
76
+ targets add <phrase> [--framing <f>] [--offering <offering-id|company>]
77
+ targets update <target-id> [--phrase <p>] [--framing <f> | --clear-framing] [--offering <offering-id|company>]
78
+ [--position <n>] [--state active|inactive|achieved|retired]
79
+ targets activate <target-id>
80
+ targets deactivate <target-id>
81
+ targets cap <offering-id|company> <n|default>
82
+ targets suggest [--offering <offering-id|company>]`;
83
+
84
+ export async function cmdTargets(ctx) {
85
+ const sub = ctx.positionals[0];
86
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
87
+ switch (sub) {
88
+ case "list":
89
+ case "ls":
90
+ return targetsList(rest);
91
+ case "add":
92
+ return targetsAdd(rest);
93
+ case "update":
94
+ return targetsUpdate(rest);
95
+ case "activate":
96
+ return targetsSetStatus(rest, "active");
97
+ case "deactivate":
98
+ return targetsSetStatus(rest, "inactive");
99
+ case "cap":
100
+ return targetsCap(rest);
101
+ case "suggest":
102
+ return targetsSuggest(rest);
103
+ default:
104
+ throw new CliError(USAGE);
105
+ }
106
+ }
107
+
108
+ const scopeName = (s) => (s.offeringId === null ? "Company-wide" : s.label);
109
+
110
+ function formatScope(s) {
111
+ const where = s.capSetHere ? "set here" : s.offeringId === null ? "preset" : "company's cap";
112
+ return `${scopeName(s)} — ${s.active}/${s.cap} active (${where})`;
113
+ }
114
+
115
+ function formatTarget(t) {
116
+ const framing = t.mention_framing ? ` — as ${t.mention_framing}` : "";
117
+ return ` ${t.id} [${t.status}] ${t.phrase}${framing}`;
118
+ }
119
+
120
+ // Targets grouped under their scope, each headed by its cap and usage. A target on an
121
+ // offering the scopes list doesn't carry (archived) still shows, under its id.
122
+ export function formatTargets(r) {
123
+ const scopes = r.scopes ?? [];
124
+ const items = r.items ?? [];
125
+ if (!items.length && !scopes.length) return "No ranking targets yet.";
126
+ const lines = [];
127
+ const shown = new Set();
128
+ for (const s of scopes) {
129
+ const mine = items.filter((t) => (t.offering_id ?? null) === s.offeringId);
130
+ lines.push(formatScope(s));
131
+ for (const t of mine) {
132
+ lines.push(formatTarget(t));
133
+ shown.add(t.id);
134
+ }
135
+ if (!mine.length) lines.push(" (none)");
136
+ }
137
+ const orphans = items.filter((t) => !shown.has(t.id));
138
+ for (const id of [...new Set(orphans.map((t) => t.offering_id))]) {
139
+ lines.push(`Offering ${id} (not active)`);
140
+ for (const t of orphans.filter((o) => o.offering_id === id)) lines.push(formatTarget(t));
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+
145
+ async function targetsList(ctx) {
146
+ const { client, flags, io } = ctx;
147
+ const result = await client.callTool("list_ranking_targets", argsFromFlags(ARGS.list_ranking_targets, flags));
148
+ printResult(io, flags, result, formatTargets);
149
+ return 0;
150
+ }
151
+
152
+ async function targetsAdd(ctx) {
153
+ const { client, flags, positionals, io } = ctx;
154
+ refuseUnknownFlags(ctx, ARGS.add_ranking_target, ["offering"]);
155
+ const result = await client.callTool(
156
+ "add_ranking_target",
157
+ argsFromFlags(ARGS.add_ranking_target, flags, positionals)
158
+ );
159
+ const t = result.target;
160
+ ok(
161
+ ctx,
162
+ t?.status === "active"
163
+ ? `Added "${t.phrase}" — active.`
164
+ : `Added "${t?.phrase}" as inactive: its scope is at its active cap. Deactivate one there, or raise the cap (targets cap), then: ${ctx.bin} targets activate ${t?.id}`
165
+ );
166
+ printResult(io, flags, result, (r) => formatTarget(r.target));
167
+ return 0;
168
+ }
169
+
170
+ async function update(ctx, flags) {
171
+ const { client, positionals, io } = ctx;
172
+ const args = argsFromFlags(ARGS.update_ranking_target, flags, positionals);
173
+ if (Object.keys(args).length === 1) {
174
+ throw new CliError(
175
+ "Nothing to update — pass --phrase, --framing, --clear-framing, --offering, --position or --state."
176
+ );
177
+ }
178
+ const result = await client.callTool("update_ranking_target", args);
179
+ ok(ctx, `Updated "${result.target?.phrase}" — ${result.target?.status}.`);
180
+ printResult(io, ctx.flags, result, (r) => formatTarget(r.target));
181
+ return 0;
182
+ }
183
+
184
+ async function targetsUpdate(ctx) {
185
+ refuseUnknownFlags(ctx, ARGS.update_ranking_target, ["framing", "clear-framing", "offering"]);
186
+ return update(ctx, ctx.flags);
187
+ }
188
+
189
+ // Activate / deactivate are the first-class switch. Activating is refused when the
190
+ // target's scope is at its cap — the server's message names the scope.
191
+ async function targetsSetStatus(ctx, status) {
192
+ refuseUnknownFlags(ctx, {});
193
+ return update(ctx, { ...ctx.flags, state: status });
194
+ }
195
+
196
+ async function targetsCap(ctx) {
197
+ const { client, flags, positionals, io } = ctx;
198
+ refuseUnknownFlags(ctx, {});
199
+ const result = await client.callTool(
200
+ "set_ranking_target_cap",
201
+ argsFromFlags(ARGS.set_ranking_target_cap, flags, positionals)
202
+ );
203
+ ok(ctx, "Cap saved.");
204
+ printResult(io, flags, result, (r) => (r.scopes ?? []).map(formatScope).join("\n"));
205
+ return 0;
206
+ }
207
+
208
+ async function targetsSuggest(ctx) {
209
+ const { client, flags, io } = ctx;
210
+ const result = await client.callTool("suggest_ranking_targets", argsFromFlags(ARGS.suggest_ranking_targets, flags));
211
+ printResult(io, flags, result, (r) => {
212
+ const all = r.suggestions ?? [];
213
+ if (!all.length) return "No suggestions yet — add positioning first.";
214
+ const scope = r.offering_id ? ` --offering ${r.offering_id}` : "";
215
+ const lines = all.map(
216
+ (s) =>
217
+ ` ${s.phrase} [${s.basis}]${s.suggestedFraming ? ` — as ${s.suggestedFraming}` : ""}\n ${s.rationale}`
218
+ );
219
+ lines.push(`Nothing saved. Keep one with: ${ctx.bin} targets add <phrase>${scope}`);
220
+ return lines.join("\n");
221
+ });
222
+ return 0;
223
+ }
package/lib/commands.mjs CHANGED
@@ -34,5 +34,6 @@ export * from "./commands/shredder.mjs";
34
34
  export * from "./commands/landings.mjs";
35
35
  export * from "./commands/outbound.mjs";
36
36
  export * from "./commands/offerings.mjs";
37
+ export * from "./commands/targets.mjs";
37
38
  export * from "./commands/admin.mjs";
38
39
  export * from "./commands/feedback.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.39.0",
3
+ "version": "0.39.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",