@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 +21 -0
- package/README.md +34 -8
- package/package.json +6 -4
- package/server.json +45 -0
- package/src/index.js +60 -23
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,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
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": "
|
|
38
|
-
"typecheck": "
|
|
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
|
-
#!/
|
|
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
|
}
|
|
@@ -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 =
|
|
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:
|
|
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>
|