@unravel-tech/thing 0.4.1 → 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Unravel Tech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -3,22 +3,22 @@
3
3
  CLI for [thing](https://usething.ai): push, version, and share artifacts (HTML pages, Markdown docs, standalone images and PDFs) from coding agents.
4
4
 
5
5
  ```sh
6
- npm i -g @unravel-tech/thing
7
- thing login # defaults to https://usething.ai
8
- thing push report.html --json
9
- thing push notes.md --json # Markdown gets a styled reader view
10
- thing push chart.png --json # images and PDFs too
6
+ npx -y @unravel-tech/thing push report.html
11
7
  ```
12
8
 
9
+ That first push opens browser approval, waits for sign-in, then resumes and
10
+ prints the durable URL. Markdown, images, and PDFs work the same way. Install
11
+ globally if you use it often: `npm i -g @unravel-tech/thing`.
12
+
13
13
  ## Commands
14
14
 
15
15
  | Command | What it does |
16
16
  | --- | --- |
17
- | `thing login [--server url]` | Device-code login; stores a token (does not pin a team) |
17
+ | `thing login [--server url] [--no-browser]` | Explicit device-code login; opens browser approval and stores a token |
18
18
  | `thing logout` / `thing whoami` | Clear / show the current identity and where pushes land |
19
19
  | `thing default [team] [--clear]` | Show or set your server-side default push target (used when no `--team` is given, from any machine) |
20
20
  | `thing use <team> [project]` | Set a local active team/project override for this machine |
21
- | `thing push <file.html\|.md\|.pdf\|.png\|.jpg\|.gif\|.webp> [--name x] [--team t] [--project p] [--visibility v]` | Push a new immutable version (HTML, Markdown, or image/PDF), print the served URL |
21
+ | `thing push <file.html\|.md\|.pdf\|.png\|.jpg\|.gif\|.webp> [--name x] [--team t] [--project p] [--visibility v]` | Authenticate if needed, then push a new immutable version and print the served URL |
22
22
  | `thing list` | List artifacts you can see |
23
23
  | `thing versions <name>` | Version history for an artifact |
24
24
  | `thing rollback <name> <n>` | Point latest back to version n |
@@ -26,6 +26,9 @@ thing push chart.png --json # images and PDFs too
26
26
  | `thing mcp` | Run a Model Context Protocol server over stdio (tools: `push_artifact`, `list_artifacts`, `whoami`) |
27
27
 
28
28
  Every command accepts `--json` for machine-readable output.
29
+ An unauthenticated `push --json` emits newline-delimited authentication status;
30
+ the final JSON object is always the push result. Use `--no-login` to fail fast
31
+ instead of starting interactive authentication, such as in CI.
29
32
 
30
33
  Visibility values: `private`, `team`, `anyone-with-link` (prints a tokened share URL), `public`.
31
34
 
@@ -55,6 +58,20 @@ into your client's MCP config.
55
58
  }
56
59
  ```
57
60
 
61
+ On a machine that has Bun but no Node, swap `npx` for `bunx`:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "thing": {
67
+ "command": "bunx",
68
+ "args": ["-y", "@unravel-tech/thing", "mcp"],
69
+ "env": { "THING_TOKEN": "paste-your-token-here" }
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
58
75
  The server exposes three tools: `push_artifact` to publish a file or inline
59
76
  content and get back a link, `list_artifacts` to see what you have, and
60
77
  `whoami` to check which account and team you are pushing to.
@@ -62,4 +79,13 @@ content and get back a link, `list_artifacts` to see what you have, and
62
79
  If you already ran `thing login`, the stored credential is used and
63
80
  `THING_TOKEN` can be left out.
64
81
 
65
- Requires Node >= 18 or Bun. No runtime dependencies.
82
+ ## Runtimes
83
+
84
+ Node >= 18 or Bun, no runtime dependencies. The `thing` executable launches under
85
+ whichever of the two is on your PATH (Node first), so a global install works on a
86
+ machine that has never installed Node:
87
+
88
+ ```sh
89
+ bun i -g @unravel-tech/thing
90
+ thing login
91
+ ```
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.1",
6
+ "version": "0.5.0",
7
7
  "description": "Artifacts for any coding agent. Publish what Claude Code, Codex, Cursor or Gemini CLI builds to a durable link, with versions and comments on the page.",
8
8
  "mcpName": "io.github.unravel-team/thing",
9
9
  "license": "MIT",
@@ -18,11 +18,13 @@
18
18
  },
19
19
  "files": [
20
20
  "src/index.js",
21
- "README.md"
21
+ "README.md",
22
+ "server.json"
22
23
  ],
23
24
  "keywords": [
24
25
  "artifacts",
25
26
  "html",
27
+ "markdown",
26
28
  "cli",
27
29
  "agents",
28
30
  "claude-code",
@@ -34,8 +36,8 @@
34
36
  "cursor"
35
37
  ],
36
38
  "scripts": {
37
- "smoke": "bun test/cli-smoke.mjs",
38
- "typecheck": "bun --print \"await import('./src/index.js'); 'ok'\""
39
+ "smoke": "node test/cli-smoke.mjs",
40
+ "typecheck": "node --input-type=module -e \"await import('./src/index.js'); console.log('ok')\""
39
41
  },
40
42
  "engines": {
41
43
  "node": ">=18"
package/server.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.unravel-team/thing",
4
+ "title": "thing",
5
+ "description": "Artifacts for any coding agent: a durable link with versions, diffs, and comments on the page.",
6
+ "version": "0.5.0",
7
+ "repository": {
8
+ "url": "https://github.com/unravel-team/thing-cli",
9
+ "source": "github"
10
+ },
11
+ "websiteUrl": "https://usething.ai",
12
+ "packages": [
13
+ {
14
+ "registryType": "npm",
15
+ "identifier": "@unravel-tech/thing",
16
+ "version": "0.5.0",
17
+ "transport": {
18
+ "type": "stdio"
19
+ },
20
+ "runtimeArguments": [
21
+ {
22
+ "type": "positional",
23
+ "value": "mcp",
24
+ "valueHint": "mcp",
25
+ "description": "Run the CLI as a Model Context Protocol server over stdio",
26
+ "isRequired": true
27
+ }
28
+ ],
29
+ "environmentVariables": [
30
+ {
31
+ "name": "THING_TOKEN",
32
+ "description": "API token from usething.ai, under Settings then Tokens. Lets the server publish on your behalf with no terminal login.",
33
+ "isRequired": true,
34
+ "isSecret": true
35
+ },
36
+ {
37
+ "name": "THING_SERVER",
38
+ "description": "Base URL of the thing server. Defaults to https://usething.ai; only set this for a self-hosted instance.",
39
+ "isRequired": false,
40
+ "isSecret": false
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ }
package/src/index.js CHANGED
@@ -1,4 +1,11 @@
1
- #!/usr/bin/env node
1
+ #!/bin/sh
2
+ ':' //# ; rt="$(command -v node || command -v bun)"; [ -n "$rt" ] || { echo "thing: needs Node >= 18 or Bun on PATH" >&2; exit 127; }; exec "$rt" "$0" "$@"
3
+ // The shebang is /bin/sh, not `env node`, so a global install never hard-requires
4
+ // a `node` binary: bun links its global bins straight at this file, and the kernel
5
+ // would otherwise run `env node` and fail for anyone who installed with `bun i -g`
6
+ // and has no node. Line 2 is a command to sh (exec the first runtime we find, node
7
+ // first so npm installs behave exactly as before) and a no-op string plus comment
8
+ // to JS, so the file stays valid ESM once the runtime re-reads it.
2
9
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
10
  import { basename, dirname, extname, join, resolve } from "node:path";
4
11
  import { homedir } from "node:os";
@@ -6,7 +13,10 @@ import { spawn } from "node:child_process";
6
13
  import { pathToFileURL } from "node:url";
7
14
 
8
15
  const DEFAULT_SERVER = process.env.THING_SERVER || "https://usething.ai";
9
- let clientName = "thing-cli/0.3.0";
16
+ // One source for the version: hardcoding it here meant the MCP handshake and
17
+ // the analytics client header both kept reporting 0.3.0 releases later.
18
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
19
+ let clientName = `thing-cli/${VERSION}`;
10
20
  const VISIBILITIES = new Set(["team", "public"]);
11
21
 
12
22
  class CliError extends Error {
@@ -102,7 +112,7 @@ function context(state, flags = {}) {
102
112
  }
103
113
 
104
114
  function requireToken(ctx) {
105
- if (!ctx.token) throw new CliError("Not logged in. Run `thing login` first.");
115
+ if (!ctx.token) throw new CliError("Not logged in. Run `thing login`, or remove `--no-login` to authenticate during push.");
106
116
  }
107
117
 
108
118
  function slugify(input) {
@@ -167,10 +177,35 @@ async function sleep(ms) {
167
177
  await new Promise((resolve) => setTimeout(resolve, ms));
168
178
  }
169
179
 
170
- async function login(parsed, state, io) {
171
- const server = stripSlash(parsed.flags.server || state.global.server || DEFAULT_SERVER);
172
- const device = await api({ server }, "/api/v1/auth/device/code", { method: "POST", body: {} });
173
- const loginMessage = `Open ${device.verification_uri_complete || device.verification_uri}\nEnter code: ${device.user_code}`;
180
+ function openerCommand(url) {
181
+ if (process.platform === "darwin") return ["open", [url]];
182
+ if (process.platform === "win32") return ["cmd", ["/c", "start", "", url]];
183
+ return ["xdg-open", [url]];
184
+ }
185
+
186
+ function openBrowser(url) {
187
+ try {
188
+ const [cmd, args] = openerCommand(url);
189
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
190
+ // A missing desktop opener should not end the login flow: the URL is also
191
+ // printed, so headless and SSH users still have a path forward.
192
+ child.on("error", () => {});
193
+ child.unref();
194
+ } catch {
195
+ // Best effort only; the printed URL is the fallback.
196
+ }
197
+ }
198
+
199
+ async function login(parsed, state, io, options = {}) {
200
+ const server = context(state, parsed.flags).server;
201
+ const device = await api({ server }, "/api/v1/auth/device/code", {
202
+ method: "POST",
203
+ body: { intent: options.intent || "login" }
204
+ });
205
+ const verificationUrl = device.verification_uri_complete || device.verification_uri;
206
+ const env = state.env ?? process.env;
207
+ const shouldOpenBrowser = !parsed.flags["no-browser"] && env.THING_NO_BROWSER !== "1";
208
+ const loginMessage = `${shouldOpenBrowser ? "Opening" : "Open"} ${verificationUrl}\nEnter code: ${device.user_code}\nWaiting for approval...`;
174
209
  output(io, parsed.json, {
175
210
  server,
176
211
  verificationUri: device.verification_uri,
@@ -179,9 +214,10 @@ async function login(parsed, state, io) {
179
214
  expiresIn: device.expires_in,
180
215
  interval: device.interval
181
216
  }, loginMessage);
217
+ if (shouldOpenBrowser) openBrowser(verificationUrl);
182
218
 
183
219
  const deadline = Date.now() + (Number(device.expires_in || 900) * 1000);
184
- const interval = Math.max(250, Number(process.env.THING_POLL_INTERVAL_MS || Math.max(1, Number(device.interval || 5)) * 1000));
220
+ const interval = Math.max(250, Number(env.THING_POLL_INTERVAL_MS || Math.max(1, Number(device.interval || 5)) * 1000));
185
221
  let tokenResult = null;
186
222
  while (Date.now() < deadline) {
187
223
  await sleep(interval);
@@ -221,6 +257,15 @@ async function login(parsed, state, io) {
221
257
  user: whoami.user,
222
258
  defaultTeam
223
259
  }, `Logged in as ${whoami.user.email}${defaultTeam ? ` (pushes default to ${defaultTeam})` : ""}`);
260
+
261
+ return context(state, parsed.flags);
262
+ }
263
+
264
+ async function pushContext(parsed, state, io) {
265
+ const ctx = context(state, parsed.flags);
266
+ if (ctx.token) return ctx;
267
+ if (parsed.flags["no-login"]) requireToken(ctx);
268
+ return login(parsed, state, io, { intent: "push" });
224
269
  }
225
270
 
226
271
  async function logout(parsed, state, io) {
@@ -346,10 +391,10 @@ async function pushToServer(ctx, { doc, name, visibility }) {
346
391
  async function push(parsed, state, io) {
347
392
  const [file] = parsed.positionals;
348
393
  if (!file) throw new CliError("Usage: thing push <file.html|.md|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]");
349
- const ctx = context(state, parsed.flags);
350
- requireToken(ctx);
351
394
  const path = resolve(file);
352
395
  const doc = docFromFile(path);
396
+ if (parsed.flags.visibility && !VISIBILITIES.has(parsed.flags.visibility)) throw new CliError("Invalid visibility. Use team or public.");
397
+ const ctx = await pushContext(parsed, state, io);
353
398
  const result = await pushToServer(ctx, {
354
399
  doc,
355
400
  name: parsed.flags.name || titleFromFile(path),
@@ -383,12 +428,6 @@ async function rollback(parsed, state, io) {
383
428
  output(io, parsed.json, { artifact, ...result }, `Rolled back ${artifact.teamSlug}/${artifact.slug} to v${result.latestVersion}`);
384
429
  }
385
430
 
386
- function openerCommand(url) {
387
- if (process.platform === "darwin") return ["open", [url]];
388
- if (process.platform === "win32") return ["cmd", ["/c", "start", "", url]];
389
- return ["xdg-open", [url]];
390
- }
391
-
392
431
  async function openCommand(parsed, state, io) {
393
432
  const [name] = parsed.positionals;
394
433
  if (!name) throw new CliError("Usage: thing open <name>");
@@ -397,9 +436,7 @@ async function openCommand(parsed, state, io) {
397
436
  const artifact = await resolveArtifact(ctx, name);
398
437
  const url = `${ctx.server}/${artifact.teamSlug}/${artifact.slug}`;
399
438
  if (!parsed.json && !parsed.flags["no-browser"]) {
400
- const [cmd, args] = openerCommand(url);
401
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
402
- child.unref();
439
+ openBrowser(url);
403
440
  }
404
441
  output(io, parsed.json, { url, artifact }, url);
405
442
  }
@@ -490,14 +527,14 @@ async function mcpTool(state, parsed, name, args) {
490
527
  }
491
528
 
492
529
  async function mcp(parsed, state, io) {
493
- clientName = "thing-mcp/0.3.0";
530
+ clientName = `thing-mcp/${VERSION}`;
494
531
  const respond = (id, body) => io.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
495
532
  const handle = async (req) => {
496
533
  if (req.method === "initialize") {
497
534
  return {
498
535
  protocolVersion: req.params?.protocolVersion || "2025-06-18",
499
536
  capabilities: { tools: {} },
500
- serverInfo: { name: "thing", version: "0.3.0" }
537
+ serverInfo: { name: "thing", version: VERSION }
501
538
  };
502
539
  }
503
540
  if (req.method === "tools/list") return { tools: MCP_TOOLS };
@@ -543,12 +580,12 @@ function usage() {
543
580
  return `Usage: thing <command> [options]
544
581
 
545
582
  Commands:
546
- login [--server url]
583
+ login [--server url] [--no-browser]
547
584
  logout
548
585
  whoami
549
586
  use <team> [project]
550
587
  default [team] [--clear]
551
- push <file.html|.md|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
588
+ push <file.html|.md|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public] [--no-login] [--no-browser]
552
589
  list
553
590
  versions <name>
554
591
  rollback <name> <version>