The shared core of the CLIs built on Thinger.io servers — thinger
(@thinger-io/cli) and
ThinRemote's thinr — and of the MCP servers they expose.
It holds everything that is not specific to a product:
- API client (
api) — axios with the active profile's server and token on every request, refresh on 401, self-signed TLS for localhost. - Auth (
auth,device-login) — username/password (mints a permanent API token; asks for the authenticator code when the account has one), pasted token (its account read from it), OAuth device flow. Without a terminal the browser login comes in two steps:login --oauth(orprofile add <name> --server <s> --oauth) prints the link to approve, and--profile <name> login --waitsaves the session once approved; agents do the same with the MCPprofileactionsloginandstatus. Passwords and tokens are never taken that way. - Profiles (
config) — several servers per CLI, selected by--profile,<PREFIX>_PROFILEor the saved default, and scoped per call (withActiveProfile) so concurrent MCP calls don't mix servers. Each one carries a description of what it is for, which agents read to pick one. A profile can act as another account with the session of an admin profile (profile add <name> --credentials-from <profile> --impersonate <user>): the tokens live and are renewed only in the profile that holds them. In CI,<PREFIX>_SERVERand<PREFIX>_TOKENstand for a profile without any file. - REST helpers —
devices,property,resource,bucket,product,profile(product profiles),tokens,alarms,storage, with every account path built inpaths. - Generic commands (
cli,commands/*) — login, logout, profile, device, product, bucket, dashboard, mcp;apifor any request to the server; and, generated from the same catalog the MCP serves, the commands of every resource type (list,get,create,update,delete,clone,describe,seton properties, and its actions), run by the handlers and hooks of theresource_*tools, so a type added to the catalog reaches the CLI and the MCP at once. Commands over the data and files complete them:bucket writeandexport,storage file list|upload|download|delete,device send,overviewandresource list|call,product apply,product profile api call,plugin call,doc list|get. A command the CLI registers itself keeps its name. They all follow one grammar (below). - Dashboard apps (
dashboard,dashboard-init,dashboard-dev,dashboard-sdk) — create an app from a template of@thinger-io/dashboard-sdk, develop it on its own dev server (a link with a temporary token opens the local app against the server), and publish its build to a public storage that dashboards point at, with a warning when its SDK needs a newer server or is not the latest. - Documentation (
docs,docs/) — the guides for agents: the platform's concepts, product code and server administration (shipped here, marking what needs a given server version), the dashboard SDK's reference and types (from its package: the project's version, else the CLI's), and the CLI's own. - MCP (
mcp/*) — the stdio server and its eightresource_*tools over a catalog of the server's resource types, plus the types and actions every CLI serves next to it (bucket data, storage files, device resources, the guides, the profiles, dashboard apps…) and those of the CLI; the guides are MCP resources too.mcp --read-onlyserves only what reads. - OpenAPI (
openapi) — the server's own API description, cached per server and version, with a bundled copy as fallback. - Update check (
update-check) — once a day, the latest release of the CLI's npm package (npmPackagein the identity). After a command run in a terminal, one line says when a newer one is out; the MCP server tells the agent, in its instructions or with the first answer of the session, so it can suggest the update. Off in CI and with<PREFIX>_NO_UPDATE_CHECK=1(orNO_UPDATE_NOTIFIER=1).
The core knows nothing about the product it runs for. A CLI defines its identity once, before anything else, and runs:
import { defineIdentity } from '@thinger-io/cli-core/identity';
import { runCli } from '@thinger-io/cli-core/cli';
defineIdentity({
name: 'acme', // binary name, used in hints
displayName: 'Acme IoT',
description: 'CLI for Acme IoT servers',
configDir: 'acme-cli', // ~/.config/acme-cli/config.json
envPrefix: 'ACME', // ACME_PROFILE, ACME_INSECURE, ACME_SERVER + ACME_TOKEN
token: { id: 'acme_cli', name: 'Acme CLI' }, // minted by the password login
oauth: { clientId: null }, // OAuth device-flow client, if the platform ships one
defaultServer: null, // suggested by the login prompt
mcp: { serverName: 'acme', toolPrefix: 'acme' }, // tools become acme_resource_list, …
productTemplate: {}, // optional defaults for new products
});
await runCli({ version: '1.0.0' });Each CLI keeps its own profiles, environment variables and MCP tool names, so two of them can run side by side without one operating on the other's servers.
runCli takes:
register(program, { device, product, bucket, dashboard })— add top-level commands, or subcommands inside the generic groups.onNoCommand(program)— what to do when called without arguments (default: help).resourceHooks— the CLI's hooks on catalog types ({ product: { afterDelete } }), run after the core's: seemcp/resource-hooks.js. Adescribehook adds to whatresource_describeshows of the type.resourceTypes— resource types of the CLI's own platform, served by the sameresource_*tools as the server's: instead of a REST path each has functions for the verbs it supports (list,get,create,update,delete), itsparentandid(the keys ofids), aschema,notesandexamplesforresource_describe, andactionsforresource_call(on: 'item','collection'or'either';readOnly,confirm,bodyRequired). The core checks the ids, the filter and the examples, and searches and pages whatlistreturns. SeeLocalTypeinmcp/tools-resource.js.resourceActions— actions the CLI adds to the server's types ({ device: { reboot: { description, run } } }), run withresource_calllike the server's. A type or action that clashes with the catalog or the core fails at startup.mcpInstructions— server-level guidance sent to MCP clients. Texts refer to an operation as{op:<type>.<verb|action>}({op:doc.get}), checked when the tools are built.argumentHelp—{ byCommand, byName }: help of the positional arguments of the CLI's own commands ({ 'device logs': { unit: '…' } }).
buildProgram(opts) builds the same command tree without running it, for
tests.
Every CLI on the core reads the same way:
<cli> <noun> [<noun>…] <verb> [<parent ids>…] [<id>] [<extra>] [options]
- Nouns are singular, kebab case, nested under their parent:
device property,device bucket tag,product profile api, at most three deep; server types go underadmin. Groups run nothing themselves. - Verbs form a closed set: for records,
list,get,create,update,delete,clone,describeandset(the value of a property); for data,read,write,send,export,import,overview,call,uploadanddownload; the catalog's actions in kebab case (test-source,replace-all); and the localinit,dev,publish,login,logout,use,currentandadd. - Positionals are ids, outermost parent first, named after their noun:
device property get <device> <property>. - Options keep one meaning:
-j --json,-o --output,-y --yes,-s --set,-q --query,-X --method,-f --field;-nand-dare name and description on create and update. Bodies come from--from <file|->and-s key=value; lists take--search,--filter,--countand--index; deletes ask unless-y.
checkGrammar(program, { exceptions }) (commands/grammar) checks a whole
tree and returns what breaks it; each CLI's tests run it over its
buildProgram, stating what it adds (root, verbs), its short options
with another meaning (shorts) and the rules a command does not follow
(skip).
The MCP serves eight tools — resource_describe, resource_list,
resource_get, resource_create, resource_update, resource_delete,
resource_clone, resource_call (actions that are not CRUD) — that take a
type from the catalog (lib/mcp/catalog.js), the core's types
(lib/mcp/core-types.js) or the CLI's, and ids: one key per level, named
as the server's REST path names it ({"device": "d1", "bucket": "b1"},
{"product": "p1", "resource": "temp"}, {"rule": "high_temp"}). A record
the tools answer carries its ids, ready to pass back. The catalog is a
reviewed table, never discovered at run time: each type has its path, API
version, parent, id and the verbs it allows, with the server's operationId
for each (the tests check them against the bundled OpenAPI document).
resource_describe reads the request schemas and examples from the OpenAPI
document of the server the profile points at, and shows each type's ids,
actions, permissions and ready-to-send examples (checked by the tests).
Actions that change a lot at once (replace_all, a host's
update_settings, dashboard publish with prune…) answer a plan first and
run with confirm: true; MCP clients that support elicitation are asked
instead. mcp --read-only (or <PREFIX>_MCP_READ_ONLY=1) serves only
describe, list, get and the read-only actions.
To draft catalog entries after an API change:
curl -s https://<server>/v1/server/swagger.json -o openapi/swagger.json
node scripts/catalog-draft.jsModules are imported by path: @thinger-io/cli-core/api,
@thinger-io/cli-core/mcp/registry, …
npm install
npm test # under a made-up "acme" identity
npm run lint
npm run typecheckMIT