slingit 0.1.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 ADDED
@@ -0,0 +1,60 @@
1
+ # slingit
2
+
3
+ Fire-and-forget: share formatted Markdown notes via link.
4
+ **Encrypted end-to-end by default** — the AES-256 key lives only in the URL's `#` fragment,
5
+ the server is physically unable to read the content.
6
+
7
+ ```bash
8
+ # Encrypted note — the CLI prints a link with the key in #
9
+ npx slingit shot analysis.md
10
+
11
+ # With a pipe
12
+ cat analysis.md | npx slingit shot
13
+
14
+ # Send the link by email (the email contains the full working link)
15
+ npx slingit shot analysis.md --tomail someone@company.com
16
+
17
+ # Public note: no encryption, server-side render, SEO
18
+ npx slingit shot analysis.md --public
19
+
20
+ # Delete after 7 days
21
+ npx slingit shot analysis.md --expire 7
22
+
23
+ # Skill for Claude Code ("send these findings to X's email")
24
+ npx slingit install-skill
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ **No configuration is required** — the CLI works out of the box with `npx slingit shot`,
30
+ using a built-in public token (an anti-bot filter, not a secret; the real protection is
31
+ the server's rate limits). Overrides, e.g. for a self-hosted instance:
32
+
33
+ | Source | Keys |
34
+ |---|---|
35
+ | env | `SLINGIT_TOKEN`, `SLINGIT_URL` (defaults to the official SlingIt instance) |
36
+ | `~/.slingit/config.json` | `{ "token": "...", "url": "...", "defaultToMail": "..." }` |
37
+
38
+ Env takes precedence over the file; the file takes precedence over the built-in values.
39
+
40
+ ## Flags
41
+
42
+ | Flag | Description | Default |
43
+ |---|---|---|
44
+ | `--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 |
46
+ | `--expire <days>` | Delete the note after X days (1-365) | no TTL |
47
+ | `--title <text>` | Title (with `--public`, used for preview/SEO) | first `# H1` |
48
+ | `--copy` | Copy the link to the clipboard | off |
49
+
50
+ ## Privacy model
51
+
52
+ - **Default (encrypted):** content is encrypted with AES-256-GCM locally; only the
53
+ ciphertext goes to the server. The key in the `#` URL fragment is never sent by the
54
+ browser — full zero-knowledge.
55
+ - **`--tomail` + encrypted:** the key passes through the server (in transit, not stored)
56
+ and the mail — a deliberate decision: since the recipient needs the key anyway, they
57
+ get the full link. Security model: capability URL — whoever has the link can read it.
58
+ - **`--public`:** a deliberate opt-out from encryption (plaintext, SEO, nice preview).
59
+
60
+ Backend: Cloudflare Workers + R2. Code: `slingit` repo.
package/bin/slingit.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2)).catch((err) => {
5
+ console.error(`slingit: ${err.message}`);
6
+ process.exitCode = 1;
7
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
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.",
5
+ "type": "module",
6
+ "bin": {
7
+ "slingit": "bin/slingit.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "skill",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "markdown",
20
+ "share",
21
+ "pastebin",
22
+ "encrypted",
23
+ "zero-knowledge",
24
+ "claude-code",
25
+ "notes"
26
+ ],
27
+ "author": "BTA Systems",
28
+ "license": "MIT"
29
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,57 @@
1
+ ---
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".
4
+ ---
5
+
6
+ # SlingIt — publish notes from the session
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
10
+ key lives only in the `#` fragment of the URL, so the server cannot read the content.
11
+
12
+ ## Steps
13
+
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).
23
+
24
+ ## Choosing flags based on the user's intent
25
+
26
+ | Intent | Flags |
27
+ |---|---|
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 |
30
+ | "public link" (for a PR, docs, SEO) | `--public` (no encryption, server-side render) |
31
+ | "make it disappear after X days" | `--expire X` |
32
+ | title different from the H1 | `--title "..."` |
33
+
34
+ Flags can be combined, e.g. `--public --tomail x@y.z --expire 7`.
35
+
36
+ ## Rules (do not break these)
37
+
38
+ - Encryption is the **default** — there is no `--encrypt` flag; `--public` is a deliberate
39
+ 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`,
41
+ the CLI passes it to the server only in the `mailFragment` field, used solely to compose
42
+ the link inside the email (transit only, never stored). Do not print the key separately —
43
+ only as part of the full link.
44
+ - When the user asks for "maximum privacy", skip `--tomail` even if you know the address —
45
+ zero-knowledge only holds when the key never leaves the terminal/chat.
46
+ - No token is required — the CLI ships with a built-in one. `SLINGIT_TOKEN` (env) or
47
+ `"token"` in `~/.slingit/config.json` are optional overrides (e.g. a self-hosted
48
+ instance) — never invent a token.
49
+
50
+ ## Example
51
+
52
+ ```
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
56
+ 3. Reply: "Sent. Link (contains the decryption key): https://slingit.dev/aX7k2mQp9f#Hk3..."
57
+ ```
package/src/cli.js ADDED
@@ -0,0 +1,55 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { shot } from './shot.js';
4
+ import { installSkill } from './install-skill.js';
5
+
6
+ const HELP = `slingit — share Markdown notes via link (encrypted by default)
7
+
8
+ Usage:
9
+ slingit shot [file.md] [flags] publish a note (file or stdin)
10
+ slingit install-skill [--project] install the Claude Code skill
11
+ slingit help this help
12
+
13
+ Flags (shot):
14
+ --public disable encryption: plaintext, server-side render, SEO
15
+ --tomail [email] send the link by email (Resend); if omitted, uses
16
+ defaultToMail from ~/.slingit/config.json
17
+ --expire <days> delete the note after X days (1-365)
18
+ --title <text> note title (with --public, used for preview/SEO)
19
+ --copy copy the link to the clipboard
20
+
21
+ Configuration (optional — the CLI works out of the box, no config needed):
22
+ SLINGIT_TOKEN override the built-in bearer token (e.g. self-hosted instance)
23
+ SLINGIT_URL server address (defaults to the official SlingIt instance)
24
+ ~/.slingit/config.json { "token": "...", "url": "...", "defaultToMail": "..." }
25
+ (environment variables take precedence over the config file)
26
+
27
+ By default: the note is encrypted with AES-256-GCM locally, the key only ends up
28
+ in the #URL fragment (the server never sees it). --public is a deliberate opt-out.
29
+ `;
30
+
31
+ export async function main(argv) {
32
+ const [command, ...rest] = argv;
33
+
34
+ switch (command) {
35
+ case 'shot':
36
+ return shot(rest);
37
+ case 'install-skill':
38
+ return installSkill(rest);
39
+ case '--version':
40
+ case '-v': {
41
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
42
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
43
+ console.log(pkg.version);
44
+ return;
45
+ }
46
+ case 'help':
47
+ case '--help':
48
+ case '-h':
49
+ case undefined:
50
+ console.log(HELP);
51
+ return;
52
+ default:
53
+ throw new Error(`unknown command "${command}". See: slingit help`);
54
+ }
55
+ }
@@ -0,0 +1,25 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /** Copies text to the clipboard using a system tool; returns true on success. */
4
+ export async function copyToClipboard(text) {
5
+ const candidates =
6
+ process.platform === 'win32' ? [['clip', []]]
7
+ : process.platform === 'darwin' ? [['pbcopy', []]]
8
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
9
+
10
+ for (const [cmd, args] of candidates) {
11
+ const ok = await pipeTo(cmd, args, text);
12
+ if (ok) return true;
13
+ }
14
+ return false;
15
+ }
16
+
17
+ function pipeTo(cmd, args, text) {
18
+ return new Promise((resolve) => {
19
+ const child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
20
+ child.on('error', () => resolve(false));
21
+ child.on('close', (code) => resolve(code === 0));
22
+ child.stdin.on('error', () => resolve(false));
23
+ child.stdin.end(text);
24
+ });
25
+ }
package/src/config.js ADDED
@@ -0,0 +1,25 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const DEFAULT_URL = 'https://slingit.dev';
6
+
7
+ // Built-in public token — this is an anti-bot filter, NOT a secret (the npm
8
+ // package is public, so it hides nothing; the real protection is the Worker's rate limits).
9
+ // SLINGIT_TOKEN from env or "token" from ~/.slingit/config.json take precedence.
10
+ const DEFAULT_TOKEN = 'cnViYmVkanVtcGhhcHBlbmVkaG91c2VmbGV3Z2Fzb2xpbmVzaGVsbHNkb2dzdHJpbmc=';
11
+
12
+ /** Loads ~/.slingit/config.json (optional). Env takes precedence. */
13
+ export async function loadConfig() {
14
+ let file = {};
15
+ try {
16
+ file = JSON.parse(await readFile(join(homedir(), '.slingit', 'config.json'), 'utf8'));
17
+ } catch {
18
+ // no file or bad JSON — configuration comes from env only
19
+ }
20
+ return {
21
+ url: (process.env.SLINGIT_URL || file.url || DEFAULT_URL).replace(/\/+$/, ''),
22
+ token: process.env.SLINGIT_TOKEN || file.token || DEFAULT_TOKEN,
23
+ defaultToMail: file.defaultToMail || null,
24
+ };
25
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,22 @@
1
+ import { webcrypto as crypto } from 'node:crypto';
2
+
3
+ /**
4
+ * Encrypts a note with AES-256-GCM using a random key generated locally.
5
+ * The key never leaves this process — it's returned to the caller as
6
+ * base64url to be appended to the #URL fragment.
7
+ */
8
+ export async function encryptNote(plaintext) {
9
+ const keyBytes = crypto.getRandomValues(new Uint8Array(32));
10
+ const iv = crypto.getRandomValues(new Uint8Array(12));
11
+ const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']);
12
+ const ciphertext = await crypto.subtle.encrypt(
13
+ { name: 'AES-GCM', iv },
14
+ key,
15
+ new TextEncoder().encode(plaintext)
16
+ );
17
+ return {
18
+ ciphertextB64: Buffer.from(ciphertext).toString('base64'),
19
+ ivB64: Buffer.from(iv).toString('base64'),
20
+ keyB64url: Buffer.from(keyBytes).toString('base64url'),
21
+ };
22
+ }
@@ -0,0 +1,24 @@
1
+ import { copyFile, mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ /**
7
+ * Copies SKILL.md to ~/.claude/skills/slingit/ (default)
8
+ * or ./.claude/skills/slingit/ with --project.
9
+ */
10
+ export async function installSkill(args) {
11
+ const project = args.includes('--project');
12
+ 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');
16
+
17
+ await mkdir(targetDir, { recursive: true });
18
+ const target = join(targetDir, 'SKILL.md');
19
+ await copyFile(source, target);
20
+
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".');
23
+ console.log(' Works out of the box — no token or configuration needed.');
24
+ }
package/src/shot.js ADDED
@@ -0,0 +1,151 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { encryptNote } from './crypto.js';
3
+ import { loadConfig } from './config.js';
4
+ import { copyToClipboard } from './clipboard.js';
5
+
6
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
7
+
8
+ export async function shot(args) {
9
+ const opts = parseArgs(args);
10
+ const config = await loadConfig();
11
+
12
+ let sentTo = null;
13
+ if (opts.tomail !== undefined) {
14
+ sentTo = opts.tomail || config.defaultToMail;
15
+ if (!sentTo) {
16
+ throw new Error('--tomail given without an address and no defaultToMail in ~/.slingit/config.json');
17
+ }
18
+ if (!EMAIL_RE.test(sentTo)) throw new Error(`invalid email address: ${sentTo}`);
19
+ }
20
+
21
+ const markdown = await readInput(opts.file);
22
+ if (!markdown.trim()) throw new Error('empty note — nothing to publish');
23
+
24
+ const title = opts.title || extractTitle(markdown);
25
+
26
+ const body = {
27
+ mode: opts.public ? 'public' : 'encrypted',
28
+ expireDays: opts.expire ?? null,
29
+ sentTo,
30
+ sourceSession: process.env.CLAUDE_SESSION_ID || null,
31
+ sourceCwd: process.cwd(),
32
+ };
33
+
34
+ let key = null;
35
+ if (opts.public) {
36
+ body.content = markdown;
37
+ body.title = title;
38
+ } else {
39
+ const { ciphertextB64, ivB64, keyB64url } = await encryptNote(markdown);
40
+ key = keyB64url;
41
+ body.content = ciphertextB64;
42
+ body.encryption = { alg: 'AES-GCM', iv: ivB64, keyDerivation: 'raw-256' };
43
+ // The key goes to the server ONLY when the user requests email — in transit for the mail body.
44
+ if (sentTo) body.mailFragment = keyB64url;
45
+ }
46
+
47
+ const res = await postShare(config, body);
48
+ const link = key ? `${res.url}#${key}` : res.url;
49
+
50
+ console.log(link);
51
+
52
+ if (sentTo) {
53
+ if (res.mail?.sent) {
54
+ console.error(`✉ link sent to ${sentTo}`);
55
+ if (key) {
56
+ console.error(' (note: when sending by email, the encryption key passed through the server and the mail — the "anyone with the link can read it" model)');
57
+ }
58
+ } else {
59
+ const reason = res.mail?.detail || res.mail?.error || 'unknown error';
60
+ console.error(`⚠ mail to ${sentTo} was not sent (${reason}) — share the link manually`);
61
+ }
62
+ }
63
+ if (res.expiresAt) {
64
+ console.error(`⏳ note expires: ${res.expiresAt}`);
65
+ }
66
+ if (opts.copy) {
67
+ const ok = await copyToClipboard(link);
68
+ console.error(ok ? '⧉ link copied to clipboard' : '⚠ failed to copy to clipboard');
69
+ }
70
+ }
71
+
72
+ function parseArgs(args) {
73
+ const opts = { file: null, public: false, copy: false, tomail: undefined, expire: null, title: null };
74
+ for (let i = 0; i < args.length; i++) {
75
+ const a = args[i];
76
+ switch (a) {
77
+ case '--public':
78
+ opts.public = true;
79
+ break;
80
+ case '--copy':
81
+ opts.copy = true;
82
+ break;
83
+ case '--tomail':
84
+ if (args[i + 1] && !args[i + 1].startsWith('-')) {
85
+ opts.tomail = args[++i];
86
+ } else {
87
+ opts.tomail = null; // use defaultToMail from the config
88
+ }
89
+ break;
90
+ case '--expire': {
91
+ const days = Number(args[++i]);
92
+ if (!Number.isInteger(days) || days < 1 || days > 365) {
93
+ throw new Error('--expire requires a number of days in the range 1-365');
94
+ }
95
+ opts.expire = days;
96
+ break;
97
+ }
98
+ case '--title':
99
+ opts.title = args[++i];
100
+ if (opts.title === undefined) throw new Error('--title requires a value');
101
+ break;
102
+ default:
103
+ if (a.startsWith('-')) throw new Error(`unknown flag "${a}". See: slingit help`);
104
+ if (opts.file) throw new Error('only one file can be given');
105
+ opts.file = a;
106
+ }
107
+ }
108
+ return opts;
109
+ }
110
+
111
+ async function readInput(file) {
112
+ if (file) return readFile(file, 'utf8');
113
+ if (process.stdin.isTTY) {
114
+ throw new Error('provide a file or pipe content via stdin (e.g. cat note.md | slingit shot)');
115
+ }
116
+ const chunks = [];
117
+ for await (const chunk of process.stdin) chunks.push(chunk);
118
+ return Buffer.concat(chunks).toString('utf8');
119
+ }
120
+
121
+ function extractTitle(markdown) {
122
+ const m = markdown.match(/^#\s+(.+?)\s*$/m);
123
+ return m ? m[1] : '';
124
+ }
125
+
126
+ async function postShare(config, body) {
127
+ let res;
128
+ try {
129
+ res = await fetch(`${config.url}/api/share`, {
130
+ method: 'POST',
131
+ headers: {
132
+ authorization: `Bearer ${config.token}`,
133
+ 'content-type': 'application/json',
134
+ },
135
+ body: JSON.stringify(body),
136
+ });
137
+ } catch (err) {
138
+ throw new Error(`could not connect to ${config.url} (${err.cause?.code || err.message})`);
139
+ }
140
+ if (!res.ok) {
141
+ let detail = `HTTP ${res.status}`;
142
+ try {
143
+ const err = await res.json();
144
+ if (err.error) detail = `${err.error} (HTTP ${res.status})`;
145
+ } catch { /* not JSON — keep just the status */ }
146
+ if (res.status === 401) detail += ' — token rejected (check SLINGIT_TOKEN or update the slingit package)';
147
+ if (res.status === 413) detail += ' — note exceeds the 1 MB limit';
148
+ throw new Error(`server rejected the publish: ${detail}`);
149
+ }
150
+ return res.json();
151
+ }