slingit 0.2.0 → 0.2.1
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 +14 -1
- package/package.json +1 -1
- package/skill/SKILL.md +16 -0
- package/src/cli.js +2 -1
- package/src/pull.js +95 -4
package/README.md
CHANGED
|
@@ -29,6 +29,9 @@ npx slingit shot analysis.md --expire 7
|
|
|
29
29
|
# Load a note back — prints its Markdown to stdout (decrypts locally, key from #)
|
|
30
30
|
npx slingit pull https://slingit.dev/aX7k2mQp9f#Hk3...
|
|
31
31
|
|
|
32
|
+
# Also pull back any reader comments left on the note (feedback loop)
|
|
33
|
+
npx slingit pull https://slingit.dev/aX7k2mQp9f#Hk3... --comments
|
|
34
|
+
|
|
32
35
|
# Skill for Claude Code ("send these findings to X's email")
|
|
33
36
|
npx slingit install-skill
|
|
34
37
|
|
|
@@ -64,7 +67,7 @@ Env takes precedence over the file; the file takes precedence over the built-in
|
|
|
64
67
|
## Loading a note (`pull`)
|
|
65
68
|
|
|
66
69
|
```bash
|
|
67
|
-
npx slingit pull <url>
|
|
70
|
+
npx slingit pull <url> [--comments]
|
|
68
71
|
```
|
|
69
72
|
|
|
70
73
|
Fetches a published note and prints its Markdown to stdout — handy for pulling an
|
|
@@ -75,6 +78,16 @@ zero-knowledge model as the browser reader). Pass the **full link including the
|
|
|
75
78
|
after `#`**; `--public` links have no fragment and pull just as well. Expired or missing
|
|
76
79
|
notes produce a clear error and a non-zero exit code.
|
|
77
80
|
|
|
81
|
+
With `--comments`, any reader comments left on the note are fetched too and appended as
|
|
82
|
+
a `## Comments` section after the body — closing the feedback loop when someone reviews a
|
|
83
|
+
note you shared. Comments on encrypted notes are decrypted locally with the same key. If
|
|
84
|
+
the comments cannot be fetched, the note body still prints and a one-line notice goes to
|
|
85
|
+
stderr.
|
|
86
|
+
|
|
87
|
+
| Flag | Description | Default |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `--comments` | Also fetch reader comments and append them after the note body | off |
|
|
90
|
+
|
|
78
91
|
## Skill installation
|
|
79
92
|
|
|
80
93
|
```bash
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -25,6 +25,22 @@ fragment and decryption happens locally in the CLI (the key is never sent to the
|
|
|
25
25
|
server). Public links (`--public`) have no key and pull just as well. If the note has
|
|
26
26
|
expired or the link is wrong, `pull` prints a clear error and exits non-zero.
|
|
27
27
|
|
|
28
|
+
### Reader feedback (`--comments`)
|
|
29
|
+
|
|
30
|
+
Add `--comments` to also fetch any reader comments left on the note and append them as
|
|
31
|
+
a `## Comments` section after the body:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx slingit pull <url> --comments
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Use this to **check for reviewer feedback on a note you shared earlier**: sling an
|
|
38
|
+
analysis to a human, and once they have read it and left comments in the browser reader,
|
|
39
|
+
pull it back with `--comments` to bring their feedback into the session. Comments on
|
|
40
|
+
encrypted notes are decrypted locally with the same key from the `#` fragment. If a note
|
|
41
|
+
has no comments yet, the section reads `## Comments (0)`; if the comments cannot be
|
|
42
|
+
fetched, the note body still prints and a one-line notice goes to stderr.
|
|
43
|
+
|
|
28
44
|
## Choose the input method first
|
|
29
45
|
|
|
30
46
|
Use the method that matches the user's request:
|
package/src/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ const HELP = `slingit — share Markdown notes via link (encrypted by default)
|
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
10
|
slingit shot [file.md] [flags] publish a note (file or stdin)
|
|
11
|
-
slingit pull <url>
|
|
11
|
+
slingit pull <url> [--comments] fetch a note by link, print its Markdown to stdout
|
|
12
12
|
slingit install-skill [flags] install the agent skill
|
|
13
13
|
slingit help this help
|
|
14
14
|
|
|
@@ -39,6 +39,7 @@ in the #URL fragment (the server never sees it). --public is a deliberate opt-ou
|
|
|
39
39
|
pull decrypts locally too: it reads the note id from the URL path and the key from
|
|
40
40
|
the #fragment, fetches the ciphertext by id alone, and prints the Markdown. The key
|
|
41
41
|
is never sent to the server. Pass the full link, including the part after #.
|
|
42
|
+
Add --comments to also fetch reader feedback and append it as a Comments section.
|
|
42
43
|
`;
|
|
43
44
|
|
|
44
45
|
export async function main(argv) {
|
package/src/pull.js
CHANGED
|
@@ -11,7 +11,7 @@ const ID_RE = /^[A-Za-z0-9_-]{10,14}$/;
|
|
|
11
11
|
* reader, so the key never reaches the server — the iron rule stays intact.
|
|
12
12
|
*/
|
|
13
13
|
export async function pull(args) {
|
|
14
|
-
const { url } = parseArgs(args);
|
|
14
|
+
const { url, comments: wantComments } = parseArgs(args);
|
|
15
15
|
const { id, origin, key } = parseUrl(url);
|
|
16
16
|
|
|
17
17
|
const meta = await fetchMeta(origin, id);
|
|
@@ -39,18 +39,36 @@ export async function pull(args) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
let output = markdown.endsWith('\n') ? markdown : `${markdown}\n`;
|
|
43
|
+
|
|
44
|
+
if (wantComments) {
|
|
45
|
+
// The note itself already loaded — a comments hiccup must not fail the pull.
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fetchComments(origin, id);
|
|
48
|
+
const normalized = await decryptComments(raw, meta, key);
|
|
49
|
+
output += formatCommentsSection(normalized);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
process.stderr.write(`slingit: could not load comments (${err.message}); skipping\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
process.stdout.write(output);
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
function parseArgs(args) {
|
|
46
59
|
let url = null;
|
|
60
|
+
let comments = false;
|
|
47
61
|
for (const a of args) {
|
|
62
|
+
if (a === '--comments') {
|
|
63
|
+
comments = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
48
66
|
if (a.startsWith('-')) throw new Error(`unknown flag "${a}". See: slingit help`);
|
|
49
67
|
if (url) throw new Error('only one URL can be given');
|
|
50
68
|
url = a;
|
|
51
69
|
}
|
|
52
|
-
if (!url) throw new Error('usage: slingit pull <url>');
|
|
53
|
-
return { url };
|
|
70
|
+
if (!url) throw new Error('usage: slingit pull <url> [--comments]');
|
|
71
|
+
return { url, comments };
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
function parseUrl(input) {
|
|
@@ -91,6 +109,79 @@ async function fetchBlob(origin, id) {
|
|
|
91
109
|
return res.arrayBuffer();
|
|
92
110
|
}
|
|
93
111
|
|
|
112
|
+
/** Fetch the reader comments for a note. Returns the raw `comments` array. */
|
|
113
|
+
async function fetchComments(origin, id) {
|
|
114
|
+
const res = await httpGet(`${origin}/${id}/comments`);
|
|
115
|
+
if (!res.ok) throw new Error(`server returned HTTP ${res.status}`);
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
data = await res.json();
|
|
119
|
+
} catch {
|
|
120
|
+
throw new Error('invalid comments response');
|
|
121
|
+
}
|
|
122
|
+
return Array.isArray(data?.comments) ? data.comments : [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Normalize raw comments into `{ author, createdAt, scope, text, quote }`.
|
|
127
|
+
* For encrypted notes each comment carries its own ciphertext + fresh IV and is
|
|
128
|
+
* decrypted with the note's key (same in-memory key, never logged or stored — the
|
|
129
|
+
* iron rule holds exactly like the note-body path). A comment that fails to
|
|
130
|
+
* decrypt or parse is skipped rather than aborting the whole pull.
|
|
131
|
+
*/
|
|
132
|
+
async function decryptComments(raw, meta, key) {
|
|
133
|
+
if (meta.mode === 'public') {
|
|
134
|
+
// Public comments are already plaintext.
|
|
135
|
+
return raw.map((c) => ({
|
|
136
|
+
author: c.author,
|
|
137
|
+
createdAt: c.createdAt,
|
|
138
|
+
scope: c.scope,
|
|
139
|
+
text: c.text,
|
|
140
|
+
quote: c.quote ?? null,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const c of raw) {
|
|
145
|
+
try {
|
|
146
|
+
const ciphertext = Buffer.from(c.ciphertext, 'base64');
|
|
147
|
+
const { text, quote } = JSON.parse(await decryptNote(ciphertext, key, c.iv));
|
|
148
|
+
out.push({
|
|
149
|
+
author: c.author,
|
|
150
|
+
createdAt: c.createdAt,
|
|
151
|
+
scope: c.scope,
|
|
152
|
+
text,
|
|
153
|
+
quote: quote ?? null,
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
// Corrupted or tampered comment — skip it.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render a normalized comments array as a Markdown section appended after the
|
|
164
|
+
* note body. Pure and self-contained so it can be unit-tested without a server.
|
|
165
|
+
*/
|
|
166
|
+
export function formatCommentsSection(comments) {
|
|
167
|
+
let out = `\n---\n\n## Comments (${comments.length})\n\n`;
|
|
168
|
+
if (comments.length === 0) {
|
|
169
|
+
return `${out}_No comments yet._\n`;
|
|
170
|
+
}
|
|
171
|
+
out += comments.map(formatComment).join('\n\n');
|
|
172
|
+
return out.endsWith('\n') ? out : `${out}\n`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatComment(c) {
|
|
176
|
+
const date = typeof c.createdAt === 'string' ? c.createdAt.slice(0, 10) : '';
|
|
177
|
+
const lines = [`**${c.author}** — ${date}`];
|
|
178
|
+
if (c.scope === 'selection' && c.quote != null && c.quote !== '') {
|
|
179
|
+
lines.push(`> ${c.quote}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push('', c.text ?? '');
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
94
185
|
function assertNotExpired(res) {
|
|
95
186
|
if (res.status === 410) {
|
|
96
187
|
throw new Error('this note has expired and is no longer available');
|