@letterstory/cli 0.4.0 → 0.5.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 +81 -1
- package/lib/cli.mjs +52 -1
- package/lib/commands/covers.mjs +89 -0
- package/lib/commands/seers.mjs +673 -0
- package/lib/commands.mjs +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,9 +11,15 @@ The CLI is plain ESM with **zero dependencies** and needs no build step (Node
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g @letterstory/cli # puts `letterstory` (and `phantom`) on your PATH
|
|
13
13
|
# …or run it without installing:
|
|
14
|
-
npx @letterstory/cli --help
|
|
14
|
+
npx -p @letterstory/cli letterstory --help
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
The `-p` is not optional. This package ships two commands, `letterstory` and `phantom`,
|
|
18
|
+
and npx only infers one for you when a package has a single command or one named after
|
|
19
|
+
the package — neither is true here, so a bare `npx @letterstory/cli` exits with
|
|
20
|
+
`could not determine executable to run`. Name the command you want (`letterstory` or
|
|
21
|
+
`phantom`) and pass the package with `-p`.
|
|
22
|
+
|
|
17
23
|
Contributing to the CLI itself? Run it straight from a checkout instead:
|
|
18
24
|
|
|
19
25
|
```bash
|
|
@@ -236,6 +242,80 @@ letterstory kernel status <kernel-job-id>
|
|
|
236
242
|
`phantom-job create --topic "…" --collection <uuid>` runs topic → draft → publish → rebuild
|
|
237
243
|
end to end, with `phantom-job status <job-id>` to follow it.
|
|
238
244
|
|
|
245
|
+
## Seers — event-driven signals
|
|
246
|
+
|
|
247
|
+
A **seer** watches an external source (a GitHub repo, a Notion workspace, a spec URL, a
|
|
248
|
+
news or regulation query) and turns what it detects into headline suggestions — or, in
|
|
249
|
+
`auto` mode, straight into drafts. Needs `seer:read` / `seer:write` capabilities
|
|
250
|
+
(`template:read` / `template:write` for the templates group).
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
letterstory seers providers # the self-describing config catalog — start here
|
|
254
|
+
letterstory seers list
|
|
255
|
+
letterstory seers create --name "Ship notes" --collection <uuid> --provider github_pr \
|
|
256
|
+
--config '{"owner":"letterstory","repo":"letterbrace"}'
|
|
257
|
+
letterstory seers update <seer-id> --mode auto --interval 60
|
|
258
|
+
letterstory seers run <seer-id> # poll + produce now (minutes; spends AI budget)
|
|
259
|
+
letterstory seers events <seer-id> # detected signals, dedup aliases, relevance verdicts
|
|
260
|
+
letterstory seers dismiss <seer-id> <event-id>
|
|
261
|
+
letterstory seers headlines [--collection <uuid>] # the seer-sourced suggestion feed
|
|
262
|
+
letterstory seers held # drafts parked pending human review
|
|
263
|
+
letterstory seers release <article-id> # …or: seers discard <article-id>
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Reproducible setup — the onboarding path. `export` writes the org's seers as a
|
|
267
|
+
manifest; `apply` is an idempotent, **name-keyed** upsert from one (it never deletes;
|
|
268
|
+
seers in the org but missing from the manifest are listed as extras and left alone):
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
letterstory seers export --file seers.json
|
|
272
|
+
letterstory seers apply --file seers.json --dry-run # print the create/update/ok plan
|
|
273
|
+
letterstory seers apply --file seers.json # apply it
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Connections, and one honest gap:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
letterstory seers connections # GitHub + Notion status
|
|
280
|
+
letterstory seers connect github --token <pat> # org-wide token; unlocks private repos
|
|
281
|
+
letterstory seers connect notion # prints instructions — see below
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
> **Known gaps.** Notion connects via browser OAuth in the app only — the CLI/API can
|
|
285
|
+
> read connection status but cannot complete the OAuth flow. `seers run` executes
|
|
286
|
+
> synchronously server-side (up to ~5 minutes for web-scan providers); there is no
|
|
287
|
+
> start-then-poll variant yet. And `create`'s config takes raw JSON — `seers providers`
|
|
288
|
+
> is the schema reference the server validates against.
|
|
289
|
+
|
|
290
|
+
Content templates (the compose structures seers bind via `config.template_key`):
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
letterstory templates list # custom + read-only built-ins
|
|
294
|
+
letterstory templates create --name "Release notes" --angle "What shipped and why" \
|
|
295
|
+
--sections-file sections.json
|
|
296
|
+
letterstory templates update <template-id> --angle "…" # unset fields keep their values
|
|
297
|
+
letterstory templates delete <template-id> --yes # bound seers fall back to defaults
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Cover images
|
|
301
|
+
|
|
302
|
+
Which image model draws a post's cover is a CLI decision, not a UI-only one. `variants`
|
|
303
|
+
fans one headline out across up to four models and hands back a preview per model;
|
|
304
|
+
`select` commits the one you want:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
letterstory covers models # image models connected to this org
|
|
308
|
+
letterstory covers variants --article <uuid> --model bloom --model openai
|
|
309
|
+
letterstory covers select --article <uuid> --url <variant-url> --provider bloom
|
|
310
|
+
|
|
311
|
+
# …or skip the comparison and let the server pick, using the collection's cover template:
|
|
312
|
+
letterstory covers generate <article-id> [--regenerate] [--stock-only]
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`--ref-url <url>` (repeatable) conditions the look on existing images — pair it with
|
|
316
|
+
`extract_blog_reference_images` to match a site you're mirroring. Each variant costs one
|
|
317
|
+
image generation, which is why the fan-out is capped at four models.
|
|
318
|
+
|
|
239
319
|
## Insights
|
|
240
320
|
|
|
241
321
|
Search Console performance, network-wide, per post, or top posts:
|
package/lib/cli.mjs
CHANGED
|
@@ -27,12 +27,15 @@ import {
|
|
|
27
27
|
cmdOnboarding,
|
|
28
28
|
cmdInsights,
|
|
29
29
|
cmdResearch,
|
|
30
|
+
cmdCovers,
|
|
30
31
|
cmdKernel,
|
|
31
32
|
cmdPhantomJob,
|
|
33
|
+
cmdSeers,
|
|
34
|
+
cmdTemplates,
|
|
32
35
|
} from "./commands.mjs";
|
|
33
36
|
|
|
34
37
|
// Keep in sync with cli/package.json.
|
|
35
|
-
export const VERSION = "0.
|
|
38
|
+
export const VERSION = "0.5.0";
|
|
36
39
|
|
|
37
40
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
38
41
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -50,6 +53,13 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
50
53
|
"rebuild",
|
|
51
54
|
"print-key",
|
|
52
55
|
"stdin",
|
|
56
|
+
// `covers generate <id> --regenerate` and `research start --gate --article <id>`
|
|
57
|
+
// both put a value-less flag next to a value: without these entries the parser
|
|
58
|
+
// hands the following token to the flag and the real argument disappears.
|
|
59
|
+
"regenerate",
|
|
60
|
+
"stock-only",
|
|
61
|
+
"gate",
|
|
62
|
+
"skip",
|
|
53
63
|
]);
|
|
54
64
|
|
|
55
65
|
// Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
|
|
@@ -198,6 +208,15 @@ Research agent (deep research -> outline written into the post):
|
|
|
198
208
|
Answer the --gate pause
|
|
199
209
|
research cancel --article <uuid> Cancel a run in flight
|
|
200
210
|
|
|
211
|
+
Cover images (which image model draws the post's cover):
|
|
212
|
+
covers models Image models connected right now
|
|
213
|
+
covers variants --article <uuid> [--model <id> …] [--ref-url <url> …]
|
|
214
|
+
One preview per model (max 4)
|
|
215
|
+
covers select --article <uuid> --url <url> --provider <p> [--model <id>]
|
|
216
|
+
Commit a previewed variant
|
|
217
|
+
covers generate <article-id> [--regenerate] [--stock-only]
|
|
218
|
+
Single-shot mint, default provider
|
|
219
|
+
|
|
201
220
|
Writing kernels (kernels write the draft; they do not research — run research first):
|
|
202
221
|
kernel list List available writing kernels
|
|
203
222
|
kernel run --article <uuid> --kernel <uuid> [--brief <text>|--brief-file <path>]
|
|
@@ -209,6 +228,35 @@ Phantom orchestrator:
|
|
|
209
228
|
Topic -> draft -> publish -> rebuild
|
|
210
229
|
phantom-job status <job-id> Check a job's stage
|
|
211
230
|
|
|
231
|
+
Seers (event-driven signals -> drafts):
|
|
232
|
+
seers list List seers with recent activity
|
|
233
|
+
seers get <seer-id> One seer's full config + poll state
|
|
234
|
+
seers providers Provider catalog: config fields + behavior keys
|
|
235
|
+
seers create --name <n> --collection <uuid> --provider <p>
|
|
236
|
+
[--mode suggest|auto] [--interval <min>] [--config '<json>'|--config-file <path>]
|
|
237
|
+
seers update <seer-id> [--name] [--collection] [--mode] [--enabled true|false]
|
|
238
|
+
[--interval <min>] [--config '<json>'|--config-file <path>]
|
|
239
|
+
seers delete <seer-id> --yes
|
|
240
|
+
seers run <seer-id> Poll + produce now (can take minutes; spends AI budget)
|
|
241
|
+
seers events <seer-id> [--limit <n>] [--offset <n>] Detected signals w/ dedup + relevance
|
|
242
|
+
seers dismiss <seer-id> <event-id> Dismiss a signal (feeds the relevance judge)
|
|
243
|
+
seers headlines [--collection <uuid>] Seer-sourced suggestion feed
|
|
244
|
+
seers held [--collection <uuid>] Drafts parked pending human review
|
|
245
|
+
seers release <article-id> | seers discard <article-id>
|
|
246
|
+
seers connections GitHub/Notion connection status
|
|
247
|
+
seers connect github --token <pat> Store an org-wide GitHub token
|
|
248
|
+
seers connect notion (browser-only OAuth — prints instructions)
|
|
249
|
+
seers export [--file <path>] Dump the org's seers as a manifest
|
|
250
|
+
seers apply --file <path> [--dry-run] Idempotent, name-keyed upsert from a manifest
|
|
251
|
+
(never deletes; --dry-run prints the plan)
|
|
252
|
+
|
|
253
|
+
Content templates (compose structures seers bind via config.template_key):
|
|
254
|
+
templates list Custom + built-in catalog
|
|
255
|
+
templates show <template-id>
|
|
256
|
+
templates create --name <n> --angle <text> (--sections '<json>'|--sections-file <path>)
|
|
257
|
+
templates update <template-id> [--name] [--angle] [--sections '<json>'|--sections-file <path>]
|
|
258
|
+
templates delete <template-id> --yes
|
|
259
|
+
|
|
212
260
|
Insights:
|
|
213
261
|
insights site [--period 14d|30d|90d] [--collection <uuid>]
|
|
214
262
|
insights post <article-id> [--period 14d|30d|90d]
|
|
@@ -266,8 +314,11 @@ const CLIENT_COMMANDS = {
|
|
|
266
314
|
onboarding: cmdOnboarding,
|
|
267
315
|
insights: cmdInsights,
|
|
268
316
|
research: cmdResearch,
|
|
317
|
+
covers: cmdCovers,
|
|
269
318
|
kernel: cmdKernel,
|
|
270
319
|
"phantom-job": cmdPhantomJob,
|
|
320
|
+
seers: cmdSeers,
|
|
321
|
+
templates: cmdTemplates,
|
|
271
322
|
};
|
|
272
323
|
|
|
273
324
|
// LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `covers` — a post's cover image, including WHICH image model draws it. `models`
|
|
2
|
+
// lists what's connected, `variants` fans a headline out across models and returns a
|
|
3
|
+
// preview per model, `select` commits the one you want, and `generate` is the
|
|
4
|
+
// single-shot headless mint (collection's template + the server's default provider).
|
|
5
|
+
// Each variant costs one image generation, so the fan-out is capped at 4 models.
|
|
6
|
+
|
|
7
|
+
import { CliError } from "../client.mjs";
|
|
8
|
+
import { flagStr, flagBool, flagList, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
|
|
9
|
+
|
|
10
|
+
export async function cmdCovers(ctx) {
|
|
11
|
+
const sub = ctx.positionals[0];
|
|
12
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
13
|
+
switch (sub) {
|
|
14
|
+
case "models":
|
|
15
|
+
return coverModels(rest);
|
|
16
|
+
case "variants":
|
|
17
|
+
case "compare":
|
|
18
|
+
return coverVariants(rest);
|
|
19
|
+
case "select":
|
|
20
|
+
case "pick":
|
|
21
|
+
return coverSelect(rest);
|
|
22
|
+
case "generate":
|
|
23
|
+
return coverGenerate(rest);
|
|
24
|
+
default:
|
|
25
|
+
throw new CliError(
|
|
26
|
+
`Unknown covers subcommand: ${sub ?? "(none)"}. Try: models, variants, select, generate`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function coverModels(ctx) {
|
|
32
|
+
const { client, flags, io } = ctx;
|
|
33
|
+
const result = await client.callTool("list_image_models", {});
|
|
34
|
+
printResult(io, flags, result);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function coverVariants(ctx) {
|
|
39
|
+
const { client, flags, io } = ctx;
|
|
40
|
+
const article = requireFlag(flags, "article");
|
|
41
|
+
const modelIds = flagList(flags.model);
|
|
42
|
+
const refs = flagList(flags["ref-url"]);
|
|
43
|
+
const args = compact({
|
|
44
|
+
article_id: article,
|
|
45
|
+
model_ids: modelIds.length ? modelIds : undefined,
|
|
46
|
+
reference_image_urls: refs.length ? refs : undefined,
|
|
47
|
+
});
|
|
48
|
+
const result = await client.callTool("generate_cover_variants", args);
|
|
49
|
+
const first = (result?.variants ?? []).find((v) => v.url);
|
|
50
|
+
ok(
|
|
51
|
+
ctx,
|
|
52
|
+
first
|
|
53
|
+
? `Generated ${result.variants.length} variant(s). Commit one with: covers select --article ${article} --url <url> --provider <provider>`
|
|
54
|
+
: "No variant produced an image — see the per-model errors below."
|
|
55
|
+
);
|
|
56
|
+
printResult(io, flags, result);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function coverSelect(ctx) {
|
|
61
|
+
const { client, flags, io } = ctx;
|
|
62
|
+
const article = requireFlag(flags, "article");
|
|
63
|
+
const args = compact({
|
|
64
|
+
article_id: article,
|
|
65
|
+
image_url: requireFlag(flags, "url"),
|
|
66
|
+
provider: requireFlag(flags, "provider"),
|
|
67
|
+
model: flagStr(flags.model),
|
|
68
|
+
});
|
|
69
|
+
const result = await client.callTool("select_cover_variant", args);
|
|
70
|
+
ok(ctx, "Cover set on the post.");
|
|
71
|
+
printResult(io, flags, result);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function coverGenerate(ctx) {
|
|
76
|
+
const { client, positionals, flags, io } = ctx;
|
|
77
|
+
// `covers generate <article-id>` reads naturally, but --article keeps it
|
|
78
|
+
// consistent with every other post-scoped command; accept both.
|
|
79
|
+
const article = flagStr(flags.article) ?? requirePositional(positionals, 0, "article-id");
|
|
80
|
+
const args = compact({
|
|
81
|
+
article_id: article,
|
|
82
|
+
regenerate: flagBool(flags.regenerate) ? true : undefined,
|
|
83
|
+
stock_only: flagBool(flags["stock-only"]) ? true : undefined,
|
|
84
|
+
});
|
|
85
|
+
const result = await client.callTool("generate_article_cover", args);
|
|
86
|
+
ok(ctx, result?.generated ? "Cover generated and attached." : `No cover generated: ${result?.reason ?? "unknown"}`);
|
|
87
|
+
printResult(io, flags, result);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
// `seers` + `templates` — event-driven signal watchers and the content templates
|
|
2
|
+
// that shape what they compose. Wraps the seer:*/template:* tools 1:1 with the
|
|
3
|
+
// app's Seers tab, plus two CLI-only compositions for reproducible onboarding:
|
|
4
|
+
// `seers export` (dump the org's seers as a manifest) and `seers apply` (idempotent,
|
|
5
|
+
// name-keyed diff-then-upsert from a manifest, with --dry-run).
|
|
6
|
+
//
|
|
7
|
+
// Known surface gaps, stated here because this is where operators hit them:
|
|
8
|
+
// - Notion connects via browser OAuth in the app only. `seers connect notion`
|
|
9
|
+
// prints instructions; there is no headless flow.
|
|
10
|
+
// - `seers run` executes synchronously server-side (news/regulation web scans can
|
|
11
|
+
// take minutes) and spends model budget — it is usage-gated per org.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { CliError } from "../client.mjs";
|
|
15
|
+
import { flagStr, flagNum, flagBool, requirePositional, printResult, compact, ok } from "./shared.mjs";
|
|
16
|
+
|
|
17
|
+
export async function cmdSeers(ctx) {
|
|
18
|
+
const sub = ctx.positionals[0];
|
|
19
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
20
|
+
switch (sub) {
|
|
21
|
+
case "list":
|
|
22
|
+
case "ls":
|
|
23
|
+
return seersList(rest);
|
|
24
|
+
case "get":
|
|
25
|
+
case "show":
|
|
26
|
+
return seersGet(rest);
|
|
27
|
+
case "create":
|
|
28
|
+
case "new":
|
|
29
|
+
return seersCreate(rest);
|
|
30
|
+
case "update":
|
|
31
|
+
return seersUpdate(rest);
|
|
32
|
+
case "delete":
|
|
33
|
+
case "rm":
|
|
34
|
+
return seersDelete(rest);
|
|
35
|
+
case "run":
|
|
36
|
+
return seersRun(rest);
|
|
37
|
+
case "events":
|
|
38
|
+
return seersEvents(rest);
|
|
39
|
+
case "dismiss":
|
|
40
|
+
return seersDismiss(rest);
|
|
41
|
+
case "headlines":
|
|
42
|
+
return seersHeadlines(rest);
|
|
43
|
+
case "held":
|
|
44
|
+
return seersHeld(rest);
|
|
45
|
+
case "release":
|
|
46
|
+
return seersResolveHeld(rest, "release");
|
|
47
|
+
case "discard":
|
|
48
|
+
return seersResolveHeld(rest, "discard");
|
|
49
|
+
case "providers":
|
|
50
|
+
return seersProviders(rest);
|
|
51
|
+
case "connections":
|
|
52
|
+
return seersConnections(rest);
|
|
53
|
+
case "connect":
|
|
54
|
+
return seersConnect(rest);
|
|
55
|
+
case "export":
|
|
56
|
+
return seersExport(rest);
|
|
57
|
+
case "apply":
|
|
58
|
+
return seersApply(rest);
|
|
59
|
+
default:
|
|
60
|
+
throw new CliError(
|
|
61
|
+
`Unknown seers subcommand: ${sub ?? "(none)"}. Try: list, get, create, update, delete, run, events, dismiss, headlines, held, release, discard, providers, connections, connect, export, apply`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- JSON input helpers ------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
// Read a JSON object from --<name> '<json>' or --<name>-file <path>.
|
|
69
|
+
function readJsonObjectFlag(flags, name) {
|
|
70
|
+
const inline = flagStr(flags[name]);
|
|
71
|
+
const file = flagStr(flags[`${name}-file`]);
|
|
72
|
+
if (inline === undefined && file === undefined) return undefined;
|
|
73
|
+
const raw = inline !== undefined ? inline : readFileOrThrow(file, `--${name}-file`);
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new CliError(`--${name} is not valid JSON: ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
81
|
+
throw new CliError(`--${name} must be a JSON object`);
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readFileOrThrow(path, label) {
|
|
87
|
+
try {
|
|
88
|
+
return readFileSync(path, "utf8");
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw new CliError(`Could not read ${label} ${path}: ${err.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Seer CRUD ---------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function formatSeerLine(s) {
|
|
97
|
+
const state = s.enabled === false ? "paused" : "enabled";
|
|
98
|
+
const events = s.event_count !== undefined ? ` · ${s.event_count} events, ${s.produced_count} produced` : "";
|
|
99
|
+
const coll = s.collection_name ? ` → ${s.collection_name}` : "";
|
|
100
|
+
return `${s.id} [${s.provider}/${s.mode}, ${state}, every ${s.poll_interval_minutes}m] ${s.name}${coll}${events}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function seersList(ctx) {
|
|
104
|
+
const { client, flags, io } = ctx;
|
|
105
|
+
const result = await client.callTool("list_seers", {});
|
|
106
|
+
printResult(io, flags, result, (r) => {
|
|
107
|
+
const lines = (r.seers ?? []).map(formatSeerLine);
|
|
108
|
+
if (!lines.length) lines.push("No seers yet. Create one with `seers create` (see `seers providers`).");
|
|
109
|
+
if (r.positioning_status && !r.positioning_status.configured) {
|
|
110
|
+
lines.push("");
|
|
111
|
+
lines.push("Note: org positioning is not configured — relevance judging has nothing to gate on.");
|
|
112
|
+
}
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
});
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function seersGet(ctx) {
|
|
119
|
+
const { client, positionals, flags, io } = ctx;
|
|
120
|
+
const seerId = requirePositional(positionals, 0, "seer-id");
|
|
121
|
+
const result = await client.callTool("get_seer", { seer_id: seerId });
|
|
122
|
+
printResult(io, flags, result);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function seerWriteArgsFromFlags(flags) {
|
|
127
|
+
const enabledRaw = flagStr(flags.enabled);
|
|
128
|
+
if (enabledRaw !== undefined && enabledRaw !== "true" && enabledRaw !== "false") {
|
|
129
|
+
throw new CliError('--enabled must be "true" or "false"');
|
|
130
|
+
}
|
|
131
|
+
return compact({
|
|
132
|
+
name: flagStr(flags.name),
|
|
133
|
+
collection_id: flagStr(flags.collection),
|
|
134
|
+
mode: flagStr(flags.mode),
|
|
135
|
+
poll_interval_minutes: flagNum(flags.interval),
|
|
136
|
+
enabled: enabledRaw === undefined ? undefined : enabledRaw === "true",
|
|
137
|
+
config: readJsonObjectFlag(flags, "config"),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function seersCreate(ctx) {
|
|
142
|
+
const { client, flags, io } = ctx;
|
|
143
|
+
const args = seerWriteArgsFromFlags(flags);
|
|
144
|
+
if (!args.name) throw new CliError("Missing required --name");
|
|
145
|
+
if (!args.collection_id) throw new CliError("Missing required --collection <uuid>");
|
|
146
|
+
const provider = flagStr(flags.provider);
|
|
147
|
+
if (!provider) throw new CliError("Missing required --provider (run `seers providers` for the catalog)");
|
|
148
|
+
delete args.enabled; // create has no enabled field; new seers start enabled
|
|
149
|
+
const result = await client.callTool("create_seer", { ...args, provider, config: args.config ?? {} });
|
|
150
|
+
ok(ctx, "Seer created.");
|
|
151
|
+
printResult(io, flags, result, (r) => formatSeerLine(r.seer));
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function seersUpdate(ctx) {
|
|
156
|
+
const { client, positionals, flags, io } = ctx;
|
|
157
|
+
const seerId = requirePositional(positionals, 0, "seer-id");
|
|
158
|
+
const args = seerWriteArgsFromFlags(flags);
|
|
159
|
+
if (Object.keys(args).length === 0) {
|
|
160
|
+
throw new CliError("Nothing to update — pass --name/--collection/--mode/--enabled/--interval/--config");
|
|
161
|
+
}
|
|
162
|
+
const result = await client.callTool("update_seer", { seer_id: seerId, ...args });
|
|
163
|
+
ok(ctx, "Seer updated.");
|
|
164
|
+
printResult(io, flags, result, (r) => formatSeerLine(r.seer));
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function seersDelete(ctx) {
|
|
169
|
+
const { client, positionals, flags, io } = ctx;
|
|
170
|
+
const seerId = requirePositional(positionals, 0, "seer-id");
|
|
171
|
+
if (!flagBool(flags.yes)) throw new CliError("Deleting a seer removes its detected events. Re-run with --yes.");
|
|
172
|
+
const result = await client.callTool("delete_seer", { seer_id: seerId });
|
|
173
|
+
ok(ctx, "Seer deleted.");
|
|
174
|
+
printResult(io, flags, result);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function seersRun(ctx) {
|
|
179
|
+
const { client, positionals, flags, io } = ctx;
|
|
180
|
+
const seerId = requirePositional(positionals, 0, "seer-id");
|
|
181
|
+
ok(ctx, "Polling now — web-scan providers can take a few minutes…");
|
|
182
|
+
const result = await client.callTool("run_seer", { seer_id: seerId });
|
|
183
|
+
printResult(
|
|
184
|
+
io,
|
|
185
|
+
flags,
|
|
186
|
+
result,
|
|
187
|
+
(r) =>
|
|
188
|
+
`Detected ${r.detected}, produced ${r.produced} (auto-drafted ${r.auto_drafted}, suggested-only ${r.suggested_only}, dismissed ${r.dismissed}).`
|
|
189
|
+
);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- Events / headlines / holding pen ----------------------------------------------
|
|
194
|
+
|
|
195
|
+
async function seersEvents(ctx) {
|
|
196
|
+
const { client, positionals, flags, io } = ctx;
|
|
197
|
+
const seerId = requirePositional(positionals, 0, "seer-id");
|
|
198
|
+
const args = compact({ seer_id: seerId, limit: flagNum(flags.limit), offset: flagNum(flags.offset) });
|
|
199
|
+
const result = await client.callTool("list_seer_events", args);
|
|
200
|
+
printResult(io, flags, result, (r) => {
|
|
201
|
+
const lines = (r.events ?? []).map((e) => {
|
|
202
|
+
const blocked = e.auto_production_blocked_reason ? ` [blocked: ${e.auto_production_blocked_reason}]` : "";
|
|
203
|
+
const aliases = e.aliases?.length ? ` (+${e.aliases.length} aliases)` : "";
|
|
204
|
+
return `${e.id} [${e.status}] ${e.detected_at} ${e.title}${aliases}${blocked}`;
|
|
205
|
+
});
|
|
206
|
+
if (!lines.length) lines.push("No events detected yet.");
|
|
207
|
+
if (r.has_more) lines.push(`… more available (--offset ${r.next_offset})`);
|
|
208
|
+
return lines.join("\n");
|
|
209
|
+
});
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function seersDismiss(ctx) {
|
|
214
|
+
const { client, positionals, flags, io } = ctx;
|
|
215
|
+
const seerId = requirePositional(positionals, 0, "seer-id");
|
|
216
|
+
const eventId = requirePositional(positionals, 1, "event-id");
|
|
217
|
+
const result = await client.callTool("dismiss_seer_event", { seer_id: seerId, event_id: eventId });
|
|
218
|
+
ok(ctx, "Event dismissed.");
|
|
219
|
+
printResult(io, flags, result);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function seersHeadlines(ctx) {
|
|
224
|
+
const { client, flags, io } = ctx;
|
|
225
|
+
const args = compact({
|
|
226
|
+
collection_id: flagStr(flags.collection),
|
|
227
|
+
limit: flagNum(flags.limit),
|
|
228
|
+
offset: flagNum(flags.offset),
|
|
229
|
+
});
|
|
230
|
+
const result = await client.callTool("list_seer_headlines", args);
|
|
231
|
+
printResult(io, flags, result, (r) => {
|
|
232
|
+
const lines = (r.headlines ?? []).map(
|
|
233
|
+
(h) => `${h.id} [${h.status}] ${h.title ?? h.headline ?? "(untitled)"}`
|
|
234
|
+
);
|
|
235
|
+
if (!lines.length) lines.push("No seer-sourced headlines right now.");
|
|
236
|
+
return lines.join("\n");
|
|
237
|
+
});
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function seersHeld(ctx) {
|
|
242
|
+
const { client, flags, io } = ctx;
|
|
243
|
+
const args = compact({ collection_id: flagStr(flags.collection) });
|
|
244
|
+
const result = await client.callTool("list_held_drafts", args);
|
|
245
|
+
printResult(io, flags, result, (r) => {
|
|
246
|
+
const lines = (r.drafts ?? []).map(
|
|
247
|
+
(d) => `${d.articleId} [${d.kind}] ${d.title} — ${d.reason} (dropped ${d.dropped}, unsure ${d.unsure})`
|
|
248
|
+
);
|
|
249
|
+
if (!lines.length) lines.push("Holding pen is empty.");
|
|
250
|
+
else lines.push("", "Release with `seers release <article-id>`, or drop with `seers discard <article-id>`.");
|
|
251
|
+
return lines.join("\n");
|
|
252
|
+
});
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function seersResolveHeld(ctx, action) {
|
|
257
|
+
const { client, positionals, flags, io } = ctx;
|
|
258
|
+
const articleId = requirePositional(positionals, 0, "article-id");
|
|
259
|
+
const result = await client.callTool("resolve_held_draft", { article_id: articleId, action });
|
|
260
|
+
ok(ctx, action === "release" ? "Draft released — it can publish through the normal path." : "Draft discarded.");
|
|
261
|
+
printResult(io, flags, result);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- Providers & connections -------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
async function seersProviders(ctx) {
|
|
268
|
+
const { client, flags, io } = ctx;
|
|
269
|
+
const result = await client.callTool("list_seer_providers", {});
|
|
270
|
+
printResult(io, flags, result, (r) => {
|
|
271
|
+
const blocks = (r.providers ?? []).map((p) => {
|
|
272
|
+
const fields = p.fields
|
|
273
|
+
.map((f) => ` ${f.key}${f.required ? " (required)" : ""} — ${f.help ?? f.label}`)
|
|
274
|
+
.join("\n");
|
|
275
|
+
const conn = p.requires_connection ? ` connection: ${p.requires_connection}\n` : "";
|
|
276
|
+
return ` ${p.id} — ${p.tagline}\n${conn}${fields}`;
|
|
277
|
+
});
|
|
278
|
+
const behavior = Object.entries(r.behavior_keys ?? {})
|
|
279
|
+
.map(([k, v]) => ` ${k} — ${v}`)
|
|
280
|
+
.join("\n");
|
|
281
|
+
return `Providers:\n${blocks.join("\n\n")}\n\nShared config behavior keys (all providers):\n${behavior}`;
|
|
282
|
+
});
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function seersConnections(ctx) {
|
|
287
|
+
const { client, flags, io } = ctx;
|
|
288
|
+
const result = await client.callTool("get_seer_connections", {});
|
|
289
|
+
printResult(io, flags, result, (r) => {
|
|
290
|
+
const gh = r.github.connected
|
|
291
|
+
? `GitHub: connected as ${r.github.account_login} (${r.github.connected_at})`
|
|
292
|
+
: `GitHub: not connected${r.github.configured ? " — connect with `seers connect github --token <pat>`" : " (token storage not configured on this deployment)"}`;
|
|
293
|
+
const notion = r.notion.connected
|
|
294
|
+
? `Notion: connected to ${r.notion.workspace_name} (${r.notion.connected_at})`
|
|
295
|
+
: "Notion: not connected — connect via browser in the app (Seers tab → Connect Notion); there is no headless flow";
|
|
296
|
+
return `${gh}\n${notion}`;
|
|
297
|
+
});
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function seersConnect(ctx) {
|
|
302
|
+
const which = ctx.positionals[0];
|
|
303
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
304
|
+
if (which === "github") {
|
|
305
|
+
const token = flagStr(rest.flags.token);
|
|
306
|
+
if (!token) throw new CliError("Missing required --token <github-pat> (repo read access)");
|
|
307
|
+
const result = await rest.client.callTool("set_github_connection", { access_token: token });
|
|
308
|
+
ok(rest, "GitHub connected.");
|
|
309
|
+
printResult(rest.io, rest.flags, result);
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
if (which === "notion") {
|
|
313
|
+
// Deliberate gap: Notion's OAuth flow needs a browser session; the integrations
|
|
314
|
+
// surface can read status but can't complete the dance.
|
|
315
|
+
rest.io.log("Notion connects via browser OAuth and can't be completed from the CLI.");
|
|
316
|
+
rest.io.log(`Open ${rest.config.url.replace(/\/$/, "")}/seers and use "Connect Notion", then verify with:`);
|
|
317
|
+
rest.io.log(` ${rest.bin} seers connections`);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
throw new CliError(`Unknown connect target: ${which ?? "(none)"}. Try: github, notion`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- Export / apply (reproducible onboarding) --------------------------------------
|
|
324
|
+
|
|
325
|
+
const MANIFEST_VERSION = 1;
|
|
326
|
+
// The seer fields a manifest owns. Cursor/lease/poll state is deliberately NOT
|
|
327
|
+
// exported — a manifest describes configuration, not runtime progress.
|
|
328
|
+
function manifestEntryFromSeer(s) {
|
|
329
|
+
return {
|
|
330
|
+
name: s.name,
|
|
331
|
+
provider: s.provider,
|
|
332
|
+
mode: s.mode,
|
|
333
|
+
enabled: s.enabled !== false,
|
|
334
|
+
poll_interval_minutes: s.poll_interval_minutes,
|
|
335
|
+
collection_id: s.collection_id,
|
|
336
|
+
collection_name: s.collection_name ?? undefined,
|
|
337
|
+
config: s.config ?? {},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function seersExport(ctx) {
|
|
342
|
+
const { client, flags, io } = ctx;
|
|
343
|
+
const result = await client.callTool("list_seers", {});
|
|
344
|
+
const manifest = {
|
|
345
|
+
version: MANIFEST_VERSION,
|
|
346
|
+
seers: (result.seers ?? []).map(manifestEntryFromSeer),
|
|
347
|
+
};
|
|
348
|
+
const out = JSON.stringify(manifest, null, 2);
|
|
349
|
+
const file = flagStr(flags.file);
|
|
350
|
+
if (file) {
|
|
351
|
+
writeFileSync(file, `${out}\n`);
|
|
352
|
+
ok(ctx, `Exported ${manifest.seers.length} seers to ${file}.`);
|
|
353
|
+
} else {
|
|
354
|
+
io.log(out);
|
|
355
|
+
}
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Key-order-independent flat-object equality — the same rule the server uses to
|
|
360
|
+
// decide whether an update actually changed the config (and must reset the cursor).
|
|
361
|
+
function sameFlatObject(a, b) {
|
|
362
|
+
const stable = (o) =>
|
|
363
|
+
JSON.stringify(
|
|
364
|
+
Object.keys(o ?? {})
|
|
365
|
+
.sort()
|
|
366
|
+
.map((k) => [k, o[k]])
|
|
367
|
+
);
|
|
368
|
+
return stable(a) === stable(b);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function loadManifest(flags) {
|
|
372
|
+
const file = flagStr(flags.file);
|
|
373
|
+
if (!file) throw new CliError("Missing required --file <manifest.json> (produce one with `seers export`)");
|
|
374
|
+
let parsed;
|
|
375
|
+
try {
|
|
376
|
+
parsed = JSON.parse(readFileOrThrow(file, "--file"));
|
|
377
|
+
} catch (err) {
|
|
378
|
+
throw err instanceof CliError ? err : new CliError(`--file is not valid JSON: ${err.message}`);
|
|
379
|
+
}
|
|
380
|
+
if (parsed?.version !== MANIFEST_VERSION || !Array.isArray(parsed.seers)) {
|
|
381
|
+
throw new CliError(`Manifest must be { "version": ${MANIFEST_VERSION}, "seers": [...] }`);
|
|
382
|
+
}
|
|
383
|
+
for (const [i, entry] of parsed.seers.entries()) {
|
|
384
|
+
if (!entry?.name) throw new CliError(`Manifest seers[${i}] is missing "name" (names key the upsert)`);
|
|
385
|
+
if (!entry.provider) throw new CliError(`Manifest seers[${i}] ("${entry.name}") is missing "provider"`);
|
|
386
|
+
}
|
|
387
|
+
const names = parsed.seers.map((s) => s.name);
|
|
388
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
389
|
+
if (dupes.length) throw new CliError(`Manifest has duplicate seer names: ${[...new Set(dupes)].join(", ")}`);
|
|
390
|
+
return parsed;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Resolve each manifest entry's destination collection: an explicit collection_id
|
|
394
|
+
// wins; otherwise collection_name is matched against the org's collections.
|
|
395
|
+
async function resolveCollections(client, entries) {
|
|
396
|
+
const needsLookup = entries.some((e) => !e.collection_id && e.collection_name);
|
|
397
|
+
if (!needsLookup) return;
|
|
398
|
+
let collections;
|
|
399
|
+
try {
|
|
400
|
+
const result = await client.callTool("list_collections", {});
|
|
401
|
+
collections = result.collections ?? result;
|
|
402
|
+
} catch (err) {
|
|
403
|
+
throw new CliError(
|
|
404
|
+
`Manifest entries name collections by collection_name, which needs the list_collections tool (capability article:read): ${err.message}`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const byName = new Map((collections ?? []).map((c) => [c.name, c.id]));
|
|
408
|
+
for (const entry of entries) {
|
|
409
|
+
if (entry.collection_id || !entry.collection_name) continue;
|
|
410
|
+
const id = byName.get(entry.collection_name);
|
|
411
|
+
if (!id) throw new CliError(`Manifest seer "${entry.name}": no collection named "${entry.collection_name}"`);
|
|
412
|
+
entry.collection_id = id;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function planSeerApply(existingSeers, entries) {
|
|
417
|
+
const byName = new Map(existingSeers.map((s) => [s.name, s]));
|
|
418
|
+
const plan = [];
|
|
419
|
+
for (const entry of entries) {
|
|
420
|
+
if (!entry.collection_id) {
|
|
421
|
+
throw new CliError(`Manifest seer "${entry.name}" needs a collection_id or collection_name`);
|
|
422
|
+
}
|
|
423
|
+
const current = byName.get(entry.name);
|
|
424
|
+
if (!current) {
|
|
425
|
+
plan.push({ action: "create", entry });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (current.provider !== entry.provider) {
|
|
429
|
+
throw new CliError(
|
|
430
|
+
`Manifest seer "${entry.name}" is ${entry.provider} but the existing seer is ${current.provider}. ` +
|
|
431
|
+
"A seer's provider can't change — rename one of them."
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const changes = {};
|
|
435
|
+
if (entry.collection_id !== current.collection_id) changes.collection_id = entry.collection_id;
|
|
436
|
+
if (entry.mode !== undefined && entry.mode !== current.mode) changes.mode = entry.mode;
|
|
437
|
+
if (entry.enabled !== undefined && entry.enabled !== (current.enabled !== false))
|
|
438
|
+
changes.enabled = entry.enabled;
|
|
439
|
+
if (
|
|
440
|
+
entry.poll_interval_minutes !== undefined &&
|
|
441
|
+
entry.poll_interval_minutes !== current.poll_interval_minutes
|
|
442
|
+
) {
|
|
443
|
+
changes.poll_interval_minutes = entry.poll_interval_minutes;
|
|
444
|
+
}
|
|
445
|
+
if (entry.config !== undefined && !sameFlatObject(entry.config, current.config)) changes.config = entry.config;
|
|
446
|
+
plan.push(
|
|
447
|
+
Object.keys(changes).length
|
|
448
|
+
? { action: "update", entry, current, changes }
|
|
449
|
+
: { action: "unchanged", entry, current }
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const manifestNames = new Set(entries.map((e) => e.name));
|
|
453
|
+
const extras = existingSeers.filter((s) => !manifestNames.has(s.name));
|
|
454
|
+
return { plan, extras };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function describePlan(plan, extras) {
|
|
458
|
+
const lines = [];
|
|
459
|
+
for (const step of plan) {
|
|
460
|
+
if (step.action === "create") {
|
|
461
|
+
lines.push(` + create ${step.entry.name} [${step.entry.provider}/${step.entry.mode ?? "suggest"}]`);
|
|
462
|
+
} else if (step.action === "update") {
|
|
463
|
+
lines.push(` ~ update ${step.entry.name} (${Object.keys(step.changes).join(", ")})`);
|
|
464
|
+
} else {
|
|
465
|
+
lines.push(` = ok ${step.entry.name}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (extras.length) {
|
|
469
|
+
lines.push("");
|
|
470
|
+
lines.push(" Present in the org but not in the manifest (left untouched — apply never deletes):");
|
|
471
|
+
for (const s of extras) lines.push(` ? extra ${s.name} [${s.provider}]`);
|
|
472
|
+
}
|
|
473
|
+
return lines.join("\n");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function seersApply(ctx) {
|
|
477
|
+
const { client, flags, io } = ctx;
|
|
478
|
+
const manifest = loadManifest(flags);
|
|
479
|
+
await resolveCollections(client, manifest.seers);
|
|
480
|
+
|
|
481
|
+
const current = await client.callTool("list_seers", {});
|
|
482
|
+
const { plan, extras } = planSeerApply(current.seers ?? [], manifest.seers);
|
|
483
|
+
|
|
484
|
+
const creates = plan.filter((p) => p.action === "create");
|
|
485
|
+
const updates = plan.filter((p) => p.action === "update");
|
|
486
|
+
|
|
487
|
+
if (flags.json) {
|
|
488
|
+
// Machine-readable plan; on a real apply the executed flag flips below.
|
|
489
|
+
const payload = { plan, extras: extras.map((s) => ({ name: s.name, id: s.id })), executed: false };
|
|
490
|
+
if (flagBool(flags["dry-run"]) || (!creates.length && !updates.length)) {
|
|
491
|
+
io.log(JSON.stringify(payload, null, 2));
|
|
492
|
+
if (flagBool(flags["dry-run"])) return 0;
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
io.log(`Plan for ${manifest.seers.length} manifest seers:`);
|
|
496
|
+
io.log(describePlan(plan, extras));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (flagBool(flags["dry-run"])) {
|
|
500
|
+
ok(ctx, "\nDry run — nothing applied.");
|
|
501
|
+
return 0;
|
|
502
|
+
}
|
|
503
|
+
if (!creates.length && !updates.length) {
|
|
504
|
+
ok(ctx, "\nEverything already matches the manifest.");
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const applied = [];
|
|
509
|
+
for (const step of creates) {
|
|
510
|
+
const { entry } = step;
|
|
511
|
+
const result = await client.callTool("create_seer", {
|
|
512
|
+
name: entry.name,
|
|
513
|
+
provider: entry.provider,
|
|
514
|
+
collection_id: entry.collection_id,
|
|
515
|
+
...(entry.mode !== undefined ? { mode: entry.mode } : {}),
|
|
516
|
+
...(entry.poll_interval_minutes !== undefined
|
|
517
|
+
? { poll_interval_minutes: entry.poll_interval_minutes }
|
|
518
|
+
: {}),
|
|
519
|
+
config: entry.config ?? {},
|
|
520
|
+
});
|
|
521
|
+
applied.push({ action: "created", name: entry.name, id: result.seer?.id });
|
|
522
|
+
ok(ctx, `Created ${entry.name}.`);
|
|
523
|
+
// The create tool has no enabled field; a manifest that wants it paused
|
|
524
|
+
// needs a follow-up update.
|
|
525
|
+
if (entry.enabled === false && result.seer?.id) {
|
|
526
|
+
await client.callTool("update_seer", { seer_id: result.seer.id, enabled: false });
|
|
527
|
+
ok(ctx, ` …and paused it (manifest says enabled: false).`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const step of updates) {
|
|
531
|
+
await client.callTool("update_seer", { seer_id: step.current.id, ...step.changes });
|
|
532
|
+
applied.push({
|
|
533
|
+
action: "updated",
|
|
534
|
+
name: step.entry.name,
|
|
535
|
+
id: step.current.id,
|
|
536
|
+
changes: Object.keys(step.changes),
|
|
537
|
+
});
|
|
538
|
+
ok(ctx, `Updated ${step.entry.name} (${Object.keys(step.changes).join(", ")}).`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (flags.json) {
|
|
542
|
+
io.log(
|
|
543
|
+
JSON.stringify(
|
|
544
|
+
{ plan, extras: extras.map((s) => ({ name: s.name, id: s.id })), executed: true, applied },
|
|
545
|
+
null,
|
|
546
|
+
2
|
|
547
|
+
)
|
|
548
|
+
);
|
|
549
|
+
} else {
|
|
550
|
+
ok(
|
|
551
|
+
ctx,
|
|
552
|
+
`\nApplied: ${creates.length} created, ${updates.length} updated, ${plan.length - creates.length - updates.length} unchanged.`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- Templates ---------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
export async function cmdTemplates(ctx) {
|
|
561
|
+
const sub = ctx.positionals[0];
|
|
562
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
563
|
+
switch (sub) {
|
|
564
|
+
case "list":
|
|
565
|
+
case "ls":
|
|
566
|
+
return templatesList(rest);
|
|
567
|
+
case "show":
|
|
568
|
+
case "get":
|
|
569
|
+
return templatesShow(rest);
|
|
570
|
+
case "create":
|
|
571
|
+
case "new":
|
|
572
|
+
return templatesCreate(rest);
|
|
573
|
+
case "update":
|
|
574
|
+
return templatesUpdate(rest);
|
|
575
|
+
case "delete":
|
|
576
|
+
case "rm":
|
|
577
|
+
return templatesDelete(rest);
|
|
578
|
+
default:
|
|
579
|
+
throw new CliError(
|
|
580
|
+
`Unknown templates subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function templatesList(ctx) {
|
|
586
|
+
const { client, flags, io } = ctx;
|
|
587
|
+
const result = await client.callTool("list_templates", {});
|
|
588
|
+
printResult(io, flags, result, (r) => {
|
|
589
|
+
const custom = (r.templates ?? []).map((t) => ` ${t.key} ${t.name} — ${t.angle} (id ${t.id})`);
|
|
590
|
+
const builtIn = (r.built_ins ?? []).map((t) => ` ${t.key} ${t.name}`);
|
|
591
|
+
return [
|
|
592
|
+
custom.length ? "Custom templates:" : "No custom templates yet.",
|
|
593
|
+
...custom,
|
|
594
|
+
"",
|
|
595
|
+
"Built-in templates (read-only):",
|
|
596
|
+
...builtIn,
|
|
597
|
+
].join("\n");
|
|
598
|
+
});
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function templatesShow(ctx) {
|
|
603
|
+
const { client, positionals, flags, io } = ctx;
|
|
604
|
+
const templateId = requirePositional(positionals, 0, "template-id");
|
|
605
|
+
const result = await client.callTool("get_template", { template_id: templateId });
|
|
606
|
+
printResult(io, flags, result);
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function templateSectionsFromFlags(flags) {
|
|
611
|
+
const inline = flagStr(flags.sections);
|
|
612
|
+
const file = flagStr(flags["sections-file"]);
|
|
613
|
+
if (inline === undefined && file === undefined) return undefined;
|
|
614
|
+
const raw = inline !== undefined ? inline : readFileOrThrow(file, "--sections-file");
|
|
615
|
+
let parsed;
|
|
616
|
+
try {
|
|
617
|
+
parsed = JSON.parse(raw);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
throw new CliError(`--sections is not valid JSON: ${err.message}`);
|
|
620
|
+
}
|
|
621
|
+
if (!Array.isArray(parsed)) throw new CliError("--sections must be a JSON array of section objects");
|
|
622
|
+
return parsed;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function templatesCreate(ctx) {
|
|
626
|
+
const { client, flags, io } = ctx;
|
|
627
|
+
const name = flagStr(flags.name);
|
|
628
|
+
const angle = flagStr(flags.angle);
|
|
629
|
+
const sections = templateSectionsFromFlags(flags);
|
|
630
|
+
if (!name) throw new CliError("Missing required --name");
|
|
631
|
+
if (!angle) throw new CliError("Missing required --angle");
|
|
632
|
+
if (!sections) throw new CliError("Missing required --sections '<json>' or --sections-file <path>");
|
|
633
|
+
const result = await client.callTool("create_template", { name, angle, sections });
|
|
634
|
+
ok(ctx, `Template created — key ${result.template?.key}.`);
|
|
635
|
+
printResult(io, flags, result);
|
|
636
|
+
return 0;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function templatesUpdate(ctx) {
|
|
640
|
+
const { client, positionals, flags, io } = ctx;
|
|
641
|
+
const templateId = requirePositional(positionals, 0, "template-id");
|
|
642
|
+
const name = flagStr(flags.name);
|
|
643
|
+
const angle = flagStr(flags.angle);
|
|
644
|
+
const sections = templateSectionsFromFlags(flags);
|
|
645
|
+
// update_template replaces the whole template; fetch current values for any
|
|
646
|
+
// field the caller didn't pass so a partial edit doesn't blank the rest.
|
|
647
|
+
let current;
|
|
648
|
+
if (!name || !angle || !sections) {
|
|
649
|
+
const existing = await client.callTool("get_template", { template_id: templateId });
|
|
650
|
+
current = existing.template;
|
|
651
|
+
}
|
|
652
|
+
const result = await client.callTool("update_template", {
|
|
653
|
+
template_id: templateId,
|
|
654
|
+
name: name ?? current.name,
|
|
655
|
+
angle: angle ?? current.angle,
|
|
656
|
+
sections: sections ?? current.sections,
|
|
657
|
+
});
|
|
658
|
+
ok(ctx, "Template updated.");
|
|
659
|
+
printResult(io, flags, result);
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function templatesDelete(ctx) {
|
|
664
|
+
const { client, positionals, flags, io } = ctx;
|
|
665
|
+
const templateId = requirePositional(positionals, 0, "template-id");
|
|
666
|
+
if (!flagBool(flags.yes)) {
|
|
667
|
+
throw new CliError("Seers bound to this template fall back to defaults. Re-run with --yes to delete.");
|
|
668
|
+
}
|
|
669
|
+
const result = await client.callTool("delete_template", { template_id: templateId });
|
|
670
|
+
ok(ctx, "Template deleted.");
|
|
671
|
+
printResult(io, flags, result);
|
|
672
|
+
return 0;
|
|
673
|
+
}
|
package/lib/commands.mjs
CHANGED
|
@@ -15,6 +15,8 @@ export * from "./commands/flows.mjs";
|
|
|
15
15
|
export * from "./commands/connectors.mjs";
|
|
16
16
|
export * from "./commands/strategy.mjs";
|
|
17
17
|
export * from "./commands/insights.mjs";
|
|
18
|
+
export * from "./commands/covers.mjs";
|
|
18
19
|
export * from "./commands/research.mjs";
|
|
19
20
|
export * from "./commands/kernel.mjs";
|
|
20
21
|
export * from "./commands/phantom-job.mjs";
|
|
22
|
+
export * from "./commands/seers.mjs";
|