thingd-cli 0.1.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 CHANGED
@@ -1,50 +1,153 @@
1
1
  # thingd-cli
2
2
 
3
- Admin and operator CLI for `thingd`.
3
+ [![npm version](https://badge.fury.io/js/thingd-cli.svg)](https://www.npmjs.com/package/thingd-cli)
4
4
 
5
- This package provides the `thingd` binary. It uses the public
6
- `thingd` SDK for store access and can connect to a local store or
7
- a remote sidecar through `THINGD_URL`.
5
+ Admin and operator CLI for [thingd](https://www.npmjs.com/package/thingd).
8
6
 
9
- ## Build And Test
7
+ This package provides the `thingd` binary. It uses the public `thingd` SDK for store access and can connect to a local store or a remote sidecar through `THINGD_URL`.
8
+
9
+ ## Installation
10
+
11
+ Install globally via npm to use the `thingd` command anywhere:
10
12
 
11
13
  ```bash
12
- pnpm --filter thingd-cli build
13
- pnpm --filter thingd-cli test
14
+ npm install -g thingd-cli
14
15
  ```
15
16
 
16
- ## Usage
17
+ Or run it on the fly using `npx`:
17
18
 
18
19
  ```bash
19
- thingd status
20
- thingd objects put decisions rust-core --text "Use Rust for the core engine."
21
- thingd objects get decisions rust-core
22
- thingd events append project:thingd decision.made --text "Picked the CLI shape."
23
- thingd events list project:thingd
24
- thingd queues push embed --payload '{"object":"docs/readme"}'
25
- thingd queues claim embed
20
+ npx thingd-cli
26
21
  ```
27
22
 
28
- Remote sidecar mode:
23
+ ## Two Modes of Operation
24
+
25
+ The CLI is designed to support both human operators and automated scripts through two distinct modes:
26
+
27
+ 1. **Interactive Dashboard (TUI)**: Run `thingd` without any arguments to launch a beautiful, interactive terminal UI. Perfect for exploring collections, editing objects, and managing queues manually.
28
+ 2. **Non-Interactive CLI**: Run `thingd <command>` for one-off operations. This mode intentionally emits JSON so it is easy to use from shell scripts, CI/CD pipelines, and AI-agent workflows.
29
+
30
+ ---
31
+
32
+ ## 1. Interactive Dashboard (TUI)
29
33
 
34
+ To start the interactive dashboard, simply run:
30
35
  ```bash
31
- THINGD_URL=http://127.0.0.1:8757
32
- THINGD_AUTH_TOKEN=change-me
33
- thingd status
34
- thingd tools
36
+ thingd
35
37
  ```
36
38
 
37
- Common options:
39
+ ### Connection Mode
40
+ Upon launch, you will be prompted to select a connection driver:
41
+ - `Memory`: An ephemeral, in-memory instance (great for quick testing).
42
+ - `Native`: Connects directly to a local SQLite `.db` file (requires a file path).
43
+ - `Cloud`: Connects to a remote ThingD cluster (requires a URL and an optional Bearer Token).
44
+
45
+ ### Keyboard Shortcuts
46
+ Once connected, the TUI provides a dual-pane layout: a navigation sidebar on the left and a detailed viewer/editor on the right.
47
+
48
+ | Shortcut | Action |
49
+ |----------|--------|
50
+ | `↑` / `↓` (or `k` / `j`) | Navigate the tree vertically |
51
+ | `←` / `→` (or `h` / `l`) | Expand or collapse folders (Collections, Streams, Queues) |
52
+ | `Enter` | Expand node or open a folder |
53
+ | `c` | **Create** a new Object, Event, or push a Queue job |
54
+ | `e` | **Edit** an Object or retry a Queue job |
55
+ | `d` | **Delete** an Object or Ack/Resolve a Queue job |
56
+ | `/` (or `f`) | **Global Semantic Search** across the database |
57
+ | `i` | **Info**: View Connection Info & Cloud Cluster Status |
58
+ | `r` | **Refresh** data from the database |
59
+ | `s` | **Switch** database connections |
60
+ | `q` | **Quit** |
61
+
62
+ ---
63
+
64
+ ## 2. Non-Interactive CLI
65
+
66
+ For scripts and automation, you can bypass the TUI entirely.
67
+
68
+ ### Common Options
69
+ You can configure the connection via environment variables or CLI flags:
38
70
 
39
71
  ```txt
40
72
  --url <url> remote thingd URL. Defaults to THINGD_URL
41
- --auth-token <tok> remote bearer token. Defaults to THINGD_AUTH_TOKEN
42
- --path <path> local database path. Defaults to THINGD_PATH or :memory:
43
- --driver <driver> memory, native, or remote
44
- --pretty pretty-print JSON output
45
- --limit <n> result limit for search and list commands
73
+ --auth-token <tok> remote bearer token. Defaults to THINGD_AUTH_TOKEN
74
+ --path <path> local database path. Defaults to THINGD_PATH or :memory:
75
+ --driver <driver> memory, native, or cloud
76
+ --pretty pretty-print JSON output
77
+ --limit <n> result limit for search and list commands
78
+ ```
79
+
80
+ ### Claude Desktop & MCP Integration
81
+
82
+ The `thingd` CLI has a built-in `mcp` subcommand that exposes an MCP server over `stdio`. This allows Claude Desktop to securely connect to your `thingd` database.
83
+
84
+ **Connecting to a Local Database:**
85
+ To let Claude read and write to a local `thingd.db` file, add this to your `claude_desktop_config.json`:
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "thingd-local": {
91
+ "command": "thingd",
92
+ "args": ["mcp", "--path", "/absolute/path/to/thingd.db", "--driver", "native"]
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ **Bridging Claude to the Cloud:**
99
+ Claude Desktop natively only supports local `stdio` servers. However, `thingd mcp` can act as a bridge! If you provide a `--url`, the CLI will launch locally but seamlessly proxy all of Claude's requests to your remote cluster:
100
+
101
+ ```json
102
+ {
103
+ "mcpServers": {
104
+ "thingd-cloud": {
105
+ "command": "thingd",
106
+ "args": ["mcp", "--url", "https://your-thingd-cloud.com/mcp", "--auth-token", "your-secret-token"]
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ ### Command Reference
113
+
114
+ **Metrics & Discovery**
115
+ ```bash
116
+ thingd metrics # Get total counts (objects, events, activeJobs, deadJobs)
117
+ thingd status # Check cluster health (requires --url)
118
+ thingd tools # List available MCP tools (requires --url)
119
+ thingd collections list # List all collection names
120
+ thingd streams list # List all stream names
121
+ thingd queues list-all # List all queue names
122
+ ```
123
+
124
+ **Search**
125
+ ```bash
126
+ thingd search "my query" [--collection <name>] [--limit <n>]
127
+ ```
128
+
129
+ **Objects**
130
+ ```bash
131
+ thingd objects put decisions rust-core --text "Use Rust for the core engine."
132
+ thingd objects put decisions rust-core --data '{"status":"active"}'
133
+ thingd objects get decisions rust-core
134
+ thingd objects delete decisions rust-core
135
+ ```
136
+
137
+ **Events**
138
+ ```bash
139
+ thingd events append project:thingd decision.made --text "Picked the CLI shape."
140
+ thingd events list project:thingd
141
+ ```
142
+
143
+ **Queues**
144
+ ```bash
145
+ thingd queues push embed --payload '{"object":"docs/readme"}'
146
+ thingd queues claim embed
147
+ thingd queues ack embed <jobId>
148
+ thingd queues nack embed <jobId> --error "failed to fetch" --delay-ms 5000
149
+ thingd queues list embed
150
+ thingd queues dead embed
46
151
  ```
47
152
 
48
- The first CLI version intentionally emits JSON so it is easy to use from shell
49
- scripts, tests, and AI-agent workflows.
50
153
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { ThingD, type ThingDDriver } from "thingd";
2
3
  type CliEnv = Record<string, string | undefined>;
3
4
  type WritableLike = {
4
5
  write(chunk: string): void;
@@ -8,6 +9,26 @@ export type RunCliOptions = {
8
9
  stdout?: WritableLike;
9
10
  stderr?: WritableLike;
10
11
  };
12
+ type ParsedArgs = {
13
+ tokens: string[];
14
+ flags: Map<string, string[]>;
15
+ booleans: Set<string>;
16
+ };
17
+ export type CliContext = {
18
+ parsed: ParsedArgs;
19
+ env: CliEnv;
20
+ stdout: WritableLike;
21
+ stderr: WritableLike;
22
+ pretty: boolean;
23
+ };
24
+ export type ConnectionOptions = {
25
+ path: string;
26
+ driver?: ThingDDriver;
27
+ authToken?: string;
28
+ cloud: boolean;
29
+ };
11
30
  export declare function runCli(args?: string[], options?: RunCliOptions): Promise<number>;
31
+ export declare function withDb(context: CliContext, callback: (db: ThingD) => Promise<void>): Promise<void>;
32
+ export declare function resolveConnection(context: CliContext): ConnectionOptions;
12
33
  export {};
13
34
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAgBA,KAAK,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjD,KAAK,YAAY,GAAG;IAClB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB,CAAC;AAwDF,wBAAsB,MAAM,CAC1B,IAAI,WAAwB,EAC5B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,MAAM,CAAC,CA4BjB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAKA,OAAO,EAOL,MAAM,EACN,KAAK,YAAY,EAClB,MAAM,QAAQ,CAAC;AAIhB,KAAK,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjD,KAAK,YAAY,GAAG;IAClB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB,CAAC;AAEF,KAAK,UAAU,GAAG;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7B,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AAuCF,wBAAsB,MAAM,CAC1B,IAAI,WAAwB,EAC5B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,MAAM,CAAC,CAiCjB;AAmUD,wBAAsB,MAAM,CAC1B,OAAO,EAAE,UAAU,EACnB,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GACtC,OAAO,CAAC,IAAI,CAAC,CAcf;AA4BD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,UAAU,GAAG,iBAAiB,CAYxE"}
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ import { pathToFileURL } from "node:url";
3
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
4
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
5
  import { ThingD, } from "thingd";
6
+ import { runInteractiveCli } from "./interactive.js";
7
+ import { runMcp } from "./mcp.js";
6
8
  const HELP_TEXT = `thingd
7
9
 
8
10
  Admin and operator CLI for thingd.
@@ -17,18 +19,22 @@ Usage:
17
19
  thingd objects delete <collection> <id>
18
20
  thingd events append <stream> <type> [--text <text>] [--data '{"field":"value"}']
19
21
  thingd events list [stream] [--limit <n>]
22
+ thingd collections list
23
+ thingd streams list
24
+ thingd queues list-all
20
25
  thingd queues push <queue> --payload '{"key":"value"}'
21
26
  thingd queues claim <queue> [--lease-ms <ms>]
22
27
  thingd queues ack <queue> <jobId>
23
28
  thingd queues nack <queue> <jobId> [--error <message>] [--delay-ms <ms>]
24
29
  thingd queues list <queue> [--limit <n>]
25
30
  thingd queues dead <queue> [--limit <n>]
31
+ thingd metrics
26
32
 
27
33
  Options:
28
34
  --url <url> remote thingd URL. Defaults to THINGD_URL
29
35
  --auth-token <tok> remote bearer token. Defaults to THINGD_AUTH_TOKEN
30
36
  --path <path> local database path. Defaults to THINGD_PATH or :memory:
31
- --driver <driver> memory, native, or remote
37
+ --driver <driver> memory, native, or cloud
32
38
  --pretty pretty-print JSON output
33
39
  --limit <n> result limit for search and list commands
34
40
  -h, --help show help
@@ -44,10 +50,14 @@ export async function runCli(args = process.argv.slice(2), options = {}) {
44
50
  pretty: hasFlag(parsed, "pretty"),
45
51
  };
46
52
  try {
47
- if (hasFlag(parsed, "help") || hasFlag(parsed, "h") || parsed.tokens.length === 0) {
53
+ if (hasFlag(parsed, "help") || hasFlag(parsed, "h")) {
48
54
  writeText(context.stdout, HELP_TEXT);
49
55
  return 0;
50
56
  }
57
+ if (parsed.tokens.length === 0) {
58
+ await runInteractiveCli();
59
+ return 0;
60
+ }
51
61
  await runCommand(context);
52
62
  return 0;
53
63
  }
@@ -72,6 +82,10 @@ async function runCommand(context) {
72
82
  await runSearch(context);
73
83
  return;
74
84
  }
85
+ if (command === "mcp") {
86
+ await runMcp(context);
87
+ return;
88
+ }
75
89
  if (command === "objects") {
76
90
  await runObjects(context);
77
91
  return;
@@ -80,15 +94,27 @@ async function runCommand(context) {
80
94
  await runEvents(context);
81
95
  return;
82
96
  }
97
+ if (command === "collections") {
98
+ await runCollections(context);
99
+ return;
100
+ }
101
+ if (command === "streams") {
102
+ await runStreams(context);
103
+ return;
104
+ }
83
105
  if (command === "queues") {
84
106
  await runQueues(context);
85
107
  return;
86
108
  }
109
+ if (command === "metrics") {
110
+ await runMetrics(context);
111
+ return;
112
+ }
87
113
  throw new Error(`Unknown command: ${command}`);
88
114
  }
89
115
  async function runStatus(context) {
90
116
  const connection = resolveConnection(context);
91
- if (!connection.remote) {
117
+ if (!connection.cloud) {
92
118
  writeJson(context.stdout, {
93
119
  mode: "local",
94
120
  driver: connection.driver ?? "memory",
@@ -96,28 +122,28 @@ async function runStatus(context) {
96
122
  }, context.pretty);
97
123
  return;
98
124
  }
99
- const baseUrl = resolveRemoteBaseUrl(connection.path);
125
+ const baseUrl = resolveCloudBaseUrl(connection.path);
100
126
  const [health, cluster] = await Promise.all([
101
127
  fetchJson(new URL("/healthz", baseUrl), connection.authToken),
102
128
  fetchJson(new URL("/cluster/status", baseUrl), connection.authToken),
103
129
  ]);
104
130
  writeJson(context.stdout, {
105
- mode: "remote",
106
- url: resolveRemoteMcpUrl(connection.path),
131
+ mode: "cloud",
132
+ url: resolveCloudMcpUrl(connection.path),
107
133
  health,
108
134
  cluster,
109
135
  }, context.pretty);
110
136
  }
111
137
  async function runTools(context) {
112
138
  const connection = resolveConnection(context);
113
- if (!connection.remote) {
139
+ if (!connection.cloud) {
114
140
  throw new Error("tools requires --url or THINGD_URL because tools are exposed by the MCP runtime");
115
141
  }
116
142
  const client = new Client({
117
143
  name: "thingd-cli",
118
144
  version: "0.1.0",
119
145
  });
120
- const transport = new StreamableHTTPClientTransport(new URL(resolveRemoteMcpUrl(connection.path)), {
146
+ const transport = new StreamableHTTPClientTransport(new URL(resolveCloudMcpUrl(connection.path)), {
121
147
  requestInit: connection.authToken
122
148
  ? {
123
149
  headers: {
@@ -194,10 +220,50 @@ async function runEvents(context) {
194
220
  throw new Error(`Unknown events action: ${action}`);
195
221
  });
196
222
  }
223
+ async function runCollections(context) {
224
+ const action = requiredToken(context.parsed, 1, "collections action");
225
+ await withDb(context, async (db) => {
226
+ if (action === "list") {
227
+ writeJson(context.stdout, await db.listCollections(), context.pretty);
228
+ return;
229
+ }
230
+ throw new Error(`Unknown collections action: ${action}`);
231
+ });
232
+ }
233
+ async function runStreams(context) {
234
+ const action = requiredToken(context.parsed, 1, "streams action");
235
+ await withDb(context, async (db) => {
236
+ if (action === "list") {
237
+ writeJson(context.stdout, await db.listStreams(), context.pretty);
238
+ return;
239
+ }
240
+ throw new Error(`Unknown streams action: ${action}`);
241
+ });
242
+ }
243
+ async function runMetrics(context) {
244
+ await withDb(context, async (db) => {
245
+ const [objects, events, activeJobs, deadJobs] = await Promise.all([
246
+ db.countObjects(),
247
+ db.countEvents(),
248
+ db.countActiveJobs(),
249
+ db.countDeadJobs(),
250
+ ]);
251
+ writeJson(context.stdout, {
252
+ objects,
253
+ events,
254
+ activeJobs,
255
+ deadJobs,
256
+ }, context.pretty);
257
+ });
258
+ }
197
259
  async function runQueues(context) {
198
260
  const action = requiredToken(context.parsed, 1, "queues action");
199
- const queueName = requiredToken(context.parsed, 2, "queue");
200
261
  await withDb(context, async (db) => {
262
+ if (action === "list-all") {
263
+ writeJson(context.stdout, await db.listQueues(), context.pretty);
264
+ return;
265
+ }
266
+ const queueName = requiredToken(context.parsed, 2, "queue");
201
267
  const queue = db.queue(queueName);
202
268
  if (action === "push") {
203
269
  const payload = parseJsonRecord(requiredFlag(context.parsed, "payload"));
@@ -239,11 +305,11 @@ async function runQueues(context) {
239
305
  throw new Error(`Unknown queues action: ${action}`);
240
306
  });
241
307
  }
242
- async function withDb(context, callback) {
308
+ export async function withDb(context, callback) {
243
309
  const connection = resolveConnection(context);
244
310
  const db = await ThingD.open({
245
311
  path: connection.path,
246
- url: connection.remote ? connection.path : undefined,
312
+ url: connection.cloud ? connection.path : undefined,
247
313
  driver: connection.driver,
248
314
  authToken: connection.authToken,
249
315
  });
@@ -275,16 +341,16 @@ function buildMemoryEvent(parsed, type) {
275
341
  ...(text === undefined ? {} : { text }),
276
342
  };
277
343
  }
278
- function resolveConnection(context) {
344
+ export function resolveConnection(context) {
279
345
  const url = stringFlag(context.parsed, "url") ?? context.env.THINGD_URL;
280
346
  const path = url ?? stringFlag(context.parsed, "path") ?? context.env.THINGD_PATH ?? ":memory:";
281
- const remote = isRemotePath(path);
347
+ const cloud = isCloudPath(path);
282
348
  const driver = parseDriver(stringFlag(context.parsed, "driver") ?? context.env.THINGD_DRIVER);
283
349
  return {
284
350
  path,
285
- driver: driver ?? (remote ? "remote" : undefined),
351
+ driver: driver ?? (cloud ? "cloud" : undefined),
286
352
  authToken: stringFlag(context.parsed, "auth-token") ?? context.env.THINGD_AUTH_TOKEN,
287
- remote,
353
+ cloud,
288
354
  };
289
355
  }
290
356
  function parseArgs(args) {
@@ -366,7 +432,7 @@ function parseDriver(value) {
366
432
  if (value === undefined) {
367
433
  return undefined;
368
434
  }
369
- if (value === "memory" || value === "native" || value === "remote") {
435
+ if (value === "memory" || value === "native" || value === "cloud") {
370
436
  return value;
371
437
  }
372
438
  throw new Error(`Unsupported driver: ${value}`);
@@ -384,24 +450,24 @@ function compactOptions(options) {
384
450
  function limitItems(items, limit) {
385
451
  return limit === undefined ? items : items.slice(0, limit);
386
452
  }
387
- function isRemotePath(path) {
453
+ function isCloudPath(path) {
388
454
  return path.startsWith("http://") || path.startsWith("https://") || path.startsWith("thingd://");
389
455
  }
390
- function resolveRemoteMcpUrl(value) {
391
- const url = new URL(normalizeRemoteUrl(value));
456
+ function resolveCloudMcpUrl(value) {
457
+ const url = new URL(normalizeCloudUrl(value));
392
458
  if (url.pathname === "" || url.pathname === "/") {
393
459
  url.pathname = "/mcp";
394
460
  }
395
461
  return url.toString();
396
462
  }
397
- function resolveRemoteBaseUrl(value) {
398
- const url = new URL(normalizeRemoteUrl(value));
463
+ function resolveCloudBaseUrl(value) {
464
+ const url = new URL(normalizeCloudUrl(value));
399
465
  if (url.pathname === "/mcp") {
400
466
  url.pathname = "/";
401
467
  }
402
468
  return url.toString();
403
469
  }
404
- function normalizeRemoteUrl(value) {
470
+ function normalizeCloudUrl(value) {
405
471
  return value.startsWith("thingd://") ? `http://${value.slice("thingd://".length)}` : value;
406
472
  }
407
473
  async function fetchJson(url, authToken) {
@@ -0,0 +1,2 @@
1
+ export declare function runInteractiveCli(): Promise<void>;
2
+ //# sourceMappingURL=interactive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interactive.d.ts","sourceRoot":"","sources":["../src/interactive.ts"],"names":[],"mappings":"AA2jDA,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CA4BvD"}