diff --git a/docs/agent-guide.md b/docs/agent-guide.md index e178d31..d71ded3 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -30,6 +30,25 @@ temperature — with the three files already in the right relationship, and its own `README.md` covering the mechanics of copying it out. This guide covers the judgment the template cannot. +### Leave the repository readable by the next agent + +You are probably not the last agent to open this repository, and the next one +will not have this conversation. Run the pack once, in the repository root: + +```bash +miakapp agent-pack +``` + +It copies this guide to `.miakapp/agent-guide.md`, points `AGENTS.md` and +`CLAUDE.md` at it, and registers `miakapp mcp` in `.mcp.json` so the tools are +wired rather than described. It edits instead of replacing: prose outside the +`` markers is kept, other MCP servers are kept by name, +and running it twice changes nothing. + +If you are reading this file *as* `.miakapp/agent-guide.md`, someone already +ran it. Run it again after upgrading the CLI, and commit what changes — a guide +that contradicts the CLI installed beside it is worse than no guide. + ## 2. The division of responsibility **The coordinator authorizes everything.** The relay proves *who* is calling and @@ -286,6 +305,7 @@ writes no history, rewrites no source it did not generate, and invents no control-plane endpoint. The repository is the owner's. ```bash +miakapp agent-pack # once, per repository miakapp init --home --control-plane miakapp check miakapp publish --expected-generation @@ -375,6 +395,8 @@ that is the failure this design exists to prevent. ## Where to read further +- `packages/cli/README.md` — every command and every tool, including what the + pack writes and what it refuses to overwrite. - `templates/home/README.md` — the mechanics of the template itself. - `docs/rfcs/0001` (Miakapp-V3) — wire protocol: ownership, collisions, `4409`. - `docs/rfcs/0002` — component runtime and the staleness rule. **The broker is diff --git a/packages/cli/README.md b/packages/cli/README.md index 7396242..0a7c9ce 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,6 +56,7 @@ duplicate keys — is rejected with the offending line rather than guessed at. | Command | What it does | | --- | --- | | `init` | Writes `miakapp.yaml`. Never overwrites an existing one. | +| `agent-pack` | Offline. Installs the guide and the MCP wiring into a repository. | | `discover` | Offline. Inventories a Node-RED installation from its flows export. | | `check` | Offline. Parses the project, verifies the artifact, prints the digest. | | `publish` | Capability → delivery → finalization → activation, in one run. | @@ -84,6 +85,39 @@ not model, so the reader knows what the inventory missed. It reports that a coordinator secret is present in the export; it never prints the secret itself. `docs/agent-guide.md` §3 explains what to do with each finding. +## The agent pack + +`agent-pack` is the command to run *once* in a home repository, so that the next +agent to open it arrives already knowing the rules: + +``` +miakapp agent-pack # or --dir /path/to/the/repository +``` + +It writes four files and reports what it did to each one: + +| File | Why | +| --- | --- | +| `.miakapp/agent-guide.md` | The full guide, copied out of this package. No network, no stale bookmark. | +| `AGENTS.md` | The instruction file Codex reads. | +| `CLAUDE.md` | The instruction file Claude Code reads. | +| `.mcp.json` | Project-scope MCP configuration, registering `miakapp mcp`. | + +The repository is yours, so the pack edits rather than replaces. The guide is a +file it owns outright. The instruction files are touched only between +`` and ``: prose above and below the +markers is copied through byte for byte, and a second run rewrites the block in +place instead of appending another copy. `.mcp.json` is merged as a structure — +one key, by name — so every other server in it survives, and a file that does +not parse is refused rather than replaced with a valid one. + +The server is registered as the bare `miakapp` command rather than an absolute +path, because the file is committed and the next machine to check it out will +not have this one's directory layout. + +Run it again whenever the CLI is upgraded: an unchanged file is reported +`unchanged`, and a guide that moved on is reported `updated`. + ## MCP An agent that already runs a shell does not need this. An agent that speaks the @@ -109,6 +143,7 @@ tools over newline-delimited JSON-RPC on stdio. | `miakapp_release` | `release` | read-only | | `miakapp_upload` | `upload` | read-only | | `miakapp_init` | `init` | writes `miakapp.yaml`, never overwrites | +| `miakapp_agent_pack` | `agent-pack` | offline, writes the pack into a repository | | `miakapp_publish` | `publish` | **moves the pointer — needs `confirm: true`** | | `miakapp_activate` | `activate` | **moves the pointer — needs `confirm: true`** | | `miakapp_rollback` | `rollback` | **moves the pointer — needs `confirm: true`** | diff --git a/packages/cli/assets/agent-guide.md b/packages/cli/assets/agent-guide.md new file mode 100644 index 0000000..d71ded3 --- /dev/null +++ b/packages/cli/assets/agent-guide.md @@ -0,0 +1,408 @@ +# Building a Miakapp home + +This guide is written for a coding agent — Claude Code, Codex, or any successor — +that has been handed someone's house and asked to make it work. You are expected +to read the existing installation, write a coordinator, write an interface, test +both, and publish. The owner is not a home-automation developer and should not +have to become one. + +Everything below is true of the code in this repository. Where a rule exists for +a reason that is not obvious, the reason is given, because a rule whose purpose +you cannot see is one you will optimize away. + +## 1. What you are building + +Three artifacts, and no more: + +| Artifact | Where it runs | What it owns | +| --- | --- | --- | +| Coordinator | the owner's machine, under Bun | state, events, functions, **authorization** | +| Component | a sandboxed Worker in the household's browser | the interface | +| `miakapp.yaml` | neither; it is the contract | what the component may ask for | + +The coordinator is trusted. The component is not. The relay between them is +platform-untrusted but not blind: it terminates TLS, stores plaintext state and +enforces routing, so self-hosting it does not give end-to-end confidentiality. +Do not tell the owner otherwise. + +Start from `templates/home`. It is a complete working home — one lamp, one +temperature — with the three files already in the right relationship, and its +own `README.md` covering the mechanics of copying it out. This guide covers the +judgment the template cannot. + +### Leave the repository readable by the next agent + +You are probably not the last agent to open this repository, and the next one +will not have this conversation. Run the pack once, in the repository root: + +```bash +miakapp agent-pack +``` + +It copies this guide to `.miakapp/agent-guide.md`, points `AGENTS.md` and +`CLAUDE.md` at it, and registers `miakapp mcp` in `.mcp.json` so the tools are +wired rather than described. It edits instead of replacing: prose outside the +`` markers is kept, other MCP servers are kept by name, +and running it twice changes nothing. + +If you are reading this file *as* `.miakapp/agent-guide.md`, someone already +ran it. Run it again after upgrading the CLI, and commit what changes — a guide +that contradicts the CLI installed beside it is worse than no guide. + +## 2. The division of responsibility + +**The coordinator authorizes everything.** The relay proves *who* is calling and +attaches non-spoofable caller metadata. Deciding whether that person *may* act is +the coordinator's job and nobody else's. In the template that decision is the +first line of the function body: + +```ts +if (call.source.kind !== 'user' || call.source.id !== options.ownerUserId) { + throw new ApplicationCallError(2003, 'Only the owner may drive the lights'); +} +``` + +Removing that check does not produce an error anywhere. It produces a home that +anyone enrolled can drive. There is no second layer that will catch it for you. + +**The component trusts nothing it was not given.** It has no network, no storage +and no DOM. It cannot reach the lamp except through a call the coordinator +declared, and it cannot read a state path the coordinator did not grant. This is +why it is safe to let a component be rewritten often and reviewed lightly, and +why it is not safe to move a decision into it. + +**You own the UI; the coordinator owns the facts.** The coordinator exposes state +and actions. What the household actually sees — the layout, the wording, the +language, which controls are prominent — is yours to design from the semantic +vocabulary in §5. Do not push presentation choices into the coordinator, and do +not push authorization into the component. + +## 3. Before you write anything: characterize the house + +You are almost never starting from an empty building. Read what is already there +before you design anything: + +- existing hubs and brokers — Node-RED, MQTT, Zigbee/Z-Wave coordinators, an + HTTP-speaking hub; +- what each device actually reports, and how often; +- which values are *measurements* (temperature, power) and which are + *commanded* (a lamp, a valve) — they have different failure modes; +- which actions are physically consequential: anything that heats, locks, + unlocks, opens or closes. + +Write down what you found before you write the configuration. The state paths you +choose become a disclosure boundary and a public interface at the same time, and +renaming one after the household has used it is not free. + +### Reading a V3 house you inherited + +Most houses arriving at V4 already run Node-RED with the v3 MiakAPI nodes. Ask +the owner for the `flows.json` Node-RED writes, or for an *Export > All flows* +download, and read it before you read anything else: + +``` +miakapp discover --flows ~/node-red/flows.json +miakapp discover --flows ~/node-red/flows.json --json +``` + +The command is offline and read-only: it opens no socket, contacts no broker and +never writes back into the export. It reports the tabs, the MQTT brokers with the +topics their nodes actually reach, the `initMiakapi` home bindings, every +`commitVariables` path as a state candidate, every `onUserAction` id as a +function candidate with the groups allowed to invoke it, and every node type it +does not model — so you know what the inventory missed rather than assuming it +missed nothing. + +Four of its findings decide work you would otherwise discover late: + +- **`secret_in_export`.** The v3 `initMiakapi` node declares `coordSecret` in its + `defaults`, not in its `credentials`, so Node-RED stores that secret in + cleartext in `flows.json` rather than in the encrypted `flows_cred.json`. If + the export has one, treat it as leaked: rotate it, and keep the file out of + Git. §9 is the V4 rule that replaces it. +- **`unrestricted_action`.** The v3 handler allows an action outright when its + node lists no group, so an empty `allowedGroups` is a grant to every signed-in + user, not a deny. Each one needs a deliberate V4 rule before you port it. +- **`name_needs_rename`.** A v3 variable path or action id that is not a legal V4 + dotted name has to be renamed now, while nobody depends on it. +- **`wildcard_subscription`.** A topic holding `#` or `+` is a subscription + pattern, not one device. Enumerate what it actually matches. + +The command deliberately does not tell you which actions are physically +consequential. It lists every action it found; deciding which of them heats, +locks, unlocks, opens or closes is a judgement you make with the owner, and no +keyword list should make it for you. + +## 4. The coordinator + +`templates/home/coordinator/home.ts` is the shape to copy: the configuration is a +**pure function of its options**, so it can be tested without a relay, a control +plane or a network. `coordinator/main.ts` is the only file that touches the +outside world. Keep that split. It is what makes `test/home.test.ts` possible, +and the authorization rules are exactly the thing you want under test. + +Four declarations: + +```ts +{ + state: { 'zone.salon.light.on': false }, // paths and initial values + stateAccess: [{ userId, patterns: ['zone.salon.*'] }], + events: [{ topic, directions: EventDirection.publishToUsers }], + eventAccess: [{ userId, publish: [], subscribe: [topic] }], + functions: { async 'lighting.set'(call) { /* authorize, act, return */ } }, +} +``` + +`stateAccess[].patterns` is the disclosure boundary. A user sees exactly those +paths and nothing else. Widen it deliberately, one path at a time, and never with +a wildcard that happens to be convenient. + +Three ordering and failure rules that are easy to get wrong: + +**State first, event second.** Write the state, *then* publish the event: + +```ts +await coordinator.state.set([{ path: STATE.lightOn, value: on }]); +await coordinator.events.publish(EVENT_LIGHT_CHANGED, { on }); +``` + +A subscriber that reacts to the event and immediately reads the state must never +see the old value. The reverse order is a race that will reproduce once a month +and waste a day. + +**Reject bad arguments with a typed application error.** `ApplicationCallError` +carries a numeric code the component can branch on. Do not throw a bare `Error` +for a caller mistake; the distinction between "you asked wrongly" and "something +broke" is the one the interface needs most. + +**A home has several coordinators.** Names are namespace sharding by convention. +The relay keeps ownership tables for topics, state paths and functions, detects +collisions and rejects with `4409`. If you claim `lighting.set` for the whole +house, you have taken a name another integration may need; scope what you own. + +## 5. The component + +Import from `@miakapp/component`. The whole public surface is one module, and the +whole rendering vocabulary is `ui.*`: + +``` +screen stack grid section layout +text status progress media output +button toggle input select interaction +``` + +You return one complete semantic tree per render; the trusted host draws it with +its own components. You do not ship CSS, you do not ship a DOM, and you cannot +style your way around the host. This is a constraint worth accepting rather than +fighting: it is what lets the host stay accessible, themed and localized without +auditing your code. + +Handlers are functions, not identifiers to wire up by hand: + +```ts +ui.toggle({ + id: 'salon-light', + label: 'Lampe du salon', + value: asBoolean(home.state.get(LIGHT_ON)), + disabled: home.staging || !healthy, + pending, + onChange: (next) => void setLight(next), +}) +``` + +Handlers are registered for the render that created them, so a stale tree cannot +fire an action against new state. + +The limits are real and enforced: 1 024 nodes, depth 32, 30 renders per second, +32 outstanding calls, 8 KiB per text node. If you are approaching any of them you +are building a dashboard the household will not read. + +### Two states the interface must never hide + +**Staleness.** `home.state.stale` means the snapshot may no longer reflect the +house. Show it. Per RFC 0002 §12.2 it is exposed, never hidden — a thermostat +reading that is silently forty minutes old is worse than one labelled uncertain. + +**Staging.** `home.staging` is true while a release is staged: rendering works, +calls and events do not. Disable the controls and say why, as the template does. +An interface that looks live and silently does nothing is the worst outcome +available. + +### An unknown outcome is never retried + +This is the rule that will most tempt you to break it, so it is stated plainly: + +```ts +try { + await home.call('lighting.set', { on }, { deadlineMs: 10_000 }); +} catch (error) { + // Deliberately not retried. The call may already have reached the lamp, + // and the next state snapshot settles the question. + failure = error instanceof Error ? error.message : 'La commande a échoué'; +} +``` + +A failed call is not a call that did not happen. `CallOutcomeUnknownError` exists +precisely to name the case where the effect is undetermined, and the physical +world does not have a rollback. Surface it, let the next state snapshot settle +it, and let the person decide. The same principle has a CLI counterpart: exit +code 7. + +## 6. The intersection rule + +Every name a component may touch appears in **two** places, and the effective +grant is the intersection: + +| `miakapp.yaml` → `requires` | `coordinator/home.ts` | +| --- | --- | +| `state_read` | `stateAccess[].patterns` | +| `event_subscribe` | `eventAccess[].subscribe` | +| `event_publish` | `eventAccess[].publish` | +| `call` | `functions` | + +Asking for more than the coordinator grants **does not fail loudly at +publication**. The component simply never receives that path, and the interface +renders a hole — an empty card, a control that does nothing, a temperature that +is permanently unavailable. This is the single most common way a Miakapp home +breaks, and it breaks quietly. + +So: assert the correspondence in a test. `templates/home/test/home.test.ts` does +exactly this, and it is the reason a mismatch fails in CI rather than in +someone's living room. When you add a path, change four things in one commit — +the state declaration, the access pattern, the `requires` entry, and the test. + +## 7. The loop + +```bash +bun run check # typecheck → bundle → test → validate the artifact offline +``` + +Run it before every publication and in CI. It costs nothing and catches the +artifact rules the runtime would reject anyway. + +Do not treat a check that exists as a check that runs. On 2026-09-14 this +repository had a template and a component example that were both verified +locally and built by nobody, because the workflow called `bun run check` and not +`check:packages`; when CI finally exercised them they failed three times in a +row on defects that had been sitting there invisibly. If you add a package, add +it to the job that runs in CI, then watch one run go green before believing it. + +Reproduce CI with the toolchain version it pins, not the one you have. Bun 1.4 +self-references the root package and resolves `bunx` from `node_modules`; the +pinned 1.2.23 does neither, so a green local run proves nothing about the runner. +Install the pinned version alongside yours: + +```bash +BUN_INSTALL=/tmp/bun1223 curl -fsSL https://bun.sh/install | bash -s bun-v1.2.23 +``` + +Note also that `bun test foo/` is a **substring filter**, not a directory scope. + +## 8. Publishing + +The CLI builds, validates, publishes and rolls back. **It never owns Git.** It +writes no history, rewrites no source it did not generate, and invents no +control-plane endpoint. The repository is the owner's. + +```bash +miakapp agent-pack # once, per repository +miakapp init --home --control-plane +miakapp check +miakapp publish --expected-generation +miakapp activate --sha256 --expected-generation +miakapp rollback --sha256 --expected-generation # alias of activate +miakapp release # read one finalized release +miakapp upload # reconcile a lost request +``` + +`--expected-generation` is a compare-and-swap on the home's component pointer: the +generation you believe it currently holds. It is `0` for a home that has never +published. It is required, and it is what stops two agents from silently +overwriting each other. + +Rollback is `activate` pointed at a digest you already trust. Keep the digest of +every release you shipped; a rollback you can perform in one command is worth +more than an incident you can explain. + +### Driving the CLI as a program + +Every failure maps to exactly one stable exit code and one stable +machine-readable kind. New kinds may be added; existing codes never change +meaning. Pass `--json` and you get exactly one closed object on stdout. + +| Code | Kind | Meaning | +| --- | --- | --- | +| 0 | `success` | | +| 1 | `usage` | the invocation was wrong | +| 2 | `project` | `miakapp.yaml` is missing or invalid | +| 3 | `artifact` | the built bytes violate an artifact rule | +| 4 | `authorization` | the Home Key is missing, wrong or unscoped | +| 5 | `contract` | the control plane rejected the request | +| 6 | `conflict` | `--expected-generation` did not match; re-read, do not retry | +| 7 | `unknown_outcome` | **effect undetermined — reconcile with a read** | + +Branch on the code, not on the prose. On 7, call `miakapp upload ` or +`miakapp release ` and reconcile before acting again. Never retry a 7 +with a fresh capability. + +### If you speak MCP instead of shell + +`miakapp mcp` serves the same commands as tools over JSON-RPC on stdio. It is the +same code: a tool call becomes the argv a person would have typed and runs the +same dispatch, so everything above still holds — the same defaults, the same +validation, the same `kind` on every failure. + +Three differences are worth knowing before you call anything: + +- `miakapp_publish`, `miakapp_activate` and `miakapp_rollback` refuse to run + without `confirm: true`. Set it when the owner asked for that publication, and + not to get past an error; +- a failure arrives as a tool result with `isError: true`, carrying the same + closed object, not as a JSON-RPC error. A JSON-RPC error means your call never + happened; `isError` means it ran and failed, and `kind` says what to do next; +- a tool argument is the option name with `_` instead of `-`. An argument the + tool does not declare is refused, never ignored. + +`packages/cli/README.md` lists the tools. The exit codes above are the +`exit_code` field in every result, so branch on the same table either way. + +## 9. Secrets + +`MIAKAPP_HOME_KEY` comes from the environment. It is never a command-line +argument, never printed, never written into a project file. No command accepts +one, deliberately: an argument lands in shell history, in a process listing and +in most CI logs. + +```bash +export MIAKAPP_HOME_KEY="$(your-secret-manager read miakapp/home-key)" +``` + +If you are about to write a secret into `miakapp.yaml` so something works, stop: +that is the failure this design exists to prevent. + +## 10. Before you tell the owner you are done + +- [ ] Every physically consequential function authorizes its caller on its first + line, and a test proves an unauthorized caller is refused. +- [ ] Every name in `miakapp.yaml` is covered by the coordinator, and a test + asserts the correspondence. +- [ ] `home.state.stale` and `home.staging` are visible in the interface. +- [ ] No call path retries an unknown outcome. +- [ ] State is written before its event is published. +- [ ] `bun run check` is green, and CI ran it — not just you. +- [ ] The Home Key exists only in the environment. +- [ ] You recorded the digest of what you published, so rollback is one command. + +## Where to read further + +- `packages/cli/README.md` — every command and every tool, including what the + pack writes and what it refuses to overwrite. +- `templates/home/README.md` — the mechanics of the template itself. +- `docs/rfcs/0001` (Miakapp-V3) — wire protocol: ownership, collisions, `4409`. +- `docs/rfcs/0002` — component runtime and the staleness rule. **The broker is + the authority for the guest ABI, not the RFC**: `component-runtime/src/runtime-broker.ts` + and `contract.ts` define the exact payloads, and the broker terminates the + instance rather than answering when a field is wrong. +- `docs/rfcs/0003` — the coordinator SDK surface. +- `docs/rfcs/0004` — Home Key bootstrap, scopes, publication. +- `docs/rfcs/0005` — the trusted browser client. diff --git a/packages/cli/package.json b/packages/cli/package.json index eb2ef8b..1067494 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,6 +12,7 @@ "miakapp": "./bin/miakapp.js" }, "files": [ + "assets", "bin", "dist", "LICENSE", diff --git a/packages/cli/src/agent-pack.ts b/packages/cli/src/agent-pack.ts new file mode 100644 index 0000000..bebb578 --- /dev/null +++ b/packages/cli/src/agent-pack.ts @@ -0,0 +1,255 @@ +/** + * The installable pack. + * + * A coding agent handed someone's house does not arrive knowing how a Miakapp + * home is built. It arrives in the owner's repository, with whatever files are + * already there. This command puts three things in that repository: + * + * - the guide, as a file, so the knowledge survives without a network; + * - a pointer to it in the instruction file each client actually reads — + * `AGENTS.md` for Codex, `CLAUDE.md` for Claude Code; + * - the MCP server entry, so the tools are wired rather than described. + * + * The repository belongs to the owner, and the CLI's rule that it never + * rewrites what it did not generate holds here too. That rule is what shapes + * every merge below: the guide is a file this command owns outright, the + * instruction files are edited only between markers this command wrote, and + * `.mcp.json` is edited as a structure — one key, by name — never as text. + * Bytes outside those regions are copied through untouched. + */ +import { projectError } from './errors.js'; + +/** Directory the pack owns inside the owner's repository. */ +export const PACK_DIRECTORY = '.miakapp'; + +/** The guide, copied out of the package so it is readable offline. */ +export const GUIDE_FILE = `${PACK_DIRECTORY}/agent-guide.md`; + +/** Project-scope MCP configuration. Claude Code reads this file by name. */ +export const MCP_FILE = '.mcp.json'; + +/** The name the server is registered under, and the tool-name prefix. */ +export const SERVER_NAME = 'miakapp'; + +/** + * Instruction files, by the client that reads each one. Both are written: + * a repository is handed to whichever agent the owner has, and an unused + * pointer costs a paragraph. + */ +export const INSTRUCTION_FILES: readonly { readonly path: string; readonly client: string }[] = [ + { path: 'AGENTS.md', client: 'Codex' }, + { path: 'CLAUDE.md', client: 'Claude Code' }, +]; + +export const BEGIN_MARKER = ''; +export const END_MARKER = ''; + +/** What happened to one file, reported per file rather than summed. */ +export type PackAction = 'created' | 'updated' | 'unchanged'; + +export interface PackedFile { + readonly path: string; + readonly action: PackAction; +} + +export interface FileStore { + read(path: string): Promise; + write(path: string, bytes: Uint8Array): Promise; + replace(path: string, bytes: Uint8Array): Promise; + exists(path: string): Promise; + makeDirectory(path: string): Promise; +} + +const encoder = new TextEncoder(); + +function decode(bytes: Uint8Array, path: string): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw projectError( + `${path} is not readable as UTF-8 text`, + 'The pack edits text files in place; move this one aside and run the command again.', + ); + } +} + +/** + * Writes `bytes` at `path`, creating or replacing. + * + * Returns `unchanged` when the bytes already on disk are identical, so a second + * run of the pack reports honestly instead of claiming work it did not do. + */ +async function put( + files: FileStore, + path: string, + content: string, +): Promise { + const bytes = encoder.encode(content); + if (!await files.exists(path)) { + await files.write(path, bytes); + return { path, action: 'created' }; + } + if (decode(await files.read(path), path) === content) return { path, action: 'unchanged' }; + await files.replace(path, bytes); + return { path, action: 'updated' }; +} + +/** + * The block written into each instruction file. + * + * It is short on purpose. Everything an agent needs to know is in the guide, + * and a summary that drifts from the guide is worse than no summary: the agent + * would believe the stale copy it read first. + */ +export function instructionBlock(client: string): string { + return `${BEGIN_MARKER} +## Miakapp + +This repository is a Miakapp home: a coordinator that runs on the owner's +machine and owns state, events and authorization, and a component that runs +sandboxed in the household's browser and owns nothing but the interface. + +**Read \`${GUIDE_FILE}\` before writing or publishing anything here.** It is the +full guide, copied into this repository so it is readable offline, and it is the +source of truth for the rules below. + +The \`${SERVER_NAME}\` MCP server in \`${MCP_FILE}\` exposes the toolchain to ${client}: +inventory an existing installation, validate the project, publish, and roll back. +The same commands exist as \`${SERVER_NAME}\` on the command line; they are one +implementation, so neither surface can drift from the other. + +Three rules the guide explains and this file repeats because getting them wrong +is expensive: + +- \`publish\`, \`activate\` and \`rollback\` change what every device in the home + runs. Over MCP they refuse to act without \`confirm: true\`. Set it when the + owner asked for that publication, never to get past an error. +- Every failure carries a stable \`kind\`. Branch on it, not on the message. + \`conflict\` means re-read the pointer; \`unknown_outcome\` means the effect is + undetermined — reconcile with \`${SERVER_NAME} release\` or \`${SERVER_NAME} upload\` + before acting again, and never retry it. +- The Home Key lives in \`MIAKAPP_HOME_KEY\` in the environment. No command + accepts it as an argument, and it belongs in no file in this repository. +${END_MARKER}`; +} + +/** + * Merges the block into an instruction file. + * + * Three cases, and the owner's prose survives all three: no file, a file with + * no block, and a file with a block from an earlier run. The block is appended + * rather than prepended because the top of an instruction file is where the + * owner put what matters to them. + */ +export function mergeInstructions(existing: string | undefined, block: string): string { + if (existing === undefined || existing.trim() === '') return `${block}\n`; + + const start = existing.indexOf(BEGIN_MARKER); + if (start === -1) { + const separator = existing.endsWith('\n') ? '\n' : '\n\n'; + return `${existing}${separator}${block}\n`; + } + + const end = existing.indexOf(END_MARKER, start); + if (end === -1) { + throw projectError( + `An unterminated ${BEGIN_MARKER} block is open in this file`, + `Close it with ${END_MARKER}, or delete the block and run the pack again.`, + ); + } + return existing.slice(0, start) + block + existing.slice(end + END_MARKER.length); +} + +/** + * The server entry, as a client expects to find it. + * + * `command` is the bare binary name: the pack is written into a repository that + * may be opened on another machine, where an absolute path from this one would + * resolve to nothing. + */ +export function serverEntry(): Record { + return { + type: 'stdio', + command: SERVER_NAME, + args: ['mcp'], + }; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Merges the server entry into an existing `.mcp.json`. + * + * Parsed and re-serialized rather than patched as text: every other server in + * the file is carried across by name, and a file that does not parse is refused + * instead of being overwritten with a valid one. An owner who hand-edited that + * file into a syntax error still wants their edit back. + */ +export function mergeMcpConfig(existing: string | undefined): string { + let document: Record = {}; + if (existing !== undefined && existing.trim() !== '') { + let parsed: unknown; + try { + parsed = JSON.parse(existing); + } catch (error) { + throw projectError( + `${MCP_FILE} is not valid JSON: ${error instanceof Error ? error.message : 'parse failed'}`, + 'The pack merges one entry into this file and will not replace it. Fix the JSON first.', + ); + } + if (!isPlainObject(parsed)) { + throw projectError( + `${MCP_FILE} must hold a JSON object`, + 'The pack merges one entry into this file and will not replace it.', + ); + } + document = parsed; + } + + const servers = document['mcpServers']; + if (servers !== undefined && !isPlainObject(servers)) { + throw projectError( + `${MCP_FILE} has an "mcpServers" key that is not an object`, + 'The pack merges one entry into this file and will not replace it.', + ); + } + + const merged = { ...(servers ?? {}), [SERVER_NAME]: serverEntry() }; + return `${JSON.stringify({ ...document, mcpServers: merged }, null, 2)}\n`; +} + +export interface PackResult { + readonly root: string; + readonly files: readonly PackedFile[]; +} + +/** + * Installs the pack under `root`. + * + * `guide` is passed in rather than read here so the caller decides where the + * guide comes from: the packaged asset in production, a fixture in a test. + */ +export async function installPack( + files: FileStore, + root: string, + guide: string, +): Promise { + const written: PackedFile[] = []; + + await files.makeDirectory(`${root}/${PACK_DIRECTORY}`); + written.push(await put(files, `${root}/${GUIDE_FILE}`, guide)); + + for (const { path, client } of INSTRUCTION_FILES) { + const full = `${root}/${path}`; + const existing = await files.exists(full) ? decode(await files.read(full), full) : undefined; + written.push(await put(files, full, mergeInstructions(existing, instructionBlock(client)))); + } + + const mcpPath = `${root}/${MCP_FILE}`; + const config = await files.exists(mcpPath) ? decode(await files.read(mcpPath), mcpPath) : undefined; + written.push(await put(files, mcpPath, mergeMcpConfig(config))); + + return { root, files: written }; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index e93442d..758b58d 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -14,6 +14,7 @@ * {@link EXIT_CODE}), so a wrapper decides without parsing prose; * - `--json`, which prints exactly one closed object on stdout. */ +import { installPack } from './agent-pack.js'; import { prepareArtifact, type Artifact } from './artifact.js'; import { exchangePublisherToken, fetchDiscovery } from './control-plane.js'; import { discoverFlows, inventoryJson, type Inventory } from './discovery.js'; @@ -48,8 +49,19 @@ export const HOME_KEY_VARIABLE = 'MIAKAPP_HOME_KEY'; export interface FileSystem { read(path: string): Promise; + /** Creates. Fails if the path exists: no command may clobber by accident. */ write(path: string, bytes: Uint8Array): Promise; + /** + * Creates or overwrites. + * + * Separate from {@link FileSystem.write} so overwriting is never the default + * a command falls into. Only `agent-pack` calls it, and only after merging + * the existing bytes, so what it replaces is a file it generated. + */ + replace(path: string, bytes: Uint8Array): Promise; exists(path: string): Promise; + /** Creates the directory and its parents. Succeeds if it already exists. */ + makeDirectory(path: string): Promise; } export interface CliHost { @@ -87,6 +99,7 @@ Usage Commands init Write ${PROJECT_FILE} in the current directory + agent-pack Install the guide and the MCP wiring into a repository discover Inventory an existing Node-RED installation offline check Validate the project and the artifact offline publish Upload, finalize and activate the built artifact @@ -120,6 +133,9 @@ mcp options command above becomes one tool; publish, activate and rollback additionally require confirm: true. +agent-pack options + --dir Repository to install into (default: cwd) + init options --home Home ID to write into ${PROJECT_FILE} (required) --control-plane Control-plane issuer (required) @@ -142,6 +158,7 @@ const GLOBAL_OPTIONS = ['project'] as const; /** Exported so the MCP surface can be proved to expose every option, and no other. */ export const COMMAND_OPTIONS: Record = { init: ['home', 'control-plane', 'artifact', 'release'], + 'agent-pack': ['dir'], discover: ['flows'], check: [], publish: ['expected-generation', 'generation', 'release'], @@ -277,6 +294,12 @@ async function nodeFileSystem(): Promise { async write(path, bytes) { await fs.writeFile(path, bytes, { flag: 'wx' }); }, + async replace(path, bytes) { + await fs.writeFile(path, bytes); + }, + async makeDirectory(path) { + await fs.mkdir(path, { recursive: true }); + }, async exists(path) { try { await fs.access(path); @@ -406,6 +429,61 @@ async function runInit(host: CliHost, invocation: Invocation): Promise { + const { fileURLToPath } = await import('node:url'); + return fileURLToPath(new URL('../assets/agent-guide.md', import.meta.url)); +} + +/** + * Installs the agent pack. Like `discover`, it loads no project file: the + * repository it prepares is usually one that has no V4 project yet. + */ +async function runAgentPack(host: CliHost, invocation: Invocation): Promise { + const filesystem = await files(host); + const root = invocation.options.get('dir') ?? host.cwd(); + if (!await filesystem.exists(root)) { + throw projectError( + `No directory at ${root}`, + 'Point --dir at the repository to install into, or run the command inside it.', + ); + } + + const guidePath = await guideAssetPath(); + let guide: string; + try { + guide = new TextDecoder('utf-8', { fatal: true }).decode(await filesystem.read(guidePath)); + } catch { + throw projectError( + `The packaged guide is missing or unreadable at ${guidePath}`, + 'Reinstall @miakapp/cli: the pack copies the guide out of the package, never off the network.', + ); + } + + const result = await installPack(filesystem, root, guide); + const changed = result.files.filter((entry) => entry.action !== 'unchanged').length; + return { + summary: changed === 0 + ? `The pack in ${root} is already current` + : `Installed the agent pack in ${root}`, + fields: result.files.map((entry) => [entry.path, entry.action] as Field), + json: { + root: result.root, + changed, + files: result.files.map((entry) => ({ path: entry.path, action: entry.action })), + }, + }; +} + /** * Reads an existing installation. `discover` never loads the project file: an * agent runs it on a house that has no V4 project yet, which is the whole point @@ -622,6 +700,8 @@ export async function dispatch(host: CliHost, invocation: Invocation): Promise = {}): Promise { + const files = new MemoryFiles(entries); + await files.makeDirectory(PROJECT_ROOT); + await files.write(await guideAssetPath(), new TextEncoder().encode(GUIDE_TEXT)); + return files; +} + +function config(files: MemoryFiles): Record { + return JSON.parse(files.text(`${PROJECT_ROOT}/${MCP_FILE}`)); +} + +/** A failure prints its one object on stderr, which is where `--json` puts it. */ +function failure(host: { stderr(): string }): Record { + return JSON.parse(host.stderr()) as Record; +} + +describe('the pack installs into an empty repository', () => { + test('it writes the guide, both instruction files and the server entry', async () => { + const files = await repository(); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.success); + + const result = host.json(); + expect(result['root']).toBe(PROJECT_ROOT); + expect(result['changed']).toBe(4); + expect((result['files'] as { path: string; action: string }[]).map((entry) => entry.action)) + .toEqual(['created', 'created', 'created', 'created']); + + expect(files.text(`${PROJECT_ROOT}/${GUIDE_FILE}`)).toBe(GUIDE_TEXT); + for (const { path } of INSTRUCTION_FILES) { + expect([path, files.text(`${PROJECT_ROOT}/${path}`).includes(GUIDE_FILE)]).toEqual([path, true]); + } + expect(config(files)['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + }); + + test('the server is launched by bare name, so the repository is portable', () => { + const entry = serverEntry(); + expect(entry['command']).toBe(SERVER_NAME); + expect(entry['args']).toEqual(['mcp']); + expect(JSON.stringify(entry)).not.toContain('/'); + }); + + test('it installs where --dir points, not where the process happens to be', async () => { + const files = await repository(); + await files.makeDirectory('/srv/another-home'); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--dir', '/srv/another-home', '--json'], host)) + .toBe(EXIT_CODE.success); + + expect(await files.exists(`/srv/another-home/${GUIDE_FILE}`)).toBe(true); + expect(await files.exists(`${PROJECT_ROOT}/${GUIDE_FILE}`)).toBe(false); + }); + + test('a directory that does not exist is a project error, not a silent mkdir', async () => { + const host = testHost({ files: await repository() }); + + expect(await run(['agent-pack', '--dir', '/srv/absent', '--json'], host)) + .toBe(EXIT_CODE.project); + expect(failure(host)['kind']).toBe('project'); + }); + + test('a missing packaged guide fails loudly instead of installing an empty one', async () => { + const files = new MemoryFiles(); + await files.makeDirectory(PROJECT_ROOT); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.project); + expect(failure(host)['message']).toContain('guide'); + expect(await files.exists(`${PROJECT_ROOT}/${GUIDE_FILE}`)).toBe(false); + }); +}); + +describe('the pack keeps what the owner wrote', () => { + test('prose already in an instruction file survives, and the block is appended', async () => { + const existing = '# Our house\n\nRun the tests before you touch the heating.\n'; + const files = await repository({ [`${PROJECT_ROOT}/AGENTS.md`]: existing }); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.success); + + const written = files.text(`${PROJECT_ROOT}/AGENTS.md`); + expect(written.startsWith(existing)).toBe(true); + expect(written).toContain(BEGIN_MARKER); + expect(written).toContain(END_MARKER); + }); + + test('a second run updates the block in place instead of stacking copies', () => { + const first = mergeInstructions('# Our house\n', instructionBlock('Codex')); + const stale = first.replace('## Miakapp', '## Miakapp (an older pack wrote this)'); + const second = mergeInstructions(stale, instructionBlock('Codex')); + + expect(second.split(BEGIN_MARKER).length - 1).toBe(1); + expect(second).not.toContain('an older pack wrote this'); + expect(second.startsWith('# Our house\n')).toBe(true); + expect(second).toBe(first); + }); + + test('text after the block is carried across untouched', () => { + const withTail = `${instructionBlock('Codex')}\n\n## After\n\nKept.\n`; + const merged = mergeInstructions(withTail, instructionBlock('Claude Code')); + + expect(merged).toContain('## After\n\nKept.\n'); + expect(merged).toContain('Claude Code'); + }); + + test('an unterminated block is refused rather than guessed at', () => { + expect(() => mergeInstructions(`# House\n\n${BEGIN_MARKER}\nhalf a block\n`, 'x')) + .toThrow(/unterminated/i); + }); + + test('other servers and other keys in .mcp.json are kept by name', () => { + const merged = JSON.parse(mergeMcpConfig(JSON.stringify({ + $schema: 'https://example.test/mcp.json', + mcpServers: { + sentry: { type: 'http', url: 'https://mcp.sentry.dev/mcp' }, + }, + }))); + + expect(merged['$schema']).toBe('https://example.test/mcp.json'); + expect(merged['mcpServers']['sentry']).toEqual({ type: 'http', url: 'https://mcp.sentry.dev/mcp' }); + expect(merged['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + }); + + test('an earlier miakapp entry is replaced, not duplicated', () => { + const merged = JSON.parse(mergeMcpConfig(JSON.stringify({ + mcpServers: { [SERVER_NAME]: { type: 'stdio', command: '/opt/old/miakapp', args: ['serve'] } }, + }))); + + expect(Object.keys(merged['mcpServers'])).toEqual([SERVER_NAME]); + expect(merged['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + }); + + test('a .mcp.json that does not parse is refused and left on disk', async () => { + const broken = '{ "mcpServers": { oops\n'; + const files = await repository({ [`${PROJECT_ROOT}/${MCP_FILE}`]: broken }); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.project); + expect(failure(host)['kind']).toBe('project'); + expect(files.text(`${PROJECT_ROOT}/${MCP_FILE}`)).toBe(broken); + }); + + test('an mcpServers key of the wrong shape is refused', () => { + expect(() => mergeMcpConfig('{"mcpServers": []}')).toThrow(/not an object/); + expect(() => mergeMcpConfig('["a list"]')).toThrow(/JSON object/); + }); + + test('an empty file is treated as an empty document, not as a parse failure', () => { + expect(JSON.parse(mergeMcpConfig(''))['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + expect(JSON.parse(mergeMcpConfig(undefined))['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + }); +}); + +describe('running the pack twice is safe', () => { + test('the second run changes nothing and says so', async () => { + const files = await repository(); + + expect(await run(['agent-pack', '--json'], testHost({ files }))).toBe(EXIT_CODE.success); + const second = testHost({ files }); + expect(await run(['agent-pack', '--json'], second)).toBe(EXIT_CODE.success); + + const result = second.json(); + expect(result['changed']).toBe(0); + expect((result['files'] as { action: string }[]).every((entry) => entry.action === 'unchanged')) + .toBe(true); + + const prose = testHost({ files }); + expect(await run(['agent-pack'], prose)).toBe(EXIT_CODE.success); + expect(prose.stdout()).toContain('already current'); + }); + + test('a guide that moved on is rewritten, and reported as updated', async () => { + const files = await repository(); + expect(await run(['agent-pack', '--json'], testHost({ files }))).toBe(EXIT_CODE.success); + + await files.replace(await guideAssetPath(), new TextEncoder().encode('# Newer guide\n')); + const host = testHost({ files }); + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.success); + + expect(files.text(`${PROJECT_ROOT}/${GUIDE_FILE}`)).toBe('# Newer guide\n'); + const guide = (host.json()['files'] as { path: string; action: string }[]) + .find((entry) => entry.path.endsWith(GUIDE_FILE)); + expect(guide?.action).toBe('updated'); + }); +}); + +describe('the block tells an agent what it must not get wrong', () => { + test('it names the guide, the server and the confirmation rule', () => { + const block = instructionBlock('Codex'); + expect(block).toContain(GUIDE_FILE); + expect(block).toContain(MCP_FILE); + expect(block).toContain('confirm: true'); + expect(block).toContain('MIAKAPP_HOME_KEY'); + expect(block).toContain('unknown_outcome'); + }); + + test('each instruction file names the client that reads it', () => { + for (const { path, client } of INSTRUCTION_FILES) { + expect([path, instructionBlock(client).includes(client)]).toEqual([path, true]); + } + expect(INSTRUCTION_FILES.map((entry) => entry.path)).toEqual(['AGENTS.md', 'CLAUDE.md']); + }); +}); + +describe('the packaged guide is the repository guide', () => { + test('the asset the command reads is byte-identical to docs/agent-guide.md', async () => { + const shipped = await readFile(await guideAssetPath()); + const source = await readFile(fileURLToPath(new URL('../../../docs/agent-guide.md', import.meta.url))); + + // If this fails, docs/agent-guide.md changed and packages/cli/assets did + // not. Copy it across: the pack installs the asset, so a stale asset ships + // an agent the wrong rules. + expect(shipped.equals(source)).toBe(true); + }); + + test('the guide is served as a tool and reaches the command as plain argv', () => { + const tool = TOOLS.find((entry) => entry.name === 'miakapp_agent_pack'); + expect(tool?.command).toBe('agent-pack'); + expect(tool?.guarded).toBe(false); + expect(tool?.readOnly).toBe(false); + expect(buildArgv(tool!, { dir: '/srv/home' })).toEqual(['agent-pack', '--dir', '/srv/home']); + expect(buildArgv(tool!, {})).toEqual(['agent-pack']); + }); +}); diff --git a/packages/cli/test/support/host.ts b/packages/cli/test/support/host.ts index 4f32e5b..f5b0fc5 100644 --- a/packages/cli/test/support/host.ts +++ b/packages/cli/test/support/host.ts @@ -24,11 +24,14 @@ component: export class MemoryFiles implements FileSystem { readonly entries: Map; + /** Directories, tracked separately so `exists` answers for both kinds. */ + readonly directories: Set; constructor(entries: Record = {}) { this.entries = new Map( Object.entries(entries).map(([path, text]) => [path, new TextEncoder().encode(text)]), ); + this.directories = new Set(); } async read(path: string): Promise { @@ -42,8 +45,16 @@ export class MemoryFiles implements FileSystem { this.entries.set(path, bytes); } + async replace(path: string, bytes: Uint8Array): Promise { + this.entries.set(path, bytes); + } + + async makeDirectory(path: string): Promise { + this.directories.add(path); + } + async exists(path: string): Promise { - return this.entries.has(path); + return this.entries.has(path) || this.directories.has(path); } text(path: string): string {