innernote 0.3.4 → 0.4.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 +8 -4
  2. package/dist/index.js +725 -69
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -90,16 +90,20 @@ from it fails the suite, so what you read here is what the binary has.
90
90
  | `capture <text>` | save an idea to your inbox |
91
91
  | `write [topic]` | draft a post in your voice |
92
92
  | `shape <shape...>` | push a post shorter, punchier, warmer |
93
- | `ask <anything>` | talk to the post in your own words |
93
+ | `ask [--post <id>] <anything>` | talk to the post in your own words; with `--post`, a saved post in one command. `--passage`, `--hook`, `--ending`, `--preset` and `--shape` edit one part of it, as the editor's selection, toolbar and format picker do |
94
+ | `edit <id>` | put new text, a title, a first comment, a series or a ready state on a saved post |
95
+ | `attach <id> <file...>` | put an image, images, a PDF or a video on a saved post |
94
96
  | `save` | keep the draft you are holding |
95
- | `show` | reprint the post you are holding |
97
+ | `show [id]` | a post with everything the app knows about it, or the one you are holding |
96
98
  | `drop` | put down whatever you are holding |
97
99
  | `open <n or id>` | pull a post out of the last list and work on it |
98
100
  | `ideas [status]` | list what you have captured |
99
- | `drafts [status]` | list your posts, numbered |
101
+ | `drafts [status] [--series <name>]` | list your posts, numbered, or one series in its order |
100
102
  | `week` | what is scheduled for the next seven days |
101
- | `queue <post-id>` | schedule a post into your next open slot |
103
+ | `queue <post-id> [--at <time>]` | schedule a post into your next open slot, or at a time you pick |
102
104
  | `unqueue <post-id>` | pull it back out before it goes live |
105
+ | `delete <id>` | move a post to the trash |
106
+ | `restore <id>` | bring it back out of the trash |
103
107
  | `skill [--install]` | print the agent skill, or register it for Claude Code |
104
108
 
105
109
  ### write
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,32 @@ 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] = await Promise.all([
654
718
  request("/api/pillars"),
655
- request("/api/campaigns")
719
+ request("/api/campaigns"),
720
+ request("/api/notebook/brief").catch(() => ({ brief: null }))
656
721
  ]);
657
722
  if (json) {
658
- out(JSON.stringify({ pillars: pillars.pillars, series: series.campaigns }, null, 2));
723
+ out(JSON.stringify({ pillars: pillars.pillars, series: series.campaigns, brief: notebook.brief }, null, 2));
659
724
  return 0;
660
725
  }
661
726
  out();
727
+ if (notebook.brief) {
728
+ out(` ${bold2("Who you are, as innernote knows it")}`);
729
+ for (const line of notebook.brief.split(`
730
+ `)) {
731
+ if (line.trim())
732
+ out(` ${dim2(line.replace(/^#+\s*/, ""))}`);
733
+ }
734
+ out();
735
+ }
662
736
  if (pillars.pillars.length) {
663
737
  out(` ${bold2("What you post about")}`);
664
738
  for (const p of pillars.pillars) {
665
739
  out(` ${caramel(p.name)}${typeof p.weight === "number" ? dim2(` ${p.weight}%`) : ""}`);
666
740
  }
667
741
  } else {
668
- out(` ${dim2("No content pillars yet. Set them in Settings.")}`);
742
+ out(` ${dim2("No topics yet. Set them in Settings.")}`);
669
743
  }
670
744
  out();
671
745
  if (series.campaigns.length) {
@@ -853,6 +927,7 @@ async function whileThinking(phases, run) {
853
927
  }
854
928
 
855
929
  // src/commands/write.ts
930
+ import { readFile as readFile2 } from "node:fs/promises";
856
931
  async function capture(args) {
857
932
  const { json, rest } = parseFlags(args);
858
933
  const typed = rest.join(" ").trim();
@@ -876,14 +951,26 @@ async function capture(args) {
876
951
  note(dim2("`ideas` to see them, `write` to turn one into a post"));
877
952
  return 0;
878
953
  }
954
+ async function saveNew(content, series, seriesSummary, title) {
955
+ return request("/api/posts", {
956
+ method: "POST",
957
+ body: {
958
+ content,
959
+ source: "cli",
960
+ ...title ? { title } : {},
961
+ ...series ? { series, seriesSummary: seriesSummary ?? undefined } : {}
962
+ }
963
+ });
964
+ }
879
965
  async function write(args) {
880
- const { json, rest, value, has } = parseFlags(args, ["thought", "format", "series"]);
966
+ const { json, rest, value, has } = parseFlags(args, ["thought", "format", "series", "title"]);
881
967
  const topic = rest.join(" ").trim();
882
968
  const thoughtFlag = value("thought");
883
969
  const piped = thoughtFlag || topic ? null : await readStdin();
884
970
  const thought = thoughtFlag ?? (piped ?? undefined);
885
971
  const format = value("format");
886
972
  const series = value("series");
973
+ const title = value("title");
887
974
  const save = has("save");
888
975
  const call = () => request("/api/content/generate", {
889
976
  method: "POST",
@@ -902,8 +989,14 @@ async function write(args) {
902
989
  seriesSummary: post.seriesSummary ?? undefined,
903
990
  quality: post.qualityScore
904
991
  });
992
+ const saved = save ? await saveNew(post.content, series, post.seriesSummary, title) : null;
993
+ if (saved)
994
+ updateDraft({ id: saved.id });
995
+ if (saved && !title) {
996
+ await request(`/api/posts/${encodeURIComponent(saved.id)}/title`, { method: "POST", timeoutMs: 30000 }).catch(() => {});
997
+ }
905
998
  if (json) {
906
- out(JSON.stringify(post, null, 2));
999
+ out(JSON.stringify(saved ? { ...post, id: saved.id } : post, null, 2));
907
1000
  return 0;
908
1001
  }
909
1002
  out();
@@ -920,16 +1013,7 @@ async function write(args) {
920
1013
  else if (post.similarity?.band === "adjacent")
921
1014
  notes.push("close to something you already published");
922
1015
  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 });
1016
+ if (saved) {
933
1017
  ok(series ? `Saved into "${series}". ${dim2(saved.id)}` : `Saved as a draft. ${dim2(saved.id)}`);
934
1018
  } else {
935
1019
  note(dim2("Not saved yet. `save` to keep it, `shape shorter` to change it."));
@@ -993,8 +1077,17 @@ async function shape(args) {
993
1077
  note(dim2(held?.id ? "`save` to update it" : "`save` to keep it"));
994
1078
  return 0;
995
1079
  }
1080
+ function parseWhen(input) {
1081
+ const local = input.trim().match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})$/);
1082
+ if (local) {
1083
+ const [, y, mo, d, h, mi] = local.map(Number);
1084
+ return new Date(y, mo - 1, d, h, mi).getTime();
1085
+ }
1086
+ const t = Date.parse(input);
1087
+ return Number.isFinite(t) ? t : null;
1088
+ }
996
1089
  async function queue(args) {
997
- const { json, rest } = parseFlags(args);
1090
+ const { json, rest, value } = parseFlags(args, ["at"]);
998
1091
  const ref = rest[0] ? resolveRef(rest[0]) : null;
999
1092
  if (!ref) {
1000
1093
  fail("Which post?");
@@ -1002,7 +1095,20 @@ async function queue(args) {
1002
1095
  note(dim2(`Or pass the id: ${CMD} queue <post-id>`));
1003
1096
  return 1;
1004
1097
  }
1005
- const result = await request(`/api/posts/${encodeURIComponent(ref.id)}/queue`, { method: "POST", timeoutMs: 60000 });
1098
+ const at = value("at");
1099
+ let result;
1100
+ if (at !== undefined) {
1101
+ const when2 = parseWhen(at);
1102
+ if (when2 == null) {
1103
+ fail(`"${at}" is not a time.`);
1104
+ note(dim2(`${CMD} queue <post-id> --at "2026-10-12 12:31"`));
1105
+ return 1;
1106
+ }
1107
+ await request(`/api/linkedin/schedule`, { method: "POST", body: { postId: ref.id, scheduledFor: when2 }, timeoutMs: 60000 });
1108
+ result = { success: true, scheduledFor: when2 };
1109
+ } else {
1110
+ result = await request(`/api/posts/${encodeURIComponent(ref.id)}/queue`, { method: "POST", timeoutMs: 60000 });
1111
+ }
1006
1112
  if (json) {
1007
1113
  out(JSON.stringify(result, null, 2));
1008
1114
  return 0;
@@ -1076,9 +1182,19 @@ async function open(args) {
1076
1182
  return 0;
1077
1183
  }
1078
1184
  async function ask(args) {
1079
- const { json, rest } = parseFlags(args);
1185
+ const { json, rest, value, has } = parseFlags(args, ["post", "passage", "preset", "shape"]);
1080
1186
  const message = rest.join(" ").trim();
1081
1187
  const held = getDraft();
1188
+ const postRef = value("post");
1189
+ if (postRef) {
1190
+ const target = {
1191
+ passage: value("passage"),
1192
+ preset: value("preset"),
1193
+ shape: value("shape"),
1194
+ region: has("hook") ? "hook" : has("ending") ? "ending" : undefined
1195
+ };
1196
+ return askAboutPost(postRef, message, target, json);
1197
+ }
1082
1198
  if (!message) {
1083
1199
  fail("Say what you want changed.");
1084
1200
  note(dim2("ask cut the second half and land it harder"));
@@ -1104,7 +1220,7 @@ async function ask(args) {
1104
1220
  id = saved.id;
1105
1221
  updateDraft({ id });
1106
1222
  }
1107
- const call = () => request(`/api/posts/${encodeURIComponent(id)}/edit`, { method: "POST", body: { text: message, scope: "whole" }, timeoutMs: 150000 });
1223
+ const call = () => request(`/api/posts/${encodeURIComponent(id)}/edit`, { method: "POST", body: { text: held.content, instruction: message, scope: "whole" }, timeoutMs: 150000 });
1108
1224
  const result = json ? await call() : await whileThinking([
1109
1225
  { at: 0, text: "reading the post..." },
1110
1226
  { at: 3, text: "working on it..." },
@@ -1130,7 +1246,9 @@ async function ask(args) {
1130
1246
  return 0;
1131
1247
  }
1132
1248
  async function show(args) {
1133
- const { json } = parseFlags(args);
1249
+ const { json, rest } = parseFlags(args);
1250
+ if (rest[0])
1251
+ return showPost(rest[0], json);
1134
1252
  const held = getDraft();
1135
1253
  if (!held?.content) {
1136
1254
  note("Nothing in hand.");
@@ -1184,6 +1302,267 @@ async function unqueue(args) {
1184
1302
  note(dim2("`drafts` to find it, `queue` to send it back"));
1185
1303
  return 0;
1186
1304
  }
1305
+ async function postFromRef(refArg, usage) {
1306
+ const ref = resolveRef(refArg);
1307
+ if (!ref) {
1308
+ fail(`No post ${refArg}.`);
1309
+ note(dim2(usage));
1310
+ return null;
1311
+ }
1312
+ const post = await fetchPost(ref.id);
1313
+ if (!post) {
1314
+ fail("That post could not be found.");
1315
+ note(dim2(`\`${CMD} drafts\` lists them with their ids`));
1316
+ return null;
1317
+ }
1318
+ return post;
1319
+ }
1320
+ var PRESETS = {
1321
+ selection: ["sharpen", "shorter", "punchier", "simpler", "example", "deslop", "concrete", "warmer", "list"],
1322
+ whole: ["tighten_all", "as_list", "as_story", "fix_spacing"],
1323
+ region: ["rewrite_hook", "rewrite_ending", "ending_question"]
1324
+ };
1325
+ var SHAPE_NAMES = [
1326
+ "story",
1327
+ "opinion",
1328
+ "listicle",
1329
+ "contrarian",
1330
+ "experience",
1331
+ "howto",
1332
+ "lessons",
1333
+ "casestudy",
1334
+ "framework",
1335
+ "question",
1336
+ "announcement",
1337
+ "prediction",
1338
+ "beliefbreak"
1339
+ ];
1340
+ function askBody(content, message, t) {
1341
+ const preset = t.preset;
1342
+ if (preset && ![...PRESETS.selection, ...PRESETS.whole, ...PRESETS.region].includes(preset)) {
1343
+ return `There is no preset called "${preset}". Presets: ${[...PRESETS.selection, ...PRESETS.whole, ...PRESETS.region].join(", ")}`;
1344
+ }
1345
+ if (t.shape) {
1346
+ if (!SHAPE_NAMES.includes(t.shape))
1347
+ return `There is no shape called "${t.shape}". Shapes: ${SHAPE_NAMES.join(", ")}`;
1348
+ return { text: content, format: t.shape };
1349
+ }
1350
+ if (t.passage !== undefined) {
1351
+ const at = content.indexOf(t.passage);
1352
+ if (!t.passage.trim() || at < 0)
1353
+ return "That passage is not in the post. Copy it exactly, from `show`.";
1354
+ if (preset && !PRESETS.selection.includes(preset)) {
1355
+ return `"${preset}" works on the whole post or its hook or ending, not on a passage. On a passage: ${PRESETS.selection.join(", ")}`;
1356
+ }
1357
+ if (!preset && !message)
1358
+ return "Say what to change in that passage, or name a preset.";
1359
+ return {
1360
+ text: content,
1361
+ scope: "selection",
1362
+ fragment: t.passage,
1363
+ selStart: at,
1364
+ selEnd: at + t.passage.length,
1365
+ ...preset ? { preset } : { instruction: message }
1366
+ };
1367
+ }
1368
+ if (t.region) {
1369
+ if (preset)
1370
+ return "Name a hook or ending preset on its own: --preset rewrite_hook, rewrite_ending or ending_question.";
1371
+ if (!message)
1372
+ return `Say what to change in the ${t.region}.`;
1373
+ return { text: content, scope: "region", region: t.region, instruction: message };
1374
+ }
1375
+ if (preset) {
1376
+ if (PRESETS.selection.includes(preset))
1377
+ return `"${preset}" works on a passage: add --passage "the exact words".`;
1378
+ return { text: content, preset };
1379
+ }
1380
+ if (!message)
1381
+ return "Say what you want changed.";
1382
+ return { text: content, instruction: message, scope: "whole" };
1383
+ }
1384
+ async function askAboutPost(refArg, message, target, json) {
1385
+ const post = await postFromRef(refArg, `${CMD} ask --post <post-id> "what to change"`);
1386
+ if (!post)
1387
+ return 1;
1388
+ if (post.status === "published") {
1389
+ fail("That post is already live on LinkedIn, so it cannot be changed here.");
1390
+ return 1;
1391
+ }
1392
+ const body = askBody(post.content, message, target);
1393
+ if (typeof body === "string") {
1394
+ fail(body);
1395
+ return 1;
1396
+ }
1397
+ const call = () => request(`/api/posts/${encodeURIComponent(post._id)}/edit`, { method: "POST", body, timeoutMs: 150000 });
1398
+ const result = json ? await call() : await whileThinking([
1399
+ { at: 0, text: "reading the post..." },
1400
+ { at: 3, text: "working on it..." },
1401
+ { at: 14, text: "still going..." }
1402
+ ], call);
1403
+ let stored = post.content;
1404
+ if (result.changed && result.post) {
1405
+ const after = await fetchPost(post._id);
1406
+ stored = after?.content ?? stored;
1407
+ if (stored !== result.post) {
1408
+ await request(`/api/posts/${encodeURIComponent(post._id)}/draft`, {
1409
+ method: "POST",
1410
+ body: { text: result.post, previous: stored, origin: "suggestion" }
1411
+ });
1412
+ stored = result.post;
1413
+ }
1414
+ }
1415
+ if (json) {
1416
+ out(JSON.stringify({ id: post._id, changed: !!result.changed, post: stored, passage: result.fragment ?? null, reply: result.reply ?? null }, null, 2));
1417
+ return 0;
1418
+ }
1419
+ out();
1420
+ out(stored);
1421
+ out();
1422
+ if (result.changed && result.fragment && target.passage !== undefined) {
1423
+ note(dim2(`was: ${target.passage}`));
1424
+ note(dim2(`now: ${result.fragment}`));
1425
+ }
1426
+ if (result.reply)
1427
+ note(dim2(result.reply));
1428
+ note(dim2(result.changed ? `Saved. ${post._id}` : `No change. ${post._id}`));
1429
+ return 0;
1430
+ }
1431
+ async function showPost(refArg, json) {
1432
+ const post = await postFromRef(refArg, `${CMD} show <post-id>`);
1433
+ if (!post)
1434
+ return 1;
1435
+ const series = await listSeries();
1436
+ if (json) {
1437
+ out(JSON.stringify(postForJson(post, series), null, 2));
1438
+ return 0;
1439
+ }
1440
+ const when = (ms) => new Date(ms).toLocaleString(undefined, { weekday: "short", day: "numeric", month: "short", hour: "numeric", minute: "2-digit" });
1441
+ const seriesName = post.campaignId ? series.find((s) => s._id === post.campaignId)?.name : undefined;
1442
+ const media = describeMedia(post);
1443
+ const url = linkedinUrl(post);
1444
+ out();
1445
+ out(post.content);
1446
+ out();
1447
+ const facts = [
1448
+ ["title", post.title || "untitled"],
1449
+ ["status", post.status],
1450
+ ["series", seriesName],
1451
+ ["attached", media ?? undefined],
1452
+ ["first comment", post.firstComment ? post.firstComment.replace(/\s+/g, " ").slice(0, 80) : undefined],
1453
+ ["scheduled", post.scheduledFor && post.status !== "published" ? when(post.scheduledFor) : undefined],
1454
+ ["published", post.publishedAt ? when(post.publishedAt) : undefined],
1455
+ ["on linkedin", url ?? undefined],
1456
+ [
1457
+ "reactions",
1458
+ post.linkedinLikes !== undefined || post.linkedinComments !== undefined ? `${post.linkedinLikes ?? 0} likes, ${post.linkedinComments ?? 0} comments` : undefined
1459
+ ],
1460
+ ["id", post._id]
1461
+ ];
1462
+ for (const [label, valueText] of facts) {
1463
+ if (valueText)
1464
+ note(`${dim2(label.padEnd(14))}${valueText}`);
1465
+ }
1466
+ return 0;
1467
+ }
1468
+ async function edit(args) {
1469
+ const { json, rest, value } = parseFlags(args, ["text", "file", "title", "first-comment", "series", "status"]);
1470
+ const refArg = rest[0];
1471
+ if (!refArg) {
1472
+ fail("Edit which post?");
1473
+ note(dim2(`${CMD} edit <post-id> --file post.txt`));
1474
+ note(dim2(`${CMD} edit <post-id> --title "A title"`));
1475
+ return 1;
1476
+ }
1477
+ const title = value("title");
1478
+ const firstComment = value("first-comment");
1479
+ const seriesName = value("series");
1480
+ const status = value("status");
1481
+ const file = value("file");
1482
+ let text = value("text");
1483
+ if (file)
1484
+ text = await readFile2(file, "utf8");
1485
+ const fieldsGiven = title !== undefined || firstComment !== undefined || seriesName !== undefined || status !== undefined;
1486
+ if (text === undefined && !fieldsGiven) {
1487
+ text = await readStdin() ?? undefined;
1488
+ }
1489
+ if (text === undefined && !fieldsGiven) {
1490
+ fail("Nothing to change.");
1491
+ note(dim2("--file, --text or piped text for the post, --title, --first-comment, --series, --status"));
1492
+ return 1;
1493
+ }
1494
+ if (status !== undefined && status !== "ready" && status !== "draft") {
1495
+ fail("--status is ready or draft.");
1496
+ note(dim2("Scheduling is `queue`, and taking it out of the queue is `unqueue`."));
1497
+ return 1;
1498
+ }
1499
+ const post = await postFromRef(refArg, `${CMD} edit <post-id> --file post.txt`);
1500
+ if (!post)
1501
+ return 1;
1502
+ const changed = [];
1503
+ let content = post.content;
1504
+ if (text !== undefined) {
1505
+ const next = text.replace(/\s+$/, "");
1506
+ if (!next.trim()) {
1507
+ fail("That text is empty.");
1508
+ return 1;
1509
+ }
1510
+ if (post.status === "published") {
1511
+ fail("That post is already live on LinkedIn, so its text cannot be changed here.");
1512
+ return 1;
1513
+ }
1514
+ if (next !== post.content) {
1515
+ await request(`/api/posts/${encodeURIComponent(post._id)}/draft`, {
1516
+ method: "POST",
1517
+ body: { text: next, previous: post.content, origin: "stream" }
1518
+ });
1519
+ content = next;
1520
+ changed.push("text");
1521
+ }
1522
+ }
1523
+ if (title !== undefined || firstComment !== undefined) {
1524
+ await request("/api/posts", {
1525
+ method: "POST",
1526
+ body: {
1527
+ id: post._id,
1528
+ content,
1529
+ ...title !== undefined ? { title } : {},
1530
+ ...firstComment !== undefined ? { firstComment } : {}
1531
+ }
1532
+ });
1533
+ if (title !== undefined)
1534
+ changed.push("title");
1535
+ if (firstComment !== undefined)
1536
+ changed.push("first comment");
1537
+ }
1538
+ if (seriesName !== undefined) {
1539
+ let toCampaignId = null;
1540
+ if (seriesName.trim().toLowerCase() !== "none" && seriesName.trim()) {
1541
+ const all = await listSeries();
1542
+ const target = matchSeries(all, seriesName);
1543
+ if (!target) {
1544
+ fail(`No series called "${seriesName}".`);
1545
+ if (all.length)
1546
+ note(dim2(`Yours: ${all.map((x) => x.name).join(", ")}`));
1547
+ return 1;
1548
+ }
1549
+ toCampaignId = target._id;
1550
+ }
1551
+ await request("/api/campaigns/move", { method: "POST", body: { postId: post._id, toCampaignId } });
1552
+ changed.push(toCampaignId ? "series" : "series (taken out)");
1553
+ }
1554
+ if (status !== undefined) {
1555
+ await request(`/api/posts/${encodeURIComponent(post._id)}/ready`, { method: status === "ready" ? "POST" : "DELETE" });
1556
+ changed.push(`status (${status})`);
1557
+ }
1558
+ if (json) {
1559
+ out(JSON.stringify({ id: post._id, changed }, null, 2));
1560
+ return 0;
1561
+ }
1562
+ ok(changed.length ? `Updated the ${changed.join(", ")}. ${dim2(post._id)}` : `Nothing was different. ${dim2(post._id)}`);
1563
+ note(dim2(`\`${CMD} show ${post._id}\` to read it back`));
1564
+ return 0;
1565
+ }
1187
1566
 
1188
1567
  // src/ui.ts
1189
1568
  var useColor2 = process.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== "dumb";
@@ -1340,12 +1719,13 @@ var COMMANDS = {
1340
1719
  write: {
1341
1720
  summary: "draft a post in your voice",
1342
1721
  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.",
1722
+ 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
1723
  options: [
1345
1724
  ["--thought <text>", "turn a half-formed note into a post, keeping your phrasing"],
1346
1725
  ["--series <name>", "write into one of your series, so it follows on"],
1347
1726
  ["--format <name>", "story, listicle, contrarian, ..."],
1348
- ["--save", "keep it as a draft in the app"]
1727
+ ["--save", "keep it as a draft in the app"],
1728
+ ["--title <text>", "the title it is saved under (with --save); without it, one is written for you"]
1349
1729
  ],
1350
1730
  examples: [
1351
1731
  "innernote write",
@@ -1373,15 +1753,70 @@ var COMMANDS = {
1373
1753
  ]
1374
1754
  },
1375
1755
  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.",
1756
+ summary: "talk to the post in your own words, or edit one passage of it",
1757
+ usage: "innernote ask [--post <id>] [--passage <text> | --hook | --ending] [--preset <name> | --shape <name>] [what you want]",
1758
+ 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.",
1759
+ options: [
1760
+ ["--post <id>", "a saved post, by id, instead of the one in hand"],
1761
+ ["--passage <text>", "change only these exact words of the post"],
1762
+ ["--hook, --ending", "change only the opening, or the closing"],
1763
+ ["--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"],
1764
+ ["--shape <name>", "story opinion listicle contrarian experience howto lessons casestudy framework question announcement prediction beliefbreak"]
1765
+ ],
1379
1766
  examples: [
1380
1767
  "ask cut the second half and land it harder",
1381
1768
  "ask this ending is soft, what would you do",
1382
- "ask say what happened instead of what I learned"
1769
+ 'innernote ask --post p17abc... "make the opening shorter"',
1770
+ 'innernote ask --post p17abc... --passage "we got lucky" "say what the luck actually was"',
1771
+ 'innernote ask --post p17abc... --passage "we got lucky" --preset concrete',
1772
+ "innernote ask --post p17abc... --preset ending_question",
1773
+ "innernote ask --post p17abc... --shape story"
1774
+ ]
1775
+ },
1776
+ edit: {
1777
+ summary: "change a saved post: its text, title, first comment, series or status",
1778
+ usage: "innernote edit <id> [--file <path> | --text <text>] [--title <text>] [--first-comment <text>] [--series <name>] [--status ready|draft]",
1779
+ 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`.",
1780
+ options: [
1781
+ ["--file <path>", "the new text of the post, from a file"],
1782
+ ["--text <text>", "the new text of the post, inline"],
1783
+ ["--title <text>", "the post's title in the app"],
1784
+ ["--first-comment <text>", "the comment posted under it when it goes live"],
1785
+ ["--series <name>", "file it into a series, or `none` to take it out"],
1786
+ ["--status <ready|draft>", "mark it ready, or send it back to draft"]
1787
+ ],
1788
+ examples: [
1789
+ "innernote edit p17abc... --file post.txt",
1790
+ "pbpaste | innernote edit p17abc...",
1791
+ 'innernote edit p17abc... --title "The sales call" --first-comment "link: innernote.space"',
1792
+ 'innernote edit p17abc... --series "Build in Public" --status ready'
1793
+ ]
1794
+ },
1795
+ attach: {
1796
+ summary: "put an image, images, a PDF or a video on a saved post",
1797
+ usage: "innernote attach <id> <file...> [--title <text>] | innernote attach <id> --remove",
1798
+ 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.",
1799
+ options: [
1800
+ ["--title <text>", "the title of a PDF or a video (default: the file name)"],
1801
+ ["--remove", "take the attachment off"]
1802
+ ],
1803
+ examples: [
1804
+ "innernote attach p17abc... picture.png",
1805
+ 'innernote attach p17abc... slides.pdf --title "How we price"',
1806
+ "innernote attach p17abc... --remove"
1383
1807
  ]
1384
1808
  },
1809
+ delete: {
1810
+ summary: "move a post to the trash",
1811
+ usage: "innernote delete <id>",
1812
+ 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.",
1813
+ examples: ["innernote delete p17abc...", "innernote restore p17abc..."]
1814
+ },
1815
+ restore: {
1816
+ summary: "bring a post back out of the trash",
1817
+ usage: "innernote restore <id>",
1818
+ examples: ["innernote restore p17abc..."]
1819
+ },
1385
1820
  save: {
1386
1821
  summary: "keep the draft you are looking at",
1387
1822
  usage: "innernote save",
@@ -1389,10 +1824,10 @@ var COMMANDS = {
1389
1824
  examples: ['write "hiring your first engineer"', "shape shorter", "save"]
1390
1825
  },
1391
1826
  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"]
1827
+ summary: "see a post, with everything the app knows about it",
1828
+ usage: "innernote show [id]",
1829
+ 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.",
1830
+ examples: ["innernote show p17abc...", "innernote show p17abc... --json", "show"]
1396
1831
  },
1397
1832
  drop: {
1398
1833
  summary: "put down whatever you are holding",
@@ -1407,9 +1842,10 @@ var COMMANDS = {
1407
1842
  },
1408
1843
  drafts: {
1409
1844
  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'"]
1845
+ usage: "innernote drafts [status] [--series <name>]",
1846
+ 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.",
1847
+ options: [["--series <name>", "only the posts in this series, in its order"]],
1848
+ examples: ["drafts", "drafts ready", 'drafts --series "Growing innernote"', "drafts --json | jq -r '.[]._id'"]
1413
1849
  },
1414
1850
  open: {
1415
1851
  summary: "pull a post out of the list and work on it",
@@ -1435,15 +1871,16 @@ var COMMANDS = {
1435
1871
  examples: ["innernote skill", "innernote skill --install"]
1436
1872
  },
1437
1873
  context: {
1438
- summary: "what you post about: your pillars and your series",
1874
+ summary: "what you post about: your topics and your series",
1439
1875
  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.",
1876
+ detail: "Your standing context: your topics 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.",
1441
1877
  examples: ["innernote context", "innernote context --json"]
1442
1878
  },
1443
1879
  queue: {
1444
1880
  summary: "schedule a post into your next open slot",
1445
- usage: "innernote queue <post-id>",
1881
+ usage: "innernote queue <post-id> [--at <time>]",
1446
1882
  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.",
1883
+ options: [["--at <time>", `a time you pick, like "2026-10-12 12:31" (this machine's timezone), instead of the next open slot`]],
1447
1884
  examples: ["innernote drafts", "innernote queue 3", "innernote queue p17a1yk936ywnan10643efjtc58dhkah"]
1448
1885
  }
1449
1886
  };
@@ -1557,7 +1994,7 @@ function suggest(state) {
1557
1994
  // package.json
1558
1995
  var package_default = {
1559
1996
  name: "innernote",
1560
- version: "0.3.4",
1997
+ version: "0.4.0",
1561
1998
  description: "Write LinkedIn posts in your voice, from the terminal.",
1562
1999
  keywords: [
1563
2000
  "linkedin",
@@ -1662,7 +2099,7 @@ async function home(inSession = false) {
1662
2099
  }
1663
2100
 
1664
2101
  // src/commands/skill.ts
1665
- import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
2102
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
1666
2103
  import { homedir as homedir2 } from "node:os";
1667
2104
  import { dirname, join as join2 } from "node:path";
1668
2105
  import { fileURLToPath } from "node:url";
@@ -1670,7 +2107,7 @@ async function readSkill() {
1670
2107
  const here = dirname(fileURLToPath(import.meta.url));
1671
2108
  for (const rel of ["../../skills/innernote-cli/SKILL.md", "../skills/innernote-cli/SKILL.md"]) {
1672
2109
  try {
1673
- return await readFile2(join2(here, rel), "utf8");
2110
+ return await readFile3(join2(here, rel), "utf8");
1674
2111
  } catch {}
1675
2112
  }
1676
2113
  return null;
@@ -1697,6 +2134,158 @@ async function skill(args) {
1697
2134
  return 0;
1698
2135
  }
1699
2136
 
2137
+ // src/commands/post.ts
2138
+ import { readFile as readFile4, stat } from "node:fs/promises";
2139
+ import { basename, extname } from "node:path";
2140
+ var TYPES = {
2141
+ ".jpg": "image/jpeg",
2142
+ ".jpeg": "image/jpeg",
2143
+ ".png": "image/png",
2144
+ ".gif": "image/gif",
2145
+ ".webp": "image/webp",
2146
+ ".pdf": "application/pdf",
2147
+ ".mp4": "video/mp4",
2148
+ ".mov": "video/quicktime"
2149
+ };
2150
+ function imageSize(buf) {
2151
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
2152
+ if (buf.length > 24 && buf[0] === 137 && buf[1] === 80) {
2153
+ return { width: view.getUint32(16), height: view.getUint32(20) };
2154
+ }
2155
+ if (buf.length > 10 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) {
2156
+ return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
2157
+ }
2158
+ if (buf.length > 30 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
2159
+ const kind = String.fromCharCode(buf[12], buf[13], buf[14], buf[15]);
2160
+ if (kind === "VP8X") {
2161
+ return { width: 1 + (buf[24] | buf[25] << 8 | buf[26] << 16), height: 1 + (buf[27] | buf[28] << 8 | buf[29] << 16) };
2162
+ }
2163
+ if (kind === "VP8 ")
2164
+ return { width: view.getUint16(26, true) & 16383, height: view.getUint16(28, true) & 16383 };
2165
+ if (kind === "VP8L") {
2166
+ const b = view.getUint32(21, true);
2167
+ return { width: (b & 16383) + 1, height: (b >> 14 & 16383) + 1 };
2168
+ }
2169
+ }
2170
+ if (buf.length > 4 && buf[0] === 255 && buf[1] === 216) {
2171
+ let i = 2;
2172
+ while (i + 9 < buf.length) {
2173
+ if (buf[i] !== 255)
2174
+ return;
2175
+ const marker = buf[i + 1];
2176
+ const len = view.getUint16(i + 2);
2177
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
2178
+ return { width: view.getUint16(i + 7), height: view.getUint16(i + 5) };
2179
+ }
2180
+ i += 2 + len;
2181
+ }
2182
+ }
2183
+ return;
2184
+ }
2185
+ async function upload(path) {
2186
+ const contentType = TYPES[extname(path).toLowerCase()];
2187
+ if (!contentType) {
2188
+ throw new Error(`Can't attach ${basename(path)}. Images (jpg, png, gif, webp), a PDF, or a video (mp4, mov).`);
2189
+ }
2190
+ const size = (await stat(path)).size;
2191
+ const bytes = await readFile4(path);
2192
+ const signed = await request("/api/uploads/presign", {
2193
+ method: "POST",
2194
+ body: { filename: basename(path), contentType, size }
2195
+ });
2196
+ const put = await fetch(signed.uploadUrl, { method: "PUT", headers: { "Content-Type": contentType }, body: bytes });
2197
+ if (!put.ok)
2198
+ throw new Error(`The upload of ${basename(path)} failed (${put.status}).`);
2199
+ const dims = contentType.startsWith("image/") ? imageSize(new Uint8Array(bytes)) : undefined;
2200
+ return { asset: { url: signed.publicUrl, name: basename(path), ...dims ?? {} }, contentType };
2201
+ }
2202
+ async function attach(args) {
2203
+ const { json, rest, value, has } = parseFlags(args, ["title"]);
2204
+ const [refArg, ...files] = rest;
2205
+ if (!refArg || !files.length && !has("remove")) {
2206
+ fail("Attach what to which post?");
2207
+ note(dim2(`${CMD} attach <post-id> picture.png`));
2208
+ note(dim2(`${CMD} attach <post-id> slides.pdf --title "How we price"`));
2209
+ return 1;
2210
+ }
2211
+ const ref = resolveRef(refArg);
2212
+ const post = ref ? await fetchPost(ref.id) : null;
2213
+ if (!post) {
2214
+ fail("That post could not be found.");
2215
+ note(dim2(`\`${CMD} drafts\` lists them with their ids`));
2216
+ return 1;
2217
+ }
2218
+ if (post.status === "published") {
2219
+ fail("That post is already live on LinkedIn, so its attachment cannot change here.");
2220
+ return 1;
2221
+ }
2222
+ let media;
2223
+ if (has("remove")) {
2224
+ media = { kind: "text" };
2225
+ } else {
2226
+ const uploaded = [];
2227
+ for (const f of files)
2228
+ uploaded.push(await upload(f));
2229
+ const kinds = new Set(uploaded.map((u) => u.contentType.split("/")[0]));
2230
+ const title = value("title") ?? basename(files[0], extname(files[0]));
2231
+ if (uploaded.every((u) => u.contentType.startsWith("image/"))) {
2232
+ if (uploaded.length > 20) {
2233
+ fail("A post takes at most 20 images.");
2234
+ return 1;
2235
+ }
2236
+ media = uploaded.length === 1 ? { kind: "image", asset: uploaded[0].asset } : { kind: "multiImage", assets: uploaded.map((u) => u.asset) };
2237
+ } else if (uploaded.length === 1 && uploaded[0].contentType === "application/pdf") {
2238
+ media = { kind: "document", asset: uploaded[0].asset, title };
2239
+ } else if (uploaded.length === 1 && kinds.has("video")) {
2240
+ media = { kind: "video", asset: uploaded[0].asset, title };
2241
+ } else {
2242
+ fail("A post carries one kind of attachment: images, or one PDF, or one video.");
2243
+ return 1;
2244
+ }
2245
+ }
2246
+ await request("/api/posts", { method: "POST", body: { id: post._id, content: post.content, media } });
2247
+ if (json) {
2248
+ out(JSON.stringify({ id: post._id, media }, null, 2));
2249
+ return 0;
2250
+ }
2251
+ ok(media.kind === "text" ? `Took the attachment off. ${dim2(post._id)}` : `Attached. ${dim2(post._id)}`);
2252
+ note(dim2(`\`${CMD} show ${post._id}\` to check it`));
2253
+ return 0;
2254
+ }
2255
+ async function deletePost(args) {
2256
+ const { json, rest } = parseFlags(args);
2257
+ const ref = rest[0] ? resolveRef(rest[0]) : null;
2258
+ if (!ref) {
2259
+ fail("Delete which post?");
2260
+ note(dim2(`${CMD} delete <post-id>`));
2261
+ return 1;
2262
+ }
2263
+ await request(`/api/posts/${encodeURIComponent(ref.id)}`, { method: "DELETE" });
2264
+ if (json) {
2265
+ out(JSON.stringify({ id: ref.id, deleted: true }, null, 2));
2266
+ return 0;
2267
+ }
2268
+ ok(`Moved to the trash. ${dim2(ref.id)}`);
2269
+ note(dim2(`\`${CMD} restore ${ref.id}\` brings it back`));
2270
+ return 0;
2271
+ }
2272
+ async function restorePost(args) {
2273
+ const { json, rest } = parseFlags(args);
2274
+ const ref = rest[0] ? resolveRef(rest[0]) : null;
2275
+ if (!ref) {
2276
+ fail("Restore which post?");
2277
+ note(dim2(`${CMD} restore <post-id>`));
2278
+ return 1;
2279
+ }
2280
+ await request(`/api/posts/${encodeURIComponent(ref.id)}/restore`, { method: "POST" });
2281
+ if (json) {
2282
+ out(JSON.stringify({ id: ref.id, restored: true }, null, 2));
2283
+ return 0;
2284
+ }
2285
+ ok(`Restored. It is back in your posts. ${dim2(ref.id)}`);
2286
+ return 0;
2287
+ }
2288
+
1700
2289
  // src/session.ts
1701
2290
  import { createInterface } from "node:readline";
1702
2291
 
@@ -1754,12 +2343,13 @@ var COMMANDS2 = {
1754
2343
  write: {
1755
2344
  summary: "draft a post in your voice",
1756
2345
  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.",
2346
+ 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
2347
  options: [
1759
2348
  ["--thought <text>", "turn a half-formed note into a post, keeping your phrasing"],
1760
2349
  ["--series <name>", "write into one of your series, so it follows on"],
1761
2350
  ["--format <name>", "story, listicle, contrarian, ..."],
1762
- ["--save", "keep it as a draft in the app"]
2351
+ ["--save", "keep it as a draft in the app"],
2352
+ ["--title <text>", "the title it is saved under (with --save); without it, one is written for you"]
1763
2353
  ],
1764
2354
  examples: [
1765
2355
  "innernote write",
@@ -1787,15 +2377,70 @@ var COMMANDS2 = {
1787
2377
  ]
1788
2378
  },
1789
2379
  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.",
2380
+ summary: "talk to the post in your own words, or edit one passage of it",
2381
+ usage: "innernote ask [--post <id>] [--passage <text> | --hook | --ending] [--preset <name> | --shape <name>] [what you want]",
2382
+ 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.",
2383
+ options: [
2384
+ ["--post <id>", "a saved post, by id, instead of the one in hand"],
2385
+ ["--passage <text>", "change only these exact words of the post"],
2386
+ ["--hook, --ending", "change only the opening, or the closing"],
2387
+ ["--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"],
2388
+ ["--shape <name>", "story opinion listicle contrarian experience howto lessons casestudy framework question announcement prediction beliefbreak"]
2389
+ ],
1793
2390
  examples: [
1794
2391
  "ask cut the second half and land it harder",
1795
2392
  "ask this ending is soft, what would you do",
1796
- "ask say what happened instead of what I learned"
2393
+ 'innernote ask --post p17abc... "make the opening shorter"',
2394
+ 'innernote ask --post p17abc... --passage "we got lucky" "say what the luck actually was"',
2395
+ 'innernote ask --post p17abc... --passage "we got lucky" --preset concrete',
2396
+ "innernote ask --post p17abc... --preset ending_question",
2397
+ "innernote ask --post p17abc... --shape story"
1797
2398
  ]
1798
2399
  },
2400
+ edit: {
2401
+ summary: "change a saved post: its text, title, first comment, series or status",
2402
+ usage: "innernote edit <id> [--file <path> | --text <text>] [--title <text>] [--first-comment <text>] [--series <name>] [--status ready|draft]",
2403
+ 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`.",
2404
+ options: [
2405
+ ["--file <path>", "the new text of the post, from a file"],
2406
+ ["--text <text>", "the new text of the post, inline"],
2407
+ ["--title <text>", "the post's title in the app"],
2408
+ ["--first-comment <text>", "the comment posted under it when it goes live"],
2409
+ ["--series <name>", "file it into a series, or `none` to take it out"],
2410
+ ["--status <ready|draft>", "mark it ready, or send it back to draft"]
2411
+ ],
2412
+ examples: [
2413
+ "innernote edit p17abc... --file post.txt",
2414
+ "pbpaste | innernote edit p17abc...",
2415
+ 'innernote edit p17abc... --title "The sales call" --first-comment "link: innernote.space"',
2416
+ 'innernote edit p17abc... --series "Build in Public" --status ready'
2417
+ ]
2418
+ },
2419
+ attach: {
2420
+ summary: "put an image, images, a PDF or a video on a saved post",
2421
+ usage: "innernote attach <id> <file...> [--title <text>] | innernote attach <id> --remove",
2422
+ 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.",
2423
+ options: [
2424
+ ["--title <text>", "the title of a PDF or a video (default: the file name)"],
2425
+ ["--remove", "take the attachment off"]
2426
+ ],
2427
+ examples: [
2428
+ "innernote attach p17abc... picture.png",
2429
+ 'innernote attach p17abc... slides.pdf --title "How we price"',
2430
+ "innernote attach p17abc... --remove"
2431
+ ]
2432
+ },
2433
+ delete: {
2434
+ summary: "move a post to the trash",
2435
+ usage: "innernote delete <id>",
2436
+ 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.",
2437
+ examples: ["innernote delete p17abc...", "innernote restore p17abc..."]
2438
+ },
2439
+ restore: {
2440
+ summary: "bring a post back out of the trash",
2441
+ usage: "innernote restore <id>",
2442
+ examples: ["innernote restore p17abc..."]
2443
+ },
1799
2444
  save: {
1800
2445
  summary: "keep the draft you are looking at",
1801
2446
  usage: "innernote save",
@@ -1803,10 +2448,10 @@ var COMMANDS2 = {
1803
2448
  examples: ['write "hiring your first engineer"', "shape shorter", "save"]
1804
2449
  },
1805
2450
  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"]
2451
+ summary: "see a post, with everything the app knows about it",
2452
+ usage: "innernote show [id]",
2453
+ 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.",
2454
+ examples: ["innernote show p17abc...", "innernote show p17abc... --json", "show"]
1810
2455
  },
1811
2456
  drop: {
1812
2457
  summary: "put down whatever you are holding",
@@ -1821,9 +2466,10 @@ var COMMANDS2 = {
1821
2466
  },
1822
2467
  drafts: {
1823
2468
  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'"]
2469
+ usage: "innernote drafts [status] [--series <name>]",
2470
+ 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.",
2471
+ options: [["--series <name>", "only the posts in this series, in its order"]],
2472
+ examples: ["drafts", "drafts ready", 'drafts --series "Growing innernote"', "drafts --json | jq -r '.[]._id'"]
1827
2473
  },
1828
2474
  open: {
1829
2475
  summary: "pull a post out of the list and work on it",
@@ -1849,15 +2495,16 @@ var COMMANDS2 = {
1849
2495
  examples: ["innernote skill", "innernote skill --install"]
1850
2496
  },
1851
2497
  context: {
1852
- summary: "what you post about: your pillars and your series",
2498
+ summary: "what you post about: your topics and your series",
1853
2499
  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.",
2500
+ detail: "Your standing context: your topics 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.",
1855
2501
  examples: ["innernote context", "innernote context --json"]
1856
2502
  },
1857
2503
  queue: {
1858
2504
  summary: "schedule a post into your next open slot",
1859
- usage: "innernote queue <post-id>",
2505
+ usage: "innernote queue <post-id> [--at <time>]",
1860
2506
  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.",
2507
+ options: [["--at <time>", `a time you pick, like "2026-10-12 12:31" (this machine's timezone), instead of the next open slot`]],
1861
2508
  examples: ["innernote drafts", "innernote queue 3", "innernote queue p17a1yk936ywnan10643efjtc58dhkah"]
1862
2509
  }
1863
2510
  };
@@ -1914,7 +2561,7 @@ function tokenize(line2) {
1914
2561
  quote = null;
1915
2562
  else
1916
2563
  cur += ch;
1917
- } else if (ch === '"' || ch === "'") {
2564
+ } else if ((ch === '"' || ch === "'") && cur === "") {
1918
2565
  quote = ch;
1919
2566
  } else if (/\s/.test(ch)) {
1920
2567
  if (cur)
@@ -2143,9 +2790,10 @@ var VERSION2 = package_default.version;
2143
2790
  function commandList() {
2144
2791
  const groups = [
2145
2792
  ["Getting connected", ["login", "logout", "whoami", "skill"]],
2146
- ["Writing", ["write", "shape", "ask", "save", "capture"]],
2793
+ ["Writing", ["write", "shape", "ask", "edit", "attach", "save", "capture"]],
2147
2794
  ["Looking", ["show", "ideas", "drafts", "open", "week", "context"]],
2148
- ["Shipping", ["queue", "unqueue"]]
2795
+ ["Shipping", ["queue", "unqueue"]],
2796
+ ["Tidying", ["delete", "restore"]]
2149
2797
  ];
2150
2798
  const lines = [];
2151
2799
  for (const [title, names] of groups) {
@@ -2179,6 +2827,10 @@ var COMMANDS3 = {
2179
2827
  write,
2180
2828
  shape,
2181
2829
  ask,
2830
+ edit,
2831
+ attach,
2832
+ delete: deletePost,
2833
+ restore: restorePost,
2182
2834
  save,
2183
2835
  show,
2184
2836
  drop,
@@ -2205,6 +2857,10 @@ async function main() {
2205
2857
  return d.id ? "saved" : "unsaved";
2206
2858
  },
2207
2859
  run: async (name, rest) => {
2860
+ if (rest.includes("--help")) {
2861
+ out2(commandHelp(name) ?? HELP());
2862
+ return 0;
2863
+ }
2208
2864
  const h = COMMANDS3[name];
2209
2865
  return h ? h(rest) : 1;
2210
2866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "innernote",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Write LinkedIn posts in your voice, from the terminal.",
5
5
  "keywords": [
6
6
  "linkedin",