innernote 0.3.3 → 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 +807 -112
  3. package/package.json +22 -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
@@ -83,6 +83,9 @@ class ApiError extends Error {
83
83
  get isRefusal() {
84
84
  return this.status === 402 || this.status === 429;
85
85
  }
86
+ get isNotConnected() {
87
+ return this.status === 401;
88
+ }
86
89
  }
87
90
 
88
91
  class NotLoggedIn extends Error {
@@ -497,13 +500,92 @@ function resolveRef(ref) {
497
500
  return /^[a-z0-9]{20,}$/.test(ref) ? { id: ref, title: ref } : null;
498
501
  }
499
502
 
500
- // src/commands/read.ts
501
- function parseFlags(args) {
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;
502
547
  return {
503
- json: args.includes("--json"),
504
- rest: args.filter((a) => !a.startsWith("--"))
548
+ ...post,
549
+ seriesName: inSeries?.name ?? null,
550
+ linkedinUrl: linkedinUrl(post)
505
551
  };
506
552
  }
553
+
554
+ // src/flags.ts
555
+ function parseFlags(args, valueFlags = []) {
556
+ const values = new Map;
557
+ const present = new Set;
558
+ const rest = [];
559
+ for (let i = 0;i < args.length; i++) {
560
+ const arg = args[i];
561
+ if (!arg.startsWith("--")) {
562
+ rest.push(arg);
563
+ continue;
564
+ }
565
+ const eq = arg.indexOf("=");
566
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
567
+ present.add(name);
568
+ if (!valueFlags.includes(name))
569
+ continue;
570
+ if (eq !== -1) {
571
+ values.set(name, arg.slice(eq + 1));
572
+ continue;
573
+ }
574
+ const next = args[i + 1];
575
+ if (next !== undefined && !next.startsWith("--")) {
576
+ values.set(name, next);
577
+ i++;
578
+ }
579
+ }
580
+ return {
581
+ json: present.has("json"),
582
+ rest,
583
+ value: (name) => values.get(name),
584
+ has: (name) => present.has(name)
585
+ };
586
+ }
587
+
588
+ // src/commands/read.ts
507
589
  function powers(scopes) {
508
590
  if (!scopes || scopes.length === 0)
509
591
  return "full access";
@@ -569,29 +651,42 @@ ${data.ideas.length} ${data.ideas.length === 1 ? "idea" : "ideas"}`));
569
651
  return 0;
570
652
  }
571
653
  async function drafts(args) {
572
- const { json, rest } = parseFlags(args);
654
+ const { json, rest, value } = parseFlags(args, ["series"]);
573
655
  const status = rest[0];
574
- 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
+ }
575
669
  if (json) {
576
- out(JSON.stringify(data.posts, null, 2));
670
+ out(JSON.stringify(posts.map((p) => postForJson(p, series)), null, 2));
577
671
  return 0;
578
672
  }
579
- if (data.posts.length === 0) {
580
- 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");
581
675
  return 0;
582
676
  }
583
- const shown = data.posts.slice(0, 30);
677
+ const shown = posts.slice(0, 30);
584
678
  rememberList(shown.map((p) => ({ id: p._id, title: (p.title || p.content).replace(/\s+/g, " ") })));
585
679
  for (const [i, post] of shown.entries()) {
586
680
  const n = String(i + 1).padStart(2);
587
- 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}`);
588
683
  }
589
- if (data.posts.length > shown.length) {
684
+ if (posts.length > shown.length) {
590
685
  note(dim2(`
591
- ...and ${data.posts.length - shown.length} more`));
686
+ ...and ${posts.length - shown.length} more`));
592
687
  }
593
688
  note(dim2(`
594
- open <number> to work on one`));
689
+ open <number> to work on one show <id> for everything about one`));
595
690
  return 0;
596
691
  }
597
692
  async function week(args) {
@@ -619,22 +714,32 @@ async function week(args) {
619
714
  }
620
715
  async function context(args) {
621
716
  const { json } = parseFlags(args);
622
- const [pillars, series] = await Promise.all([
717
+ const [pillars, series, notebook] = await Promise.all([
623
718
  request("/api/pillars"),
624
- request("/api/campaigns")
719
+ request("/api/campaigns"),
720
+ request("/api/notebook/brief").catch(() => ({ brief: null }))
625
721
  ]);
626
722
  if (json) {
627
- 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));
628
724
  return 0;
629
725
  }
630
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
+ }
631
736
  if (pillars.pillars.length) {
632
737
  out(` ${bold2("What you post about")}`);
633
738
  for (const p of pillars.pillars) {
634
739
  out(` ${caramel(p.name)}${typeof p.weight === "number" ? dim2(` ${p.weight}%`) : ""}`);
635
740
  }
636
741
  } else {
637
- out(` ${dim2("No content pillars yet. Set them in Settings.")}`);
742
+ out(` ${dim2("No topics yet. Set them in Settings.")}`);
638
743
  }
639
744
  out();
640
745
  if (series.campaigns.length) {
@@ -654,14 +759,6 @@ async function context(args) {
654
759
  return 0;
655
760
  }
656
761
 
657
- // src/commands/read.ts
658
- function parseFlags2(args) {
659
- return {
660
- json: args.includes("--json"),
661
- rest: args.filter((a) => !a.startsWith("--"))
662
- };
663
- }
664
-
665
762
  // src/progress.ts
666
763
  var DRAFTING = [
667
764
  { at: 0, text: "reading your voice..." },
@@ -830,10 +927,12 @@ async function whileThinking(phases, run) {
830
927
  }
831
928
 
832
929
  // src/commands/write.ts
930
+ import { readFile as readFile2 } from "node:fs/promises";
833
931
  async function capture(args) {
834
- const { json, rest } = parseFlags2(args);
835
- const piped = await readStdin();
836
- const content = (rest.join(" ").trim() || piped || "").trim();
932
+ const { json, rest } = parseFlags(args);
933
+ const typed = rest.join(" ").trim();
934
+ const piped = typed ? null : await readStdin();
935
+ const content = (typed || piped || "").trim();
837
936
  if (!content) {
838
937
  fail("Nothing to capture.");
839
938
  note(dim2('Try: innernote capture "the thing you just thought of"'));
@@ -852,22 +951,27 @@ async function capture(args) {
852
951
  note(dim2("`ideas` to see them, `write` to turn one into a post"));
853
952
  return 0;
854
953
  }
855
- async function write(args) {
856
- const { json, rest } = parseFlags2(args);
857
- const flagValue = (name) => {
858
- const exact = args.indexOf(`--${name}`);
859
- if (exact !== -1 && args[exact + 1] && !args[exact + 1].startsWith("--")) {
860
- return args[exact + 1];
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 } : {}
861
962
  }
862
- const inline = args.find((a) => a.startsWith(`--${name}=`));
863
- return inline?.slice(name.length + 3);
864
- };
865
- const piped = await readStdin();
866
- const thought = flagValue("thought") ?? (piped ?? undefined);
867
- const topic = rest.filter((a) => a !== thought).join(" ").trim();
868
- const format = flagValue("format");
869
- const series = flagValue("series");
870
- const save = args.includes("--save");
963
+ });
964
+ }
965
+ async function write(args) {
966
+ const { json, rest, value, has } = parseFlags(args, ["thought", "format", "series", "title"]);
967
+ const topic = rest.join(" ").trim();
968
+ const thoughtFlag = value("thought");
969
+ const piped = thoughtFlag || topic ? null : await readStdin();
970
+ const thought = thoughtFlag ?? (piped ?? undefined);
971
+ const format = value("format");
972
+ const series = value("series");
973
+ const title = value("title");
974
+ const save = has("save");
871
975
  const call = () => request("/api/content/generate", {
872
976
  method: "POST",
873
977
  body: {
@@ -885,8 +989,14 @@ async function write(args) {
885
989
  seriesSummary: post.seriesSummary ?? undefined,
886
990
  quality: post.qualityScore
887
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
+ }
888
998
  if (json) {
889
- out(JSON.stringify(post, null, 2));
999
+ out(JSON.stringify(saved ? { ...post, id: saved.id } : post, null, 2));
890
1000
  return 0;
891
1001
  }
892
1002
  out();
@@ -903,16 +1013,7 @@ async function write(args) {
903
1013
  else if (post.similarity?.band === "adjacent")
904
1014
  notes.push("close to something you already published");
905
1015
  note(dim2(notes.join(", ")));
906
- if (save) {
907
- const saved = await request("/api/posts", {
908
- method: "POST",
909
- body: {
910
- content: post.content,
911
- source: "cli",
912
- ...series ? { series, seriesSummary: post.seriesSummary } : {}
913
- }
914
- });
915
- updateDraft({ id: saved.id });
1016
+ if (saved) {
916
1017
  ok(series ? `Saved into "${series}". ${dim2(saved.id)}` : `Saved as a draft. ${dim2(saved.id)}`);
917
1018
  } else {
918
1019
  note(dim2("Not saved yet. `save` to keep it, `shape shorter` to change it."));
@@ -934,11 +1035,11 @@ var SHAPES = [
934
1035
  "jargon"
935
1036
  ];
936
1037
  async function shape(args) {
937
- const { json, rest } = parseFlags2(args);
938
- const piped = await readStdin();
1038
+ const { json, rest } = parseFlags(args);
1039
+ const held = getDraft();
1040
+ const piped = held ? null : await readStdin();
939
1041
  const chosen = rest.filter((a) => SHAPES.includes(a));
940
1042
  const freeText = rest.filter((a) => !SHAPES.includes(a)).join(" ").trim();
941
- const held = getDraft();
942
1043
  const content = (piped ?? held?.content ?? "").trim();
943
1044
  if (!content) {
944
1045
  fail("Nothing to reshape.");
@@ -976,15 +1077,38 @@ async function shape(args) {
976
1077
  note(dim2(held?.id ? "`save` to update it" : "`save` to keep it"));
977
1078
  return 0;
978
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
+ }
979
1089
  async function queue(args) {
980
- const { json, rest } = parseFlags2(args);
981
- const id = rest[0];
982
- if (!id) {
1090
+ const { json, rest, value } = parseFlags(args, ["at"]);
1091
+ const ref = rest[0] ? resolveRef(rest[0]) : null;
1092
+ if (!ref) {
983
1093
  fail("Which post?");
984
- note(dim2(`${CMD} queue <post-id> (ids come from \`${CMD} drafts --json\`)`));
1094
+ note(dim2(`${CMD} drafts then ${CMD} queue 3`));
1095
+ note(dim2(`Or pass the id: ${CMD} queue <post-id>`));
985
1096
  return 1;
986
1097
  }
987
- const result = await request(`/api/posts/${encodeURIComponent(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
+ }
988
1112
  if (json) {
989
1113
  out(JSON.stringify(result, null, 2));
990
1114
  return 0;
@@ -999,7 +1123,7 @@ async function queue(args) {
999
1123
  return 0;
1000
1124
  }
1001
1125
  async function save(args) {
1002
- const { json } = parseFlags2(args);
1126
+ const { json } = parseFlags(args);
1003
1127
  const held = getDraft();
1004
1128
  if (!held?.content) {
1005
1129
  fail("Nothing to save.");
@@ -1026,7 +1150,7 @@ async function save(args) {
1026
1150
  return 0;
1027
1151
  }
1028
1152
  async function open(args) {
1029
- const { json, rest } = parseFlags2(args);
1153
+ const { json, rest } = parseFlags(args);
1030
1154
  const ref = rest[0];
1031
1155
  if (!ref) {
1032
1156
  fail("Open which one?");
@@ -1058,9 +1182,19 @@ async function open(args) {
1058
1182
  return 0;
1059
1183
  }
1060
1184
  async function ask(args) {
1061
- const { json, rest } = parseFlags2(args);
1185
+ const { json, rest, value, has } = parseFlags(args, ["post", "passage", "preset", "shape"]);
1062
1186
  const message = rest.join(" ").trim();
1063
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
+ }
1064
1198
  if (!message) {
1065
1199
  fail("Say what you want changed.");
1066
1200
  note(dim2("ask cut the second half and land it harder"));
@@ -1086,7 +1220,7 @@ async function ask(args) {
1086
1220
  id = saved.id;
1087
1221
  updateDraft({ id });
1088
1222
  }
1089
- 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 });
1090
1224
  const result = json ? await call() : await whileThinking([
1091
1225
  { at: 0, text: "reading the post..." },
1092
1226
  { at: 3, text: "working on it..." },
@@ -1112,7 +1246,9 @@ async function ask(args) {
1112
1246
  return 0;
1113
1247
  }
1114
1248
  async function show(args) {
1115
- const { json } = parseFlags2(args);
1249
+ const { json, rest } = parseFlags(args);
1250
+ if (rest[0])
1251
+ return showPost(rest[0], json);
1116
1252
  const held = getDraft();
1117
1253
  if (!held?.content) {
1118
1254
  note("Nothing in hand.");
@@ -1150,7 +1286,7 @@ async function drop() {
1150
1286
  return 0;
1151
1287
  }
1152
1288
  async function unqueue(args) {
1153
- const { json, rest } = parseFlags2(args);
1289
+ const { json, rest } = parseFlags(args);
1154
1290
  const ref = rest[0] ? resolveRef(rest[0]) : null;
1155
1291
  if (!ref) {
1156
1292
  fail("Which post?");
@@ -1166,6 +1302,267 @@ async function unqueue(args) {
1166
1302
  note(dim2("`drafts` to find it, `queue` to send it back"));
1167
1303
  return 0;
1168
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
+ }
1169
1566
 
1170
1567
  // src/ui.ts
1171
1568
  var useColor2 = process.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== "dumb";
@@ -1322,12 +1719,13 @@ var COMMANDS = {
1322
1719
  write: {
1323
1720
  summary: "draft a post in your voice",
1324
1721
  usage: "innernote write [topic] [options]",
1325
- 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.",
1326
1723
  options: [
1327
1724
  ["--thought <text>", "turn a half-formed note into a post, keeping your phrasing"],
1328
1725
  ["--series <name>", "write into one of your series, so it follows on"],
1329
1726
  ["--format <name>", "story, listicle, contrarian, ..."],
1330
- ["--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"]
1331
1729
  ],
1332
1730
  examples: [
1333
1731
  "innernote write",
@@ -1355,15 +1753,70 @@ var COMMANDS = {
1355
1753
  ]
1356
1754
  },
1357
1755
  ask: {
1358
- summary: "talk to the post in your own words",
1359
- usage: "innernote ask <what you want>",
1360
- 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
+ ],
1361
1766
  examples: [
1362
1767
  "ask cut the second half and land it harder",
1363
1768
  "ask this ending is soft, what would you do",
1364
- "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"
1365
1774
  ]
1366
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"
1807
+ ]
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
+ },
1367
1820
  save: {
1368
1821
  summary: "keep the draft you are looking at",
1369
1822
  usage: "innernote save",
@@ -1371,10 +1824,10 @@ var COMMANDS = {
1371
1824
  examples: ['write "hiring your first engineer"', "shape shorter", "save"]
1372
1825
  },
1373
1826
  show: {
1374
- summary: "see the post you are holding",
1375
- usage: "innernote show",
1376
- 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.",
1377
- 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"]
1378
1831
  },
1379
1832
  drop: {
1380
1833
  summary: "put down whatever you are holding",
@@ -1389,9 +1842,10 @@ var COMMANDS = {
1389
1842
  },
1390
1843
  drafts: {
1391
1844
  summary: "your posts, numbered",
1392
- usage: "innernote drafts [status]",
1393
- 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.",
1394
- 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'"]
1395
1849
  },
1396
1850
  open: {
1397
1851
  summary: "pull a post out of the list and work on it",
@@ -1417,16 +1871,17 @@ var COMMANDS = {
1417
1871
  examples: ["innernote skill", "innernote skill --install"]
1418
1872
  },
1419
1873
  context: {
1420
- summary: "what you post about: your pillars and your series",
1874
+ summary: "what you post about: your topics and your series",
1421
1875
  usage: "innernote context",
1422
- 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.",
1423
1877
  examples: ["innernote context", "innernote context --json"]
1424
1878
  },
1425
1879
  queue: {
1426
1880
  summary: "schedule a post into your next open slot",
1427
- usage: "innernote queue <post-id>",
1428
- 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.",
1429
- examples: ["innernote drafts", "innernote queue p17a1yk936ywnan10643efjtc58dhkah"]
1881
+ usage: "innernote queue <post-id> [--at <time>]",
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`]],
1884
+ examples: ["innernote drafts", "innernote queue 3", "innernote queue p17a1yk936ywnan10643efjtc58dhkah"]
1430
1885
  }
1431
1886
  };
1432
1887
  function commandHelp(name) {
@@ -1539,8 +1994,29 @@ function suggest(state) {
1539
1994
  // package.json
1540
1995
  var package_default = {
1541
1996
  name: "innernote",
1542
- version: "0.3.3",
1997
+ version: "0.4.0",
1543
1998
  description: "Write LinkedIn posts in your voice, from the terminal.",
1999
+ keywords: [
2000
+ "linkedin",
2001
+ "mcp",
2002
+ "mcp-server",
2003
+ "model-context-protocol",
2004
+ "ai-agent",
2005
+ "claude",
2006
+ "claude-code",
2007
+ "cursor",
2008
+ "agent-skill",
2009
+ "writing",
2010
+ "content",
2011
+ "personal-branding",
2012
+ "ghostwriting",
2013
+ "cli"
2014
+ ],
2015
+ homepage: "https://www.innernote.space/cli",
2016
+ license: "UNLICENSED",
2017
+ bugs: {
2018
+ url: "https://www.innernote.space/cli"
2019
+ },
1544
2020
  type: "module",
1545
2021
  bin: {
1546
2022
  innernote: "dist/index.js"
@@ -1623,7 +2099,7 @@ async function home(inSession = false) {
1623
2099
  }
1624
2100
 
1625
2101
  // src/commands/skill.ts
1626
- 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";
1627
2103
  import { homedir as homedir2 } from "node:os";
1628
2104
  import { dirname, join as join2 } from "node:path";
1629
2105
  import { fileURLToPath } from "node:url";
@@ -1631,7 +2107,7 @@ async function readSkill() {
1631
2107
  const here = dirname(fileURLToPath(import.meta.url));
1632
2108
  for (const rel of ["../../skills/innernote-cli/SKILL.md", "../skills/innernote-cli/SKILL.md"]) {
1633
2109
  try {
1634
- return await readFile2(join2(here, rel), "utf8");
2110
+ return await readFile3(join2(here, rel), "utf8");
1635
2111
  } catch {}
1636
2112
  }
1637
2113
  return null;
@@ -1658,6 +2134,158 @@ async function skill(args) {
1658
2134
  return 0;
1659
2135
  }
1660
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
+
1661
2289
  // src/session.ts
1662
2290
  import { createInterface } from "node:readline";
1663
2291
 
@@ -1715,12 +2343,13 @@ var COMMANDS2 = {
1715
2343
  write: {
1716
2344
  summary: "draft a post in your voice",
1717
2345
  usage: "innernote write [topic] [options]",
1718
- 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.",
1719
2347
  options: [
1720
2348
  ["--thought <text>", "turn a half-formed note into a post, keeping your phrasing"],
1721
2349
  ["--series <name>", "write into one of your series, so it follows on"],
1722
2350
  ["--format <name>", "story, listicle, contrarian, ..."],
1723
- ["--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"]
1724
2353
  ],
1725
2354
  examples: [
1726
2355
  "innernote write",
@@ -1748,15 +2377,70 @@ var COMMANDS2 = {
1748
2377
  ]
1749
2378
  },
1750
2379
  ask: {
1751
- summary: "talk to the post in your own words",
1752
- usage: "innernote ask <what you want>",
1753
- 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
+ ],
1754
2390
  examples: [
1755
2391
  "ask cut the second half and land it harder",
1756
2392
  "ask this ending is soft, what would you do",
1757
- "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"
2398
+ ]
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"
1758
2431
  ]
1759
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
+ },
1760
2444
  save: {
1761
2445
  summary: "keep the draft you are looking at",
1762
2446
  usage: "innernote save",
@@ -1764,10 +2448,10 @@ var COMMANDS2 = {
1764
2448
  examples: ['write "hiring your first engineer"', "shape shorter", "save"]
1765
2449
  },
1766
2450
  show: {
1767
- summary: "see the post you are holding",
1768
- usage: "innernote show",
1769
- 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.",
1770
- 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"]
1771
2455
  },
1772
2456
  drop: {
1773
2457
  summary: "put down whatever you are holding",
@@ -1782,9 +2466,10 @@ var COMMANDS2 = {
1782
2466
  },
1783
2467
  drafts: {
1784
2468
  summary: "your posts, numbered",
1785
- usage: "innernote drafts [status]",
1786
- 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.",
1787
- 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'"]
1788
2473
  },
1789
2474
  open: {
1790
2475
  summary: "pull a post out of the list and work on it",
@@ -1810,16 +2495,17 @@ var COMMANDS2 = {
1810
2495
  examples: ["innernote skill", "innernote skill --install"]
1811
2496
  },
1812
2497
  context: {
1813
- summary: "what you post about: your pillars and your series",
2498
+ summary: "what you post about: your topics and your series",
1814
2499
  usage: "innernote context",
1815
- 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.",
1816
2501
  examples: ["innernote context", "innernote context --json"]
1817
2502
  },
1818
2503
  queue: {
1819
2504
  summary: "schedule a post into your next open slot",
1820
- usage: "innernote queue <post-id>",
1821
- 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.",
1822
- examples: ["innernote drafts", "innernote queue p17a1yk936ywnan10643efjtc58dhkah"]
2505
+ usage: "innernote queue <post-id> [--at <time>]",
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`]],
2508
+ examples: ["innernote drafts", "innernote queue 3", "innernote queue p17a1yk936ywnan10643efjtc58dhkah"]
1823
2509
  }
1824
2510
  };
1825
2511
  function commandHelp2(name) {
@@ -1875,7 +2561,7 @@ function tokenize(line2) {
1875
2561
  quote = null;
1876
2562
  else
1877
2563
  cur += ch;
1878
- } else if (ch === '"' || ch === "'") {
2564
+ } else if ((ch === '"' || ch === "'") && cur === "") {
1879
2565
  quote = ch;
1880
2566
  } else if (/\s/.test(ch)) {
1881
2567
  if (cur)
@@ -2104,9 +2790,10 @@ var VERSION2 = package_default.version;
2104
2790
  function commandList() {
2105
2791
  const groups = [
2106
2792
  ["Getting connected", ["login", "logout", "whoami", "skill"]],
2107
- ["Writing", ["write", "shape", "ask", "save", "capture"]],
2793
+ ["Writing", ["write", "shape", "ask", "edit", "attach", "save", "capture"]],
2108
2794
  ["Looking", ["show", "ideas", "drafts", "open", "week", "context"]],
2109
- ["Shipping", ["queue", "unqueue"]]
2795
+ ["Shipping", ["queue", "unqueue"]],
2796
+ ["Tidying", ["delete", "restore"]]
2110
2797
  ];
2111
2798
  const lines = [];
2112
2799
  for (const [title, names] of groups) {
@@ -2140,6 +2827,10 @@ var COMMANDS3 = {
2140
2827
  write,
2141
2828
  shape,
2142
2829
  ask,
2830
+ edit,
2831
+ attach,
2832
+ delete: deletePost,
2833
+ restore: restorePost,
2143
2834
  save,
2144
2835
  show,
2145
2836
  drop,
@@ -2166,6 +2857,10 @@ async function main() {
2166
2857
  return d.id ? "saved" : "unsaved";
2167
2858
  },
2168
2859
  run: async (name, rest) => {
2860
+ if (rest.includes("--help")) {
2861
+ out2(commandHelp(name) ?? HELP());
2862
+ return 0;
2863
+ }
2169
2864
  const h = COMMANDS3[name];
2170
2865
  return h ? h(rest) : 1;
2171
2866
  }
@@ -2205,7 +2900,7 @@ main().then((code) => {
2205
2900
  }
2206
2901
  if (isApiError(err)) {
2207
2902
  fail2(err.message);
2208
- process.exitCode = err.isRefusal ? 3 : 1;
2903
+ process.exitCode = err.isNotConnected ? 2 : err.isRefusal ? 3 : 1;
2209
2904
  return;
2210
2905
  }
2211
2906
  fail2(err instanceof Error ? err.message : String(err));
package/package.json CHANGED
@@ -1,7 +1,28 @@
1
1
  {
2
2
  "name": "innernote",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Write LinkedIn posts in your voice, from the terminal.",
5
+ "keywords": [
6
+ "linkedin",
7
+ "mcp",
8
+ "mcp-server",
9
+ "model-context-protocol",
10
+ "ai-agent",
11
+ "claude",
12
+ "claude-code",
13
+ "cursor",
14
+ "agent-skill",
15
+ "writing",
16
+ "content",
17
+ "personal-branding",
18
+ "ghostwriting",
19
+ "cli"
20
+ ],
21
+ "homepage": "https://www.innernote.space/cli",
22
+ "license": "UNLICENSED",
23
+ "bugs": {
24
+ "url": "https://www.innernote.space/cli"
25
+ },
5
26
  "type": "module",
6
27
  "bin": {
7
28
  "innernote": "dist/index.js"