@unravel-tech/thing 0.4.0 → 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 +21 -0
- package/README.md +34 -8
- package/package.json +15 -6
- package/server.json +45 -0
- package/src/index.js +65 -26
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
|
-
|
|
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]` |
|
|
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]` |
|
|
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
|
-
|
|
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,8 +3,9 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
7
|
-
"description": "
|
|
6
|
+
"version": "0.5.0",
|
|
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
|
+
"mcpName": "io.github.unravel-team/thing",
|
|
8
9
|
"license": "MIT",
|
|
9
10
|
"repository": {
|
|
10
11
|
"type": "git",
|
|
@@ -17,18 +18,26 @@
|
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"src/index.js",
|
|
20
|
-
"README.md"
|
|
21
|
+
"README.md",
|
|
22
|
+
"server.json"
|
|
21
23
|
],
|
|
22
24
|
"keywords": [
|
|
23
25
|
"artifacts",
|
|
24
26
|
"html",
|
|
27
|
+
"markdown",
|
|
25
28
|
"cli",
|
|
26
29
|
"agents",
|
|
27
|
-
"claude-code"
|
|
30
|
+
"claude-code",
|
|
31
|
+
"mcp",
|
|
32
|
+
"model-context-protocol",
|
|
33
|
+
"share",
|
|
34
|
+
"publish",
|
|
35
|
+
"codex",
|
|
36
|
+
"cursor"
|
|
28
37
|
],
|
|
29
38
|
"scripts": {
|
|
30
|
-
"smoke": "
|
|
31
|
-
"typecheck": "
|
|
39
|
+
"smoke": "node test/cli-smoke.mjs",
|
|
40
|
+
"typecheck": "node --input-type=module -e \"await import('./src/index.js'); console.log('ok')\""
|
|
32
41
|
},
|
|
33
42
|
"engines": {
|
|
34
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
|
-
#!/
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
|
@@ -414,7 +451,7 @@ const MCP_TOOLS = [
|
|
|
414
451
|
{
|
|
415
452
|
name: "push_artifact",
|
|
416
453
|
description:
|
|
417
|
-
"
|
|
454
|
+
"Use this whenever the user wants to share, send, show, or publish something you produced: a report, a dashboard, a prototype, a Markdown doc, a PDF, an image. Publishes it to a durable URL that stays live, and returns the link. Pushing the same name again creates a new version at the same link rather than a second link, so prefer reusing a name over inventing one. Pass either `path` (html, md, pdf, png, jpg, gif, webp) or inline `content` with a `filename` (html or md).",
|
|
418
455
|
inputSchema: {
|
|
419
456
|
type: "object",
|
|
420
457
|
properties: {
|
|
@@ -430,7 +467,8 @@ const MCP_TOOLS = [
|
|
|
430
467
|
},
|
|
431
468
|
{
|
|
432
469
|
name: "list_artifacts",
|
|
433
|
-
description:
|
|
470
|
+
description:
|
|
471
|
+
"Use this to find a link the user published earlier, or to check whether something is already published before pushing it again. Lists artifacts the signed-in user can see, with team, visibility and title.",
|
|
434
472
|
inputSchema: {
|
|
435
473
|
type: "object",
|
|
436
474
|
properties: {
|
|
@@ -441,7 +479,8 @@ const MCP_TOOLS = [
|
|
|
441
479
|
},
|
|
442
480
|
{
|
|
443
481
|
name: "whoami",
|
|
444
|
-
description:
|
|
482
|
+
description:
|
|
483
|
+
"Use this to confirm the user is signed in and which team will own a new artifact. Shows the account, the server, and where pushes land when no team is named.",
|
|
445
484
|
inputSchema: { type: "object", properties: {} }
|
|
446
485
|
}
|
|
447
486
|
];
|
|
@@ -488,14 +527,14 @@ async function mcpTool(state, parsed, name, args) {
|
|
|
488
527
|
}
|
|
489
528
|
|
|
490
529
|
async function mcp(parsed, state, io) {
|
|
491
|
-
clientName =
|
|
530
|
+
clientName = `thing-mcp/${VERSION}`;
|
|
492
531
|
const respond = (id, body) => io.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
|
|
493
532
|
const handle = async (req) => {
|
|
494
533
|
if (req.method === "initialize") {
|
|
495
534
|
return {
|
|
496
535
|
protocolVersion: req.params?.protocolVersion || "2025-06-18",
|
|
497
536
|
capabilities: { tools: {} },
|
|
498
|
-
serverInfo: { name: "thing", version:
|
|
537
|
+
serverInfo: { name: "thing", version: VERSION }
|
|
499
538
|
};
|
|
500
539
|
}
|
|
501
540
|
if (req.method === "tools/list") return { tools: MCP_TOOLS };
|
|
@@ -541,12 +580,12 @@ function usage() {
|
|
|
541
580
|
return `Usage: thing <command> [options]
|
|
542
581
|
|
|
543
582
|
Commands:
|
|
544
|
-
login [--server url]
|
|
583
|
+
login [--server url] [--no-browser]
|
|
545
584
|
logout
|
|
546
585
|
whoami
|
|
547
586
|
use <team> [project]
|
|
548
587
|
default [team] [--clear]
|
|
549
|
-
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]
|
|
550
589
|
list
|
|
551
590
|
versions <name>
|
|
552
591
|
rollback <name> <version>
|