@thinger-io/cli-core 0.4.2 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +82 -31
  3. package/lib/bucket-export.js +91 -0
  4. package/lib/cli.js +106 -50
  5. package/lib/commands/_shared.js +89 -1
  6. package/lib/commands/api.js +90 -0
  7. package/lib/commands/arguments.js +62 -0
  8. package/lib/commands/bucket.js +52 -44
  9. package/lib/commands/dashboard.js +5 -2
  10. package/lib/commands/data.js +539 -0
  11. package/lib/commands/device.js +128 -241
  12. package/lib/commands/grammar.js +279 -0
  13. package/lib/commands/product.js +104 -214
  14. package/lib/commands/profile.js +1 -0
  15. package/lib/commands/resources.js +975 -0
  16. package/lib/dashboard-init.js +4 -3
  17. package/lib/dashboard.js +10 -2
  18. package/lib/mcp/catalog.js +301 -63
  19. package/lib/mcp/core-types.js +36 -0
  20. package/lib/mcp/helpers.js +23 -2
  21. package/lib/mcp/naming.js +20 -0
  22. package/lib/mcp/registry.js +18 -67
  23. package/lib/mcp/resource-hooks.js +19 -5
  24. package/lib/mcp/server.js +43 -11
  25. package/lib/mcp/tools-resource.js +1232 -330
  26. package/lib/mcp/types/bucket-data.js +151 -0
  27. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  28. package/lib/mcp/types/device-resource.js +117 -0
  29. package/lib/mcp/types/doc.js +35 -0
  30. package/lib/mcp/types/plugin-call.js +75 -0
  31. package/lib/mcp/types/product-api.js +44 -0
  32. package/lib/mcp/types/profile.js +37 -0
  33. package/lib/mcp/types/storage-file.js +170 -0
  34. package/lib/openapi.js +18 -0
  35. package/lib/product-apply.js +121 -0
  36. package/lib/raw-api.js +111 -0
  37. package/lib/storage.js +41 -13
  38. package/lib/tokens.js +16 -2
  39. package/package.json +1 -1
  40. package/lib/mcp/tools-buckets.js +0 -130
  41. package/lib/mcp/tools-devices.js +0 -134
  42. package/lib/mcp/tools-misc.js +0 -48
  43. package/lib/mcp/tools-plugins.js +0 -63
  44. package/lib/mcp/tools-products.js +0 -52
  45. package/lib/mcp/tools-storage.js +0 -173
package/CHANGELOG.md CHANGED
@@ -1,5 +1,200 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-10-21
4
+
5
+ One grammar for every command, noun first:
6
+ `<cli> <noun> [<noun>…] <verb> [<parent ids>…] [<id>] [options]`. Nouns
7
+ nest under their parent (`device property`, `product profile api`), verbs
8
+ come from a closed set, ids are positional and named after their noun,
9
+ and the options keep one meaning everywhere. Some commands of 0.4 change
10
+ form (see the table below); the old forms are gone, not aliased.
11
+
12
+ The MCP serves eight tools, `resource_describe|list|get|create|update|
13
+ delete|clone|call`, instead of 25: what had a tool of its own (bucket data,
14
+ storage files, device resources, the guides, the profiles, dashboard apps,
15
+ plugin and product API calls) is a type or an action of them, and every
16
+ record is addressed with `ids`, one key per level named as the server's
17
+ REST path names it. The old tool names and the `parent`/`id` arguments are
18
+ gone, not aliased (see the second table below).
19
+
20
+ ### Added
21
+
22
+ - MCP: eight tools for everything. Each takes a `type` and `ids`
23
+ (`{"device": "d1", "bucket": "b1"}`); the keys are the server's REST
24
+ placeholders (`type` for an asset type, `group` for an asset group,
25
+ `rule` and `instance` for alarms, `resource` for every product profile
26
+ category), so a body's id field and its `ids` key share a name and
27
+ `create` and `clone` need no mapping. Records answered by `list`, `get`
28
+ and `create` carry their `ids`, ready to pass back. A call with an
29
+ unknown argument is refused listing the ones the tool takes, and points
30
+ a misplaced key to `ids`, `filter` or `body` (an id in an action's body
31
+ too: `{"device": …}` goes in `ids`). An action that refuses its input
32
+ shows one of its examples.
33
+
34
+
35
+ - The core's types and actions, served by the same tools: `bucket_data`
36
+ (list reads, create writes), `storage_file` (`ids.file` is the path;
37
+ `body.local` uploads files and folders of this machine), `device_resource`
38
+ (list, and its `call` action), `doc` (the guides), `profile`; actions
39
+ `device.send`, `device.overview`, `product_profile_api.call`,
40
+ `plugin.call` and `dashboard.init|dev|publish`. A CLI adds its own the
41
+ same way (`resourceTypes`, `resourceActions`).
42
+ - `resource_describe`: without a type, the catalog with each type's ids and
43
+ ready-to-send examples; with one, its ids, actions, the permission each
44
+ verb needs, its schemas and examples (the tests check every example
45
+ against the tools). A `describe` resource hook adds to it, and hooks
46
+ get `ids` and `merge` (an update that patches) in their context.
47
+ - Actions that change a lot at once answer a plan and run with
48
+ `confirm: true`: `replace_all` of the product profile categories (a diff
49
+ of what it adds, changes and removes), a host's `update_settings` (a diff
50
+ of the settings) and `restart`, `plugin.remove|kill`, alarm rules'
51
+ `test_notification`, and `dashboard.publish` with `prune`. MCP clients
52
+ that support elicitation are asked instead. The CLI asks unless `-y`.
53
+ - `mcp --read-only` (or `<PREFIX>_MCP_READ_ONLY=1`): only describe, list,
54
+ get and the read-only actions are served.
55
+ - Tool annotations (read-only, destructive, idempotent) on the eight tools.
56
+ - `doc list` and `doc get <doc>`: the guides from the command line.
57
+ - `api <path>`: any request to the profile's server, as `gh api` does for
58
+ GitHub. `{user}` stands for the account (`--user` for another), `-X`
59
+ picks the method (GET, or POST with a body), `--from <file|->` and
60
+ `-s key=value` (dots nest) build the body, `-q key=value` the query, and
61
+ `-o <file>` saves the answer (binary answers are only written to a file
62
+ or a pipe). Only paths of the profile's server: a full URL is refused.
63
+ - Commands for every resource type, generated from the MCP catalog:
64
+ `list|get|create|update|delete|clone|describe` and the type's actions,
65
+ for its 47 types. A child type nests under its parent's command and takes
66
+ its ids first (`device bucket tag get <device> <bucket> <tag>`); the
67
+ product profile categories go under `product profile` (`api`, `bucket`,
68
+ `property`, `autoprovision`, `flow`, `code`), alarms under `alarm`
69
+ (`alarm rule`, `alarm instance`) and assets under `asset` (`asset type`,
70
+ `asset group`); server types go under
71
+ `admin` (`admin host restart <host>`). They run the handlers of the
72
+ `resource_*` tools, hooks included (generated device credentials, token
73
+ expiries such as "30d"). Bodies come from `--from <file|->` and
74
+ `-s key=value` (JSON values, dots nest); create, update and clone take
75
+ `-n/--name` and `-d/--description` on the types that have them, `update
76
+ --merge` patches, `--projects` sets the projects; `list` takes
77
+ `--search`, `--filter`, `--count` and `--index` and returns every record
78
+ unless `--count`; `get` takes `-f <dot.path>` (one field) and
79
+ `-o <file>`; actions take `-f`, `-o` and `-q`; `delete`,
80
+ `replace-all`, `plugin uninstall` and `admin host restart` ask unless
81
+ `-y`. With `-j` they print `{ ok, data }`. What they print speaks of
82
+ commands, not tools: types by their command (`device property`, not
83
+ `device_property`), `--index` for the next page, and `describe` lists
84
+ the type's commands and actions where the MCP lists the tools that run
85
+ them, its ids as the command takes them, the create, update and clone
86
+ bodies as tables of fields (nested ones by their dot path, one table per
87
+ alternative) with their examples pretty-printed, and the permission each
88
+ verb needs (`-j` keeps the MCP's text).
89
+ - `set <parents…> <property> <value>` on every property type (device,
90
+ product, plugin, asset type, asset group, project and account
91
+ properties): the value, parsed as JSON, created when missing.
92
+ - Commands over the MCP tools that are not CRUD: `bucket write` (a JSON
93
+ sample, an array or NDJSON), `bucket export` (create the export, wait for
94
+ it, download it, delete it from the server unless `--keep`), `storage
95
+ file list|upload|download|delete` (folders and binary files), `device
96
+ send` (data of an HTTP device), `device overview`, `device resource
97
+ list|call`, `product profile api call` (a product API resource for the
98
+ whole product), `product apply --from <file|->` (create or update a
99
+ product from the JSON of `product export`: record, each profile
100
+ category, services and product properties) and `plugin call`.
101
+ - `device list`, `product list` and `bucket list` take the list options
102
+ too, keeping their tables; `device create` and `product create` take
103
+ `--from`, `-s` and `--projects`, and their id may come from the body
104
+ (when the record is created but its projects cannot be set, the error
105
+ says so).
106
+ - `buildProgram(opts)`: the command tree `runCli` runs, for tests; and
107
+ `checkGrammar(program, { exceptions })` (`commands/grammar`), which checks
108
+ a tree against the grammar (names, verbs, positionals, options, help
109
+ groups, the catalog's commands) for the tests of every CLI.
110
+ - `runCli({ argumentHelp })`: help of the positional arguments of the CLI's
111
+ own commands. Every positional argument has a description in the help.
112
+ - The help groups commands the same way at every level: Resources,
113
+ Administration, Configuration, Integration and Operations at the root;
114
+ Records, Data, Actions and Subresources inside a noun.
115
+ - MCP tool results carry their structured data for these commands (under
116
+ a symbol: MCP clients see the same text as before).
117
+
118
+ ### Changed (breaking)
119
+
120
+ | 0.4 | 0.5 |
121
+ | -------------------------------------------- | ------------------------------------------------------------ |
122
+ | `device list <pattern>` | `device list --search <text>` (or `--filter name=<regex>`) |
123
+ | `device info <device>` | `device get <device>` |
124
+ | `device property <device>` | `device property list <device>` |
125
+ | `device property <device> <property> [-f p]` | `device property get <device> <property> [-f p]` |
126
+ | `device property-set <device> <prop> <json>` | `device property set <device> <property> <json>` |
127
+ | `device resource <device>` | `device resource list <device>` |
128
+ | `device resource <device> <res> -i k=v` | `device resource call <device> <resource> -s k=v` (`--from`) |
129
+ | `product info <product>` | `product get <product>` |
130
+ | `product property-list <product>` | `product property list <product>` |
131
+ | `product property-get <product> <prop>` | `product property get <product> <property>` |
132
+ | `product property-set <product> <prop> <v>` | `product property set <product> <property> <value>` |
133
+ | `product property-delete <product> <prop>` | `product property delete <product> <property> -y` |
134
+
135
+ - The outputs change with the commands: `device get` and `product get`
136
+ print the record as JSON (as `info` did); `device property get` and
137
+ `product property get` print the property record, with its value under
138
+ `value`, so `-f` paths start there (`-f value.setpoint` where 0.4 took
139
+ `-f setpoint`); `device property list` and
140
+ `product property list` print the property records (with `-j`, the
141
+ records instead of `{ product, properties: [names] }`); `product property
142
+ delete` asks unless `-y`. `device resource call` has no `-i/--input`: its
143
+ input comes from `--from` and `-s`, like every command that sends data.
144
+ - `register(program, groups)`'s groups are Resources at the root (they were
145
+ Operations), and `registerToolCommands(program)` is now
146
+ `registerDataCommands(program)` (`commands/data`) and no longer takes
147
+ them.
148
+ - The MCP tools (the CLI commands keep their forms and `-j` outputs; only
149
+ the help of some positionals is renamed: `asset type get <type>`,
150
+ `asset group get <group>`, `alarm rule get <rule>`, `alarm instance get
151
+ <instance>`, `product profile <category> get <product> <resource>`):
152
+
153
+ | 0.4 tool | 0.5 call |
154
+ | ---------------------------------------- | ------------------------------------------------------------------------------ |
155
+ | `resource_* parent "d1" id "b1"` | `resource_* ids {"device": "d1", "bucket": "b1"}` |
156
+ | `bucket_data_read bucket` | `resource_list type "bucket_data" ids {bucket}` (`filter` for the query) |
157
+ | `bucket_data_write bucket data` | `resource_create type "bucket_data" ids {bucket} body <sample or samples>` |
158
+ | `storage_file_list storage path` | `resource_list type "storage_file" ids {storage} filter {path}` |
159
+ | `storage_file_read storage path` | `resource_get type "storage_file" ids {storage, file}` |
160
+ | `storage_file_write storage path …` | `resource_create type "storage_file" ids {storage, file} body {content\|local}` |
161
+ | `storage_file_delete storage path` | `resource_delete type "storage_file" ids {storage, file}` |
162
+ | `device_resource_list device` | `resource_list type "device_resource" ids {device}` |
163
+ | `device_resource_call device resource` | `resource_call type "device_resource" action "call" ids {device, resource}` |
164
+ | `device_data_send device data` | `resource_call type "device" action "send" ids {device} body <data>` |
165
+ | `device_overview` | `resource_call type "device" action "overview"` |
166
+ | `product_resource_call product resource` | `resource_call type "product_profile_api" action "call" ids {product, resource}` |
167
+ | `plugin_call plugin path` | `resource_call type "plugin" action "call" ids {plugin} body {path, method}` |
168
+ | `doc_read id` | `resource_get type "doc" ids {doc}` (`resource_list type "doc"` lists them) |
169
+ | `profile_list` | `resource_list type "profile"` |
170
+ | `dashboard_init` / `_dev` / `_publish` | `resource_call type "dashboard" action "init"` / `"dev"` / `"publish"` |
171
+
172
+ - `runCli({ mcpTools })` is gone, with `mcp/registry`'s `genericTools` and
173
+ `mcpTools`: a CLI adds types and actions (`resourceTypes`,
174
+ `resourceActions`) instead of tools, and `buildTools(opts)` builds the
175
+ eight. Texts refer to operations as `{op:<type>.<verb|action>}`.
176
+ - `lib/mcp/tools-*.js` moved to `lib/mcp/types/` (as types and actions) and
177
+ `lib/commands/tools.js` to `lib/commands/data.js`.
178
+
179
+ ### Fixed
180
+
181
+ - Account tokens created with an expiry as a duration ("30d") through
182
+ `resource_create` (and `createToken`) got it in seconds: the server takes
183
+ unix milliseconds and refused it. A timestamp too small to be
184
+ milliseconds is still read as seconds. Device tokens keep seconds.
185
+ - Ids `.` and `..` are refused (`resource_*` tools too): the URL resolved
186
+ them to the parent, so deleting the property `..` of a device reached
187
+ the device.
188
+ - The storage helpers (storage files, thinr's product scripts)
189
+ escape each segment of a file path and refuse empty, `.` and `..`
190
+ segments: `a#b` reached the file `a`, `..` the storage. The plugin
191
+ `call` action also refuses `%2e%2e`.
192
+ - Listing a storage folder answered each file's path relative to that
193
+ folder: it is now the whole path, the one its `ids.file` reads.
194
+ - The help of `bucket read` and `device resource` printed some defaults
195
+
196
+ twice ("(default: 10) (default: 10)", "(default: {})").
197
+
3
198
  ## [0.4.2] - 2026-10-17
4
199
 
5
200
  ### Fixed
package/README.md CHANGED
@@ -19,7 +19,16 @@ It holds everything that is not specific to a product:
19
19
  `profile` (product profiles), `tokens`, `alarms`, `storage`, with every
20
20
  account path built in `paths`.
21
21
  - **Generic commands** (`cli`, `commands/*`) — login, logout, profile, device,
22
- product, bucket, dashboard, mcp.
22
+ product, bucket, dashboard, mcp; `api` for any request to the server; and,
23
+ generated from the same catalog the MCP serves, the commands of every
24
+ resource type (`list`, `get`, `create`, `update`, `delete`, `clone`,
25
+ `describe`, `set` on properties, and its actions), run by the handlers and
26
+ hooks of the `resource_*` tools, so a type added to the catalog reaches the
27
+ CLI and the MCP at once. Commands over the data and files complete them:
28
+ `bucket write` and `export`, `storage file list|upload|download|delete`,
29
+ `device send`, `overview` and `resource list|call`, `product apply`,
30
+ `product profile api call`, `plugin call`, `doc list|get`. A command the
31
+ CLI registers itself keeps its name. They all follow one grammar (below).
23
32
  - **Dashboard apps** (`dashboard`, `dashboard-init`, `dashboard-dev`,
24
33
  `dashboard-sdk`) — create an app from a template of
25
34
  `@thinger-io/dashboard-sdk`, develop it on its own dev server (a link with
@@ -31,11 +40,12 @@ It holds everything that is not specific to a product:
31
40
  here, marking what needs a given server version), the dashboard SDK's
32
41
  reference and types (from its package: the project's version, else the
33
42
  CLI's), and the CLI's own.
34
- - **MCP** (`mcp/*`) — the stdio server and 25 generic tools: eight
35
- `resource_*` tools over a catalog of the server's resource types, and the
36
- operations that are not CRUD (bucket data, device resources, storage
37
- files, dashboard development and publishing, the documentation…), plus that
38
- documentation as MCP resources.
43
+ - **MCP** (`mcp/*`) — the stdio server and its eight `resource_*` tools
44
+ over a catalog of the server's resource types, plus the types and actions
45
+ every CLI serves next to it (bucket data, storage files, device
46
+ resources, the guides, the profiles, dashboard apps…) and those of the
47
+ CLI; the guides are MCP resources too. `mcp --read-only` serves only what
48
+ reads.
39
49
  - **OpenAPI** (`openapi`) — the server's own API description, cached per
40
50
  server and version, with a bundled copy as fallback.
41
51
 
@@ -76,42 +86,83 @@ servers.
76
86
  or subcommands inside the generic groups.
77
87
  - `onNoCommand(program)` — what to do when called without arguments
78
88
  (default: help).
79
- - `mcpTools` — the CLI's own MCP tools, declared with bare names
80
- (`name: 'device_exec'`) and referring to others in descriptions as
81
- `{tool:resource_list}` or `{family:storage_file}`. Unknown references fail
82
- at startup. A tool flagged `replaces: true` swaps out the generic tool of
83
- the same name.
84
89
  - `resourceHooks` — the CLI's hooks on catalog types (`{ product: {
85
- afterDelete } }`), run after the core's: see `mcp/resource-hooks.js`.
90
+ afterDelete } }`), run after the core's: see `mcp/resource-hooks.js`. A
91
+ `describe` hook adds to what `resource_describe` shows of the type.
86
92
  - `resourceTypes` — resource types of the CLI's own platform, served by the
87
93
  same `resource_*` tools as the server's: instead of a REST path each has
88
94
  functions for the verbs it supports (`list`, `get`, `create`, `update`,
89
- `delete`), its `parent` and `id`, a `schema` and `notes` for
90
- `resource_describe`, and `actions` for `resource_call` (`on: 'item'`,
91
- `'collection'` or `'either'`). The core checks the parent and id, and
92
- searches and pages what `list` returns. See `LocalType` in
95
+ `delete`), its `parent` and `id` (the keys of `ids`), a `schema`, `notes`
96
+ and `examples` for `resource_describe`, and `actions` for `resource_call`
97
+ (`on: 'item'`, `'collection'` or `'either'`; `readOnly`, `confirm`,
98
+ `bodyRequired`). The core checks the ids, the filter and the examples,
99
+ and searches and pages what `list` returns. See `LocalType` in
93
100
  `mcp/tools-resource.js`.
94
101
  - `resourceActions` — actions the CLI adds to the server's types
95
102
  (`{ device: { reboot: { description, run } } }`), run with
96
103
  `resource_call` like the server's. A type or action that clashes with the
97
- catalog fails at startup.
98
- - `mcpInstructions` — server-level guidance sent to MCP clients.
104
+ catalog or the core fails at startup.
105
+ - `mcpInstructions` — server-level guidance sent to MCP clients. Texts
106
+ refer to an operation as `{op:<type>.<verb|action>}` (`{op:doc.get}`),
107
+ checked when the tools are built.
108
+ - `argumentHelp` — `{ byCommand, byName }`: help of the positional
109
+ arguments of the CLI's own commands (`{ 'device logs': { unit: '…' } }`).
110
+
111
+ `buildProgram(opts)` builds the same command tree without running it, for
112
+ tests.
113
+
114
+ ## Command grammar
115
+
116
+ Every CLI on the core reads the same way:
117
+
118
+ ```
119
+ <cli> <noun> [<noun>…] <verb> [<parent ids>…] [<id>] [<extra>] [options]
120
+ ```
121
+
122
+ - **Nouns** are singular, kebab case, nested under their parent:
123
+ `device property`, `device bucket tag`, `product profile api`, at most
124
+ three deep; server types go under `admin`. Groups run nothing themselves.
125
+ - **Verbs** form a closed set: for records, `list`, `get`, `create`,
126
+ `update`, `delete`, `clone`, `describe` and `set` (the value of a
127
+ property); for data, `read`, `write`, `send`, `export`, `import`,
128
+ `overview`, `call`, `upload` and `download`; the catalog's actions in
129
+ kebab case (`test-source`, `replace-all`); and the local `init`, `dev`,
130
+ `publish`, `login`, `logout`, `use`, `current` and `add`.
131
+ - **Positionals** are ids, outermost parent first, named after their noun:
132
+ `device property get <device> <property>`.
133
+ - **Options** keep one meaning: `-j --json`, `-o --output`, `-y --yes`,
134
+ `-s --set`, `-q --query`, `-X --method`, `-f --field`; `-n` and `-d` are
135
+ name and description on create and update. Bodies come from
136
+ `--from <file|->` and `-s key=value`; lists take `--search`, `--filter`,
137
+ `--count` and `--index`; deletes ask unless `-y`.
138
+
139
+ `checkGrammar(program, { exceptions })` (`commands/grammar`) checks a whole
140
+ tree and returns what breaks it; each CLI's tests run it over its
141
+ `buildProgram`, stating what it adds (`root`, `verbs`), its short options
142
+ with another meaning (`shorts`) and the rules a command does not follow
143
+ (`skip`).
99
144
 
100
145
  ## MCP tools
101
146
 
102
- Tool names follow `<object>[_<subobject>]_<verb>`, with the verbs list, get,
103
- describe, create, update, delete, clone, call, read, write, send.
104
-
105
- The server's collections are reached through eight generic tools —
106
- `resource_describe`, `resource_list`, `resource_get`, `resource_create`,
107
- `resource_update`, `resource_delete`, `resource_clone`, `resource_call`
108
- (actions that are not CRUD) — that take a `type`
109
- from the catalog (`lib/mcp/catalog.js`), a `parent` for child types and an
110
- `id`. The catalog is a reviewed table, never discovered at run time: each
111
- type has its path, API version, parent, id and the verbs it allows, with the
112
- server's operationId for each (the tests check them against the bundled
113
- OpenAPI document). `resource_describe` reads the request schemas and
114
- examples from the OpenAPI document of the server the profile points at.
147
+ The MCP serves eight tools — `resource_describe`, `resource_list`,
148
+ `resource_get`, `resource_create`, `resource_update`, `resource_delete`,
149
+ `resource_clone`, `resource_call` (actions that are not CRUD) — that take a
150
+ `type` from the catalog (`lib/mcp/catalog.js`), the core's types
151
+ (`lib/mcp/core-types.js`) or the CLI's, and `ids`: one key per level, named
152
+ as the server's REST path names it (`{"device": "d1", "bucket": "b1"}`,
153
+ `{"product": "p1", "resource": "temp"}`, `{"rule": "high_temp"}`). A record
154
+ the tools answer carries its `ids`, ready to pass back. The catalog is a
155
+ reviewed table, never discovered at run time: each type has its path, API
156
+ version, parent, id and the verbs it allows, with the server's operationId
157
+ for each (the tests check them against the bundled OpenAPI document).
158
+ `resource_describe` reads the request schemas and examples from the OpenAPI
159
+ document of the server the profile points at, and shows each type's ids,
160
+ actions, permissions and ready-to-send examples (checked by the tests).
161
+ Actions that change a lot at once (`replace_all`, a host's
162
+ `update_settings`, `dashboard publish` with prune…) answer a plan first and
163
+ run with `confirm: true`; MCP clients that support elicitation are asked
164
+ instead. `mcp --read-only` (or `<PREFIX>_MCP_READ_ONLY=1`) serves only
165
+ describe, list, get and the read-only actions.
115
166
 
116
167
  To draft catalog entries after an API change:
117
168
 
@@ -0,0 +1,91 @@
1
+ // @ts-check
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import api from './api.js';
4
+ import { TIMEOUTS } from './constants.js';
5
+ import { apiError, inputError } from './errors.js';
6
+ import { v1 } from './paths.js';
7
+ import { rawApiCall } from './raw-api.js';
8
+
9
+ /** States of an export record (buckets.cpp). */
10
+ const READY = 1;
11
+ const FAILED = 2;
12
+
13
+ /**
14
+ * Export a bucket's data to a file and download it: the server writes the
15
+ * file in the background and lists it among the bucket's exports when
16
+ * ready. The export is deleted from the server afterwards unless `keep`.
17
+ *
18
+ * @param {{
19
+ * bucket: string,
20
+ * user?: string | null,
21
+ * format?: 'csv' | 'json' | 'arff',
22
+ * timestamps?: 'iso' | 'seconds' | 'milliseconds',
23
+ * minTs?: number,
24
+ * maxTs?: number,
25
+ * keep?: boolean,
26
+ * pollMs?: number,
27
+ * timeoutMs?: number,
28
+ * }} opts
29
+ * @returns {Promise<{ file: string, data: Buffer | string }>}
30
+ */
31
+ export async function exportBucket({
32
+ bucket,
33
+ user,
34
+ format = 'csv',
35
+ timestamps = 'iso',
36
+ minTs,
37
+ maxTs,
38
+ keep = false,
39
+ pollMs = 1000,
40
+ timeoutMs = TIMEOUTS.API_LONG_MS,
41
+ }) {
42
+ if (!['csv', 'json', 'arff'].includes(format)) throw inputError('format: csv, json or arff');
43
+ if (!['iso', 'seconds', 'milliseconds'].includes(timestamps)) {
44
+ throw inputError('timestamps: iso, seconds or milliseconds');
45
+ }
46
+ const base = `${v1(user)}/buckets/${encodeURIComponent(bucket)}/exports`;
47
+ let file;
48
+ try {
49
+ const res = await api.post(base, {
50
+ data_type: format,
51
+ timestamp_format: timestamps,
52
+ ...(minTs !== undefined ? { min_ts: minTs } : {}),
53
+ ...(maxTs !== undefined ? { max_ts: maxTs } : {}),
54
+ });
55
+ file = res.data?.file;
56
+ } catch (e) {
57
+ throw apiError(e, { notFound: `Bucket not found: ${bucket}` });
58
+ }
59
+ if (!file) throw new Error('the server did not name the export file');
60
+
61
+ const deadline = Date.now() + timeoutMs;
62
+ for (;;) {
63
+ let state;
64
+ try {
65
+ const res = await api.get(base);
66
+ state = (Array.isArray(res.data) ? res.data : []).find((e) => e.file === file)?.state;
67
+ } catch (e) {
68
+ throw apiError(e);
69
+ }
70
+ if (state === READY) break;
71
+ if (state === FAILED) throw new Error(`the server could not export ${bucket}`);
72
+ if (Date.now() > deadline) {
73
+ throw new Error(
74
+ `the export ${file} is not ready yet: find it later with bucket export-file list`,
75
+ );
76
+ }
77
+ await sleep(pollMs);
78
+ }
79
+
80
+ const res = await rawApiCall({ path: `${base}/${encodeURIComponent(file)}` });
81
+ // The bytes as the server wrote them: parsing would round numbers.
82
+ const data = res.raw;
83
+ if (!keep) {
84
+ try {
85
+ await api.delete(`${base}/${encodeURIComponent(file)}`);
86
+ } catch {
87
+ // The data is downloaded: a leftover file on the server is no failure.
88
+ }
89
+ }
90
+ return { file, data };
91
+ }
package/lib/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // @ts-check
2
2
  import { Command } from 'commander';
3
3
  import { setActiveProfile } from './config.js';
4
- import { identity } from './identity.js';
4
+ import { env, identity } from './identity.js';
5
+
5
6
  import { classifyError, detectJsonModeFromArgv } from './output.js';
6
7
  import { startMCPServer } from './mcp/server.js';
7
8
  import { registerAuthCommands } from './commands/auth.js';
@@ -10,66 +11,82 @@ import { registerDeviceCommands } from './commands/device.js';
10
11
  import { registerProductCommands } from './commands/product.js';
11
12
  import { registerBucketCommands } from './commands/bucket.js';
12
13
  import { registerDashboardCommands } from './commands/dashboard.js';
14
+ import { registerApiCommand } from './commands/api.js';
15
+ import { registerResourceCommands } from './commands/resources.js';
16
+ import { registerDataCommands } from './commands/data.js';
17
+ import { describeArguments } from './commands/arguments.js';
18
+ import { NOUN_GROUPS, ROOT_GROUPS } from './commands/grammar.js';
19
+
20
+ /** Verbs of the records, in the order the help shows them. */
21
+ const RECORD_VERBS = ['list', 'get', 'create', 'update', 'delete', 'clone', 'set', 'describe'];
22
+
23
+ /**
24
+ * Sort the commands of `cmd` (and below) by help group, records by verb,
25
+ * keeping the order they were added in otherwise: the hand-written ones
26
+ * and the generated ones of a group come together.
27
+ *
28
+ * @param {Command} cmd
29
+ * @param {string[]} groups
30
+ */
31
+ function orderHelp(cmd, groups) {
32
+ const rank = (c) => {
33
+ const i = groups.indexOf(c.helpGroup());
34
+ // Other verbs of the records (thinr's `logs presets`) go after these.
35
+ const verb = RECORD_VERBS.indexOf(c.name());
36
+ const at = c.helpGroup() === 'Records:' ? (verb === -1 ? RECORD_VERBS.length : verb) : 0;
37
+ return (i === -1 ? groups.length : i) * 100 + at;
38
+ };
39
+ // `commands` is typed readonly, but it is a plain array the help reads in order.
40
+ /** @type {Command[]} */ (cmd.commands).sort((a, b) => rank(a) - rank(b));
41
+ for (const sub of cmd.commands) orderHelp(sub, NOUN_GROUPS);
42
+ }
43
+
44
+ /**
45
+ * @typedef {object} CliOptions
46
+ * @property {string} version
47
+ * @property {(program: Command, groups: { device: Command, product: Command, bucket: Command, dashboard: Command }) => void} [register]
48
+ * Adds the CLI's own commands, at the top level or inside the generic
49
+ * groups.
50
+ * @property {(program: Command) => Promise<void> | void} [onNoCommand]
51
+ * Runs when the CLI is called without arguments (default: print the help).
52
+ * @property {string} [mcpInstructions]
53
+ * @property {Record<string, import('./mcp/resource-hooks.js').ResourceHooks>} [resourceHooks]
54
+ * The CLI's hooks on the catalog types the generic `resource_*` tools
55
+ * (and the generated commands) serve.
56
+ * @property {Record<string, import('./mcp/tools-resource.js').LocalType>} [resourceTypes]
57
+ * Types the CLI adds to that catalog (its platform's own things).
58
+ * @property {Record<string, Record<string, import('./mcp/tools-resource.js').LocalAction>>} [resourceActions]
59
+ * Actions it adds to the catalog's types and its own.
60
+ * @property {Parameters<typeof import('./docs.js').defineCliDocs>[0]} [docs]
61
+ * Guides the CLI ships, served as type `doc` of the resource tools and as
62
+ * MCP resources next to the server's documentation.
63
+ * @property {Parameters<typeof describeArguments>[1]} [argumentHelp]
64
+ * Help of the positional arguments of the CLI's own commands.
65
+ */
13
66
 
14
67
  /**
15
- * Build and run a CLI on top of the core: global options, the generic
16
- * commands (login, logout, profile, device, product, bucket, dashboard) and `mcp`.
17
- * The identity must be defined before calling it.
68
+ * The command tree of a CLI on top of the core: global options, the
69
+ * commands written by hand (login, logout, profile, device, product,
70
+ * bucket, dashboard, api), the CLI's own (`register`), the commands over
71
+ * the MCP tools, the generated resource commands and `mcp`. The identity
72
+ * must be defined before calling it.
18
73
  *
19
- * @param {{
20
- * version: string,
21
- * argv?: string[],
22
- * register?: (program: Command, groups: { device: Command, product: Command, bucket: Command, dashboard: Command }) => void,
23
- * onNoCommand?: (program: Command) => Promise<void> | void,
24
- * mcpTools?: Array<object>,
25
- * mcpInstructions?: string,
26
- * resourceHooks?: Record<string, import('./mcp/resource-hooks.js').ResourceHooks>,
27
- * resourceTypes?: Record<string, import('./mcp/tools-resource.js').LocalType>,
28
- * resourceActions?: Record<string, Record<string, import('./mcp/tools-resource.js').LocalAction>>,
29
- * docs?: Parameters<typeof import('./docs.js').defineCliDocs>[0],
30
- * }} opts
31
- * `register` adds the CLI's own commands, at the top level or inside the
32
- * generic groups. `onNoCommand` runs when the CLI is called without
33
- * arguments (default: print the help). `mcpTools` are the CLI's own MCP
34
- * tools (see buildTools), and `resourceHooks` its hooks on the
35
- * catalog types the generic `resource_*` tools serve; `resourceTypes` and
36
- * `resourceActions` are types and actions it adds to that catalog (its
37
- * platform's own things, served by the same tools). `docs` are guides
38
- * the CLI ships, served by `doc_read` and as MCP resources next to the
39
- * server's documentation.
74
+ * @param {CliOptions & { strict?: boolean }} opts
75
+ * `strict` (tests): a generated group a CLI's command takes the place of
76
+ * throws instead of being left out.
40
77
  */
41
- export async function runCli({
78
+ export function buildProgram({
42
79
  version,
43
- argv = process.argv,
44
80
  register,
45
- onNoCommand,
46
- mcpTools = [],
47
81
  mcpInstructions,
48
82
  resourceHooks,
49
83
  resourceTypes,
50
84
  resourceActions,
51
85
  docs,
86
+ argumentHelp,
87
+ strict = false,
52
88
  }) {
53
89
  const id = identity();
54
-
55
- // Pick up --profile early so subsequent config reads use the right one.
56
- // Commander parses options later, but the config is touched before that.
57
- for (let i = 2; i < argv.length; i++) {
58
- const a = argv[i];
59
- if (a === '--') break;
60
- if (a === '--profile' && argv[i + 1]) {
61
- setActiveProfile(argv[i + 1]);
62
- break;
63
- }
64
- if (a.startsWith('--profile=')) {
65
- setActiveProfile(a.slice('--profile='.length));
66
- break;
67
- }
68
- }
69
-
70
- // Set JSON mode before any command runs so spinners/logs can opt out.
71
- detectJsonModeFromArgv(argv);
72
-
73
90
  const program = new Command();
74
91
  program
75
92
  .name(id.name)
@@ -86,17 +103,26 @@ export async function runCli({
86
103
  };
87
104
  registerAuthCommands(program);
88
105
  registerProfileCommands(program);
106
+ registerApiCommand(program);
89
107
  register?.(program, groups);
108
+ // After the CLI's own: a command it registers keeps its name.
109
+ registerDataCommands(program);
110
+ registerResourceCommands(program, { hooks: resourceHooks, strict });
111
+ describeArguments(program, argumentHelp);
90
112
 
91
113
  program
92
114
  .command('mcp')
93
115
  .helpGroup('Integration:')
94
116
  .description('Start the MCP server (stdio) for AI tool integration')
95
- .action(async () => {
117
+ .option(
118
+ '--read-only',
119
+ `Serve only the tools that read: describe, list, get and the read-only actions (or set ${identity().envPrefix}_MCP_READ_ONLY=1)`,
120
+ )
121
+ .action(async (opts) => {
96
122
  try {
97
123
  await startMCPServer({
98
124
  version,
99
- tools: mcpTools,
125
+ readOnly: !!opts.readOnly || /^(1|true|yes)$/i.test(env('MCP_READ_ONLY') ?? ''),
100
126
  instructions: mcpInstructions,
101
127
  resourceHooks,
102
128
  resourceTypes,
@@ -109,6 +135,36 @@ export async function runCli({
109
135
  }
110
136
  });
111
137
 
138
+ orderHelp(program, ROOT_GROUPS);
139
+ return program;
140
+ }
141
+
142
+ /**
143
+ * Build and run a CLI on top of the core (see buildProgram). The identity
144
+ * must be defined before calling it.
145
+ *
146
+ * @param {CliOptions & { argv?: string[] }} opts
147
+ */
148
+ export async function runCli({ argv = process.argv, onNoCommand, ...opts }) {
149
+ // Pick up --profile early so subsequent config reads use the right one.
150
+ // Commander parses options later, but the config is touched before that.
151
+ for (let i = 2; i < argv.length; i++) {
152
+ const a = argv[i];
153
+ if (a === '--') break;
154
+ if (a === '--profile' && argv[i + 1]) {
155
+ setActiveProfile(argv[i + 1]);
156
+ break;
157
+ }
158
+ if (a.startsWith('--profile=')) {
159
+ setActiveProfile(a.slice('--profile='.length));
160
+ break;
161
+ }
162
+ }
163
+
164
+ // Set JSON mode before any command runs so spinners/logs can opt out.
165
+ detectJsonModeFromArgv(argv);
166
+
167
+ const program = buildProgram(opts);
112
168
  if (argv.length <= 2) {
113
169
  if (onNoCommand) await onNoCommand(program);
114
170
  else program.help();