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 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 --tomail someone@company.com
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
- | `--tomail [email]` | Send the link by email; without a value, uses `defaultToMail` | off |
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
- - **`--tomail` + encrypted:** the key passes through the server (in transit, not stored)
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.1.0",
4
- "description": "Fire-and-forget: share formatted Markdown notes via link. Encrypted by default (key in #URL), optional email delivery.",
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", 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
 
12
- ## Steps
13
+ ## Loading a note into the session (`pull`)
13
14
 
14
- 1. Collect the session's findings into a `.md` file (in a temp/scratchpad directory).
15
- Format it properly: `##` headings, code blocks with a language tag (```php, ```ts etc.),
16
- first line as `# Title`.
17
- 2. Run the CLI (works out of the box — no token or configuration required):
18
- ```bash
19
- npx slingit shot <file.md> [flags]
20
- ```
21
- 3. Return the full link from stdout to the user in chat (for an encrypted note the link
22
- contains the key after `#`without it the content cannot be decrypted).
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" | `--tomail X` (the email contains the full working link) |
29
- | "maximum privacy" | **no** `--tomail` — the default; the link with the key stays in chat only, the user shares it themselves |
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 --tomail x@y.z --expire 7`.
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 `--tomail`,
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 `--tomail` even if you know the address —
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
- > /slingit boss@company.com
54
- 1. Save the findings to analysis.md (with a # Title and language-tagged code blocks)
55
- 2. npx slingit shot analysis.md --tomail boss@company.com
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 install-skill [--project] install the Claude Code skill
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
- --tomail [email] send the link by email (Resend); if omitted, uses
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
+ }
@@ -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 project = args.includes('--project');
7
+ const opts = parseArgs(args);
12
8
  const source = fileURLToPath(new URL('../skill/SKILL.md', import.meta.url));
13
- const targetDir = project
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
- await mkdir(targetDir, { recursive: true });
18
- const target = join(targetDir, 'SKILL.md');
19
- await copyFile(source, target);
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(`✔ Skill installed: ${target}`);
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.tomail !== undefined) {
14
- sentTo = opts.tomail || config.defaultToMail;
13
+ if (opts.mailto !== undefined) {
14
+ sentTo = opts.mailto || config.defaultToMail;
15
15
  if (!sentTo) {
16
- throw new Error('--tomail given without an address and no defaultToMail in ~/.slingit/config.json');
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 = { file: null, public: false, copy: false, tomail: undefined, expire: null, title: null };
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 '--tomail':
98
+ case '--mailto':
84
99
  if (args[i + 1] && !args[i + 1].startsWith('-')) {
85
- opts.tomail = args[++i];
100
+ opts.mailto = args[++i];
86
101
  } else {
87
- opts.tomail = null; // use defaultToMail from the config
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');