innernote 0.3.4 → 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.
Files changed (3) hide show
  1. package/README.md +13 -5
  2. package/dist/index.js +1179 -72
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -500,6 +500,57 @@ function resolveRef(ref) {
500
500
  return /^[a-z0-9]{20,}$/.test(ref) ? { id: ref, title: ref } : null;
501
501
  }
502
502
 
503
+ // src/posts.ts
504
+ async function listPosts(status) {
505
+ const data = await request(`/api/posts${status ? `?status=${encodeURIComponent(status)}` : ""}`);
506
+ return data.posts;
507
+ }
508
+ async function fetchPost(id) {
509
+ return (await listPosts()).find((p) => p._id === id) ?? null;
510
+ }
511
+ async function listSeries() {
512
+ const data = await request("/api/campaigns");
513
+ return data.campaigns;
514
+ }
515
+ function matchSeries(all, name) {
516
+ const want = name.trim().toLowerCase().replace(/\s+/g, " ");
517
+ return all.find((s) => s.name.trim().toLowerCase().replace(/\s+/g, " ") === want) ?? null;
518
+ }
519
+ function linkedinUrl(post) {
520
+ return post.linkedinPostUrn ? `https://www.linkedin.com/feed/update/${post.linkedinPostUrn}/` : null;
521
+ }
522
+ function describeMedia(post) {
523
+ const m = post.media;
524
+ if (!m || m.kind === "text")
525
+ return null;
526
+ const title = typeof m.title === "string" && m.title ? `: ${m.title}` : "";
527
+ const assets = Array.isArray(m.assets) ? m.assets.length : 0;
528
+ switch (m.kind) {
529
+ case "image":
530
+ return "image";
531
+ case "multiImage":
532
+ return `${assets} images`;
533
+ case "video":
534
+ return `video${title}`;
535
+ case "document":
536
+ return `PDF${title}`;
537
+ case "poll":
538
+ return "poll";
539
+ case "link":
540
+ return "link";
541
+ default:
542
+ return m.kind;
543
+ }
544
+ }
545
+ function postForJson(post, series) {
546
+ const inSeries = post.campaignId ? series.find((s) => s._id === post.campaignId) : undefined;
547
+ return {
548
+ ...post,
549
+ seriesName: inSeries?.name ?? null,
550
+ linkedinUrl: linkedinUrl(post)
551
+ };
552
+ }
553
+
503
554
  // src/flags.ts
504
555
  function parseFlags(args, valueFlags = []) {
505
556
  const values = new Map;
@@ -600,29 +651,42 @@ ${data.ideas.length} ${data.ideas.length === 1 ? "idea" : "ideas"}`));
600
651
  return 0;
601
652
  }
602
653
  async function drafts(args) {
603
- const { json, rest } = parseFlags(args);
654
+ const { json, rest, value } = parseFlags(args, ["series"]);
604
655
  const status = rest[0];
605
- const data = await request(`/api/posts${status ? `?status=${encodeURIComponent(status)}` : ""}`);
656
+ const seriesName = value("series");
657
+ const [all, series] = await Promise.all([listPosts(status), listSeries()]);
658
+ let posts = all;
659
+ if (seriesName) {
660
+ const target = matchSeries(series, seriesName);
661
+ if (!target) {
662
+ note(`No series called "${seriesName}".`);
663
+ if (series.length)
664
+ note(dim2(`Yours: ${series.map((s) => s.name).join(", ")}`));
665
+ return 1;
666
+ }
667
+ posts = all.filter((p) => p.campaignId === target._id).sort((a, b) => (a.campaignOrder ?? "").localeCompare(b.campaignOrder ?? ""));
668
+ }
606
669
  if (json) {
607
- out(JSON.stringify(data.posts, null, 2));
670
+ out(JSON.stringify(posts.map((p) => postForJson(p, series)), null, 2));
608
671
  return 0;
609
672
  }
610
- if (data.posts.length === 0) {
611
- note("No posts yet. Write one with: write");
673
+ if (posts.length === 0) {
674
+ note(seriesName ? "Nothing in that series yet." : "No posts yet. Write one with: write");
612
675
  return 0;
613
676
  }
614
- const shown = data.posts.slice(0, 30);
677
+ const shown = posts.slice(0, 30);
615
678
  rememberList(shown.map((p) => ({ id: p._id, title: (p.title || p.content).replace(/\s+/g, " ") })));
616
679
  for (const [i, post] of shown.entries()) {
617
680
  const n = String(i + 1).padStart(2);
618
- out(`${caramel(n)} ${dim2(post.status.padEnd(9))} ${oneLine(post.title || post.content, 52)}`);
681
+ const likes = post.status === "published" && post.linkedinLikes !== undefined ? dim2(` ${post.linkedinLikes} likes`) : "";
682
+ out(`${caramel(n)} ${dim2(post.status.padEnd(9))} ${oneLine(post.title || post.content, 52)}${likes}`);
619
683
  }
620
- if (data.posts.length > shown.length) {
684
+ if (posts.length > shown.length) {
621
685
  note(dim2(`
622
- ...and ${data.posts.length - shown.length} more`));
686
+ ...and ${posts.length - shown.length} more`));
623
687
  }
624
688
  note(dim2(`
625
- open <number> to work on one`));
689
+ open <number> to work on one show <id> for everything about one`));
626
690
  return 0;
627
691
  }
628
692
  async function week(args) {
@@ -650,22 +714,34 @@ async function week(args) {
650
714
  }
651
715
  async function context(args) {
652
716
  const { json } = parseFlags(args);
653
- const [pillars, series] = await Promise.all([
717
+ const [pillars, series, notebook, products] = await Promise.all([
654
718
  request("/api/pillars"),
655
- request("/api/campaigns")
719
+ request("/api/campaigns"),
720
+ request("/api/notebook/brief").catch(() => ({ brief: null })),
721
+ request("/api/products").catch(() => ({ products: [] }))
656
722
  ]);
723
+ const productRows = products.products.map((p) => ({ name: p.name, oneLiner: p.oneLiner, postCount: p.postCount }));
657
724
  if (json) {
658
- out(JSON.stringify({ pillars: pillars.pillars, series: series.campaigns }, null, 2));
725
+ out(JSON.stringify({ pillars: pillars.pillars, series: series.campaigns, products: productRows, brief: notebook.brief }, null, 2));
659
726
  return 0;
660
727
  }
661
728
  out();
729
+ if (notebook.brief) {
730
+ out(` ${bold2("Who you are, as innernote knows it")}`);
731
+ for (const line of notebook.brief.split(`
732
+ `)) {
733
+ if (line.trim())
734
+ out(` ${dim2(line.replace(/^#+\s*/, ""))}`);
735
+ }
736
+ out();
737
+ }
662
738
  if (pillars.pillars.length) {
663
739
  out(` ${bold2("What you post about")}`);
664
740
  for (const p of pillars.pillars) {
665
741
  out(` ${caramel(p.name)}${typeof p.weight === "number" ? dim2(` ${p.weight}%`) : ""}`);
666
742
  }
667
743
  } else {
668
- out(` ${dim2("No content pillars yet. Set them in Settings.")}`);
744
+ out(` ${dim2("No topics yet. Set them in Settings.")}`);
669
745
  }
670
746
  out();
671
747
  if (series.campaigns.length) {
@@ -681,6 +757,15 @@ async function context(args) {
681
757
  } else {
682
758
  out(` ${dim2("No series running.")}`);
683
759
  }
760
+ if (productRows.length) {
761
+ out();
762
+ out(` ${bold2("Products")}${dim2(" work one into a post with")} ${caramel('ask --post <id> --product "<name>"')}`);
763
+ for (const p of productRows) {
764
+ out(` ${caramel(p.name)}${p.postCount ? dim2(` ${p.postCount} ${p.postCount === 1 ? "post" : "posts"}`) : ""}`);
765
+ if (p.oneLiner)
766
+ out(` ${dim2(oneLine(p.oneLiner, 64))}`);
767
+ }
768
+ }
684
769
  out();
685
770
  return 0;
686
771
  }
@@ -853,6 +938,7 @@ async function whileThinking(phases, run) {
853
938
  }
854
939
 
855
940
  // src/commands/write.ts
941
+ import { readFile as readFile2 } from "node:fs/promises";
856
942
  async function capture(args) {
857
943
  const { json, rest } = parseFlags(args);
858
944
  const typed = rest.join(" ").trim();
@@ -876,14 +962,26 @@ async function capture(args) {
876
962
  note(dim2("`ideas` to see them, `write` to turn one into a post"));
877
963
  return 0;
878
964
  }
965
+ async function saveNew(content, series, seriesSummary, title) {
966
+ return request("/api/posts", {
967
+ method: "POST",
968
+ body: {
969
+ content,
970
+ source: "cli",
971
+ ...title ? { title } : {},
972
+ ...series ? { series, seriesSummary: seriesSummary ?? undefined } : {}
973
+ }
974
+ });
975
+ }
879
976
  async function write(args) {
880
- const { json, rest, value, has } = parseFlags(args, ["thought", "format", "series"]);
977
+ const { json, rest, value, has } = parseFlags(args, ["thought", "format", "series", "title"]);
881
978
  const topic = rest.join(" ").trim();
882
979
  const thoughtFlag = value("thought");
883
980
  const piped = thoughtFlag || topic ? null : await readStdin();
884
981
  const thought = thoughtFlag ?? (piped ?? undefined);
885
982
  const format = value("format");
886
983
  const series = value("series");
984
+ const title = value("title");
887
985
  const save = has("save");
888
986
  const call = () => request("/api/content/generate", {
889
987
  method: "POST",
@@ -902,8 +1000,14 @@ async function write(args) {
902
1000
  seriesSummary: post.seriesSummary ?? undefined,
903
1001
  quality: post.qualityScore
904
1002
  });
1003
+ const saved = save ? await saveNew(post.content, series, post.seriesSummary, title) : null;
1004
+ if (saved)
1005
+ updateDraft({ id: saved.id });
1006
+ if (saved && !title) {
1007
+ await request(`/api/posts/${encodeURIComponent(saved.id)}/title`, { method: "POST", timeoutMs: 30000 }).catch(() => {});
1008
+ }
905
1009
  if (json) {
906
- out(JSON.stringify(post, null, 2));
1010
+ out(JSON.stringify(saved ? { ...post, id: saved.id } : post, null, 2));
907
1011
  return 0;
908
1012
  }
909
1013
  out();
@@ -920,16 +1024,7 @@ async function write(args) {
920
1024
  else if (post.similarity?.band === "adjacent")
921
1025
  notes.push("close to something you already published");
922
1026
  note(dim2(notes.join(", ")));
923
- if (save) {
924
- const saved = await request("/api/posts", {
925
- method: "POST",
926
- body: {
927
- content: post.content,
928
- source: "cli",
929
- ...series ? { series, seriesSummary: post.seriesSummary } : {}
930
- }
931
- });
932
- updateDraft({ id: saved.id });
1027
+ if (saved) {
933
1028
  ok(series ? `Saved into "${series}". ${dim2(saved.id)}` : `Saved as a draft. ${dim2(saved.id)}`);
934
1029
  } else {
935
1030
  note(dim2("Not saved yet. `save` to keep it, `shape shorter` to change it."));
@@ -993,8 +1088,17 @@ async function shape(args) {
993
1088
  note(dim2(held?.id ? "`save` to update it" : "`save` to keep it"));
994
1089
  return 0;
995
1090
  }
1091
+ function parseWhen(input) {
1092
+ const local = input.trim().match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})$/);
1093
+ if (local) {
1094
+ const [, y, mo, d, h, mi] = local.map(Number);
1095
+ return new Date(y, mo - 1, d, h, mi).getTime();
1096
+ }
1097
+ const t = Date.parse(input);
1098
+ return Number.isFinite(t) ? t : null;
1099
+ }
996
1100
  async function queue(args) {
997
- const { json, rest } = parseFlags(args);
1101
+ const { json, rest, value } = parseFlags(args, ["at"]);
998
1102
  const ref = rest[0] ? resolveRef(rest[0]) : null;
999
1103
  if (!ref) {
1000
1104
  fail("Which post?");
@@ -1002,7 +1106,20 @@ async function queue(args) {
1002
1106
  note(dim2(`Or pass the id: ${CMD} queue <post-id>`));
1003
1107
  return 1;
1004
1108
  }
1005
- const result = await request(`/api/posts/${encodeURIComponent(ref.id)}/queue`, { method: "POST", timeoutMs: 60000 });
1109
+ const at = value("at");
1110
+ let result;
1111
+ if (at !== undefined) {
1112
+ const when2 = parseWhen(at);
1113
+ if (when2 == null) {
1114
+ fail(`"${at}" is not a time.`);
1115
+ note(dim2(`${CMD} queue <post-id> --at "2026-10-12 12:31"`));
1116
+ return 1;
1117
+ }
1118
+ await request(`/api/linkedin/schedule`, { method: "POST", body: { postId: ref.id, scheduledFor: when2 }, timeoutMs: 60000 });
1119
+ result = { success: true, scheduledFor: when2 };
1120
+ } else {
1121
+ result = await request(`/api/posts/${encodeURIComponent(ref.id)}/queue`, { method: "POST", timeoutMs: 60000 });
1122
+ }
1006
1123
  if (json) {
1007
1124
  out(JSON.stringify(result, null, 2));
1008
1125
  return 0;
@@ -1076,9 +1193,21 @@ async function open(args) {
1076
1193
  return 0;
1077
1194
  }
1078
1195
  async function ask(args) {
1079
- const { json, rest } = parseFlags(args);
1196
+ const { json, rest, value, has } = parseFlags(args, ["post", "passage", "preset", "shape", "product"]);
1080
1197
  const message = rest.join(" ").trim();
1081
1198
  const held = getDraft();
1199
+ const postRef = value("post");
1200
+ if (postRef) {
1201
+ const target = {
1202
+ passage: value("passage"),
1203
+ preset: value("preset"),
1204
+ shape: value("shape"),
1205
+ region: has("hook") ? "hook" : has("ending") ? "ending" : undefined,
1206
+ product: value("product"),
1207
+ productMode: has("rewrite") ? "rewrite" : "weave"
1208
+ };
1209
+ return askAboutPost(postRef, message, target, json);
1210
+ }
1082
1211
  if (!message) {
1083
1212
  fail("Say what you want changed.");
1084
1213
  note(dim2("ask cut the second half and land it harder"));
@@ -1104,7 +1233,7 @@ async function ask(args) {
1104
1233
  id = saved.id;
1105
1234
  updateDraft({ id });
1106
1235
  }
1107
- const call = () => request(`/api/posts/${encodeURIComponent(id)}/edit`, { method: "POST", body: { text: message, scope: "whole" }, timeoutMs: 150000 });
1236
+ const call = () => request(`/api/posts/${encodeURIComponent(id)}/edit`, { method: "POST", body: { text: held.content, instruction: message, scope: "whole" }, timeoutMs: 150000 });
1108
1237
  const result = json ? await call() : await whileThinking([
1109
1238
  { at: 0, text: "reading the post..." },
1110
1239
  { at: 3, text: "working on it..." },
@@ -1130,7 +1259,9 @@ async function ask(args) {
1130
1259
  return 0;
1131
1260
  }
1132
1261
  async function show(args) {
1133
- const { json } = parseFlags(args);
1262
+ const { json, rest } = parseFlags(args);
1263
+ if (rest[0])
1264
+ return showPost(rest[0], json);
1134
1265
  const held = getDraft();
1135
1266
  if (!held?.content) {
1136
1267
  note("Nothing in hand.");
@@ -1184,6 +1315,280 @@ async function unqueue(args) {
1184
1315
  note(dim2("`drafts` to find it, `queue` to send it back"));
1185
1316
  return 0;
1186
1317
  }
1318
+ async function postFromRef(refArg, usage) {
1319
+ const ref = resolveRef(refArg);
1320
+ if (!ref) {
1321
+ fail(`No post ${refArg}.`);
1322
+ note(dim2(usage));
1323
+ return null;
1324
+ }
1325
+ const post = await fetchPost(ref.id);
1326
+ if (!post) {
1327
+ fail("That post could not be found.");
1328
+ note(dim2(`\`${CMD} drafts\` lists them with their ids`));
1329
+ return null;
1330
+ }
1331
+ return post;
1332
+ }
1333
+ var PRESETS = {
1334
+ selection: ["sharpen", "shorter", "punchier", "simpler", "example", "deslop", "concrete", "warmer", "list"],
1335
+ whole: ["tighten_all", "as_list", "as_story", "fix_spacing"],
1336
+ region: ["rewrite_hook", "rewrite_ending", "ending_question"]
1337
+ };
1338
+ var SHAPE_NAMES = [
1339
+ "story",
1340
+ "opinion",
1341
+ "listicle",
1342
+ "contrarian",
1343
+ "experience",
1344
+ "howto",
1345
+ "lessons",
1346
+ "casestudy",
1347
+ "framework",
1348
+ "question",
1349
+ "announcement",
1350
+ "prediction",
1351
+ "beliefbreak"
1352
+ ];
1353
+ function askBody(content, message, t) {
1354
+ const preset = t.preset;
1355
+ if (preset && ![...PRESETS.selection, ...PRESETS.whole, ...PRESETS.region].includes(preset)) {
1356
+ return `There is no preset called "${preset}". Presets: ${[...PRESETS.selection, ...PRESETS.whole, ...PRESETS.region].join(", ")}`;
1357
+ }
1358
+ if (t.productId) {
1359
+ return { text: content, productId: t.productId, productMode: t.productMode ?? "weave" };
1360
+ }
1361
+ if (t.shape) {
1362
+ if (!SHAPE_NAMES.includes(t.shape))
1363
+ return `There is no shape called "${t.shape}". Shapes: ${SHAPE_NAMES.join(", ")}`;
1364
+ return { text: content, format: t.shape };
1365
+ }
1366
+ if (t.passage !== undefined) {
1367
+ const at = content.indexOf(t.passage);
1368
+ if (!t.passage.trim() || at < 0)
1369
+ return "That passage is not in the post. Copy it exactly, from `show`.";
1370
+ if (preset && !PRESETS.selection.includes(preset)) {
1371
+ return `"${preset}" works on the whole post or its hook or ending, not on a passage. On a passage: ${PRESETS.selection.join(", ")}`;
1372
+ }
1373
+ if (!preset && !message)
1374
+ return "Say what to change in that passage, or name a preset.";
1375
+ return {
1376
+ text: content,
1377
+ scope: "selection",
1378
+ fragment: t.passage,
1379
+ selStart: at,
1380
+ selEnd: at + t.passage.length,
1381
+ ...preset ? { preset } : { instruction: message }
1382
+ };
1383
+ }
1384
+ if (t.region) {
1385
+ if (preset)
1386
+ return "Name a hook or ending preset on its own: --preset rewrite_hook, rewrite_ending or ending_question.";
1387
+ if (!message)
1388
+ return `Say what to change in the ${t.region}.`;
1389
+ return { text: content, scope: "region", region: t.region, instruction: message };
1390
+ }
1391
+ if (preset) {
1392
+ if (PRESETS.selection.includes(preset))
1393
+ return `"${preset}" works on a passage: add --passage "the exact words".`;
1394
+ return { text: content, preset };
1395
+ }
1396
+ if (!message)
1397
+ return "Say what you want changed.";
1398
+ return { text: content, instruction: message, scope: "whole" };
1399
+ }
1400
+ async function askAboutPost(refArg, message, target, json) {
1401
+ const post = await postFromRef(refArg, `${CMD} ask --post <post-id> "what to change"`);
1402
+ if (!post)
1403
+ return 1;
1404
+ if (post.status === "published") {
1405
+ fail("That post is already live on LinkedIn, so it cannot be changed here.");
1406
+ return 1;
1407
+ }
1408
+ if (target.product) {
1409
+ const { products } = await request("/api/products");
1410
+ const want = target.product.trim().toLowerCase();
1411
+ const hit = products.find((p) => p.name.trim().toLowerCase() === want) ?? products.find((p) => p.name.toLowerCase().includes(want));
1412
+ if (!hit) {
1413
+ fail(products.length ? `There is no product called "${target.product}". Products: ${products.map((p) => p.name).join(", ")}` : "There are no products yet. Add one in Settings, under Products.");
1414
+ return 1;
1415
+ }
1416
+ target = { ...target, productId: hit._id };
1417
+ }
1418
+ const body = askBody(post.content, message, target);
1419
+ if (typeof body === "string") {
1420
+ fail(body);
1421
+ return 1;
1422
+ }
1423
+ const call = () => request(`/api/posts/${encodeURIComponent(post._id)}/edit`, { method: "POST", body, timeoutMs: 150000 });
1424
+ const result = json ? await call() : await whileThinking([
1425
+ { at: 0, text: "reading the post..." },
1426
+ { at: 3, text: "working on it..." },
1427
+ { at: 14, text: "still going..." }
1428
+ ], call);
1429
+ let stored = post.content;
1430
+ if (result.changed && result.post) {
1431
+ const after = await fetchPost(post._id);
1432
+ stored = after?.content ?? stored;
1433
+ if (stored !== result.post) {
1434
+ await request(`/api/posts/${encodeURIComponent(post._id)}/draft`, {
1435
+ method: "POST",
1436
+ body: { text: result.post, previous: stored, origin: "suggestion" }
1437
+ });
1438
+ stored = result.post;
1439
+ }
1440
+ }
1441
+ if (json) {
1442
+ out(JSON.stringify({ id: post._id, changed: !!result.changed, post: stored, passage: result.fragment ?? null, reply: result.reply ?? null }, null, 2));
1443
+ return 0;
1444
+ }
1445
+ out();
1446
+ out(stored);
1447
+ out();
1448
+ if (result.changed && result.fragment && target.passage !== undefined) {
1449
+ note(dim2(`was: ${target.passage}`));
1450
+ note(dim2(`now: ${result.fragment}`));
1451
+ }
1452
+ if (result.reply)
1453
+ note(dim2(result.reply));
1454
+ note(dim2(result.changed ? `Saved. ${post._id}` : `No change. ${post._id}`));
1455
+ return 0;
1456
+ }
1457
+ async function showPost(refArg, json) {
1458
+ const post = await postFromRef(refArg, `${CMD} show <post-id>`);
1459
+ if (!post)
1460
+ return 1;
1461
+ const series = await listSeries();
1462
+ if (json) {
1463
+ out(JSON.stringify(postForJson(post, series), null, 2));
1464
+ return 0;
1465
+ }
1466
+ const when = (ms) => new Date(ms).toLocaleString(undefined, { weekday: "short", day: "numeric", month: "short", hour: "numeric", minute: "2-digit" });
1467
+ const seriesName = post.campaignId ? series.find((s) => s._id === post.campaignId)?.name : undefined;
1468
+ const media = describeMedia(post);
1469
+ const url = linkedinUrl(post);
1470
+ out();
1471
+ out(post.content);
1472
+ out();
1473
+ const facts = [
1474
+ ["title", post.title || "untitled"],
1475
+ ["status", post.status],
1476
+ ["series", seriesName],
1477
+ ["attached", media ?? undefined],
1478
+ ["first comment", post.firstComment ? post.firstComment.replace(/\s+/g, " ").slice(0, 80) : undefined],
1479
+ ["scheduled", post.scheduledFor && post.status !== "published" ? when(post.scheduledFor) : undefined],
1480
+ ["published", post.publishedAt ? when(post.publishedAt) : undefined],
1481
+ ["on linkedin", url ?? undefined],
1482
+ [
1483
+ "reactions",
1484
+ post.linkedinLikes !== undefined || post.linkedinComments !== undefined ? `${post.linkedinLikes ?? 0} likes, ${post.linkedinComments ?? 0} comments` : undefined
1485
+ ],
1486
+ ["id", post._id]
1487
+ ];
1488
+ for (const [label, valueText] of facts) {
1489
+ if (valueText)
1490
+ note(`${dim2(label.padEnd(14))}${valueText}`);
1491
+ }
1492
+ return 0;
1493
+ }
1494
+ async function edit(args) {
1495
+ const { json, rest, value } = parseFlags(args, ["text", "file", "title", "first-comment", "series", "status"]);
1496
+ const refArg = rest[0];
1497
+ if (!refArg) {
1498
+ fail("Edit which post?");
1499
+ note(dim2(`${CMD} edit <post-id> --file post.txt`));
1500
+ note(dim2(`${CMD} edit <post-id> --title "A title"`));
1501
+ return 1;
1502
+ }
1503
+ const title = value("title");
1504
+ const firstComment = value("first-comment");
1505
+ const seriesName = value("series");
1506
+ const status = value("status");
1507
+ const file = value("file");
1508
+ let text = value("text");
1509
+ if (file)
1510
+ text = await readFile2(file, "utf8");
1511
+ const fieldsGiven = title !== undefined || firstComment !== undefined || seriesName !== undefined || status !== undefined;
1512
+ if (text === undefined && !fieldsGiven) {
1513
+ text = await readStdin() ?? undefined;
1514
+ }
1515
+ if (text === undefined && !fieldsGiven) {
1516
+ fail("Nothing to change.");
1517
+ note(dim2("--file, --text or piped text for the post, --title, --first-comment, --series, --status"));
1518
+ return 1;
1519
+ }
1520
+ if (status !== undefined && status !== "ready" && status !== "draft") {
1521
+ fail("--status is ready or draft.");
1522
+ note(dim2("Scheduling is `queue`, and taking it out of the queue is `unqueue`."));
1523
+ return 1;
1524
+ }
1525
+ const post = await postFromRef(refArg, `${CMD} edit <post-id> --file post.txt`);
1526
+ if (!post)
1527
+ return 1;
1528
+ const changed = [];
1529
+ let content = post.content;
1530
+ if (text !== undefined) {
1531
+ const next = text.replace(/\s+$/, "");
1532
+ if (!next.trim()) {
1533
+ fail("That text is empty.");
1534
+ return 1;
1535
+ }
1536
+ if (post.status === "published") {
1537
+ fail("That post is already live on LinkedIn, so its text cannot be changed here.");
1538
+ return 1;
1539
+ }
1540
+ if (next !== post.content) {
1541
+ await request(`/api/posts/${encodeURIComponent(post._id)}/draft`, {
1542
+ method: "POST",
1543
+ body: { text: next, previous: post.content, origin: "stream" }
1544
+ });
1545
+ content = next;
1546
+ changed.push("text");
1547
+ }
1548
+ }
1549
+ if (title !== undefined || firstComment !== undefined) {
1550
+ await request("/api/posts", {
1551
+ method: "POST",
1552
+ body: {
1553
+ id: post._id,
1554
+ content,
1555
+ ...title !== undefined ? { title } : {},
1556
+ ...firstComment !== undefined ? { firstComment } : {}
1557
+ }
1558
+ });
1559
+ if (title !== undefined)
1560
+ changed.push("title");
1561
+ if (firstComment !== undefined)
1562
+ changed.push("first comment");
1563
+ }
1564
+ if (seriesName !== undefined) {
1565
+ let toCampaignId = null;
1566
+ if (seriesName.trim().toLowerCase() !== "none" && seriesName.trim()) {
1567
+ const all = await listSeries();
1568
+ const target = matchSeries(all, seriesName);
1569
+ if (!target) {
1570
+ fail(`No series called "${seriesName}".`);
1571
+ if (all.length)
1572
+ note(dim2(`Yours: ${all.map((x) => x.name).join(", ")}`));
1573
+ return 1;
1574
+ }
1575
+ toCampaignId = target._id;
1576
+ }
1577
+ await request("/api/campaigns/move", { method: "POST", body: { postId: post._id, toCampaignId } });
1578
+ changed.push(toCampaignId ? "series" : "series (taken out)");
1579
+ }
1580
+ if (status !== undefined) {
1581
+ await request(`/api/posts/${encodeURIComponent(post._id)}/ready`, { method: status === "ready" ? "POST" : "DELETE" });
1582
+ changed.push(`status (${status})`);
1583
+ }
1584
+ if (json) {
1585
+ out(JSON.stringify({ id: post._id, changed }, null, 2));
1586
+ return 0;
1587
+ }
1588
+ ok(changed.length ? `Updated the ${changed.join(", ")}. ${dim2(post._id)}` : `Nothing was different. ${dim2(post._id)}`);
1589
+ note(dim2(`\`${CMD} show ${post._id}\` to read it back`));
1590
+ return 0;
1591
+ }
1187
1592
 
1188
1593
  // src/ui.ts
1189
1594
  var useColor2 = process.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== "dumb";
@@ -1340,12 +1745,13 @@ var COMMANDS = {
1340
1745
  write: {
1341
1746
  summary: "draft a post in your voice",
1342
1747
  usage: "innernote write [topic] [options]",
1343
- detail: "Not a prompt box. innernote already holds your voice, your pillars and what you have published, so give it the subject and let it write. Run it bare and it picks the pillar you have covered least.",
1748
+ detail: "Not a prompt box. innernote already holds your voice, your topics and what you have published, so give it the subject and let it write. Run it bare and it picks the topic you have covered least.",
1344
1749
  options: [
1345
1750
  ["--thought <text>", "turn a half-formed note into a post, keeping your phrasing"],
1346
1751
  ["--series <name>", "write into one of your series, so it follows on"],
1347
1752
  ["--format <name>", "story, listicle, contrarian, ..."],
1348
- ["--save", "keep it as a draft in the app"]
1753
+ ["--save", "keep it as a draft in the app"],
1754
+ ["--title <text>", "the title it is saved under (with --save); without it, one is written for you"]
1349
1755
  ],
1350
1756
  examples: [
1351
1757
  "innernote write",
@@ -1373,15 +1779,127 @@ var COMMANDS = {
1373
1779
  ]
1374
1780
  },
1375
1781
  ask: {
1376
- summary: "talk to the post in your own words",
1377
- usage: "innernote ask <what you want>",
1378
- detail: "The Writer, here. The app's composer is a conversation with the post in front of you, and this is the same assistant: it can change the post, answer a question about it, or say it would rather not. `shape` covers the moves that have names; this covers everything else.",
1782
+ summary: "talk to the post in your own words, or edit one passage of it",
1783
+ usage: "innernote ask [--post <id>] [--passage <text> | --hook | --ending] [--preset <name> | --shape <name> | --product <name>] [what you want]",
1784
+ detail: "The Writer, here. The app's composer is a conversation with the post in front of you, and this is the same assistant: it can change the post, answer a question about it, or say it would rather not. In a session it works on the post in hand. With --post it works on a saved post in one command, and the change is kept in the post's history. --passage is the editor's selection: the exact words to change, and only those change. --hook and --ending are the opening and closing block. --preset is a toolbar edit by name, --shape the format picker.",
1785
+ options: [
1786
+ ["--post <id>", "a saved post, by id, instead of the one in hand"],
1787
+ ["--passage <text>", "change only these exact words of the post"],
1788
+ ["--hook, --ending", "change only the opening, or the closing"],
1789
+ ["--preset <name>", "on a passage: sharpen shorter punchier simpler example deslop concrete warmer list. On the post: tighten_all as_list as_story fix_spacing rewrite_hook rewrite_ending ending_question"],
1790
+ ["--shape <name>", "story opinion listicle contrarian experience howto lessons casestudy framework question announcement prediction beliefbreak"],
1791
+ ["--product <name>", "work one of your products into the post, where it honestly belongs (`context` lists them)"],
1792
+ ["--rewrite", "with --product: rewrite the post around it instead"]
1793
+ ],
1379
1794
  examples: [
1380
1795
  "ask cut the second half and land it harder",
1381
1796
  "ask this ending is soft, what would you do",
1382
- "ask say what happened instead of what I learned"
1797
+ 'innernote ask --post p17abc... "make the opening shorter"',
1798
+ 'innernote ask --post p17abc... --passage "we got lucky" "say what the luck actually was"',
1799
+ 'innernote ask --post p17abc... --passage "we got lucky" --preset concrete',
1800
+ "innernote ask --post p17abc... --preset ending_question",
1801
+ "innernote ask --post p17abc... --shape story",
1802
+ 'innernote ask --post p17abc... --product "innernote"'
1383
1803
  ]
1384
1804
  },
1805
+ edit: {
1806
+ summary: "change a saved post: its text, title, first comment, series or status",
1807
+ usage: "innernote edit <id> [--file <path> | --text <text>] [--title <text>] [--first-comment <text>] [--series <name>] [--status ready|draft]",
1808
+ detail: "Puts your own words into a saved post. New text comes from --file, --text or a pipe, and is saved the way the app saves a draft, so the old text is one Undo away in the post's history. A post already live on LinkedIn keeps its text. The id comes from `drafts --json` or `show`.",
1809
+ options: [
1810
+ ["--file <path>", "the new text of the post, from a file"],
1811
+ ["--text <text>", "the new text of the post, inline"],
1812
+ ["--title <text>", "the post's title in the app"],
1813
+ ["--first-comment <text>", "the comment posted under it when it goes live"],
1814
+ ["--series <name>", "file it into a series, or `none` to take it out"],
1815
+ ["--status <ready|draft>", "mark it ready, or send it back to draft"]
1816
+ ],
1817
+ examples: [
1818
+ "innernote edit p17abc... --file post.txt",
1819
+ "pbpaste | innernote edit p17abc...",
1820
+ 'innernote edit p17abc... --title "The sales call" --first-comment "link: innernote.space"',
1821
+ 'innernote edit p17abc... --series "Build in Public" --status ready'
1822
+ ]
1823
+ },
1824
+ captures: {
1825
+ summary: "your Capture page: what you keep to write from later",
1826
+ usage: "innernote captures [<id>] | captures add <link or text> [--note <why>] | captures delete <id>",
1827
+ detail: "The same list as Capture in the app. With no argument it lists what you kept, newest first, with ids. With an id it prints that capture in full. `add` keeps a link (a LinkedIn post, an article, a page) or a thought in your own words: a link is read once, through innernote's cache, and kept with its text. `repurpose --capture <id>` writes a post from one.",
1828
+ options: [
1829
+ ["--note <why>", "with add: why you kept it, in a line"],
1830
+ ["--limit <n>", "how many to list (default 30)"]
1831
+ ],
1832
+ examples: [
1833
+ "innernote captures",
1834
+ 'innernote captures add https://www.linkedin.com/posts/... --note "the pricing argument"',
1835
+ "innernote captures j57abc..."
1836
+ ]
1837
+ },
1838
+ repurpose: {
1839
+ summary: "write your own post from a link, a capture or some text",
1840
+ usage: "innernote repurpose <link or text> | repurpose --capture <id> [--borrow <what>] [--instruction <text>] [--shape <name>] [--save]",
1841
+ detail: "Turn into a post, from here. The source is read, and the post is written in your voice from what you choose to borrow, then checked so it reads as yours and not theirs. --borrow idea takes their point and uses your proof, structure takes only how it is built, spark only reacts to it, and faithful relays what it reports in your words, with credit. A profile link yields its recent posts: the first is used, --pick chooses another.",
1842
+ options: [
1843
+ ["--capture <id>", "a capture from `captures`, instead of a link"],
1844
+ ["--borrow <what>", "idea, structure, spark or faithful"],
1845
+ ["--instruction <text>", "anything else it should do, in your words"],
1846
+ ["--shape <name>", "story, listicle, howto, lessons and the rest, as in `ask --shape`"],
1847
+ ["--pick <n>", "which of a profile's posts to write from"],
1848
+ ["--save", "keep it as a draft"],
1849
+ ["--series <name>", "with --save: file it into a series"],
1850
+ ["--title <text>", "with --save: its title (default: one is written for you)"]
1851
+ ],
1852
+ examples: [
1853
+ "innernote repurpose https://www.linkedin.com/posts/... --borrow structure",
1854
+ "innernote repurpose --capture j57abc... --borrow idea --save",
1855
+ "pbpaste | innernote repurpose --borrow spark"
1856
+ ]
1857
+ },
1858
+ radar: {
1859
+ summary: "recent posts from the creators you watch",
1860
+ usage: "innernote radar [--limit <n>] [--full]",
1861
+ detail: "The same feed as Radar in the app, newest first, with each post's link. Who you watch is set on the Radar page. `repurpose <link>` writes your own post from one.",
1862
+ options: [
1863
+ ["--limit <n>", "how many posts (default 15)"],
1864
+ ["--full", "print each post in full, not one line"]
1865
+ ],
1866
+ examples: ["innernote radar", "innernote radar --full --limit 5", "innernote radar --json"]
1867
+ },
1868
+ history: {
1869
+ summary: "every earlier version of a post, and putting one back",
1870
+ usage: "innernote history <id> [--show <n>] [--restore <n>]",
1871
+ detail: "Every change to a post's words is kept as a version first: yours, the assistant's, an edit from here. This lists them, newest first. --restore puts one back, and the text it replaces is kept as the newest version, so a restore is itself one step from undone.",
1872
+ options: [
1873
+ ["--show <n>", "print version n in full"],
1874
+ ["--restore <n>", "put version n back"]
1875
+ ],
1876
+ examples: ["innernote history p17abc...", "innernote history p17abc... --show 3", "innernote history p17abc... --restore 3"]
1877
+ },
1878
+ attach: {
1879
+ summary: "put an image, images, a PDF or a video on a saved post",
1880
+ usage: "innernote attach <id> <file...> [--title <text>] | innernote attach <id> --remove",
1881
+ detail: "Uploads straight to innernote's storage on a signed link, the way the app does, and attaches it to the post. Several images become a grid. A PDF shows on LinkedIn as a carousel people swipe through, under its title. A post carries one kind of attachment, so attaching replaces what was there.",
1882
+ options: [
1883
+ ["--title <text>", "the title of a PDF or a video (default: the file name)"],
1884
+ ["--remove", "take the attachment off"]
1885
+ ],
1886
+ examples: [
1887
+ "innernote attach p17abc... picture.png",
1888
+ 'innernote attach p17abc... slides.pdf --title "How we price"',
1889
+ "innernote attach p17abc... --remove"
1890
+ ]
1891
+ },
1892
+ delete: {
1893
+ summary: "move a post to the trash",
1894
+ usage: "innernote delete <id>",
1895
+ detail: "The same as Delete in the Posts list: the post is kept, and `restore` brings it back. It does not take anything down from LinkedIn.",
1896
+ examples: ["innernote delete p17abc...", "innernote restore p17abc..."]
1897
+ },
1898
+ restore: {
1899
+ summary: "bring a post back out of the trash",
1900
+ usage: "innernote restore <id>",
1901
+ examples: ["innernote restore p17abc..."]
1902
+ },
1385
1903
  save: {
1386
1904
  summary: "keep the draft you are looking at",
1387
1905
  usage: "innernote save",
@@ -1389,10 +1907,10 @@ var COMMANDS = {
1389
1907
  examples: ['write "hiring your first engineer"', "shape shorter", "save"]
1390
1908
  },
1391
1909
  show: {
1392
- summary: "see the post you are holding",
1393
- usage: "innernote show",
1394
- detail: "A session scrolls, and the post you are working on goes off the top while you look at your week. This brings it back, and says whether it is saved.",
1395
- examples: ["show"]
1910
+ summary: "see a post, with everything the app knows about it",
1911
+ usage: "innernote show [id]",
1912
+ detail: "With an id: the post's text on stdout, and its title, status, series, attachment, first comment, schedule, LinkedIn link and reactions on stderr, all of it in --json. Without one, in a session: the post you are holding, and whether it is saved.",
1913
+ examples: ["innernote show p17abc...", "innernote show p17abc... --json", "show"]
1396
1914
  },
1397
1915
  drop: {
1398
1916
  summary: "put down whatever you are holding",
@@ -1407,9 +1925,10 @@ var COMMANDS = {
1407
1925
  },
1408
1926
  drafts: {
1409
1927
  summary: "your posts, numbered",
1410
- usage: "innernote drafts [status]",
1411
- detail: "Numbered so you can pick one with `open 3` instead of copying a thirty-two character id. The numbers refer to the list you are looking at and are replaced by the next one.",
1412
- examples: ["drafts", "drafts ready", "drafts --json | jq -r '.[]._id'"]
1928
+ usage: "innernote drafts [status] [--series <name>]",
1929
+ detail: "Numbered so you can pick one with `open 3` instead of copying a thirty-two character id. The numbers refer to the list you are looking at and are replaced by the next one. --series lists one series in its own order. Each --json row also carries the series name and, once published, the LinkedIn link.",
1930
+ options: [["--series <name>", "only the posts in this series, in its order"]],
1931
+ examples: ["drafts", "drafts ready", 'drafts --series "Growing innernote"', "drafts --json | jq -r '.[]._id'"]
1413
1932
  },
1414
1933
  open: {
1415
1934
  summary: "pull a post out of the list and work on it",
@@ -1435,15 +1954,16 @@ var COMMANDS = {
1435
1954
  examples: ["innernote skill", "innernote skill --install"]
1436
1955
  },
1437
1956
  context: {
1438
- summary: "what you post about: your pillars and your series",
1957
+ summary: "what you post about: your topics, your series and your products",
1439
1958
  usage: "innernote context",
1440
- detail: "Your standing context: content pillars with their weights, and every running series with what it is about and what it is for. This is the thing to read before deciding what to write, and the thing an agent should read first for the same reason. --json prints the raw payload for scripts and models.",
1959
+ detail: "Your standing context: your topics with their weights, every running series with what it is about and what it is for, and your products. This is the thing to read before deciding what to write, and the thing an agent should read first for the same reason. --json prints the raw payload for scripts and models.",
1441
1960
  examples: ["innernote context", "innernote context --json"]
1442
1961
  },
1443
1962
  queue: {
1444
1963
  summary: "schedule a post into your next open slot",
1445
- usage: "innernote queue <post-id>",
1964
+ usage: "innernote queue <post-id> [--at <time>]",
1446
1965
  detail: "A queued post GOES LIVE on LinkedIn on its own at your next cadence slot. Nobody presses anything again. Needs a connection you allowed to publish: tick that box in Settings when you generate the code. Takes a number from the last list, or a full id. It never assumes the draft in hand: this is the one command that makes you say which post.",
1966
+ options: [["--at <time>", `a time you pick, like "2026-10-12 12:31" (this machine's timezone), instead of the next open slot`]],
1447
1967
  examples: ["innernote drafts", "innernote queue 3", "innernote queue p17a1yk936ywnan10643efjtc58dhkah"]
1448
1968
  }
1449
1969
  };
@@ -1557,7 +2077,7 @@ function suggest(state) {
1557
2077
  // package.json
1558
2078
  var package_default = {
1559
2079
  name: "innernote",
1560
- version: "0.3.4",
2080
+ version: "0.5.0",
1561
2081
  description: "Write LinkedIn posts in your voice, from the terminal.",
1562
2082
  keywords: [
1563
2083
  "linkedin",
@@ -1662,7 +2182,7 @@ async function home(inSession = false) {
1662
2182
  }
1663
2183
 
1664
2184
  // src/commands/skill.ts
1665
- import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
2185
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
1666
2186
  import { homedir as homedir2 } from "node:os";
1667
2187
  import { dirname, join as join2 } from "node:path";
1668
2188
  import { fileURLToPath } from "node:url";
@@ -1670,7 +2190,7 @@ async function readSkill() {
1670
2190
  const here = dirname(fileURLToPath(import.meta.url));
1671
2191
  for (const rel of ["../../skills/innernote-cli/SKILL.md", "../skills/innernote-cli/SKILL.md"]) {
1672
2192
  try {
1673
- return await readFile2(join2(here, rel), "utf8");
2193
+ return await readFile3(join2(here, rel), "utf8");
1674
2194
  } catch {}
1675
2195
  }
1676
2196
  return null;
@@ -1697,16 +2217,474 @@ async function skill(args) {
1697
2217
  return 0;
1698
2218
  }
1699
2219
 
2220
+ // src/commands/post.ts
2221
+ import { readFile as readFile4, stat } from "node:fs/promises";
2222
+ import { basename, extname } from "node:path";
2223
+ var TYPES = {
2224
+ ".jpg": "image/jpeg",
2225
+ ".jpeg": "image/jpeg",
2226
+ ".png": "image/png",
2227
+ ".gif": "image/gif",
2228
+ ".webp": "image/webp",
2229
+ ".pdf": "application/pdf",
2230
+ ".mp4": "video/mp4",
2231
+ ".mov": "video/quicktime"
2232
+ };
2233
+ function imageSize(buf) {
2234
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
2235
+ if (buf.length > 24 && buf[0] === 137 && buf[1] === 80) {
2236
+ return { width: view.getUint32(16), height: view.getUint32(20) };
2237
+ }
2238
+ if (buf.length > 10 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) {
2239
+ return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
2240
+ }
2241
+ if (buf.length > 30 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
2242
+ const kind = String.fromCharCode(buf[12], buf[13], buf[14], buf[15]);
2243
+ if (kind === "VP8X") {
2244
+ return { width: 1 + (buf[24] | buf[25] << 8 | buf[26] << 16), height: 1 + (buf[27] | buf[28] << 8 | buf[29] << 16) };
2245
+ }
2246
+ if (kind === "VP8 ")
2247
+ return { width: view.getUint16(26, true) & 16383, height: view.getUint16(28, true) & 16383 };
2248
+ if (kind === "VP8L") {
2249
+ const b = view.getUint32(21, true);
2250
+ return { width: (b & 16383) + 1, height: (b >> 14 & 16383) + 1 };
2251
+ }
2252
+ }
2253
+ if (buf.length > 4 && buf[0] === 255 && buf[1] === 216) {
2254
+ let i = 2;
2255
+ while (i + 9 < buf.length) {
2256
+ if (buf[i] !== 255)
2257
+ return;
2258
+ const marker = buf[i + 1];
2259
+ const len = view.getUint16(i + 2);
2260
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
2261
+ return { width: view.getUint16(i + 7), height: view.getUint16(i + 5) };
2262
+ }
2263
+ i += 2 + len;
2264
+ }
2265
+ }
2266
+ return;
2267
+ }
2268
+ async function upload(path) {
2269
+ const contentType = TYPES[extname(path).toLowerCase()];
2270
+ if (!contentType) {
2271
+ throw new Error(`Can't attach ${basename(path)}. Images (jpg, png, gif, webp), a PDF, or a video (mp4, mov).`);
2272
+ }
2273
+ const size = (await stat(path)).size;
2274
+ const bytes = await readFile4(path);
2275
+ const signed = await request("/api/uploads/presign", {
2276
+ method: "POST",
2277
+ body: { filename: basename(path), contentType, size }
2278
+ });
2279
+ const put = await fetch(signed.uploadUrl, { method: "PUT", headers: { "Content-Type": contentType }, body: bytes });
2280
+ if (!put.ok)
2281
+ throw new Error(`The upload of ${basename(path)} failed (${put.status}).`);
2282
+ const dims = contentType.startsWith("image/") ? imageSize(new Uint8Array(bytes)) : undefined;
2283
+ return { asset: { url: signed.publicUrl, name: basename(path), ...dims ?? {} }, contentType };
2284
+ }
2285
+ async function attach(args) {
2286
+ const { json, rest, value, has } = parseFlags(args, ["title"]);
2287
+ const [refArg, ...files] = rest;
2288
+ if (!refArg || !files.length && !has("remove")) {
2289
+ fail("Attach what to which post?");
2290
+ note(dim2(`${CMD} attach <post-id> picture.png`));
2291
+ note(dim2(`${CMD} attach <post-id> slides.pdf --title "How we price"`));
2292
+ return 1;
2293
+ }
2294
+ const ref = resolveRef(refArg);
2295
+ const post = ref ? await fetchPost(ref.id) : null;
2296
+ if (!post) {
2297
+ fail("That post could not be found.");
2298
+ note(dim2(`\`${CMD} drafts\` lists them with their ids`));
2299
+ return 1;
2300
+ }
2301
+ if (post.status === "published") {
2302
+ fail("That post is already live on LinkedIn, so its attachment cannot change here.");
2303
+ return 1;
2304
+ }
2305
+ let media;
2306
+ if (has("remove")) {
2307
+ media = { kind: "text" };
2308
+ } else {
2309
+ const uploaded = [];
2310
+ for (const f of files)
2311
+ uploaded.push(await upload(f));
2312
+ const kinds = new Set(uploaded.map((u) => u.contentType.split("/")[0]));
2313
+ const title = value("title") ?? basename(files[0], extname(files[0]));
2314
+ if (uploaded.every((u) => u.contentType.startsWith("image/"))) {
2315
+ if (uploaded.length > 20) {
2316
+ fail("A post takes at most 20 images.");
2317
+ return 1;
2318
+ }
2319
+ media = uploaded.length === 1 ? { kind: "image", asset: uploaded[0].asset } : { kind: "multiImage", assets: uploaded.map((u) => u.asset) };
2320
+ } else if (uploaded.length === 1 && uploaded[0].contentType === "application/pdf") {
2321
+ media = { kind: "document", asset: uploaded[0].asset, title };
2322
+ } else if (uploaded.length === 1 && kinds.has("video")) {
2323
+ media = { kind: "video", asset: uploaded[0].asset, title };
2324
+ } else {
2325
+ fail("A post carries one kind of attachment: images, or one PDF, or one video.");
2326
+ return 1;
2327
+ }
2328
+ }
2329
+ await request("/api/posts", { method: "POST", body: { id: post._id, content: post.content, media } });
2330
+ if (json) {
2331
+ out(JSON.stringify({ id: post._id, media }, null, 2));
2332
+ return 0;
2333
+ }
2334
+ ok(media.kind === "text" ? `Took the attachment off. ${dim2(post._id)}` : `Attached. ${dim2(post._id)}`);
2335
+ note(dim2(`\`${CMD} show ${post._id}\` to check it`));
2336
+ return 0;
2337
+ }
2338
+ async function deletePost(args) {
2339
+ const { json, rest } = parseFlags(args);
2340
+ const ref = rest[0] ? resolveRef(rest[0]) : null;
2341
+ if (!ref) {
2342
+ fail("Delete which post?");
2343
+ note(dim2(`${CMD} delete <post-id>`));
2344
+ return 1;
2345
+ }
2346
+ await request(`/api/posts/${encodeURIComponent(ref.id)}`, { method: "DELETE" });
2347
+ if (json) {
2348
+ out(JSON.stringify({ id: ref.id, deleted: true }, null, 2));
2349
+ return 0;
2350
+ }
2351
+ ok(`Moved to the trash. ${dim2(ref.id)}`);
2352
+ note(dim2(`\`${CMD} restore ${ref.id}\` brings it back`));
2353
+ return 0;
2354
+ }
2355
+ async function restorePost(args) {
2356
+ const { json, rest } = parseFlags(args);
2357
+ const ref = rest[0] ? resolveRef(rest[0]) : null;
2358
+ if (!ref) {
2359
+ fail("Restore which post?");
2360
+ note(dim2(`${CMD} restore <post-id>`));
2361
+ return 1;
2362
+ }
2363
+ await request(`/api/posts/${encodeURIComponent(ref.id)}/restore`, { method: "POST" });
2364
+ if (json) {
2365
+ out(JSON.stringify({ id: ref.id, restored: true }, null, 2));
2366
+ return 0;
2367
+ }
2368
+ ok(`Restored. It is back in your posts. ${dim2(ref.id)}`);
2369
+ return 0;
2370
+ }
2371
+ async function history(args) {
2372
+ const { json, rest, value } = parseFlags(args, ["show", "restore"]);
2373
+ const ref = rest[0] ? resolveRef(rest[0]) : null;
2374
+ if (!ref) {
2375
+ fail("Whose history?");
2376
+ note(dim2(`${CMD} history <post-id>`));
2377
+ return 1;
2378
+ }
2379
+ const post = await fetchPost(ref.id);
2380
+ if (!post) {
2381
+ fail("That post could not be found.");
2382
+ note(dim2(`\`${CMD} drafts\` lists them with their ids`));
2383
+ return 1;
2384
+ }
2385
+ const { versions } = await request(`/api/posts/${encodeURIComponent(post._id)}/versions`);
2386
+ const pick = (n) => versions.find((v) => String(v.versionNumber) === n || v.id === n);
2387
+ const restore = value("restore");
2388
+ if (restore) {
2389
+ const v = pick(restore);
2390
+ if (!v) {
2391
+ fail(`There is no version ${restore}. \`${CMD} history ${post._id}\` lists them.`);
2392
+ return 1;
2393
+ }
2394
+ if (post.status === "published") {
2395
+ fail("That post is live on LinkedIn, so its text cannot go back to an older version here.");
2396
+ return 1;
2397
+ }
2398
+ const res = await request(`/api/posts/${encodeURIComponent(post._id)}/versions`, {
2399
+ method: "POST",
2400
+ body: { versionId: v.id }
2401
+ });
2402
+ if (json) {
2403
+ out(JSON.stringify({ id: post._id, restored: v.versionNumber, content: res.content }, null, 2));
2404
+ return 0;
2405
+ }
2406
+ out(res.content);
2407
+ note();
2408
+ ok(`Back to version ${v.versionNumber}. What it replaced is kept as the newest version. ${dim2(post._id)}`);
2409
+ return 0;
2410
+ }
2411
+ const show2 = value("show");
2412
+ if (show2) {
2413
+ const v = pick(show2);
2414
+ if (!v) {
2415
+ fail(`There is no version ${show2}.`);
2416
+ return 1;
2417
+ }
2418
+ if (json) {
2419
+ out(JSON.stringify(v, null, 2));
2420
+ return 0;
2421
+ }
2422
+ out(v.content);
2423
+ return 0;
2424
+ }
2425
+ if (json) {
2426
+ out(JSON.stringify(versions, null, 2));
2427
+ return 0;
2428
+ }
2429
+ if (!versions.length) {
2430
+ out(dim2("No earlier versions. Every change from now on is kept."));
2431
+ return 0;
2432
+ }
2433
+ out();
2434
+ for (const v of versions) {
2435
+ const why = v.instruction ? oneLine(v.instruction, 50) : v.source;
2436
+ out(` ${`v${v.versionNumber}`.padEnd(5)}${dim2(`${ago(v.createdAt).padEnd(5)} ${why}`)}`);
2437
+ out(` ${oneLine(v.content, 66)}`);
2438
+ }
2439
+ out();
2440
+ note(dim2(`\`${CMD} history ${post._id} --show <n>\` reads one, \`--restore <n>\` puts it back`));
2441
+ return 0;
2442
+ }
2443
+
2444
+ // src/commands/write.ts
2445
+ async function saveNew2(content, series, seriesSummary, title) {
2446
+ return request("/api/posts", {
2447
+ method: "POST",
2448
+ body: {
2449
+ content,
2450
+ source: "cli",
2451
+ ...title ? { title } : {},
2452
+ ...series ? { series, seriesSummary: seriesSummary ?? undefined } : {}
2453
+ }
2454
+ });
2455
+ }
2456
+
2457
+ // src/commands/sources.ts
2458
+ function looksLikeUrl(input) {
2459
+ const t = input.trim();
2460
+ return !/\s/.test(t) && (/^https?:\/\//i.test(t) || /^[\w-]+\.[\w.-]+\/\S/.test(t));
2461
+ }
2462
+ function capturedAt(c) {
2463
+ return c.createdAt ?? c._creationTime;
2464
+ }
2465
+ async function captures(args) {
2466
+ const { json, rest, value } = parseFlags(args, ["note", "limit"]);
2467
+ const [sub, ...more] = rest;
2468
+ if (sub === "add") {
2469
+ const piped = more.length ? null : await readStdin();
2470
+ const input = (more.join(" ") || piped || "").trim();
2471
+ if (!input) {
2472
+ fail("Add what? A link, or a thought in your own words.");
2473
+ note(dim2(`${CMD} captures add https://www.linkedin.com/posts/...`));
2474
+ return 1;
2475
+ }
2476
+ const noteText = value("note");
2477
+ const body = looksLikeUrl(input) ? { kind: "link", input, ...noteText ? { note: noteText } : {} } : { kind: "thought", content: input, ...noteText ? { note: noteText } : {} };
2478
+ const call = () => request("/api/captures", { method: "POST", body, timeoutMs: 120000 });
2479
+ const { capture: capture2 } = json ? await call() : await whileThinking([{ at: 0, text: "reading it..." }, { at: 8, text: "still reading..." }], call);
2480
+ if (json) {
2481
+ out(JSON.stringify(capture2, null, 2));
2482
+ return 0;
2483
+ }
2484
+ ok(`Kept on your Capture page. ${dim2(capture2._id)}`);
2485
+ note(dim2(capture2.title ? oneLine(capture2.title, 72) : oneLine(capture2.content, 72)));
2486
+ note(dim2(`\`${CMD} repurpose --capture ${capture2._id}\` writes a post from it`));
2487
+ return 0;
2488
+ }
2489
+ if (sub === "delete") {
2490
+ const id = more[0];
2491
+ if (!id) {
2492
+ fail("Delete which capture?");
2493
+ note(dim2(`${CMD} captures delete <capture-id>`));
2494
+ return 1;
2495
+ }
2496
+ await request(`/api/captures/${encodeURIComponent(id)}`, { method: "DELETE" });
2497
+ if (json) {
2498
+ out(JSON.stringify({ id, deleted: true }, null, 2));
2499
+ return 0;
2500
+ }
2501
+ ok(`Deleted. ${dim2(id)}`);
2502
+ return 0;
2503
+ }
2504
+ if (sub) {
2505
+ const { capture: capture2 } = await request(`/api/captures/${encodeURIComponent(sub)}`);
2506
+ if (json) {
2507
+ out(JSON.stringify(capture2, null, 2));
2508
+ return 0;
2509
+ }
2510
+ out(capture2.content);
2511
+ note();
2512
+ const facts = [
2513
+ ["title", capture2.title],
2514
+ ["kind", [capture2.kind, capture2.format].filter(Boolean).join(", ")],
2515
+ ["by", [capture2.author, capture2.publication].filter(Boolean).join(", ") || undefined],
2516
+ ["link", capture2.sourceUrl],
2517
+ ["your note", capture2.note],
2518
+ ["topics", capture2.topics?.length ? capture2.topics.join(", ") : undefined],
2519
+ ["saved", capturedAt(capture2) ? `${ago(capturedAt(capture2))} ago` : undefined],
2520
+ ["id", capture2._id]
2521
+ ];
2522
+ for (const [k, v] of facts)
2523
+ if (v)
2524
+ note(`${dim2(k.padEnd(10))} ${v}`);
2525
+ return 0;
2526
+ }
2527
+ const { captures: rows } = await request("/api/captures");
2528
+ const limit = Number(value("limit") ?? 30) || 30;
2529
+ if (json) {
2530
+ out(JSON.stringify(rows.slice(0, limit), null, 2));
2531
+ return 0;
2532
+ }
2533
+ if (!rows.length) {
2534
+ out(dim2("Nothing kept yet."));
2535
+ note(dim2(`${CMD} captures add <link>`));
2536
+ return 0;
2537
+ }
2538
+ out();
2539
+ for (const c of rows.slice(0, limit)) {
2540
+ const when = capturedAt(c) ? ago(capturedAt(c)) : "";
2541
+ const what = c.title || c.content;
2542
+ out(` ${caramel(oneLine(what, 66))}`);
2543
+ out(` ${dim2([c.format ?? c.kind, c.author, when, c._id].filter(Boolean).join(" "))}`);
2544
+ out();
2545
+ }
2546
+ if (rows.length > limit)
2547
+ note(dim2(`${rows.length - limit} more. --limit ${rows.length} to see them all`));
2548
+ note(dim2(`\`${CMD} captures <id>\` reads one in full`));
2549
+ return 0;
2550
+ }
2551
+ var BORROW = ["idea", "structure", "spark", "faithful"];
2552
+ async function repurpose(args) {
2553
+ const { json, rest, value, has } = parseFlags(args, ["capture", "borrow", "instruction", "shape", "pick", "series", "title"]);
2554
+ const borrow = value("borrow");
2555
+ if (borrow && !BORROW.includes(borrow)) {
2556
+ fail(`There is no --borrow "${borrow}". One of: ${BORROW.join(", ")}`);
2557
+ return 1;
2558
+ }
2559
+ let sourceText = "";
2560
+ let captureId;
2561
+ let from = "";
2562
+ const capture2 = value("capture");
2563
+ if (capture2) {
2564
+ const { capture: c } = await request(`/api/captures/${encodeURIComponent(capture2)}`);
2565
+ sourceText = c.content;
2566
+ captureId = c._id;
2567
+ from = c.title ?? oneLine(c.content, 60);
2568
+ } else {
2569
+ const piped = rest.length ? null : await readStdin();
2570
+ const input = (rest.join(" ") || piped || "").trim();
2571
+ if (!input) {
2572
+ fail("Repurpose what? A link, some text, or --capture <id>.");
2573
+ note(dim2(`${CMD} repurpose https://www.linkedin.com/posts/...`));
2574
+ note(dim2(`${CMD} repurpose --capture <capture-id> --borrow structure`));
2575
+ return 1;
2576
+ }
2577
+ const read = () => request("/api/reframe/extract", { method: "POST", body: { input }, timeoutMs: 120000 });
2578
+ const src = json ? await read() : await whileThinking([{ at: 0, text: "reading it..." }, { at: 8, text: "still reading..." }], read);
2579
+ if (src.rejected) {
2580
+ fail(src.message ?? "That could not be read. Paste the text instead.");
2581
+ return 1;
2582
+ }
2583
+ sourceText = src.text ?? "";
2584
+ if (src.candidates && src.candidates.length > 1) {
2585
+ const pick = Number(value("pick") ?? 1);
2586
+ const chosen = src.candidates[pick - 1];
2587
+ if (!chosen) {
2588
+ fail(`There are ${src.candidates.length} posts there. --pick 1 to ${src.candidates.length}.`);
2589
+ return 1;
2590
+ }
2591
+ sourceText = chosen.text;
2592
+ if (!json) {
2593
+ note(dim2(`${src.candidates.length} posts found there, writing from number ${pick}:`));
2594
+ src.candidates.forEach((c, i) => note(dim2(` ${i + 1}. ${oneLine(c.title ?? c.text, 70)}`)));
2595
+ }
2596
+ }
2597
+ from = src.title ?? src.author ?? oneLine(sourceText, 60);
2598
+ }
2599
+ const shape2 = value("shape");
2600
+ const call = () => request("/api/reframe/rewrite", {
2601
+ method: "POST",
2602
+ body: {
2603
+ sourceText,
2604
+ origin: "found",
2605
+ ...captureId ? { captureId } : {},
2606
+ ...borrow ? { borrowMode: borrow } : {},
2607
+ ...value("instruction") ? { instruction: value("instruction") } : {},
2608
+ ...shape2 ? { postFormat: shape2 } : {}
2609
+ },
2610
+ timeoutMs: 200000
2611
+ });
2612
+ const result = json ? await call() : await whileThinking([
2613
+ { at: 0, text: "reading what it argues..." },
2614
+ { at: 6, text: "writing it in your voice..." },
2615
+ { at: 30, text: "checking it is yours, not theirs..." }
2616
+ ], call);
2617
+ const post = result.variations[0];
2618
+ if (!post?.content) {
2619
+ fail("Nothing came back. Try again, or try another --borrow.");
2620
+ return 1;
2621
+ }
2622
+ const series = value("series");
2623
+ const title = value("title");
2624
+ setDraft({ content: post.content, series });
2625
+ const saved = has("save") ? await saveNew2(post.content, series, undefined, title) : null;
2626
+ if (saved)
2627
+ updateDraft({ id: saved.id });
2628
+ if (saved && !title) {
2629
+ await request(`/api/posts/${encodeURIComponent(saved.id)}/title`, { method: "POST", timeoutMs: 30000 }).catch(() => {});
2630
+ }
2631
+ if (json) {
2632
+ out(JSON.stringify({ ...post, ...saved ? { id: saved.id } : {} }, null, 2));
2633
+ return 0;
2634
+ }
2635
+ out();
2636
+ await reveal(post.content, (chunk) => process.stdout.write(chunk));
2637
+ out();
2638
+ note(dim2(`${bar(post.qualityScore)} ${post.qualityScore} from: ${oneLine(from, 60)}`));
2639
+ if (saved)
2640
+ ok(series ? `Saved into "${series}". ${dim2(saved.id)}` : `Saved as a draft. ${dim2(saved.id)}`);
2641
+ else
2642
+ note(dim2("Not saved yet. `save` to keep it, or run again with --save."));
2643
+ return 0;
2644
+ }
2645
+ async function radar(args) {
2646
+ const { json, value, has } = parseFlags(args, ["limit"]);
2647
+ const feed = await request("/api/trend/feed");
2648
+ const limit = Number(value("limit") ?? 15) || 15;
2649
+ const cards = feed.cards.filter((c) => !c.pending);
2650
+ if (json) {
2651
+ out(JSON.stringify({ creators: feed.creators, cards: cards.slice(0, limit) }, null, 2));
2652
+ return 0;
2653
+ }
2654
+ if (!feed.creators.length) {
2655
+ out(dim2("You are not watching anyone yet. Add creators on the Radar page, under Who you watch."));
2656
+ return 0;
2657
+ }
2658
+ out();
2659
+ for (const c of cards.slice(0, limit)) {
2660
+ const m = c.metrics;
2661
+ const stats = m ? [m.likes ? `${m.likes} likes` : "", m.comments ? `${m.comments} comments` : ""].filter(Boolean).join(", ") : "";
2662
+ out(` ${bold2(c.authorName)}${dim2(` ${[c.postedAt ? ago(c.postedAt) : "", stats].filter(Boolean).join(" ")}`)}`);
2663
+ if (c.locked)
2664
+ out(` ${dim2("held back on the trial")}`);
2665
+ else if (has("full") && c.text)
2666
+ out(c.text.split(`
2667
+ `).map((l) => ` ${l}`).join(`
2668
+ `));
2669
+ else if (c.text)
2670
+ out(` ${oneLine(c.text, 72)}`);
2671
+ out(` ${dim2(c.postUrl)}`);
2672
+ out();
2673
+ }
2674
+ note(dim2(`\`${CMD} repurpose <link>\` writes your own post from one`));
2675
+ return 0;
2676
+ }
2677
+
1700
2678
  // src/session.ts
1701
2679
  import { createInterface } from "node:readline";
1702
2680
 
1703
2681
  // src/ghost.ts
1704
- function ghostFor(line2, cursor, history, names) {
2682
+ function ghostFor(line2, cursor, history2, names) {
1705
2683
  if (!line2 || cursor !== line2.length)
1706
2684
  return null;
1707
2685
  if (line2.startsWith(" "))
1708
2686
  return null;
1709
- const fromHistory = history.find((h) => h.startsWith(line2) && h.length > line2.length);
2687
+ const fromHistory = history2.find((h) => h.startsWith(line2) && h.length > line2.length);
1710
2688
  if (fromHistory)
1711
2689
  return fromHistory.slice(line2.length);
1712
2690
  if (!line2.includes(" ")) {
@@ -1754,12 +2732,13 @@ var COMMANDS2 = {
1754
2732
  write: {
1755
2733
  summary: "draft a post in your voice",
1756
2734
  usage: "innernote write [topic] [options]",
1757
- detail: "Not a prompt box. innernote already holds your voice, your pillars and what you have published, so give it the subject and let it write. Run it bare and it picks the pillar you have covered least.",
2735
+ detail: "Not a prompt box. innernote already holds your voice, your topics and what you have published, so give it the subject and let it write. Run it bare and it picks the topic you have covered least.",
1758
2736
  options: [
1759
2737
  ["--thought <text>", "turn a half-formed note into a post, keeping your phrasing"],
1760
2738
  ["--series <name>", "write into one of your series, so it follows on"],
1761
2739
  ["--format <name>", "story, listicle, contrarian, ..."],
1762
- ["--save", "keep it as a draft in the app"]
2740
+ ["--save", "keep it as a draft in the app"],
2741
+ ["--title <text>", "the title it is saved under (with --save); without it, one is written for you"]
1763
2742
  ],
1764
2743
  examples: [
1765
2744
  "innernote write",
@@ -1787,15 +2766,127 @@ var COMMANDS2 = {
1787
2766
  ]
1788
2767
  },
1789
2768
  ask: {
1790
- summary: "talk to the post in your own words",
1791
- usage: "innernote ask <what you want>",
1792
- detail: "The Writer, here. The app's composer is a conversation with the post in front of you, and this is the same assistant: it can change the post, answer a question about it, or say it would rather not. `shape` covers the moves that have names; this covers everything else.",
2769
+ summary: "talk to the post in your own words, or edit one passage of it",
2770
+ usage: "innernote ask [--post <id>] [--passage <text> | --hook | --ending] [--preset <name> | --shape <name> | --product <name>] [what you want]",
2771
+ detail: "The Writer, here. The app's composer is a conversation with the post in front of you, and this is the same assistant: it can change the post, answer a question about it, or say it would rather not. In a session it works on the post in hand. With --post it works on a saved post in one command, and the change is kept in the post's history. --passage is the editor's selection: the exact words to change, and only those change. --hook and --ending are the opening and closing block. --preset is a toolbar edit by name, --shape the format picker.",
2772
+ options: [
2773
+ ["--post <id>", "a saved post, by id, instead of the one in hand"],
2774
+ ["--passage <text>", "change only these exact words of the post"],
2775
+ ["--hook, --ending", "change only the opening, or the closing"],
2776
+ ["--preset <name>", "on a passage: sharpen shorter punchier simpler example deslop concrete warmer list. On the post: tighten_all as_list as_story fix_spacing rewrite_hook rewrite_ending ending_question"],
2777
+ ["--shape <name>", "story opinion listicle contrarian experience howto lessons casestudy framework question announcement prediction beliefbreak"],
2778
+ ["--product <name>", "work one of your products into the post, where it honestly belongs (`context` lists them)"],
2779
+ ["--rewrite", "with --product: rewrite the post around it instead"]
2780
+ ],
1793
2781
  examples: [
1794
2782
  "ask cut the second half and land it harder",
1795
2783
  "ask this ending is soft, what would you do",
1796
- "ask say what happened instead of what I learned"
2784
+ 'innernote ask --post p17abc... "make the opening shorter"',
2785
+ 'innernote ask --post p17abc... --passage "we got lucky" "say what the luck actually was"',
2786
+ 'innernote ask --post p17abc... --passage "we got lucky" --preset concrete',
2787
+ "innernote ask --post p17abc... --preset ending_question",
2788
+ "innernote ask --post p17abc... --shape story",
2789
+ 'innernote ask --post p17abc... --product "innernote"'
2790
+ ]
2791
+ },
2792
+ edit: {
2793
+ summary: "change a saved post: its text, title, first comment, series or status",
2794
+ usage: "innernote edit <id> [--file <path> | --text <text>] [--title <text>] [--first-comment <text>] [--series <name>] [--status ready|draft]",
2795
+ detail: "Puts your own words into a saved post. New text comes from --file, --text or a pipe, and is saved the way the app saves a draft, so the old text is one Undo away in the post's history. A post already live on LinkedIn keeps its text. The id comes from `drafts --json` or `show`.",
2796
+ options: [
2797
+ ["--file <path>", "the new text of the post, from a file"],
2798
+ ["--text <text>", "the new text of the post, inline"],
2799
+ ["--title <text>", "the post's title in the app"],
2800
+ ["--first-comment <text>", "the comment posted under it when it goes live"],
2801
+ ["--series <name>", "file it into a series, or `none` to take it out"],
2802
+ ["--status <ready|draft>", "mark it ready, or send it back to draft"]
2803
+ ],
2804
+ examples: [
2805
+ "innernote edit p17abc... --file post.txt",
2806
+ "pbpaste | innernote edit p17abc...",
2807
+ 'innernote edit p17abc... --title "The sales call" --first-comment "link: innernote.space"',
2808
+ 'innernote edit p17abc... --series "Build in Public" --status ready'
2809
+ ]
2810
+ },
2811
+ captures: {
2812
+ summary: "your Capture page: what you keep to write from later",
2813
+ usage: "innernote captures [<id>] | captures add <link or text> [--note <why>] | captures delete <id>",
2814
+ detail: "The same list as Capture in the app. With no argument it lists what you kept, newest first, with ids. With an id it prints that capture in full. `add` keeps a link (a LinkedIn post, an article, a page) or a thought in your own words: a link is read once, through innernote's cache, and kept with its text. `repurpose --capture <id>` writes a post from one.",
2815
+ options: [
2816
+ ["--note <why>", "with add: why you kept it, in a line"],
2817
+ ["--limit <n>", "how many to list (default 30)"]
2818
+ ],
2819
+ examples: [
2820
+ "innernote captures",
2821
+ 'innernote captures add https://www.linkedin.com/posts/... --note "the pricing argument"',
2822
+ "innernote captures j57abc..."
1797
2823
  ]
1798
2824
  },
2825
+ repurpose: {
2826
+ summary: "write your own post from a link, a capture or some text",
2827
+ usage: "innernote repurpose <link or text> | repurpose --capture <id> [--borrow <what>] [--instruction <text>] [--shape <name>] [--save]",
2828
+ detail: "Turn into a post, from here. The source is read, and the post is written in your voice from what you choose to borrow, then checked so it reads as yours and not theirs. --borrow idea takes their point and uses your proof, structure takes only how it is built, spark only reacts to it, and faithful relays what it reports in your words, with credit. A profile link yields its recent posts: the first is used, --pick chooses another.",
2829
+ options: [
2830
+ ["--capture <id>", "a capture from `captures`, instead of a link"],
2831
+ ["--borrow <what>", "idea, structure, spark or faithful"],
2832
+ ["--instruction <text>", "anything else it should do, in your words"],
2833
+ ["--shape <name>", "story, listicle, howto, lessons and the rest, as in `ask --shape`"],
2834
+ ["--pick <n>", "which of a profile's posts to write from"],
2835
+ ["--save", "keep it as a draft"],
2836
+ ["--series <name>", "with --save: file it into a series"],
2837
+ ["--title <text>", "with --save: its title (default: one is written for you)"]
2838
+ ],
2839
+ examples: [
2840
+ "innernote repurpose https://www.linkedin.com/posts/... --borrow structure",
2841
+ "innernote repurpose --capture j57abc... --borrow idea --save",
2842
+ "pbpaste | innernote repurpose --borrow spark"
2843
+ ]
2844
+ },
2845
+ radar: {
2846
+ summary: "recent posts from the creators you watch",
2847
+ usage: "innernote radar [--limit <n>] [--full]",
2848
+ detail: "The same feed as Radar in the app, newest first, with each post's link. Who you watch is set on the Radar page. `repurpose <link>` writes your own post from one.",
2849
+ options: [
2850
+ ["--limit <n>", "how many posts (default 15)"],
2851
+ ["--full", "print each post in full, not one line"]
2852
+ ],
2853
+ examples: ["innernote radar", "innernote radar --full --limit 5", "innernote radar --json"]
2854
+ },
2855
+ history: {
2856
+ summary: "every earlier version of a post, and putting one back",
2857
+ usage: "innernote history <id> [--show <n>] [--restore <n>]",
2858
+ detail: "Every change to a post's words is kept as a version first: yours, the assistant's, an edit from here. This lists them, newest first. --restore puts one back, and the text it replaces is kept as the newest version, so a restore is itself one step from undone.",
2859
+ options: [
2860
+ ["--show <n>", "print version n in full"],
2861
+ ["--restore <n>", "put version n back"]
2862
+ ],
2863
+ examples: ["innernote history p17abc...", "innernote history p17abc... --show 3", "innernote history p17abc... --restore 3"]
2864
+ },
2865
+ attach: {
2866
+ summary: "put an image, images, a PDF or a video on a saved post",
2867
+ usage: "innernote attach <id> <file...> [--title <text>] | innernote attach <id> --remove",
2868
+ detail: "Uploads straight to innernote's storage on a signed link, the way the app does, and attaches it to the post. Several images become a grid. A PDF shows on LinkedIn as a carousel people swipe through, under its title. A post carries one kind of attachment, so attaching replaces what was there.",
2869
+ options: [
2870
+ ["--title <text>", "the title of a PDF or a video (default: the file name)"],
2871
+ ["--remove", "take the attachment off"]
2872
+ ],
2873
+ examples: [
2874
+ "innernote attach p17abc... picture.png",
2875
+ 'innernote attach p17abc... slides.pdf --title "How we price"',
2876
+ "innernote attach p17abc... --remove"
2877
+ ]
2878
+ },
2879
+ delete: {
2880
+ summary: "move a post to the trash",
2881
+ usage: "innernote delete <id>",
2882
+ detail: "The same as Delete in the Posts list: the post is kept, and `restore` brings it back. It does not take anything down from LinkedIn.",
2883
+ examples: ["innernote delete p17abc...", "innernote restore p17abc..."]
2884
+ },
2885
+ restore: {
2886
+ summary: "bring a post back out of the trash",
2887
+ usage: "innernote restore <id>",
2888
+ examples: ["innernote restore p17abc..."]
2889
+ },
1799
2890
  save: {
1800
2891
  summary: "keep the draft you are looking at",
1801
2892
  usage: "innernote save",
@@ -1803,10 +2894,10 @@ var COMMANDS2 = {
1803
2894
  examples: ['write "hiring your first engineer"', "shape shorter", "save"]
1804
2895
  },
1805
2896
  show: {
1806
- summary: "see the post you are holding",
1807
- usage: "innernote show",
1808
- detail: "A session scrolls, and the post you are working on goes off the top while you look at your week. This brings it back, and says whether it is saved.",
1809
- examples: ["show"]
2897
+ summary: "see a post, with everything the app knows about it",
2898
+ usage: "innernote show [id]",
2899
+ detail: "With an id: the post's text on stdout, and its title, status, series, attachment, first comment, schedule, LinkedIn link and reactions on stderr, all of it in --json. Without one, in a session: the post you are holding, and whether it is saved.",
2900
+ examples: ["innernote show p17abc...", "innernote show p17abc... --json", "show"]
1810
2901
  },
1811
2902
  drop: {
1812
2903
  summary: "put down whatever you are holding",
@@ -1821,9 +2912,10 @@ var COMMANDS2 = {
1821
2912
  },
1822
2913
  drafts: {
1823
2914
  summary: "your posts, numbered",
1824
- usage: "innernote drafts [status]",
1825
- detail: "Numbered so you can pick one with `open 3` instead of copying a thirty-two character id. The numbers refer to the list you are looking at and are replaced by the next one.",
1826
- examples: ["drafts", "drafts ready", "drafts --json | jq -r '.[]._id'"]
2915
+ usage: "innernote drafts [status] [--series <name>]",
2916
+ detail: "Numbered so you can pick one with `open 3` instead of copying a thirty-two character id. The numbers refer to the list you are looking at and are replaced by the next one. --series lists one series in its own order. Each --json row also carries the series name and, once published, the LinkedIn link.",
2917
+ options: [["--series <name>", "only the posts in this series, in its order"]],
2918
+ examples: ["drafts", "drafts ready", 'drafts --series "Growing innernote"', "drafts --json | jq -r '.[]._id'"]
1827
2919
  },
1828
2920
  open: {
1829
2921
  summary: "pull a post out of the list and work on it",
@@ -1849,15 +2941,16 @@ var COMMANDS2 = {
1849
2941
  examples: ["innernote skill", "innernote skill --install"]
1850
2942
  },
1851
2943
  context: {
1852
- summary: "what you post about: your pillars and your series",
2944
+ summary: "what you post about: your topics, your series and your products",
1853
2945
  usage: "innernote context",
1854
- detail: "Your standing context: content pillars with their weights, and every running series with what it is about and what it is for. This is the thing to read before deciding what to write, and the thing an agent should read first for the same reason. --json prints the raw payload for scripts and models.",
2946
+ detail: "Your standing context: your topics with their weights, every running series with what it is about and what it is for, and your products. This is the thing to read before deciding what to write, and the thing an agent should read first for the same reason. --json prints the raw payload for scripts and models.",
1855
2947
  examples: ["innernote context", "innernote context --json"]
1856
2948
  },
1857
2949
  queue: {
1858
2950
  summary: "schedule a post into your next open slot",
1859
- usage: "innernote queue <post-id>",
2951
+ usage: "innernote queue <post-id> [--at <time>]",
1860
2952
  detail: "A queued post GOES LIVE on LinkedIn on its own at your next cadence slot. Nobody presses anything again. Needs a connection you allowed to publish: tick that box in Settings when you generate the code. Takes a number from the last list, or a full id. It never assumes the draft in hand: this is the one command that makes you say which post.",
2953
+ options: [["--at <time>", `a time you pick, like "2026-10-12 12:31" (this machine's timezone), instead of the next open slot`]],
1861
2954
  examples: ["innernote drafts", "innernote queue 3", "innernote queue p17a1yk936ywnan10643efjtc58dhkah"]
1862
2955
  }
1863
2956
  };
@@ -1914,7 +3007,7 @@ function tokenize(line2) {
1914
3007
  quote = null;
1915
3008
  else
1916
3009
  cur += ch;
1917
- } else if (ch === '"' || ch === "'") {
3010
+ } else if ((ch === '"' || ch === "'") && cur === "") {
1918
3011
  quote = ch;
1919
3012
  } else if (/\s/.test(ch)) {
1920
3013
  if (cur)
@@ -2143,9 +3236,11 @@ var VERSION2 = package_default.version;
2143
3236
  function commandList() {
2144
3237
  const groups = [
2145
3238
  ["Getting connected", ["login", "logout", "whoami", "skill"]],
2146
- ["Writing", ["write", "shape", "ask", "save", "capture"]],
2147
- ["Looking", ["show", "ideas", "drafts", "open", "week", "context"]],
2148
- ["Shipping", ["queue", "unqueue"]]
3239
+ ["Writing", ["write", "repurpose", "shape", "ask", "edit", "attach", "save", "capture"]],
3240
+ ["Sources", ["captures", "radar"]],
3241
+ ["Looking", ["show", "history", "ideas", "drafts", "open", "week", "context"]],
3242
+ ["Shipping", ["queue", "unqueue"]],
3243
+ ["Tidying", ["delete", "restore"]]
2149
3244
  ];
2150
3245
  const lines = [];
2151
3246
  for (const [title, names] of groups) {
@@ -2179,6 +3274,14 @@ var COMMANDS3 = {
2179
3274
  write,
2180
3275
  shape,
2181
3276
  ask,
3277
+ edit,
3278
+ attach,
3279
+ delete: deletePost,
3280
+ restore: restorePost,
3281
+ history,
3282
+ captures,
3283
+ repurpose,
3284
+ radar,
2182
3285
  save,
2183
3286
  show,
2184
3287
  drop,
@@ -2205,6 +3308,10 @@ async function main() {
2205
3308
  return d.id ? "saved" : "unsaved";
2206
3309
  },
2207
3310
  run: async (name, rest) => {
3311
+ if (rest.includes("--help")) {
3312
+ out2(commandHelp(name) ?? HELP());
3313
+ return 0;
3314
+ }
2208
3315
  const h = COMMANDS3[name];
2209
3316
  return h ? h(rest) : 1;
2210
3317
  }