@unravel-tech/thing 0.2.0 → 0.4.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 +31 -3
- package/package.json +4 -5
- package/src/index.js +176 -23
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# @unravel-tech/thing
|
|
2
2
|
|
|
3
|
-
CLI for [thing](
|
|
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
6
|
npm i -g @unravel-tech/thing
|
|
7
|
-
thing login
|
|
7
|
+
thing login # defaults to https://usething.ai
|
|
8
8
|
thing push report.html --json
|
|
9
|
+
thing push notes.md --json # Markdown gets a styled reader view
|
|
9
10
|
thing push chart.png --json # images and PDFs too
|
|
10
11
|
```
|
|
11
12
|
|
|
@@ -17,11 +18,12 @@ thing push chart.png --json # images and PDFs too
|
|
|
17
18
|
| `thing logout` / `thing whoami` | Clear / show the current identity and where pushes land |
|
|
18
19
|
| `thing default [team] [--clear]` | Show or set your server-side default push target (used when no `--team` is given, from any machine) |
|
|
19
20
|
| `thing use <team> [project]` | Set a local active team/project override for this machine |
|
|
20
|
-
| `thing push <file.html\|.pdf\|.png\|.jpg\|.gif\|.webp> [--name x] [--team t] [--project p] [--visibility v]` | Push a new immutable version (HTML
|
|
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
22
|
| `thing list` | List artifacts you can see |
|
|
22
23
|
| `thing versions <name>` | Version history for an artifact |
|
|
23
24
|
| `thing rollback <name> <n>` | Point latest back to version n |
|
|
24
25
|
| `thing open <name>` | Open the artifact in a browser |
|
|
26
|
+
| `thing mcp` | Run a Model Context Protocol server over stdio (tools: `push_artifact`, `list_artifacts`, `whoami`) |
|
|
25
27
|
|
|
26
28
|
Every command accepts `--json` for machine-readable output.
|
|
27
29
|
|
|
@@ -34,4 +36,30 @@ working directory → a local `thing use` override → your **server-side defaul
|
|
|
34
36
|
(`thing default`) → your personal space. Login no longer pins a team, so with none of
|
|
35
37
|
the overrides set the server picks your default (e.g. the Unravel org for Unravel members).
|
|
36
38
|
|
|
39
|
+
## MCP
|
|
40
|
+
|
|
41
|
+
Claude Code, Cursor, Codex, and anything else that speaks Model Context
|
|
42
|
+
Protocol can publish through thing. No terminal login needed: create a token at
|
|
43
|
+
[usething.ai](https://usething.ai) under Settings, then Tokens, and paste this
|
|
44
|
+
into your client's MCP config.
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"thing": {
|
|
50
|
+
"command": "npx",
|
|
51
|
+
"args": ["-y", "@unravel-tech/thing", "mcp"],
|
|
52
|
+
"env": { "THING_TOKEN": "paste-your-token-here" }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The server exposes three tools: `push_artifact` to publish a file or inline
|
|
59
|
+
content and get back a link, `list_artifacts` to see what you have, and
|
|
60
|
+
`whoami` to check which account and team you are pushing to.
|
|
61
|
+
|
|
62
|
+
If you already ran `thing login`, the stored credential is used and
|
|
63
|
+
`THING_TOKEN` can be left out.
|
|
64
|
+
|
|
37
65
|
Requires Node >= 18 or Bun. No runtime dependencies.
|
package/package.json
CHANGED
|
@@ -3,15 +3,14 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
7
|
-
"description": "CLI for thing: push, version, and share artifacts (HTML, images, PDFs) from coding agents.",
|
|
6
|
+
"version": "0.4.0",
|
|
7
|
+
"description": "CLI for thing: push, version, and share artifacts (HTML, Markdown, images, PDFs) from coding agents.",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/unravel-team/thing.git"
|
|
12
|
-
"directory": "packages/cli"
|
|
11
|
+
"url": "git+https://github.com/unravel-team/thing-cli.git"
|
|
13
12
|
},
|
|
14
|
-
"homepage": "https://github.com/unravel-team/thing",
|
|
13
|
+
"homepage": "https://github.com/unravel-team/thing-cli",
|
|
15
14
|
"type": "module",
|
|
16
15
|
"bin": {
|
|
17
16
|
"thing": "./src/index.js"
|
package/src/index.js
CHANGED
|
@@ -5,7 +5,8 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
|
|
8
|
-
const DEFAULT_SERVER = process.env.THING_SERVER || "https://
|
|
8
|
+
const DEFAULT_SERVER = process.env.THING_SERVER || "https://usething.ai";
|
|
9
|
+
let clientName = "thing-cli/0.3.0";
|
|
9
10
|
const VISIBILITIES = new Set(["team", "public"]);
|
|
10
11
|
|
|
11
12
|
class CliError extends Error {
|
|
@@ -36,6 +37,7 @@ function projectConfigPath(cwd) {
|
|
|
36
37
|
|
|
37
38
|
function loadState(cwd, env = process.env) {
|
|
38
39
|
return {
|
|
40
|
+
env,
|
|
39
41
|
globalPath: configPath(env),
|
|
40
42
|
global: readJson(configPath(env)),
|
|
41
43
|
projectPath: projectConfigPath(cwd),
|
|
@@ -87,9 +89,13 @@ function stripSlash(server) {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
function context(state, flags = {}) {
|
|
92
|
+
const env = state.env ?? process.env;
|
|
90
93
|
return {
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
// THING_SERVER and THING_TOKEN let an MCP client run this with no prior
|
|
95
|
+
// `thing login`: the whole config is a paste, which is the difference
|
|
96
|
+
// between usable and not for anyone who does not live in a terminal.
|
|
97
|
+
server: stripSlash(flags.server || env.THING_SERVER || state.project.server || state.global.server || DEFAULT_SERVER),
|
|
98
|
+
token: flags.token || env.THING_TOKEN || state.global.token || null,
|
|
93
99
|
team: flags.team || state.project.team || state.global.activeTeam || null,
|
|
94
100
|
project: flags.project || state.project.project || state.global.activeProject || null
|
|
95
101
|
};
|
|
@@ -129,6 +135,7 @@ function formatText(value) {
|
|
|
129
135
|
async function api(ctx, path, options = {}) {
|
|
130
136
|
const headers = {
|
|
131
137
|
Accept: "application/json",
|
|
138
|
+
"X-Thing-Client": clientName,
|
|
132
139
|
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
133
140
|
...(ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {}),
|
|
134
141
|
...(options.headers || {})
|
|
@@ -298,29 +305,24 @@ async function detail(ctx, artifact) {
|
|
|
298
305
|
return api(ctx, `/api/v1/artifacts/${encodeURIComponent(artifact.id)}`);
|
|
299
306
|
}
|
|
300
307
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
requireToken(ctx);
|
|
306
|
-
const path = resolve(file);
|
|
307
|
-
if (!existsSync(path)) throw new CliError(`File not found: ${file}`);
|
|
308
|
-
|
|
308
|
+
// HTML rides as text; Markdown and media ride as base64 bytes with their real
|
|
309
|
+
// filename so the server can derive the content-type from the extension.
|
|
310
|
+
function docFromFile(path) {
|
|
311
|
+
if (!existsSync(path)) throw new CliError(`File not found: ${path}`);
|
|
309
312
|
const ext = extname(path).toLowerCase();
|
|
310
313
|
const isHtml = ext === ".html" || ext === ".htm";
|
|
311
|
-
|
|
312
|
-
|
|
314
|
+
const isMarkdown = ext === ".md" || ext === ".markdown";
|
|
315
|
+
if (!isHtml && !isMarkdown && !MEDIA_EXTS.has(ext)) {
|
|
316
|
+
throw new CliError(`Unsupported file type: ${ext || path}. Push an HTML page, a Markdown file, or a pdf/png/jpg/gif/webp file.`);
|
|
313
317
|
}
|
|
314
|
-
|
|
315
|
-
const visibility = parsed.flags.visibility;
|
|
316
|
-
if (visibility && !VISIBILITIES.has(visibility)) throw new CliError("Invalid visibility. Use team or public.");
|
|
317
|
-
|
|
318
|
-
// HTML rides as text; media rides as base64 bytes with its real filename so
|
|
319
|
-
// the server can derive the content-type from the extension.
|
|
320
|
-
const doc = isHtml
|
|
318
|
+
return isHtml
|
|
321
319
|
? { filename: "index.html", html: readFileSync(path, "utf8") }
|
|
322
320
|
: { filename: basename(path), contentBase64: readFileSync(path).toString("base64") };
|
|
321
|
+
}
|
|
323
322
|
|
|
323
|
+
async function pushToServer(ctx, { doc, name, visibility }) {
|
|
324
|
+
requireToken(ctx);
|
|
325
|
+
if (visibility && !VISIBILITIES.has(visibility)) throw new CliError("Invalid visibility. Use team or public.");
|
|
324
326
|
const pushed = await api(ctx, "/api/v1/artifacts", {
|
|
325
327
|
method: "POST",
|
|
326
328
|
body: {
|
|
@@ -332,14 +334,28 @@ async function push(parsed, state, io) {
|
|
|
332
334
|
...doc
|
|
333
335
|
}
|
|
334
336
|
});
|
|
335
|
-
|
|
337
|
+
return {
|
|
336
338
|
artifact: pushed.artifact,
|
|
337
339
|
version: pushed.version,
|
|
338
340
|
url: pushed.artifact.url,
|
|
339
341
|
visibility: pushed.artifact.visibility,
|
|
340
342
|
tokenedUrl: null
|
|
341
343
|
};
|
|
342
|
-
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function push(parsed, state, io) {
|
|
347
|
+
const [file] = parsed.positionals;
|
|
348
|
+
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
|
+
const path = resolve(file);
|
|
352
|
+
const doc = docFromFile(path);
|
|
353
|
+
const result = await pushToServer(ctx, {
|
|
354
|
+
doc,
|
|
355
|
+
name: parsed.flags.name || titleFromFile(path),
|
|
356
|
+
visibility: parsed.flags.visibility
|
|
357
|
+
});
|
|
358
|
+
output(io, parsed.json, result, result.url);
|
|
343
359
|
}
|
|
344
360
|
|
|
345
361
|
async function versionsCommand(parsed, state, io) {
|
|
@@ -388,6 +404,139 @@ async function openCommand(parsed, state, io) {
|
|
|
388
404
|
output(io, parsed.json, { url, artifact }, url);
|
|
389
405
|
}
|
|
390
406
|
|
|
407
|
+
// --- MCP server (`thing mcp`) ---------------------------------------------
|
|
408
|
+
// Newline-delimited JSON-RPC 2.0 over stdio, per the Model Context Protocol.
|
|
409
|
+
// Zero dependencies: three tools that reuse the CLI's own auth and push path,
|
|
410
|
+
// so any MCP client (Claude Code, Cursor, a desktop assistant) can publish
|
|
411
|
+
// artifacts through the user's existing `thing login`.
|
|
412
|
+
|
|
413
|
+
const MCP_TOOLS = [
|
|
414
|
+
{
|
|
415
|
+
name: "push_artifact",
|
|
416
|
+
description:
|
|
417
|
+
"Publish a file as a thing artifact and get a live, shareable URL. Every push creates a new immutable version. Pass either `path` (any supported file: html, md, pdf, png, jpg, gif, webp) or inline `content` with a `filename` (html or md).",
|
|
418
|
+
inputSchema: {
|
|
419
|
+
type: "object",
|
|
420
|
+
properties: {
|
|
421
|
+
path: { type: "string", description: "Path to the file to push" },
|
|
422
|
+
content: { type: "string", description: "Inline document text (HTML or Markdown) — use with filename" },
|
|
423
|
+
filename: { type: "string", description: "Filename for inline content, e.g. report.html or notes.md" },
|
|
424
|
+
name: { type: "string", description: "Artifact name (defaults to the filename)" },
|
|
425
|
+
team: { type: "string", description: "Team slug to push to (defaults to the user's default team)" },
|
|
426
|
+
project: { type: "string", description: "Project slug" },
|
|
427
|
+
visibility: { type: "string", enum: ["team", "public"], description: "Who can see it" }
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: "list_artifacts",
|
|
433
|
+
description: "List the artifacts the logged-in user can see, with team, visibility, and title.",
|
|
434
|
+
inputSchema: {
|
|
435
|
+
type: "object",
|
|
436
|
+
properties: {
|
|
437
|
+
team: { type: "string", description: "Only artifacts in this team slug" },
|
|
438
|
+
project: { type: "string", description: "Only artifacts in this project" }
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "whoami",
|
|
444
|
+
description: "Show the logged-in thing user, the server, and where pushes land by default.",
|
|
445
|
+
inputSchema: { type: "object", properties: {} }
|
|
446
|
+
}
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
async function mcpTool(state, parsed, name, args) {
|
|
450
|
+
const ctx = context(state, {
|
|
451
|
+
...parsed.flags,
|
|
452
|
+
...(args.team ? { team: args.team } : {}),
|
|
453
|
+
...(args.project ? { project: args.project } : {})
|
|
454
|
+
});
|
|
455
|
+
if (name === "whoami") {
|
|
456
|
+
requireToken(ctx);
|
|
457
|
+
const data = await api(ctx, "/api/v1/whoami");
|
|
458
|
+
return { user: data.user, server: ctx.server, defaultTeam: data.defaultTeam?.slug ?? null };
|
|
459
|
+
}
|
|
460
|
+
if (name === "list_artifacts") {
|
|
461
|
+
requireToken(ctx);
|
|
462
|
+
const data = await api(ctx, "/api/v1/artifacts");
|
|
463
|
+
let artifacts = data.artifacts || [];
|
|
464
|
+
if (ctx.team) artifacts = artifacts.filter((a) => a.teamSlug === ctx.team);
|
|
465
|
+
if (ctx.project) artifacts = artifacts.filter((a) => a.projectSlug === ctx.project);
|
|
466
|
+
return { artifacts };
|
|
467
|
+
}
|
|
468
|
+
if (name === "push_artifact") {
|
|
469
|
+
requireToken(ctx);
|
|
470
|
+
let doc;
|
|
471
|
+
let fallbackName;
|
|
472
|
+
if (args.path) {
|
|
473
|
+
const path = resolve(String(args.path));
|
|
474
|
+
doc = docFromFile(path);
|
|
475
|
+
fallbackName = titleFromFile(path);
|
|
476
|
+
} else if (args.content && args.filename) {
|
|
477
|
+
const ext = extname(String(args.filename)).toLowerCase();
|
|
478
|
+
if (ext === ".html" || ext === ".htm") doc = { filename: "index.html", html: String(args.content) };
|
|
479
|
+
else if (ext === ".md" || ext === ".markdown") doc = { filename: basename(String(args.filename)), contentBase64: Buffer.from(String(args.content)).toString("base64") };
|
|
480
|
+
else throw new CliError("Inline content must be .html or .md; push binaries via `path`.");
|
|
481
|
+
fallbackName = titleFromFile(String(args.filename));
|
|
482
|
+
} else {
|
|
483
|
+
throw new CliError("Provide either `path`, or `content` plus `filename`.");
|
|
484
|
+
}
|
|
485
|
+
return pushToServer(ctx, { doc, name: args.name || fallbackName, visibility: args.visibility });
|
|
486
|
+
}
|
|
487
|
+
throw new CliError(`Unknown tool: ${name}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function mcp(parsed, state, io) {
|
|
491
|
+
clientName = "thing-mcp/0.3.0";
|
|
492
|
+
const respond = (id, body) => io.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
|
|
493
|
+
const handle = async (req) => {
|
|
494
|
+
if (req.method === "initialize") {
|
|
495
|
+
return {
|
|
496
|
+
protocolVersion: req.params?.protocolVersion || "2025-06-18",
|
|
497
|
+
capabilities: { tools: {} },
|
|
498
|
+
serverInfo: { name: "thing", version: "0.3.0" }
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (req.method === "tools/list") return { tools: MCP_TOOLS };
|
|
502
|
+
if (req.method === "ping") return {};
|
|
503
|
+
if (req.method === "tools/call") {
|
|
504
|
+
const { name, arguments: args = {} } = req.params || {};
|
|
505
|
+
try {
|
|
506
|
+
const result = await mcpTool(state, parsed, name, args);
|
|
507
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
508
|
+
} catch (error) {
|
|
509
|
+
// Tool failures are results, not protocol errors, per MCP.
|
|
510
|
+
return { content: [{ type: "text", text: error.message }], isError: true };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
throw new CliError(`Method not found: ${req.method}`);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
let buffer = "";
|
|
517
|
+
for await (const chunk of io.stdin || process.stdin) {
|
|
518
|
+
buffer += chunk.toString();
|
|
519
|
+
let newline;
|
|
520
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
521
|
+
const line = buffer.slice(0, newline).trim();
|
|
522
|
+
buffer = buffer.slice(newline + 1);
|
|
523
|
+
if (!line) continue;
|
|
524
|
+
let request;
|
|
525
|
+
try {
|
|
526
|
+
request = JSON.parse(line);
|
|
527
|
+
} catch {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (request.id === undefined || request.id === null) continue; // notification
|
|
531
|
+
try {
|
|
532
|
+
respond(request.id, { result: await handle(request) });
|
|
533
|
+
} catch (error) {
|
|
534
|
+
respond(request.id, { error: { code: -32601, message: error.message } });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
391
540
|
function usage() {
|
|
392
541
|
return `Usage: thing <command> [options]
|
|
393
542
|
|
|
@@ -397,11 +546,12 @@ Commands:
|
|
|
397
546
|
whoami
|
|
398
547
|
use <team> [project]
|
|
399
548
|
default [team] [--clear]
|
|
400
|
-
push <file.html|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
|
|
549
|
+
push <file.html|.md|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
|
|
401
550
|
list
|
|
402
551
|
versions <name>
|
|
403
552
|
rollback <name> <version>
|
|
404
553
|
open <name>
|
|
554
|
+
mcp # Model Context Protocol server over stdio
|
|
405
555
|
|
|
406
556
|
Global options:
|
|
407
557
|
--json
|
|
@@ -443,6 +593,9 @@ export async function run(argv = process.argv.slice(2), io = { stdout: process.s
|
|
|
443
593
|
case "open":
|
|
444
594
|
await openCommand(parsed, state, io);
|
|
445
595
|
break;
|
|
596
|
+
case "mcp":
|
|
597
|
+
await mcp(parsed, state, io);
|
|
598
|
+
break;
|
|
446
599
|
case "-h":
|
|
447
600
|
case "--help":
|
|
448
601
|
case undefined:
|