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.
- package/README.md +8 -4
- package/dist/index.js +725 -69
- 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` |
|
|
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
|
|
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
|
|
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(
|
|
670
|
+
out(JSON.stringify(posts.map((p) => postForJson(p, series)), null, 2));
|
|
608
671
|
return 0;
|
|
609
672
|
}
|
|
610
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
684
|
+
if (posts.length > shown.length) {
|
|
621
685
|
note(dim2(`
|
|
622
|
-
...and ${
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
1393
|
-
usage: "innernote show",
|
|
1394
|
-
detail: "
|
|
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
|
-
|
|
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
|
|
1874
|
+
summary: "what you post about: your topics and your series",
|
|
1439
1875
|
usage: "innernote context",
|
|
1440
|
-
detail: "Your standing context:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
1807
|
-
usage: "innernote show",
|
|
1808
|
-
detail: "
|
|
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
|
-
|
|
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
|
|
2498
|
+
summary: "what you post about: your topics and your series",
|
|
1853
2499
|
usage: "innernote context",
|
|
1854
|
-
detail: "Your standing context:
|
|
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
|
}
|