slingit 0.1.1 → 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 CHANGED
@@ -26,6 +26,12 @@ npx slingit shot analysis.md --public
26
26
  # Delete after 7 days
27
27
  npx slingit shot analysis.md --expire 7
28
28
 
29
+ # Load a note back — prints its Markdown to stdout (decrypts locally, key from #)
30
+ npx slingit pull https://slingit.dev/aX7k2mQp9f#Hk3...
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
+
29
35
  # Skill for Claude Code ("send these findings to X's email")
30
36
  npx slingit install-skill
31
37
 
@@ -58,6 +64,30 @@ Env takes precedence over the file; the file takes precedence over the built-in
58
64
  | `--from <text>` | Optional sender/signature shown on the note page and email | hidden |
59
65
  | `--copy` | Copy the link to the clipboard | off |
60
66
 
67
+ ## Loading a note (`pull`)
68
+
69
+ ```bash
70
+ npx slingit pull <url> [--comments]
71
+ ```
72
+
73
+ Fetches a published note and prints its Markdown to stdout — handy for pulling an
74
+ analysis another agent slung straight into a session. The note id is read from the URL
75
+ path and, for encrypted notes, the AES key from the `#` fragment: the ciphertext is
76
+ fetched by id alone and decrypted locally, so the key is never sent to the server (same
77
+ zero-knowledge model as the browser reader). Pass the **full link including the part
78
+ after `#`**; `--public` links have no fragment and pull just as well. Expired or missing
79
+ notes produce a clear error and a non-zero exit code.
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
+
61
91
  ## Skill installation
62
92
 
63
93
  ```bash
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "slingit",
3
- "version": "0.1.1",
4
- "description": "Fire-and-forget: share formatted Markdown notes via link. Encrypted by default (key in #URL), optional email delivery.",
3
+ "version": "0.2.1",
4
+ "description": "Share formatted Markdown notes via link. Encrypted by default (key in #URL), optional email delivery.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "slingit": "bin/slingit.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node test/unit.mjs"
11
+ },
9
12
  "files": [
10
13
  "bin",
11
14
  "src",
@@ -26,4 +29,4 @@
26
29
  ],
27
30
  "author": "BTA Systems",
28
31
  "license": "MIT"
29
- }
32
+ }
package/skill/SKILL.md CHANGED
@@ -1,14 +1,46 @@
1
1
  ---
2
2
  name: slingit
3
- description: Share formatted Markdown notes/analyses via a link (SlingIt). Use when the user asks to send session findings to an email, share an analysis as a link, "slingit this to X", "send these conclusions to Y's email", or "give me a link to this analysis".
3
+ description: Share formatted Markdown notes/analyses via a link (SlingIt), or load one back into the session. Use when the user asks to send session findings to an email, share an analysis as a link, "slingit this to X", "send these conclusions to Y's email", "give me a link to this analysis", or to read/load/pull an existing SlingIt link ("load this slingit note", "pull https://slingit.dev/...").
4
4
  ---
5
5
 
6
6
  # SlingIt — publish notes from the session
7
7
 
8
- You publish formatted Markdown notes with `npx slingit shot`. Each note gets a short
9
- link `https://slingit.dev/{id}`; by default it is **end-to-end encrypted** the AES-256
8
+ You publish formatted Markdown notes with `npx slingit shot`, and load published
9
+ notes back with `npx slingit pull <url>`. Each note gets a short link
10
+ `https://slingit.dev/{id}`; by default it is **end-to-end encrypted** — the AES-256
10
11
  key lives only in the `#` fragment of the URL, so the server cannot read the content.
11
12
 
13
+ ## Loading a note into the session (`pull`)
14
+
15
+ When the user gives you a SlingIt link and asks to "load", "read", "pull", or "open"
16
+ the note (e.g. another agent slung an analysis to review), fetch it with:
17
+
18
+ ```bash
19
+ npx slingit pull <url>
20
+ ```
21
+
22
+ It prints the note's Markdown to stdout so you can read it into your context. Pass the
23
+ **full link including the part after `#`** — for encrypted notes the key lives in the
24
+ fragment and decryption happens locally in the CLI (the key is never sent to the
25
+ server). Public links (`--public`) have no key and pull just as well. If the note has
26
+ expired or the link is wrong, `pull` prints a clear error and exits non-zero.
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
+
12
44
  ## Choose the input method first
13
45
 
14
46
  Use the method that matches the user's request:
package/src/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { shot } from './shot.js';
4
+ import { pull } from './pull.js';
4
5
  import { installSkill } from './install-skill.js';
5
6
 
6
7
  const HELP = `slingit — share Markdown notes via link (encrypted by default)
7
8
 
8
9
  Usage:
9
10
  slingit shot [file.md] [flags] publish a note (file or stdin)
11
+ slingit pull <url> [--comments] fetch a note by link, print its Markdown to stdout
10
12
  slingit install-skill [flags] install the agent skill
11
13
  slingit help this help
12
14
 
@@ -33,6 +35,11 @@ Configuration (optional — the CLI works out of the box, no config needed):
33
35
 
34
36
  By default: the note is encrypted with AES-256-GCM locally, the key only ends up
35
37
  in the #URL fragment (the server never sees it). --public is a deliberate opt-out.
38
+
39
+ pull decrypts locally too: it reads the note id from the URL path and the key from
40
+ the #fragment, fetches the ciphertext by id alone, and prints the Markdown. The key
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.
36
43
  `;
37
44
 
38
45
  export async function main(argv) {
@@ -41,6 +48,8 @@ export async function main(argv) {
41
48
  switch (command) {
42
49
  case 'shot':
43
50
  return shot(rest);
51
+ case 'pull':
52
+ return pull(rest);
44
53
  case 'install-skill':
45
54
  return installSkill(rest);
46
55
  case '--version':
package/src/crypto.js CHANGED
@@ -20,3 +20,21 @@ export async function encryptNote(plaintext) {
20
20
  keyB64url: Buffer.from(keyBytes).toString('base64url'),
21
21
  };
22
22
  }
23
+
24
+ /**
25
+ * Decrypts a note fetched from the server. Mirrors the browser reader:
26
+ * the AES-256-GCM key comes from the URL #fragment (base64url) and never
27
+ * touches the server; the IV lives in the note's public metadata (base64).
28
+ *
29
+ * @param {ArrayBuffer|Uint8Array} ciphertext raw bytes from GET /{id}.blob
30
+ * @param {string} keyB64url 32-byte key, base64url, from the #fragment
31
+ * @param {string} ivB64 12-byte IV, base64, from meta.encryption.iv
32
+ * @returns {Promise<string>} the decrypted Markdown
33
+ */
34
+ export async function decryptNote(ciphertext, keyB64url, ivB64) {
35
+ const keyBytes = Buffer.from(keyB64url, 'base64url');
36
+ const iv = Buffer.from(ivB64, 'base64');
37
+ const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']);
38
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
39
+ return new TextDecoder().decode(plaintext);
40
+ }
package/src/pull.js ADDED
@@ -0,0 +1,200 @@
1
+ import { decryptNote } from './crypto.js';
2
+
3
+ // Same id shape the Worker route accepts (10–14 url-safe chars).
4
+ const ID_RE = /^[A-Za-z0-9_-]{10,14}$/;
5
+
6
+ /**
7
+ * `slingit pull <url>` — fetch a published note and print its Markdown to stdout.
8
+ *
9
+ * The note id comes from the URL path and the AES key from the #fragment. The
10
+ * blob is fetched by id alone (public read, no token) exactly like the browser
11
+ * reader, so the key never reaches the server — the iron rule stays intact.
12
+ */
13
+ export async function pull(args) {
14
+ const { url, comments: wantComments } = parseArgs(args);
15
+ const { id, origin, key } = parseUrl(url);
16
+
17
+ const meta = await fetchMeta(origin, id);
18
+ const blob = await fetchBlob(origin, id);
19
+
20
+ let markdown;
21
+ if (meta.mode === 'public') {
22
+ // Public notes store plaintext Markdown — nothing to decrypt.
23
+ markdown = Buffer.from(blob).toString('utf8');
24
+ } else {
25
+ if (!key) {
26
+ throw new Error(
27
+ 'this note is encrypted but the link has no key after "#" — you need the full link (including the #fragment) to read it'
28
+ );
29
+ }
30
+ if (!meta.encryption?.iv) {
31
+ throw new Error('note metadata is missing encryption parameters — cannot decrypt');
32
+ }
33
+ try {
34
+ markdown = await decryptNote(blob, key, meta.encryption.iv);
35
+ } catch {
36
+ throw new Error(
37
+ 'could not decrypt the note — the key in the link is wrong or the content is corrupted'
38
+ );
39
+ }
40
+ }
41
+
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);
56
+ }
57
+
58
+ function parseArgs(args) {
59
+ let url = null;
60
+ let comments = false;
61
+ for (const a of args) {
62
+ if (a === '--comments') {
63
+ comments = true;
64
+ continue;
65
+ }
66
+ if (a.startsWith('-')) throw new Error(`unknown flag "${a}". See: slingit help`);
67
+ if (url) throw new Error('only one URL can be given');
68
+ url = a;
69
+ }
70
+ if (!url) throw new Error('usage: slingit pull <url> [--comments]');
71
+ return { url, comments };
72
+ }
73
+
74
+ function parseUrl(input) {
75
+ let url;
76
+ try {
77
+ url = new URL(input);
78
+ } catch {
79
+ throw new Error(`not a valid URL: ${input}`);
80
+ }
81
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
82
+ throw new Error(`unsupported URL scheme "${url.protocol}" — expected http(s)`);
83
+ }
84
+ const segment = url.pathname.split('/').filter(Boolean).pop() || '';
85
+ const id = segment.replace(/\.(blob|json|raw)$/, '');
86
+ if (!ID_RE.test(id)) {
87
+ throw new Error(`could not find a note id in the URL: ${input}`);
88
+ }
89
+ // The key is the whole #fragment, matching the reader's location.hash.slice(1).
90
+ const key = url.hash ? url.hash.slice(1) : '';
91
+ return { id, origin: url.origin, key };
92
+ }
93
+
94
+ async function fetchMeta(origin, id) {
95
+ const res = await httpGet(`${origin}/${id}.json`);
96
+ assertNotExpired(res);
97
+ if (!res.ok) throw new Error(`server returned HTTP ${res.status} for the note metadata`);
98
+ try {
99
+ return await res.json();
100
+ } catch {
101
+ throw new Error('unexpected response from the server (invalid note metadata)');
102
+ }
103
+ }
104
+
105
+ async function fetchBlob(origin, id) {
106
+ const res = await httpGet(`${origin}/${id}.blob`);
107
+ assertNotExpired(res);
108
+ if (!res.ok) throw new Error(`server returned HTTP ${res.status} for the note content`);
109
+ return res.arrayBuffer();
110
+ }
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
+
185
+ function assertNotExpired(res) {
186
+ if (res.status === 410) {
187
+ throw new Error('this note has expired and is no longer available');
188
+ }
189
+ if (res.status === 404) {
190
+ throw new Error('note not found — the link may be wrong or the note has expired');
191
+ }
192
+ }
193
+
194
+ async function httpGet(target) {
195
+ try {
196
+ return await fetch(target);
197
+ } catch (err) {
198
+ throw new Error(`could not connect to ${new URL(target).origin} (${err.cause?.code || err.message})`);
199
+ }
200
+ }