@matrice-technologies/served-tool
v0.2.1
Published
An app that serves its own CLI and skill to a Claude Code session, and mints the session's key through a device flow. The parts every such app shares.
Readme
served-tool
The parts every app shares that serves its own CLI and skill to a Claude Code
session and mints the session's key through a device flow: the CLI core, the
decisions of the device flow and the key store, and the build that stamps a
skill and two counts into one served file. The design is written up as "the
served tool" in truva-mapper-v2/docs/served-tool.md; what this package fixes
is contract.md.
No runtime dependency. Node 20 or later.
Four entry points
@matrice-technologies/served-tool/cli:createTool({ identity, commands, options, version, skillFiles, help })answersmain(argv, deps). The core'slogin,whoami,version,upgrade,skill --installandhelpcome with it; a project adds its own commands, each{ usage, summary, run({ deps, values, positionals, identity, version }) }.api(deps, cfg, path, opts)andloadConfig(deps, identity)are what a command reaches the app with;runAsMain(import.meta.url, main)is the entry's last line.@matrice-technologies/served-tool/server:openLogin,claimLogin,lookUpLogin,approveLogin,verifyKey,ownKeys,revokeOwnKeytake a store and answer an outcome; a route turns the outcome into HTTP in its own envelope.memoryStore()is the interface implemented in memory. Beside them: the device arithmetic and the limiter,originOf, and the readers for the four served files.@matrice-technologies/served-tool/buildand theserved-toolbin:served-tool build --entry cli/src/main.mjs --out cli/<tool>.mjs --skill cli/skill --ledger cli/version.json --repo-skill ../.claude/skills/<skill>bundles the entry through the project's esbuild and stamps the skill and the counts.served-tool embedstamps a script that already carries the spans.@matrice-technologies/served-tool/guides: the skill's files as pages an app draws.parseMarkdown(source)answers blocks (headings, paragraphs, code, lists, tables, quotes, figures) withtitleOf,bodyUnderTitle,headingsOf,plainTextandmarkdownOf;skillGuides(files, { labels, order, base })makes the served files guides, andguideTarget(target, pages, current, { href, base })makes a link between them an address on the app, or null for a file the app does not serve. Pure, with types.
A project's entry, in outline
import { createTool, api, loadConfig, runAsMain, EXIT } from "@matrice-technologies/served-tool/cli";
const SKILL_FILES = __SERVED_TOOL_SKILL_FILES__;
const VERSION = __SERVED_TOOL_VERSION__;
const tool = createTool({
identity: { tool: "my-tool", skill: "my-skill", app: "My App" },
version: VERSION,
skillFiles: SKILL_FILES,
selfUrl: import.meta.url,
commands: {
things: {
usage: "my-tool things [text]",
summary: "the things in the app",
run: async ({ deps, identity, positionals }) => {
const cfg = loadConfig(deps, identity);
const answer = await api(deps, cfg, `${identity.api}/things`);
deps.stdout(`${JSON.stringify(answer, null, 2)}\n`);
return EXIT.ok;
},
},
},
});
export const { main } = tool;
export { SKILL_FILES, VERSION };
await runAsMain(import.meta.url, main);runAsMain runs main when Node was started on the entry, directly or
through a link, and ends by setting process.exitCode rather than calling
process.exit. On macOS a write to a pipe is asynchronous, and
process.exit(await main(...)) dropped what the pipe had not yet taken: a
long --json answer piped to jq stopped at 64 KiB. A reader that stops
early, as head does, ends the tool quietly.
Choices for a tool that existed before it was served
Each is off unless the identity or the command line asks for it, and a tool that asks for none behaves as 0.1 did.
Published to npm as well. Name the package, and upgrade leaves a copy
npm installed to npm: it still takes the skill, and prints the command that
takes the tool.
identity: { tool: "fixit", skill: "fixit", npmPackage: "@matrice-technologies/fixit-cli" }$ fixit upgrade
updated the fixit skill (4 to 5) /home/alex/.claude/skills/fixit
The skill changed. Read /home/alex/.claude/skills/fixit/SKILL.md again and follow that.
https://fixit.example serves tool 7 and this one is 6, installed by npm as @matrice-technologies/fixit-cli in /usr/local/lib/node_modules/@matrice-technologies/fixit-cli, so npm upgrades it.
Run: npm install -g @matrice-technologies/fixit-cli@latestThe names its users already have. Their variables win over
<ENV>_URL and <ENV>_KEY, and their old config file is read while the new
one does not exist. Neither is written.
identity: {
tool: "fixit",
skill: "fixit",
envNames: { url: "FIXIT_HOST", key: "FIXIT_TOKEN" },
legacyConfig: { path: ".fixit/config.json", url: "host", key: "token" },
}One hosted app. With defaultUrl, login needs no address: it takes
the one given, then the address variable, then the default. Every other
command takes the address from the variable, then the config file, then the
default, so a CI job that sets only the key reaches the hosted app.
identity: { tool: "fixit", skill: "fixit", defaultUrl: "https://fixit.matricetechnologies.com" }A login in two commands. A session sees a command's output only when it
ends, so it opens the login, shows the person the link and the code, and then
waits for the approval. The pending login waits in
~/.config/<tool>/pending-login.json, as private as the config.
$ fixit login fixit.example --begin
open https://fixit.example/login/device
code ABCD-EFGH
...
$ fixit login --finishThe skill in one repository. skill --install --project [dir] writes it
into <dir>/.claude/skills/<skill>/, the working directory by default,
instead of ~/.claude/skills/<skill>/.
Tests
npm test runs the package's own: the device flow's properties against a
clock, the CLI core against a fake platform and a fake disk, the embed against
a temporary directory, the guides against skill files written in the test,
and an entry in its own process with its stdout a pipe.
docs/consolidation.md says why 0.2 exists.
