slingit 0.1.0 → 0.2.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 +43 -3
- package/package.json +5 -2
- package/skill/SKILL.md +92 -21
- package/src/cli.js +17 -2
- package/src/crypto.js +18 -0
- package/src/install-skill.js +44 -13
- package/src/pull.js +109 -0
- package/src/shot.js +32 -7
package/README.md
CHANGED
|
@@ -12,7 +12,13 @@ npx slingit shot analysis.md
|
|
|
12
12
|
cat analysis.md | npx slingit shot
|
|
13
13
|
|
|
14
14
|
# Send the link by email (the email contains the full working link)
|
|
15
|
-
npx slingit shot analysis.md --
|
|
15
|
+
npx slingit shot analysis.md --mailto someone@company.com
|
|
16
|
+
|
|
17
|
+
# Add a short message visible only in the email
|
|
18
|
+
npx slingit shot analysis.md --mailto someone@company.com --mailmessage "Context before you open this"
|
|
19
|
+
|
|
20
|
+
# Add a sender signature to the note page and email
|
|
21
|
+
npx slingit shot analysis.md --from "Marcin"
|
|
16
22
|
|
|
17
23
|
# Public note: no encryption, server-side render, SEO
|
|
18
24
|
npx slingit shot analysis.md --public
|
|
@@ -20,8 +26,14 @@ npx slingit shot analysis.md --public
|
|
|
20
26
|
# Delete after 7 days
|
|
21
27
|
npx slingit shot analysis.md --expire 7
|
|
22
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
|
+
|
|
23
32
|
# Skill for Claude Code ("send these findings to X's email")
|
|
24
33
|
npx slingit install-skill
|
|
34
|
+
|
|
35
|
+
# Skill for Codex
|
|
36
|
+
npx slingit install-skill --target codex
|
|
25
37
|
```
|
|
26
38
|
|
|
27
39
|
## Configuration
|
|
@@ -42,17 +54,45 @@ Env takes precedence over the file; the file takes precedence over the built-in
|
|
|
42
54
|
| Flag | Description | Default |
|
|
43
55
|
|---|---|---|
|
|
44
56
|
| `--public` | Disables encryption, enables server-side render (SEO/preview) | off (encrypted) |
|
|
45
|
-
| `--
|
|
57
|
+
| `--mailto [email]` | Send the link by email; without a value, uses `defaultToMail` | off |
|
|
58
|
+
| `--mailmessage <text>` | Add an optional message/comment to the email body; requires `--mailto` | off |
|
|
46
59
|
| `--expire <days>` | Delete the note after X days (1-365) | no TTL |
|
|
47
60
|
| `--title <text>` | Title (with `--public`, used for preview/SEO) | first `# H1` |
|
|
61
|
+
| `--from <text>` | Optional sender/signature shown on the note page and email | hidden |
|
|
48
62
|
| `--copy` | Copy the link to the clipboard | off |
|
|
49
63
|
|
|
64
|
+
## Loading a note (`pull`)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx slingit pull <url>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Fetches a published note and prints its Markdown to stdout — handy for pulling an
|
|
71
|
+
analysis another agent slung straight into a session. The note id is read from the URL
|
|
72
|
+
path and, for encrypted notes, the AES key from the `#` fragment: the ciphertext is
|
|
73
|
+
fetched by id alone and decrypted locally, so the key is never sent to the server (same
|
|
74
|
+
zero-knowledge model as the browser reader). Pass the **full link including the part
|
|
75
|
+
after `#`**; `--public` links have no fragment and pull just as well. Expired or missing
|
|
76
|
+
notes produce a clear error and a non-zero exit code.
|
|
77
|
+
|
|
78
|
+
## Skill installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx slingit install-skill # Claude Code, user profile
|
|
82
|
+
npx slingit install-skill --project # Claude Code, current repo
|
|
83
|
+
npx slingit install-skill --target codex # Codex, user profile
|
|
84
|
+
npx slingit install-skill --target all # Claude Code + Codex
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Claude Code installs to `~/.claude/skills/slingit/` or `./.claude/skills/slingit/`.
|
|
88
|
+
Codex installs to `~/.agents/skills/slingit/` or `./.agents/skills/slingit/`.
|
|
89
|
+
|
|
50
90
|
## Privacy model
|
|
51
91
|
|
|
52
92
|
- **Default (encrypted):** content is encrypted with AES-256-GCM locally; only the
|
|
53
93
|
ciphertext goes to the server. The key in the `#` URL fragment is never sent by the
|
|
54
94
|
browser — full zero-knowledge.
|
|
55
|
-
- **`--
|
|
95
|
+
- **`--mailto` + encrypted:** the key passes through the server (in transit, not stored)
|
|
56
96
|
and the mail — a deliberate decision: since the recipient needs the key anyway, they
|
|
57
97
|
get the full link. Security model: capability URL — whoever has the link can read it.
|
|
58
98
|
- **`--public`:** a deliberate opt-out from encryption (plaintext, SEO, nice preview).
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slingit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
package/skill/SKILL.md
CHANGED
|
@@ -1,57 +1,128 @@
|
|
|
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",
|
|
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
|
|
9
|
-
|
|
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
|
|
|
12
|
-
##
|
|
13
|
+
## Loading a note into the session (`pull`)
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
## Choose the input method first
|
|
29
|
+
|
|
30
|
+
Use the method that matches the user's request:
|
|
31
|
+
|
|
32
|
+
1. **Conversation/session content**: if the user asks to send findings, a summary, details
|
|
33
|
+
from this conversation, or anything you are about to compose, pipe Markdown directly to
|
|
34
|
+
`npx slingit shot`. Do **not** create a temporary `.md` file just to pass generated
|
|
35
|
+
conversation content.
|
|
36
|
+
2. **Existing Markdown file**: if the user gives a specific `.md` file or asks to publish
|
|
37
|
+
a file from the workspace, pass that file path to `npx slingit shot <file.md>`.
|
|
38
|
+
|
|
39
|
+
Always format the Markdown properly: first line as `# Title`, `##` headings, and code
|
|
40
|
+
blocks with language tags (```php, ```ts, ```bash, etc.).
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
For generated conversation content, use stdin/pipe:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cat <<'EOF' | npx slingit shot [flags]
|
|
48
|
+
# Title
|
|
49
|
+
|
|
50
|
+
Markdown content from the conversation.
|
|
51
|
+
EOF
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
In PowerShell, use a here-string:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
@'
|
|
58
|
+
# Title
|
|
59
|
+
|
|
60
|
+
Markdown content from the conversation.
|
|
61
|
+
'@ | npx slingit shot [flags]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For an existing Markdown file, use the file path:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx slingit shot <file.md> [flags]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Return the full link from stdout to the user in chat. For an encrypted note the link
|
|
71
|
+
contains the key after `#`; without it the content cannot be decrypted.
|
|
23
72
|
|
|
24
73
|
## Choosing flags based on the user's intent
|
|
25
74
|
|
|
26
75
|
| Intent | Flags |
|
|
27
76
|
|---|---|
|
|
28
|
-
| "send it to X's email" | `--
|
|
29
|
-
| "
|
|
77
|
+
| "send it to X's email" | `--mailto X` (the email contains the full working link) |
|
|
78
|
+
| "include this message/comment in the email" | `--mailmessage "..."` (only together with `--mailto`) |
|
|
79
|
+
| "maximum privacy" | **no** `--mailto` — the default; the link with the key stays in chat only, the user shares it themselves |
|
|
30
80
|
| "public link" (for a PR, docs, SEO) | `--public` (no encryption, server-side render) |
|
|
31
81
|
| "make it disappear after X days" | `--expire X` |
|
|
82
|
+
| "send from X", "signed by X", "from me/Marcin/team name" | `--from "X"` |
|
|
32
83
|
| title different from the H1 | `--title "..."` |
|
|
33
84
|
|
|
34
|
-
Flags can be combined, e.g. `--public --
|
|
85
|
+
Flags can be combined, e.g. `--public --mailto x@y.z --mailmessage "Read the summary first" --from "Marcin" --expire 7`.
|
|
35
86
|
|
|
36
87
|
## Rules (do not break these)
|
|
37
88
|
|
|
38
89
|
- Encryption is the **default** — there is no `--encrypt` flag; `--public` is a deliberate
|
|
39
90
|
opt-out (use it only when the user explicitly wants a public/indexable note).
|
|
40
|
-
- The encryption key must **never** end up in files, logs, or storage. With `--
|
|
91
|
+
- The encryption key must **never** end up in files, logs, or storage. With `--mailto`,
|
|
41
92
|
the CLI passes it to the server only in the `mailFragment` field, used solely to compose
|
|
42
93
|
the link inside the email (transit only, never stored). Do not print the key separately —
|
|
43
94
|
only as part of the full link.
|
|
44
|
-
- When the user asks for "maximum privacy", skip `--
|
|
95
|
+
- When the user asks for "maximum privacy", skip `--mailto` even if you know the address —
|
|
45
96
|
zero-knowledge only holds when the key never leaves the terminal/chat.
|
|
46
97
|
- No token is required — the CLI ships with a built-in one. `SLINGIT_TOKEN` (env) or
|
|
47
98
|
`"token"` in `~/.slingit/config.json` are optional overrides (e.g. a self-hosted
|
|
48
99
|
instance) — never invent a token.
|
|
100
|
+
- Use `--from` only when the user explicitly gives a sender/signature. If it is not
|
|
101
|
+
provided, do not invent one.
|
|
102
|
+
- Use `--mailmessage` only when the user explicitly asks to add a message/comment to
|
|
103
|
+
the email. It requires `--mailto` and is not part of the note page.
|
|
49
104
|
|
|
50
105
|
## Example
|
|
51
106
|
|
|
107
|
+
Conversation content:
|
|
108
|
+
|
|
52
109
|
```
|
|
53
|
-
|
|
54
|
-
1.
|
|
55
|
-
2.
|
|
110
|
+
User asks: "Send these findings to boss@company.com from Marcin."
|
|
111
|
+
1. Compose Markdown directly from the conversation.
|
|
112
|
+
2. Pipe it to:
|
|
113
|
+
cat <<'EOF' | npx slingit shot --mailto boss@company.com --mailmessage "Please read the recommendation first." --from "Marcin"
|
|
114
|
+
# Title
|
|
115
|
+
|
|
116
|
+
...
|
|
117
|
+
EOF
|
|
56
118
|
3. Reply: "Sent. Link (contains the decryption key): https://slingit.dev/aX7k2mQp9f#Hk3..."
|
|
57
119
|
```
|
|
120
|
+
|
|
121
|
+
Existing file:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
User asks: "Send docs/review.md to boss@company.com."
|
|
125
|
+
1. Use the file directly:
|
|
126
|
+
npx slingit shot docs/review.md --mailto boss@company.com
|
|
127
|
+
2. Reply with the full link from stdout.
|
|
128
|
+
```
|
package/src/cli.js
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
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)
|
|
10
|
-
slingit
|
|
11
|
+
slingit pull <url> fetch a note by link, print its Markdown to stdout
|
|
12
|
+
slingit install-skill [flags] install the agent skill
|
|
11
13
|
slingit help this help
|
|
12
14
|
|
|
13
15
|
Flags (shot):
|
|
14
16
|
--public disable encryption: plaintext, server-side render, SEO
|
|
15
|
-
--
|
|
17
|
+
--mailto [email] send the link by email (Resend); if omitted, uses
|
|
16
18
|
defaultToMail from ~/.slingit/config.json
|
|
19
|
+
--mailmessage <text>
|
|
20
|
+
optional message included in the email only; requires --mailto
|
|
17
21
|
--expire <days> delete the note after X days (1-365)
|
|
18
22
|
--title <text> note title (with --public, used for preview/SEO)
|
|
23
|
+
--from <text> optional sender/signature shown on the note page and email
|
|
19
24
|
--copy copy the link to the clipboard
|
|
20
25
|
|
|
26
|
+
Flags (install-skill):
|
|
27
|
+
--target <name> claude (default), codex, or all
|
|
28
|
+
--project install into this repository instead of your user profile
|
|
29
|
+
|
|
21
30
|
Configuration (optional — the CLI works out of the box, no config needed):
|
|
22
31
|
SLINGIT_TOKEN override the built-in bearer token (e.g. self-hosted instance)
|
|
23
32
|
SLINGIT_URL server address (defaults to the official SlingIt instance)
|
|
@@ -26,6 +35,10 @@ Configuration (optional — the CLI works out of the box, no config needed):
|
|
|
26
35
|
|
|
27
36
|
By default: the note is encrypted with AES-256-GCM locally, the key only ends up
|
|
28
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 #.
|
|
29
42
|
`;
|
|
30
43
|
|
|
31
44
|
export async function main(argv) {
|
|
@@ -34,6 +47,8 @@ export async function main(argv) {
|
|
|
34
47
|
switch (command) {
|
|
35
48
|
case 'shot':
|
|
36
49
|
return shot(rest);
|
|
50
|
+
case 'pull':
|
|
51
|
+
return pull(rest);
|
|
37
52
|
case 'install-skill':
|
|
38
53
|
return installSkill(rest);
|
|
39
54
|
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/install-skill.js
CHANGED
|
@@ -3,22 +3,53 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Copies SKILL.md to ~/.claude/skills/slingit/ (default)
|
|
8
|
-
* or ./.claude/skills/slingit/ with --project.
|
|
9
|
-
*/
|
|
10
6
|
export async function installSkill(args) {
|
|
11
|
-
const
|
|
7
|
+
const opts = parseArgs(args);
|
|
12
8
|
const source = fileURLToPath(new URL('../skill/SKILL.md', import.meta.url));
|
|
13
|
-
const
|
|
14
|
-
? join(process.cwd(), '.claude', 'skills', 'slingit')
|
|
15
|
-
: join(homedir(), '.claude', 'skills', 'slingit');
|
|
9
|
+
const targets = opts.target === 'all' ? ['claude', 'codex'] : [opts.target];
|
|
16
10
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
for (const targetName of targets) {
|
|
12
|
+
const targetDir = targetPath(targetName, opts.project);
|
|
13
|
+
await mkdir(targetDir, { recursive: true });
|
|
14
|
+
const target = join(targetDir, 'SKILL.md');
|
|
15
|
+
await copyFile(source, target);
|
|
16
|
+
console.log(`✔ Skill installed (${targetName}): ${target}`);
|
|
17
|
+
}
|
|
20
18
|
|
|
21
|
-
console.log(
|
|
22
|
-
console.log(' In a Claude Code session, ask e.g.: "send these findings to X\'s email" or "/slingit".');
|
|
19
|
+
console.log(' Ask e.g.: "send these findings to X\'s email" or invoke the slingit skill directly.');
|
|
23
20
|
console.log(' Works out of the box — no token or configuration needed.');
|
|
24
21
|
}
|
|
22
|
+
|
|
23
|
+
function parseArgs(args) {
|
|
24
|
+
const opts = { project: false, target: 'claude' };
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
const a = args[i];
|
|
27
|
+
switch (a) {
|
|
28
|
+
case '--project':
|
|
29
|
+
opts.project = true;
|
|
30
|
+
break;
|
|
31
|
+
case '--target': {
|
|
32
|
+
const target = args[++i];
|
|
33
|
+
if (!['claude', 'codex', 'all'].includes(target)) {
|
|
34
|
+
throw new Error('--target must be one of: claude, codex, all');
|
|
35
|
+
}
|
|
36
|
+
opts.target = target;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
default:
|
|
40
|
+
throw new Error(`unknown flag "${a}". See: slingit help`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return opts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function targetPath(target, project) {
|
|
47
|
+
if (target === 'claude') {
|
|
48
|
+
return project
|
|
49
|
+
? join(process.cwd(), '.claude', 'skills', 'slingit')
|
|
50
|
+
: join(homedir(), '.claude', 'skills', 'slingit');
|
|
51
|
+
}
|
|
52
|
+
return project
|
|
53
|
+
? join(process.cwd(), '.agents', 'skills', 'slingit')
|
|
54
|
+
: join(homedir(), '.agents', 'skills', 'slingit');
|
|
55
|
+
}
|
package/src/pull.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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 } = 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
|
+
process.stdout.write(markdown.endsWith('\n') ? markdown : `${markdown}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseArgs(args) {
|
|
46
|
+
let url = null;
|
|
47
|
+
for (const a of args) {
|
|
48
|
+
if (a.startsWith('-')) throw new Error(`unknown flag "${a}". See: slingit help`);
|
|
49
|
+
if (url) throw new Error('only one URL can be given');
|
|
50
|
+
url = a;
|
|
51
|
+
}
|
|
52
|
+
if (!url) throw new Error('usage: slingit pull <url>');
|
|
53
|
+
return { url };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseUrl(input) {
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(input);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`not a valid URL: ${input}`);
|
|
62
|
+
}
|
|
63
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
64
|
+
throw new Error(`unsupported URL scheme "${url.protocol}" — expected http(s)`);
|
|
65
|
+
}
|
|
66
|
+
const segment = url.pathname.split('/').filter(Boolean).pop() || '';
|
|
67
|
+
const id = segment.replace(/\.(blob|json|raw)$/, '');
|
|
68
|
+
if (!ID_RE.test(id)) {
|
|
69
|
+
throw new Error(`could not find a note id in the URL: ${input}`);
|
|
70
|
+
}
|
|
71
|
+
// The key is the whole #fragment, matching the reader's location.hash.slice(1).
|
|
72
|
+
const key = url.hash ? url.hash.slice(1) : '';
|
|
73
|
+
return { id, origin: url.origin, key };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fetchMeta(origin, id) {
|
|
77
|
+
const res = await httpGet(`${origin}/${id}.json`);
|
|
78
|
+
assertNotExpired(res);
|
|
79
|
+
if (!res.ok) throw new Error(`server returned HTTP ${res.status} for the note metadata`);
|
|
80
|
+
try {
|
|
81
|
+
return await res.json();
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error('unexpected response from the server (invalid note metadata)');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function fetchBlob(origin, id) {
|
|
88
|
+
const res = await httpGet(`${origin}/${id}.blob`);
|
|
89
|
+
assertNotExpired(res);
|
|
90
|
+
if (!res.ok) throw new Error(`server returned HTTP ${res.status} for the note content`);
|
|
91
|
+
return res.arrayBuffer();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertNotExpired(res) {
|
|
95
|
+
if (res.status === 410) {
|
|
96
|
+
throw new Error('this note has expired and is no longer available');
|
|
97
|
+
}
|
|
98
|
+
if (res.status === 404) {
|
|
99
|
+
throw new Error('note not found — the link may be wrong or the note has expired');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function httpGet(target) {
|
|
104
|
+
try {
|
|
105
|
+
return await fetch(target);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new Error(`could not connect to ${new URL(target).origin} (${err.cause?.code || err.message})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/shot.js
CHANGED
|
@@ -10,23 +10,29 @@ export async function shot(args) {
|
|
|
10
10
|
const config = await loadConfig();
|
|
11
11
|
|
|
12
12
|
let sentTo = null;
|
|
13
|
-
if (opts.
|
|
14
|
-
sentTo = opts.
|
|
13
|
+
if (opts.mailto !== undefined) {
|
|
14
|
+
sentTo = opts.mailto || config.defaultToMail;
|
|
15
15
|
if (!sentTo) {
|
|
16
|
-
throw new Error('--
|
|
16
|
+
throw new Error('--mailto given without an address and no defaultToMail in ~/.slingit/config.json');
|
|
17
17
|
}
|
|
18
18
|
if (!EMAIL_RE.test(sentTo)) throw new Error(`invalid email address: ${sentTo}`);
|
|
19
19
|
}
|
|
20
|
+
if (opts.mailmessage && !sentTo) {
|
|
21
|
+
throw new Error('--mailmessage can only be used together with --mailto');
|
|
22
|
+
}
|
|
20
23
|
|
|
21
24
|
const markdown = await readInput(opts.file);
|
|
22
25
|
if (!markdown.trim()) throw new Error('empty note — nothing to publish');
|
|
23
26
|
|
|
24
27
|
const title = opts.title || extractTitle(markdown);
|
|
28
|
+
const signature = opts.from ? opts.from.trim().slice(0, 120) : '';
|
|
25
29
|
|
|
26
30
|
const body = {
|
|
27
31
|
mode: opts.public ? 'public' : 'encrypted',
|
|
28
32
|
expireDays: opts.expire ?? null,
|
|
29
33
|
sentTo,
|
|
34
|
+
mailMessage: opts.mailmessage ? opts.mailmessage.trim().slice(0, 1000) : '',
|
|
35
|
+
signature,
|
|
30
36
|
sourceSession: process.env.CLAUDE_SESSION_ID || null,
|
|
31
37
|
sourceCwd: process.cwd(),
|
|
32
38
|
};
|
|
@@ -70,7 +76,16 @@ export async function shot(args) {
|
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
function parseArgs(args) {
|
|
73
|
-
const opts = {
|
|
79
|
+
const opts = {
|
|
80
|
+
file: null,
|
|
81
|
+
public: false,
|
|
82
|
+
copy: false,
|
|
83
|
+
mailto: undefined,
|
|
84
|
+
mailmessage: null,
|
|
85
|
+
expire: null,
|
|
86
|
+
title: null,
|
|
87
|
+
from: null,
|
|
88
|
+
};
|
|
74
89
|
for (let i = 0; i < args.length; i++) {
|
|
75
90
|
const a = args[i];
|
|
76
91
|
switch (a) {
|
|
@@ -80,11 +95,17 @@ function parseArgs(args) {
|
|
|
80
95
|
case '--copy':
|
|
81
96
|
opts.copy = true;
|
|
82
97
|
break;
|
|
83
|
-
case '--
|
|
98
|
+
case '--mailto':
|
|
84
99
|
if (args[i + 1] && !args[i + 1].startsWith('-')) {
|
|
85
|
-
opts.
|
|
100
|
+
opts.mailto = args[++i];
|
|
86
101
|
} else {
|
|
87
|
-
opts.
|
|
102
|
+
opts.mailto = null; // use defaultToMail from the config
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
case '--mailmessage':
|
|
106
|
+
opts.mailmessage = args[++i];
|
|
107
|
+
if (opts.mailmessage === undefined || opts.mailmessage.startsWith('-')) {
|
|
108
|
+
throw new Error('--mailmessage requires a value');
|
|
88
109
|
}
|
|
89
110
|
break;
|
|
90
111
|
case '--expire': {
|
|
@@ -99,6 +120,10 @@ function parseArgs(args) {
|
|
|
99
120
|
opts.title = args[++i];
|
|
100
121
|
if (opts.title === undefined) throw new Error('--title requires a value');
|
|
101
122
|
break;
|
|
123
|
+
case '--from':
|
|
124
|
+
opts.from = args[++i];
|
|
125
|
+
if (opts.from === undefined || opts.from.startsWith('-')) throw new Error('--from requires a value');
|
|
126
|
+
break;
|
|
102
127
|
default:
|
|
103
128
|
if (a.startsWith('-')) throw new Error(`unknown flag "${a}". See: slingit help`);
|
|
104
129
|
if (opts.file) throw new Error('only one file can be given');
|