From 84e12a2e948d677a02e85af515bdd3b08e09c221 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon <21021423+Mathieu2301@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:34:08 +0000 Subject: [PATCH] feat: install the pack so the next agent arrives knowing the rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolchain, the guide and the contract all existed, and none of them were where a coding agent actually looks. An agent opening the owner's home repository reads AGENTS.md or CLAUDE.md, and finds whatever the owner wrote there — which is not this. `miakapp agent-pack` puts the knowledge in the repository: the guide as a file under .miakapp/, a pointer to it in both instruction files, and the MCP server registered in .mcp.json so the tools are wired rather than described. The CLI's rule that it never rewrites what it did not generate is what shapes every merge. The guide is a file the command owns outright. The instruction files are edited only between markers it wrote, so prose above and below survives byte for byte and a second run rewrites the block in place instead of stacking another copy. .mcp.json is merged as a structure, one key by name: every other server survives, and a file that does not parse is refused rather than replaced with a valid one. Two new FileSystem methods carry this — `replace` is deliberately separate from `write`, so overwriting stays something a command asks for rather than something it falls into. The guide ships as a package asset, and a test asserts it is byte-equal to docs/agent-guide.md. Editing the doc without copying it across is a red test, not a pack that teaches an agent rules the CLI no longer has. Verified against the built binary, not only through injected tests: the pack installed into a repository with its own AGENTS.md and its own .mcp.json kept both, `claude mcp get miakapp` reads the generated entry back as a project-scope stdio server, a second run reported every file unchanged, and miakapp_agent_pack installed a pack over real MCP stdio. --- docs/agent-guide.md | 22 ++ packages/cli/README.md | 35 +++ packages/cli/assets/agent-guide.md | 408 +++++++++++++++++++++++++++ packages/cli/package.json | 1 + packages/cli/src/agent-pack.ts | 255 +++++++++++++++++ packages/cli/src/main.ts | 80 ++++++ packages/cli/src/mcp.ts | 20 ++ packages/cli/test/agent-pack.test.ts | 249 ++++++++++++++++ packages/cli/test/support/host.ts | 13 +- 9 files changed, 1082 insertions(+), 1 deletion(-) create mode 100644 packages/cli/assets/agent-guide.md create mode 100644 packages/cli/src/agent-pack.ts create mode 100644 packages/cli/test/agent-pack.test.ts 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 {