@unravel-tech/thing 0.2.0 → 0.3.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 +13 -3
- package/package.json +4 -5
- package/src/index.js +165 -20
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# @unravel-tech/thing
|
|
2
2
|
|
|
3
|
-
CLI for [thing](
|
|
3
|
+
CLI for [thing](https://thing.unravel.tech): 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://thing.unravel.tech
|
|
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,12 @@ 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
|
+
Any MCP client can push artifacts through your CLI login — register the server as:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "command": "thing", "args": ["mcp"] }
|
|
45
|
+
```
|
|
46
|
+
|
|
37
47
|
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.3.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
|
@@ -298,29 +298,24 @@ async function detail(ctx, artifact) {
|
|
|
298
298
|
return api(ctx, `/api/v1/artifacts/${encodeURIComponent(artifact.id)}`);
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
requireToken(ctx);
|
|
306
|
-
const path = resolve(file);
|
|
307
|
-
if (!existsSync(path)) throw new CliError(`File not found: ${file}`);
|
|
308
|
-
|
|
301
|
+
// HTML rides as text; Markdown and media ride as base64 bytes with their real
|
|
302
|
+
// filename so the server can derive the content-type from the extension.
|
|
303
|
+
function docFromFile(path) {
|
|
304
|
+
if (!existsSync(path)) throw new CliError(`File not found: ${path}`);
|
|
309
305
|
const ext = extname(path).toLowerCase();
|
|
310
306
|
const isHtml = ext === ".html" || ext === ".htm";
|
|
311
|
-
|
|
312
|
-
|
|
307
|
+
const isMarkdown = ext === ".md" || ext === ".markdown";
|
|
308
|
+
if (!isHtml && !isMarkdown && !MEDIA_EXTS.has(ext)) {
|
|
309
|
+
throw new CliError(`Unsupported file type: ${ext || path}. Push an HTML page, a Markdown file, or a pdf/png/jpg/gif/webp file.`);
|
|
313
310
|
}
|
|
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
|
|
311
|
+
return isHtml
|
|
321
312
|
? { filename: "index.html", html: readFileSync(path, "utf8") }
|
|
322
313
|
: { filename: basename(path), contentBase64: readFileSync(path).toString("base64") };
|
|
314
|
+
}
|
|
323
315
|
|
|
316
|
+
async function pushToServer(ctx, { doc, name, visibility }) {
|
|
317
|
+
requireToken(ctx);
|
|
318
|
+
if (visibility && !VISIBILITIES.has(visibility)) throw new CliError("Invalid visibility. Use team or public.");
|
|
324
319
|
const pushed = await api(ctx, "/api/v1/artifacts", {
|
|
325
320
|
method: "POST",
|
|
326
321
|
body: {
|
|
@@ -332,14 +327,28 @@ async function push(parsed, state, io) {
|
|
|
332
327
|
...doc
|
|
333
328
|
}
|
|
334
329
|
});
|
|
335
|
-
|
|
330
|
+
return {
|
|
336
331
|
artifact: pushed.artifact,
|
|
337
332
|
version: pushed.version,
|
|
338
333
|
url: pushed.artifact.url,
|
|
339
334
|
visibility: pushed.artifact.visibility,
|
|
340
335
|
tokenedUrl: null
|
|
341
336
|
};
|
|
342
|
-
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function push(parsed, state, io) {
|
|
340
|
+
const [file] = parsed.positionals;
|
|
341
|
+
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]");
|
|
342
|
+
const ctx = context(state, parsed.flags);
|
|
343
|
+
requireToken(ctx);
|
|
344
|
+
const path = resolve(file);
|
|
345
|
+
const doc = docFromFile(path);
|
|
346
|
+
const result = await pushToServer(ctx, {
|
|
347
|
+
doc,
|
|
348
|
+
name: parsed.flags.name || titleFromFile(path),
|
|
349
|
+
visibility: parsed.flags.visibility
|
|
350
|
+
});
|
|
351
|
+
output(io, parsed.json, result, result.url);
|
|
343
352
|
}
|
|
344
353
|
|
|
345
354
|
async function versionsCommand(parsed, state, io) {
|
|
@@ -388,6 +397,138 @@ async function openCommand(parsed, state, io) {
|
|
|
388
397
|
output(io, parsed.json, { url, artifact }, url);
|
|
389
398
|
}
|
|
390
399
|
|
|
400
|
+
// --- MCP server (`thing mcp`) ---------------------------------------------
|
|
401
|
+
// Newline-delimited JSON-RPC 2.0 over stdio, per the Model Context Protocol.
|
|
402
|
+
// Zero dependencies: three tools that reuse the CLI's own auth and push path,
|
|
403
|
+
// so any MCP client (Claude Code, Cursor, a desktop assistant) can publish
|
|
404
|
+
// artifacts through the user's existing `thing login`.
|
|
405
|
+
|
|
406
|
+
const MCP_TOOLS = [
|
|
407
|
+
{
|
|
408
|
+
name: "push_artifact",
|
|
409
|
+
description:
|
|
410
|
+
"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).",
|
|
411
|
+
inputSchema: {
|
|
412
|
+
type: "object",
|
|
413
|
+
properties: {
|
|
414
|
+
path: { type: "string", description: "Path to the file to push" },
|
|
415
|
+
content: { type: "string", description: "Inline document text (HTML or Markdown) — use with filename" },
|
|
416
|
+
filename: { type: "string", description: "Filename for inline content, e.g. report.html or notes.md" },
|
|
417
|
+
name: { type: "string", description: "Artifact name (defaults to the filename)" },
|
|
418
|
+
team: { type: "string", description: "Team slug to push to (defaults to the user's default team)" },
|
|
419
|
+
project: { type: "string", description: "Project slug" },
|
|
420
|
+
visibility: { type: "string", enum: ["team", "public"], description: "Who can see it" }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: "list_artifacts",
|
|
426
|
+
description: "List the artifacts the logged-in user can see, with team, visibility, and title.",
|
|
427
|
+
inputSchema: {
|
|
428
|
+
type: "object",
|
|
429
|
+
properties: {
|
|
430
|
+
team: { type: "string", description: "Only artifacts in this team slug" },
|
|
431
|
+
project: { type: "string", description: "Only artifacts in this project" }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "whoami",
|
|
437
|
+
description: "Show the logged-in thing user, the server, and where pushes land by default.",
|
|
438
|
+
inputSchema: { type: "object", properties: {} }
|
|
439
|
+
}
|
|
440
|
+
];
|
|
441
|
+
|
|
442
|
+
async function mcpTool(state, parsed, name, args) {
|
|
443
|
+
const ctx = context(state, {
|
|
444
|
+
...parsed.flags,
|
|
445
|
+
...(args.team ? { team: args.team } : {}),
|
|
446
|
+
...(args.project ? { project: args.project } : {})
|
|
447
|
+
});
|
|
448
|
+
if (name === "whoami") {
|
|
449
|
+
requireToken(ctx);
|
|
450
|
+
const data = await api(ctx, "/api/v1/whoami");
|
|
451
|
+
return { user: data.user, server: ctx.server, defaultTeam: data.defaultTeam?.slug ?? null };
|
|
452
|
+
}
|
|
453
|
+
if (name === "list_artifacts") {
|
|
454
|
+
requireToken(ctx);
|
|
455
|
+
const data = await api(ctx, "/api/v1/artifacts");
|
|
456
|
+
let artifacts = data.artifacts || [];
|
|
457
|
+
if (ctx.team) artifacts = artifacts.filter((a) => a.teamSlug === ctx.team);
|
|
458
|
+
if (ctx.project) artifacts = artifacts.filter((a) => a.projectSlug === ctx.project);
|
|
459
|
+
return { artifacts };
|
|
460
|
+
}
|
|
461
|
+
if (name === "push_artifact") {
|
|
462
|
+
requireToken(ctx);
|
|
463
|
+
let doc;
|
|
464
|
+
let fallbackName;
|
|
465
|
+
if (args.path) {
|
|
466
|
+
const path = resolve(String(args.path));
|
|
467
|
+
doc = docFromFile(path);
|
|
468
|
+
fallbackName = titleFromFile(path);
|
|
469
|
+
} else if (args.content && args.filename) {
|
|
470
|
+
const ext = extname(String(args.filename)).toLowerCase();
|
|
471
|
+
if (ext === ".html" || ext === ".htm") doc = { filename: "index.html", html: String(args.content) };
|
|
472
|
+
else if (ext === ".md" || ext === ".markdown") doc = { filename: basename(String(args.filename)), contentBase64: Buffer.from(String(args.content)).toString("base64") };
|
|
473
|
+
else throw new CliError("Inline content must be .html or .md; push binaries via `path`.");
|
|
474
|
+
fallbackName = titleFromFile(String(args.filename));
|
|
475
|
+
} else {
|
|
476
|
+
throw new CliError("Provide either `path`, or `content` plus `filename`.");
|
|
477
|
+
}
|
|
478
|
+
return pushToServer(ctx, { doc, name: args.name || fallbackName, visibility: args.visibility });
|
|
479
|
+
}
|
|
480
|
+
throw new CliError(`Unknown tool: ${name}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function mcp(parsed, state, io) {
|
|
484
|
+
const respond = (id, body) => io.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
|
|
485
|
+
const handle = async (req) => {
|
|
486
|
+
if (req.method === "initialize") {
|
|
487
|
+
return {
|
|
488
|
+
protocolVersion: req.params?.protocolVersion || "2025-06-18",
|
|
489
|
+
capabilities: { tools: {} },
|
|
490
|
+
serverInfo: { name: "thing", version: "0.3.0" }
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (req.method === "tools/list") return { tools: MCP_TOOLS };
|
|
494
|
+
if (req.method === "ping") return {};
|
|
495
|
+
if (req.method === "tools/call") {
|
|
496
|
+
const { name, arguments: args = {} } = req.params || {};
|
|
497
|
+
try {
|
|
498
|
+
const result = await mcpTool(state, parsed, name, args);
|
|
499
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
500
|
+
} catch (error) {
|
|
501
|
+
// Tool failures are results, not protocol errors, per MCP.
|
|
502
|
+
return { content: [{ type: "text", text: error.message }], isError: true };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
throw new CliError(`Method not found: ${req.method}`);
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
let buffer = "";
|
|
509
|
+
for await (const chunk of io.stdin || process.stdin) {
|
|
510
|
+
buffer += chunk.toString();
|
|
511
|
+
let newline;
|
|
512
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
513
|
+
const line = buffer.slice(0, newline).trim();
|
|
514
|
+
buffer = buffer.slice(newline + 1);
|
|
515
|
+
if (!line) continue;
|
|
516
|
+
let request;
|
|
517
|
+
try {
|
|
518
|
+
request = JSON.parse(line);
|
|
519
|
+
} catch {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (request.id === undefined || request.id === null) continue; // notification
|
|
523
|
+
try {
|
|
524
|
+
respond(request.id, { result: await handle(request) });
|
|
525
|
+
} catch (error) {
|
|
526
|
+
respond(request.id, { error: { code: -32601, message: error.message } });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
391
532
|
function usage() {
|
|
392
533
|
return `Usage: thing <command> [options]
|
|
393
534
|
|
|
@@ -397,11 +538,12 @@ Commands:
|
|
|
397
538
|
whoami
|
|
398
539
|
use <team> [project]
|
|
399
540
|
default [team] [--clear]
|
|
400
|
-
push <file.html|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
|
|
541
|
+
push <file.html|.md|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
|
|
401
542
|
list
|
|
402
543
|
versions <name>
|
|
403
544
|
rollback <name> <version>
|
|
404
545
|
open <name>
|
|
546
|
+
mcp # Model Context Protocol server over stdio
|
|
405
547
|
|
|
406
548
|
Global options:
|
|
407
549
|
--json
|
|
@@ -443,6 +585,9 @@ export async function run(argv = process.argv.slice(2), io = { stdout: process.s
|
|
|
443
585
|
case "open":
|
|
444
586
|
await openCommand(parsed, state, io);
|
|
445
587
|
break;
|
|
588
|
+
case "mcp":
|
|
589
|
+
await mcp(parsed, state, io);
|
|
590
|
+
break;
|
|
446
591
|
case "-h":
|
|
447
592
|
case "--help":
|
|
448
593
|
case undefined:
|