@kitn.ai/cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,8 @@ npx -y @kitn.ai/cli add support-widget # no install at all
21
21
  | `kai create [dir]` | the scaffolder wizard (the same one `npm create kai` runs) |
22
22
  | `kai add <block>` | writes a block from the registry into an existing project |
23
23
  | `kai add --list` | prints the blocks this release ships |
24
+ | `kai init [--form <id>]` | makes an EXISTING project kai-aware: adds the kit at this CLI's pin and prints the wiring that framework needs |
25
+ | `kai upgrade [--write]` | brings a SCAFFOLDED project up to the template this CLI emits: it replaces the files you never touched and reports the ones you edited. `--strict` exits non-zero on drift |
24
26
  | `kai doctor` | diagnoses this project's kit wiring, versions and registration |
25
27
  | `kai mcp` | runs the MCP server for an AI coding harness, if that package is installed |
26
28
  | `kai dev <construct.json>` | live preview with reload-on-edit |
@@ -29,13 +31,33 @@ npx -y @kitn.ai/cli add support-widget # no install at all
29
31
  | `kai eject <construct.json> <outDir>` | writes the generated Solid project out; the source is yours |
30
32
  | `kai validate <construct.json>` | checks a construct and prints problems with paths |
31
33
 
34
+ ## upgrade
35
+
36
+ A project made with `npm create kai` is a copy of a template, and the templates move. `kai upgrade` brings that copy up to what the current CLI emits and **never overwrites something you wrote**:
37
+
38
+ | | verdict | `--write` |
39
+ |---|---|---|
40
+ | `^` | outdated: untouched since you scaffolded it, so the template moved | replaces it |
41
+ | `+` | missing: the template emits it and you do not have it | adds it |
42
+ | `!` | edited: you changed it | nothing, ever |
43
+ | `?` | unknown: it differs, and there is no baseline to say whose change it is | nothing |
44
+ | `=` | same: already current | nothing |
45
+
46
+ `kai.json` records a sha256 of every file the scaffolder wrote, which is what makes that distinction possible. A project scaffolded before that was recorded has no baseline, so `upgrade` reports the drift and refuses to write. It renders into a temp directory with the same code the scaffolder runs, it deletes nothing, and `--strict` makes drift exit non-zero for CI. `doctor` reads the same recorded hashes and reports how far your copy has moved, without rendering anything.
47
+
32
48
  ## doctor
33
49
 
34
50
  ```bash
35
- kai doctor # human-readable
36
- kai doctor --json # the findings, for a CI job or an agent
51
+ kai doctor # human-readable
52
+ kai doctor --json # the findings, for a CI job or an agent
53
+ kai doctor --strict # warnings fail the run too, for CI
37
54
  ```
38
55
 
56
+ It also runs the MCP `debug` tool's rule set over your own source files — the forty-odd classic
57
+ kai-* mistakes (an array prop set as an HTML attribute, a wrong import path, and so on) — reporting
58
+ each matched rule with the files it matched and the fix. Those are warnings by default, since a rule
59
+ matches a PATTERN and a doc example can look like the mistake; `--strict` makes them fail.
60
+
39
61
  It reports the CLI version and the kit it was built against, the kit range this project declares
40
62
  versus the version actually installed, whether `kai.json` is present, whether anything under `src/`
41
63
  references the kit, whether a kit stylesheet is referenced, and whether the MCP package is
package/bin/kai.js CHANGED
@@ -38,8 +38,12 @@ Usage
38
38
  kai create [dir] scaffold a project (the same wizard as \`npm create kai\`)
39
39
  kai add <block> write a block from the registry into an existing project
40
40
  kai add --list print the blocks this release ships
41
+ kai init [--form <id>] make an EXISTING project kai-aware: add the kit and print the wiring
42
+ kai upgrade [--write] bring a SCAFFOLDED project up to this CLI's template (never your edits)
41
43
 
42
44
  kai doctor diagnose this project's kit wiring, versions and registration
45
+ kai doctor --strict the same, but warnings fail the run (for CI)
46
+ kai doctor --json the findings, for a CI job or an agent
43
47
 
44
48
  kai mcp run the MCP server for AI coding harnesses (@kitn.ai/mcp)
45
49
  kai dev <construct.json> live preview with reload-on-edit
package/bin/route.js CHANGED
@@ -7,9 +7,9 @@
7
7
  // 'local' -- a bundle inside THIS package. dev/compile/eject/validate are the
8
8
  // construct engine; doctor is the wiring diagnosis.
9
9
  // 'forward' -- a SEPARATE published program, launched by resolving that package's
10
- // bin and spawning it with this process's stdio. create/add are
11
- // `create-kai`'s wizard and block registry (the same implementation
12
- // `npm create kai` runs), and mcp is `@kitn.ai/mcp`'s server. They are
10
+ // bin and spawning it with this process's stdio. create/add/init are
11
+ // `create-kai`'s (the same implementation `npm create kai` runs), and
12
+ // mcp is `@kitn.ai/mcp`'s server. They are
13
13
  // not bundled into this package because neither belongs to its install
14
14
  // weight: create-kai is the scaffolder npm's own `create` convention
15
15
  // reaches, and the MCP is the only thing carrying the 5.9 MB SDK.
@@ -27,6 +27,8 @@ export const CONSTRUCT_COMMANDS = ['dev', 'compile', 'eject', 'validate'];
27
27
  export const KNOWN_COMMANDS = [
28
28
  'create',
29
29
  'add',
30
+ 'init',
31
+ 'upgrade',
30
32
  'doctor',
31
33
  'mcp',
32
34
  ...CONSTRUCT_COMMANDS,
@@ -51,6 +53,11 @@ export function decideEntry(command, rest = []) {
51
53
  // wizard is the from-scratch door, `add` the into-an-existing-project door.
52
54
  if (command === 'create') return { kind: 'forward', pkg: 'create-kai', args: rest };
53
55
  if (command === 'add') return { kind: 'forward', pkg: 'create-kai', args: ['add', ...rest] };
56
+ // `init` makes an EXISTING project kai-aware: it merges the dependency and prints the wiring.
57
+ if (command === 'init') return { kind: 'forward', pkg: 'create-kai', args: ['init', ...rest] };
58
+ // `upgrade` re-diffs a scaffolded project against the template this CLI emits; it replaces the
59
+ // files the user never touched and reports the ones they did.
60
+ if (command === 'upgrade') return { kind: 'forward', pkg: 'create-kai', args: ['upgrade', ...rest] };
54
61
  if (command === 'mcp') return { kind: 'forward', pkg: '@kitn.ai/mcp', args: [] };
55
62
  if (command === 'doctor') return { kind: 'local', verb: 'doctor' };
56
63
  if (CONSTRUCT_COMMANDS.includes(command)) return { kind: 'local', verb: 'construct' };
package/dist/doctor.es.js CHANGED
@@ -1,5 +1,199 @@
1
1
  import { readFileSync, statSync, readdirSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { join, relative } from "node:path";
4
+ const RULES = [
5
+ {
6
+ // Rule 1 — array/object data set as an HTML attribute
7
+ // Source: for-ai-agents.mdx §1; context7.json rule 2
8
+ id: "array-as-attribute",
9
+ test: (t) => /\b(messages|models|context|suggestions|triggers)\s*=\s*["']/.test(t),
10
+ title: "Array/object prop set as an HTML attribute (silent failure)",
11
+ cause: "An HTML attribute is always a string. Passing `messages`, `models`, `context`, `suggestions`, or `triggers` as an HTML attribute silently fails — the element receives a stringified value it cannot parse.",
12
+ fix: "Set the property in JavaScript, not as an HTML attribute. Only scalar props (`placeholder`, `loading`, `theme`) work as attributes.\n\n```js\n// ✅ Works — set messages in JavaScript as a property\nconst chat = document.querySelector('kai-chat');\nchat.messages = [{ id: '1', role: 'assistant', parts: [{ type: 'text', text: 'Hello!' }] }];\n```\n\n```html\n<!-- ❌ Fails — messages cannot be an HTML attribute -->\n<kai-chat messages=\"[...]\"></kai-chat>\n```"
13
+ },
14
+ {
15
+ // Rule 2 — in-place mutation → no re-render
16
+ // Source: for-ai-agents.mdx §3; context7.json rule 4
17
+ id: "in-place-mutation",
18
+ test: (t) => /don'?t\s+update|doesn'?t\s+re.?render|no\s+re.?render|\.push\(|\bpush(?:es|ing)?\s+(?:to|into|onto)\b|mutate|in.place/.test(t),
19
+ title: "In-place mutation does not trigger a re-render",
20
+ cause: "Mutating an existing message object or array in place (e.g. `chat.messages.push(…)` or `chat.messages[i].parts = […]`) does not trigger a re-render. The element only reacts when it detects a new array/object reference.",
21
+ fix: "Assign a NEW array (and a new object) on every change — never mutate in place.\n\n```js\n// ✅ Triggers re-render — new array + new object reference\nchat.messages = [\n ...chat.messages,\n { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: userText }] },\n];\n\n// ✅ During streaming: new array + new object per chunk, and FOLD the delta\n// onto the trailing text part so reasoning/tool/card parts survive.\nimport { appendTextPart } from '@kitn.ai/ui/state';\nchat.messages = chat.messages.map((m) =>\n m.id === assistantId ? { ...m, parts: appendTextPart(m.parts, delta) } : m\n);\n\n// ❌ Does NOT trigger re-render\nchat.messages.push(newMsg);\nchat.messages[i].parts = appendTextPart(chat.messages[i].parts, delta);\n\n// ❌ Re-renders, but DROPS the reasoning/tool/card parts already on the message\nchat.messages = chat.messages.map((m) =>\n m.id === assistantId ? { ...m, parts: [{ type: 'text', text: accumulated }] } : m\n);\n```\n\nThe ergonomic path: helpers in `@kitn.ai/ui/state` (`appendMessage`, `updateMessage`, `appendText`) and `createAssistantStream` handle the new-reference contract for you. `useKaiChat` (React) and `createKaiChat` (Solid) own state entirely so mutation is never an option.\n\n// setMessages((m) => appendMessage(m, msg)) // new array, no footgun"
22
+ },
23
+ {
24
+ // Rule 3 — listening for events on a parent / wrong element
25
+ // Source: for-ai-agents.mdx §2; context7.json rule 3
26
+ id: "event-bubbling",
27
+ test: (t) => /event.*not\s+fir|not\s+fir.*event|listen.*document|document.*listen|listen.*parent|parent.*listen|event.*bubbl/i.test(
28
+ t
29
+ ),
30
+ title: "Listening for events on the wrong element (events are non-bubbling)",
31
+ cause: "`kai-*` events are non-bubbling CustomEvents. Adding a listener to `document`, `window`, or a parent container will never fire because the event does not bubble up.",
32
+ fix: "Listen directly on the `kai-*` element. The submit event is `kai-submit` with `event.detail.value`.\n\n```js\nconst chat = document.querySelector('kai-chat');\n\n// ✅ Listen directly on the element\nchat.addEventListener('kai-submit', (e) => {\n console.log(e.detail.value); // the text the user typed\n});\n\n// ❌ Never fires — kai-submit does not bubble\ndocument.addEventListener('kai-submit', handler);\n```\n\nCommon events: `kai-submit`, `kai-feedback`, `kai-model-change`, `kai-new-chat`, `kai-select`."
33
+ },
34
+ {
35
+ // Rule 4 — wrong element prefix kitn-
36
+ // Source: context7.json rule 1
37
+ id: "wrong-prefix",
38
+ test: (t) => /\bkitn-/.test(t),
39
+ title: "Wrong element prefix `kitn-` (should be `kai-`)",
40
+ cause: "The custom element prefix is `kai-` (e.g. `<kai-chat>`, `<kai-artifact>`). `kitn-` is a legacy name (the register-all bundle is `kai.es.js`) — it is not a registered element name. Using `<kitn-chat>` results in an unknown element that renders nothing.",
41
+ fix: "Replace the `kitn-` prefix with `kai-` everywhere.\n\n```html\n<!-- ✅ Correct element prefix -->\n<kai-chat></kai-chat>\n<kai-artifact></kai-artifact>\n\n<!-- ❌ Wrong — kitn-chat is not an element (the bundle is kai.es.js) -->\n<kitn-chat></kitn-chat>\n```"
42
+ },
43
+ {
44
+ // Rule 6 — web components not registered / renders nothing (React #1 failure)
45
+ // Source: field-test reports; for-ai-agents.mdx §"Import order matters"
46
+ id: "web-components-not-registered",
47
+ test: (t) => {
48
+ if (/renders?\s+nothing|nothing\s+renders?|not\s+registered|unregistered|not\s+upgraded|unknown\s+element|customElements\.get|undefined\s+element|no\s+shadow\s+root/.test(t))
49
+ return true;
50
+ if (/\b(empty|blank)\b/.test(t) && /render|element|component|kai-|<[a-z]+-|shadow/.test(t))
51
+ return true;
52
+ if (/doesn'?t\s+render|won'?t\s+render/.test(t) && /kai-|element|component|custom.?element/.test(t))
53
+ return true;
54
+ return false;
55
+ },
56
+ title: "Web components not registered — renders nothing / empty box",
57
+ cause: "The `@kitn.ai/ui/react` wrappers (and bare `<kai-*>` tags) do NOT register the web components by themselves. Without the registration side-effect import, `<kai-chat>` / `<Chat>` is an un-upgraded unknown element — an empty box. `customElements.get('kai-chat') === undefined`.",
58
+ fix: "Import the web-components bundle for its side effect BEFORE your first render — it must run before the component mounts.\n\n```tsx\nimport '@kitn.ai/ui/web-components' // registers <kai-*> — REQUIRED, must come first\nimport { Chat } from '@kitn.ai/ui/react'\nimport '@kitn.ai/ui/theme.css'\n```\n\nIn plain HTML: `import '@kitn.ai/ui/web-components'` in your module script. The import is a side effect — keep it even if your linter flags it as \"unused\"."
59
+ },
60
+ {
61
+ // Rule 7 — tsc errors inside node_modules/@kitn.ai/ui/src (SolidJS source pulled in)
62
+ // Source: field-test reports; packaging gap (tracked upstream)
63
+ id: "tsc-source-pull",
64
+ test: (t) => /node_modules\/@kitn\.ai\/ui/.test(t) && /tsc|TS2786|cannot\s+be\s+used\s+as\s+a\s+jsx\s+component|Show\b|Portal\b|Dynamic\b|error\s+TS|type\s+error/.test(
65
+ t
66
+ ),
67
+ title: "tsc errors inside node_modules/@kitn.ai/ui/src (SolidJS source compiled under React)",
68
+ cause: "On versions <=0.27.x, the package shipped TypeScript/TSX source, and a type entry value-re-exports from it, so the consumer's `tsc` resolves and compiles the library's SolidJS internals (`src/components/*.tsx`) under the app's React JSX config — `Show`/`Portal`/`Dynamic` aren't React components, causing TS2786 / \"cannot be used as a JSX component\" errors. `vite`/esbuild build fine (they strip types); only `tsc` breaks. `skipLibCheck` does not help (these are `.tsx` source, not `.d.ts`). Fixed for newer releases: the package no longer ships that source tree (`files` in package.json narrowed to `dist/` plus two reachable JSON exports) — if you're still hitting this, check your installed version first (`npm ls @kitn.ai/ui`) and upgrade before reaching for the workaround below.",
69
+ fix: 'Redirect the type resolution for that subpath in your tsconfig (Vite ignores tsconfig `paths`, so runtime is unaffected):\n\n```jsonc\n// tsconfig (app)\n"baseUrl": ".",\n"paths": { "@kitn.ai/ui/web-components": ["./src/stubs/kitn-web-components.d.ts"] }\n```\n\n```ts\n// src/stubs/kitn-web-components.d.ts\nexport {}\n```\n\n(This is a known packaging gap being tracked upstream.)'
70
+ },
71
+ {
72
+ // Rule 8 — fetch('/api/chat') 404 in a Vite SPA (no server-side routes)
73
+ // Source: field-test reports; common scaffold confusion
74
+ id: "vite-api-404",
75
+ test: (t) => {
76
+ if (/\/api\/chat/.test(t) && /\b404\b|not\s+found/i.test(t)) return true;
77
+ if (/\bvite\b/.test(t) && /api\s+route|route\s+handler|\bPOST\b.*not\s+work/.test(t)) return true;
78
+ if (/next\.?js.*route|route.*next\.?js/.test(t) && /\bvite\b/.test(t)) return true;
79
+ return false;
80
+ },
81
+ title: "fetch('/api/chat') 404 — Vite SPA has no server-side API routes",
82
+ cause: "A plain Vite/CRA React SPA has no server — there are no `/api` routes. A scaffolded Next.js route handler (`export async function POST`) does not run there, so `fetch('/api/chat')` 404s.",
83
+ fix: "Either run the backend somewhere real, or skip it entirely for local dev:\n\n```ts\n// Option A — use Next.js where route handlers are supported\n// app/api/chat/route.ts: export async function POST(req) { ... }\n\n// Option B — add a Vite dev-server middleware/proxy\n// vite.config.ts: server: { proxy: { '/api': 'http://localhost:3001' } }\n\n// Option C — run a separate Express/Hono server\n// framework: 'express' in your harness config\n\n// Option D — zero-config local dev with mock integration (no backend needed)\n// Use `integration: 'mock'` in the scaffold tool\n```"
84
+ },
85
+ {
86
+ // Rule 9 — reduce bundle size / footprint / "how much does @kitn.ai/ui add"
87
+ // Source: dist/web-components/<file>.js per-web-component exports; dist/autoloader.js
88
+ id: "bundle-footprint",
89
+ test: (t) => /bundle\s*size|footprint|tree.?shak|how\s+much.*does.*@kitn|reduce.*import|import.*only.*element|per.?element\s+import|autoload|cdn.*no.?build|no.?build.*cdn/i.test(
90
+ t
91
+ ),
92
+ title: "Reducing bundle footprint — three load modes",
93
+ cause: "The default `import '@kitn.ai/ui/web-components'` registers every `kai-*` web component. If your page uses only one or two web components, that pulls in the full ~119 KB gz bundle. Two opt-in modes let you load only what you need.",
94
+ fix: "**Mode 1 — register-all (default, SSR-safe):**\nBest for multi-element apps or any SSR/meta-framework. Load once and every `kai-*` web component is available.\n\n```js\nimport '@kitn.ai/ui/web-components'; // ~119 KB gz — registers everything\n```\n\n**Mode 2 — per-web-component import (tree-shaking, bundler apps):**\nUse `import '@kitn.ai/ui/web-components/<file>'` to register only one web component. A bundler (Vite, webpack, Rollup) will tree-shake to just its chunks (~73 KB gz for `kai-chat` alone). Client-only — do not use in SSR entry points.\n\n```js\n// Registers only <kai-chat> (~73 KB gz vs ~119 KB gz register-all)\nimport '@kitn.ai/ui/web-components/chat';\n\n// Other examples:\nimport '@kitn.ai/ui/web-components/code-block'; // <kai-code-block>\nimport '@kitn.ai/ui/web-components/confirm-card'; // <kai-confirm>\n```\n\nThe file name is the web component's source basename from `web-component-manifest.json` (e.g. `kai-chat` → `chat`, `kai-confirm` → `confirm-card`).\n\n**Mode 3 — autoloader (no-build / CDN pages only):**\nWatches the DOM and dynamically imports each `kai-*` web component's module on demand. A page that uses only `<kai-chat>` never downloads the other web components. It is a CDN / static-file tool — load it from a `<script type=\"module\">` tag. It is NOT importable through a bundler: Vite/webpack relocate it and the on-demand imports 404. Client-only.\n\n```html\n<script type=\"module\" src=\"https://cdn.jsdelivr.net/npm/@kitn.ai/ui@<version>/dist/web-components/autoloader.js\"><\/script>\n```\n\nIn a BUNDLED app (Vite/webpack/Next) use Mode 1 or Mode 2 instead — not the autoloader.\n\n**SSR note:** use Mode 1 (register-all) in SSR apps — per-web-component imports and the autoloader are client-only (they call DOM APIs at module eval). Modes 1 & 2 are side-effect imports; keep them even if your linter flags them as \"unused\"."
95
+ },
96
+ {
97
+ // Rule 5 — SSR / server component / document is not defined
98
+ // Source: for-ai-agents.mdx (client-only import); context7.json rule 2 (property rule requires DOM)
99
+ id: "ssr-server-component",
100
+ test: (t) => {
101
+ if (/\bssr\b|server\s+component|document\s+is\s+not\s+defined|window\s+is\s+not\s+defined|next\.?js.*server|server.*next\.?js/.test(t)) return true;
102
+ if (/hydration/.test(t) && /kai|web.?component|custom.?element|<[a-z]+-/.test(t)) return true;
103
+ return false;
104
+ },
105
+ title: "SSR / server-side rendering — web components require the browser DOM",
106
+ cause: '`kai-*` web components are client-side, and require `document` and `customElements` to register and render. Importing them in a server component (Next.js App Router server component, Nuxt SSR, etc.) throws "document is not defined" or silently produces no output.',
107
+ fix: "Register the web components on the client only. Use your framework's \"client-only\" / island / dynamic-import pattern.\n\n```js\n// ✅ Plain HTML / vanilla — import in a <script type=\"module\">\nimport '@kitn.ai/ui/web-components';\n\n// ✅ Next.js App Router — mark the component with \"use client\"\n'use client';\nimport '@kitn.ai/ui/web-components';\n\n// ✅ Next.js — dynamic import with ssr: false\nimport dynamic from 'next/dynamic';\nconst KaiChat = dynamic(() => import('@kitn.ai/ui/web-components').then(() => 'kai-chat'), { ssr: false });\n\n// ✅ React wrapper (already client-safe)\nimport { Chat } from '@kitn.ai/ui/react';\n```"
108
+ },
109
+ {
110
+ // Rule 10 — toast() is the imperative API; there is no <kai-toast> to place
111
+ // Source: src/primitives/toast-store.ts (the `toast` fn + auto-mounted region)
112
+ id: "toast-imperative",
113
+ test: (t) => {
114
+ if (/<kai-toast(-region)?\b/.test(t)) return true;
115
+ if (/\btoast(s)?\b|notification|snackbar/i.test(t) && /how.*(show|raise|trigger|fire|display)|show.*toast|raise.*toast|trigger.*toast|toast.*(not|isn'?t|won'?t).*(show|appear|render)|where.*toast|add.*toast|kai-|@kitn/i.test(t))
116
+ return true;
117
+ return false;
118
+ },
119
+ title: "Toast is an imperative call — `toast('…')`, not a `<kai-toast>` you place",
120
+ cause: "Toasts are raised IMPERATIVELY by calling `toast(message)` — there is no `<kai-toast>` element you add to your markup. The first call lazily mounts ONE `<kai-toast-region>` on `document.body` (a real, kit-styled, viewport-positioned element) and every later toast feeds that same region. Trying to place a toast element by hand, or looking for a `messages`/`toasts` prop to push into, is the wrong model.",
121
+ fix: "Import `toast` and call it. It is exported from BOTH the root `@kitn.ai/ui` and the `@kitn.ai/ui/web-components` bundle, so the web-components-only consumer gets it too. It is SSR-safe (no DOM is touched until the first call on the client).\n\n```js\nimport { toast } from '@kitn.ai/ui/web-components'; // or '@kitn.ai/ui'\n\n// ✅ Fire-and-forget\ntoast('Copied to clipboard');\ntoast.success('Saved');\n\n// ✅ With an Undo action + an imperative handle\nconst t = toast('Item deleted', {\n action: { label: 'Undo', onAction: () => restore() },\n});\nt.update({ message: 'Restored', variant: 'success' });\nt.dismiss();\n```\n\nThe auto-mounted `<kai-toast-region>` carries its own shadow root + kit styles — do NOT add a `<kai-toast-region>` tag yourself unless you deliberately want a second, declaratively-controlled region."
122
+ },
123
+ {
124
+ // Rule 11 — dismissed cards are DEFERRED (reopenable stub), not deleted
125
+ // Source: src/primitives/card-recovery.ts (dismissRecovery) + the dismissed stub
126
+ id: "card-dismiss-deferred",
127
+ test: (t) => {
128
+ const cardCtx = /\bcard(s)?\b|envelope|kai-card|kai-cards|kai-confirm|kai-choice|kai-tasks|kai-form|generative.?ui|resolution|dismissRecovery/i;
129
+ if (!cardCtx.test(t)) return false;
130
+ return /dismiss|reopen|re-?open|\bundo\b|disappear|remove.*card|card.*(gone|remove|delete|vanish)|filter.*out|stub/i.test(t);
131
+ },
132
+ title: "Dismissed cards are DEFERRED (a reopenable stub), not deleted",
133
+ cause: "Dismissing a generative-UI card does NOT delete its envelope from history. The card stamps a `{ kind: 'dismissed' }` resolution onto its envelope and collapses to a small reopenable stub (\"Proposed: <title> — dismissed · Reopen\"). If you filter `dismissed` envelopes out of your cards array, the stub vanishes and the user can never reopen it — and you lose the audit trail of what was proposed.",
134
+ fix: "Keep dismissed envelopes in the array. Wire dismiss/reopen with `dismissRecovery()` (from `@kitn.ai/ui`), which builds the `onDismiss`/`onReopen` half of a `CardPolicy` over your store and can show a \"Dismissed · Undo\" toast via an injected adapter.\n\n```ts\nimport { dismissRecovery } from '@kitn.ai/ui';\nimport { toast } from '@kitn.ai/ui/web-components';\n\n// Adapter: map dismissRecovery's toast shape onto the imperative toast().\nconst toastAdapter = {\n show: ({ message, action, durationMs }) => {\n const handle = toast(message, {\n duration: durationMs,\n action: action && { label: action.label, onAction: action.onClick },\n });\n return { dismiss: handle.dismiss };\n },\n};\n\nconst { onDismiss, onReopen } = dismissRecovery({\n get: () => cards, // your current envelopes\n set: (next) => setCards(next), // NEW array reference (never mutate in place)\n toast: toastAdapter,\n});\n// Pass these on the CardPolicy you hand to <kai-cards> / <kai-remote>.\n```\n\n`onDismiss` writes `dismissed` immutably (Undo restores the prior resolution); `onReopen` clears it back to live (or stamps `expired` when the host says the card is no longer reopenable). Never mutate the array in place — re-render needs a new ref."
135
+ },
136
+ {
137
+ // Rule 12 — kai-compare contract: two candidates, JS data prop, stream both, terminal pick
138
+ // Source: src/web-components/compare/compare.tsx + src/components/response/response-compare-types.ts
139
+ id: "compare-contract",
140
+ test: (t) => {
141
+ if (/<kai-compare\b|kai-compare-select|ResponseCompareData|response.?compare/i.test(t)) return true;
142
+ if (/compar(e|ing|ison)|side.by.side|a\/b|two\s+(responses|candidates|answers|completions)|dual.?response/i.test(t) && /kai-|@kitn|candidate|prefer(ence)?|chosen|reject/i.test(t))
143
+ return true;
144
+ return false;
145
+ },
146
+ title: "`kai-compare` — two candidates, `data` as a JS property, terminal pick",
147
+ cause: "`<kai-compare>` shows EXACTLY two assistant candidates for one prompt and lets the user pick the better one. The `data` value is an array/object, so it must be set as a JS PROPERTY (never an HTML attribute). Both candidates can stream — but, like `kai-chat`, that needs a NEW `data` reference per chunk (mutating in place will not re-render). The pick is a COMMIT (not a Submit): it fires once and the card collapses.",
148
+ fix: "Set `data` in JS with two candidates, stream by reassigning a fresh `data` object per chunk, and listen for `kai-compare-select` directly on the element.\n\n```ts\nimport { toast } from '@kitn.ai/ui/web-components';\nimport type { ResponseCompareData, CompareSelection } from '@kitn.ai/ui';\n\nconst el = document.querySelector('kai-compare')!;\n// data is a JS PROPERTY — exactly two candidates, each with a unique id.\nel.data = {\n prompt: 'Summarise the report',\n candidates: [\n { id: 'a', content: '', streaming: true },\n { id: 'b', content: '', streaming: true },\n ],\n} satisfies ResponseCompareData;\n\n// Stream BOTH columns: replace data with a NEW object per chunk.\nel.data = { ...el.data, candidates: [{ ...a, content: aText }, { ...b, content: bText }] };\n// Clear `streaming` on a candidate when it settles — the pick stays disabled\n// until BOTH have settled, then `kai-ready` fires.\n\n// Picking is terminal: emits { chosenId, rejectedIds, at } and collapses.\nel.addEventListener('kai-compare-select', (e) => {\n const { chosenId, rejectedIds } = (e as CustomEvent<CompareSelection>).detail;\n recordPreference({ prompt, chosen: chosenId, rejected: rejectedIds });\n});\n```\n\nA malformed definition (not two candidates, missing/duplicate ids) fires `kai-error` instead. The event is non-bubbling — listen on the element, not on `document`."
149
+ },
150
+ {
151
+ // Rule 13 — kai-composer is the bare editor; attachments + send live on kai-prompt-input
152
+ // Source: src/web-components/composer/composer.tsx (element doc), src/web-components/prompt/prompt-input.tsx,
153
+ // apps/docs components/composer.mdx (the taxonomy sentence). Rung-6 F-43.
154
+ id: "composer-attachments",
155
+ test: (t) => {
156
+ if (!/kai-composer|composer\b/i.test(t)) return false;
157
+ return /attach|paperclip|file.?upload|upload|send.?button|toolbar|staging|staged/i.test(t);
158
+ },
159
+ title: "`<kai-composer>` is the bare editor — attachments and the send button live on `<kai-prompt-input>`",
160
+ cause: "`<kai-composer>` is deliberately the bare editing surface: rich text, entity pills, triggers, Enter-to-submit — and nothing else. No send button, no toolbar, no attachment surface; its `kai-submit` detail is `{ doc, text, entities }` with no `attachments` field. Nothing is missing — the batteries-included wrapper is a different element.",
161
+ fix: "Reach for `<kai-prompt-input>`, which is built on the composer and adds the send button, the toolbar, and attachments: a paperclip that stages files as chips, a `kai-attachments-change` event, and `attachments` on its `kai-submit` detail.\n\n```html\n<kai-prompt-input placeholder=\"Send a message...\"></kai-prompt-input>\n```\n\n```js\ndocument.querySelector('kai-prompt-input').addEventListener('kai-submit', (e) => {\n const { value, attachments } = e.detail; // attachments staged via the paperclip\n});\n```\n\nKeep `<kai-composer>` only when you are composing the input row yourself — then the picker, staging tray and send control are yours to build, and every staged file’s `url` must be a `data:` URI, never `URL.createObjectURL` (see the attachment-blob-url rule)."
162
+ },
163
+ {
164
+ // Rule 14 — blob: attachment URLs: the wire refuses them; use a data: URI
165
+ // Source: src/primitives/attachment-types.ts (AttachmentData.url doc),
166
+ // src/web-components/prompt/default-input.tsx (readAsDataUrl), src/wire/files.ts (the refusal),
167
+ // apps/docs patterns/attachments-flow.mdx. Rung-6 F-44; the defect PR #186 shipped.
168
+ id: "attachment-blob-url",
169
+ test: (t) => {
170
+ if (/createObjectURL/.test(t) && /attachment|type:\s*'file'|filename|kai|@kitn/i.test(t)) return true;
171
+ if (/blob:/.test(t) && /attachment|toOpenAIMessages|toAnthropicMessages|wire|kai|@kitn/i.test(t)) return true;
172
+ return false;
173
+ },
174
+ title: "Never `URL.createObjectURL` in `AttachmentData.url` — read the file as a `data:` URI",
175
+ cause: "An object URL resolves only inside the browser tab that minted it, so `url: URL.createObjectURL(file)` renders a flawless local preview and is meaningless to any provider. `toOpenAIMessages` / `toAnthropicMessages` REFUSE a `blob:` URL rather than send an address the model cannot fetch — before they refused it, an attachment-only turn reached the model as nothing at all. A mock-only app hides the defect (`createMockResponder` never encodes the thread back); it surfaces the moment you point at a real provider.",
176
+ fix: "Read every staged file as a `data:` URI and put THAT in `url` — it previews identically and is the one form both provider wires actually take:\n\n```js\nconst readAsDataUrl = (file) =>\n new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error);\n reader.readAsDataURL(file);\n });\n\nconst url = await readAsDataUrl(file); // data: URI — never URL.createObjectURL\nreturn { id, type: 'file', filename: file.name, mediaType: file.type, url };\n```\n\n`<kai-prompt-input>`’s built-in paperclip already does this for you; the conversion is only yours when you stage files yourself. With `data:` URIs there is also nothing to revoke — no object-URL lifecycle to manage."
177
+ },
178
+ {
179
+ // Rule 15 — mock tool calls: MockTurn.toolCalls scripts them; the HOST resolves them
180
+ // Source: src/state/mock.ts (MockTurn/MockToolCall), src/wire/chunk.ts (ModelTurn.toolCalls),
181
+ // src/state/stream.ts (upsertTool). Rung-6 F-46/F-47/F-52.
182
+ id: "mock-tool-calls",
183
+ test: (t) => {
184
+ const toolContext = /tool.?call|toolCalls|input-available/i.test(t);
185
+ if (/createMockResponder|mock responder|MockTurn/i.test(t) && toolContext) return true;
186
+ if (toolContext && /\bmock\b/i.test(t) && /kai|@kitn|stream|responder/i.test(t)) return true;
187
+ return false;
188
+ },
189
+ title: "Scripting mock tool calls — `MockTurn.toolCalls`; the HOST resolves the announced call",
190
+ cause: "Two separate facts. (1) The default mock replies are text-only, so out of the box `createMockResponder()` never announces a tool call — scripting one takes a `MockTurn` reply. (2) A tool call — mock or real provider — is only ever ANNOUNCED by the stream: the part parks at `state: 'input-available'` and stays there. The kit parses and renders the call; EXECUTING it and answering is the host’s job.",
191
+ fix: "Script the call with a `MockTurn` reply (`{ text?, toolCalls? }`); the mock frames it announce-then-arguments with `finish_reason: 'tool_calls'`, exactly as a real OpenAI-wire turn, so it flows through `readOpenAIStream` unchanged:\n\n```js\nimport { createMockResponder } from '@kitn.ai/ui/state';\n\nconst mock = createMockResponder({\n replies: [\n 'Plain text turn.',\n { text: 'Checking…', toolCalls: [{ name: 'get_weather', arguments: { city: 'Oslo' } }] },\n ],\n});\n```\n\nThen resolve each announced call yourself after the read settles — walk the returned `ModelTurn.toolCalls` and patch the part to `output-available`:\n\n```js\nconst result = await readOpenAIStream(response, stream);\nfor (const call of result.toolCalls) {\n const output = await runTool(call.name, call.input); // your side of the seam\n stream.upsertTool(call.id, { state: 'output-available', output });\n}\n```\n\nSkip any call with `providerExecuted: true` — the provider already ran it and its result arrived in-stream."
192
+ }
193
+ ];
194
+ function matchRules(text) {
195
+ return RULES.filter((rule) => rule.test(text));
196
+ }
3
197
  const KIT = "@kitn.ai/ui";
4
198
  const MCP = "@kitn.ai/mcp";
5
199
  const KAI_JSON = "kai.json";
@@ -46,9 +240,26 @@ function sourceFiles(dir, limit = 400) {
46
240
  if (statSync(dir, { throwIfNoEntry: false })?.isDirectory()) walk(dir);
47
241
  return out;
48
242
  }
49
- const readAll = (files) => files.flatMap((f) => {
243
+ function baselineDrift(cwd, files) {
244
+ const changed = [];
245
+ const gone = [];
246
+ let same = 0;
247
+ for (const [file, recorded] of Object.entries(files)) {
248
+ let text;
249
+ try {
250
+ text = readFileSync(join(cwd, file), "utf8");
251
+ } catch {
252
+ gone.push(file);
253
+ continue;
254
+ }
255
+ if (createHash("sha256").update(text, "utf8").digest("hex") === recorded) same += 1;
256
+ else changed.push(file);
257
+ }
258
+ return { changed: changed.sort(), gone: gone.sort(), same };
259
+ }
260
+ const readAll = (files) => files.flatMap((file) => {
50
261
  try {
51
- return [readFileSync(f, "utf8")];
262
+ return [{ file, text: readFileSync(file, "utf8") }];
52
263
  } catch {
53
264
  return [];
54
265
  }
@@ -112,7 +323,38 @@ function diagnose(input) {
112
323
  const framework = kaiJson.framework ?? "?";
113
324
  const built = kaiJson.kitBuiltAgainst ?? "?";
114
325
  const features = Array.isArray(kaiJson.features) ? kaiJson.features.join(", ") : "?";
115
- findings.push({ severity: "ok", title: `${KAI_JSON}: framework ${framework}, features ${features}`, detail: `scaffolded against kit ${built}` });
326
+ findings.push({
327
+ severity: "ok",
328
+ title: `${KAI_JSON}: framework ${framework}, features ${features}`,
329
+ detail: `scaffolded against kit ${built}`
330
+ });
331
+ const baseline = kaiJson.files;
332
+ if (baseline !== null && typeof baseline === "object" && Object.keys(baseline).length > 0) {
333
+ const files = baseline;
334
+ const { changed, gone, same } = baselineDrift(input.cwd, files);
335
+ if (changed.length === 0 && gone.length === 0) {
336
+ findings.push({
337
+ severity: "ok",
338
+ title: `${KAI_JSON}'s baseline: all ${same} scaffolded file(s) are exactly as written`
339
+ });
340
+ } else {
341
+ const names = [...changed, ...gone];
342
+ const shown = names.slice(0, 3).join(", ");
343
+ const more = names.length > 3 ? ` (and ${names.length - 3} more)` : "";
344
+ findings.push({
345
+ severity: "info",
346
+ title: `${KAI_JSON}'s baseline: ${same} of ${Object.keys(files).length} scaffolded file(s) are as written, ${changed.length} changed, ${gone.length} gone`,
347
+ detail: `${shown}${more}
348
+ Run \`kai upgrade\` to see what the template this CLI emits would change (it replaces only the files you never touched), or \`kai upgrade --strict\` in CI.`
349
+ });
350
+ }
351
+ } else {
352
+ findings.push({
353
+ severity: "info",
354
+ title: `${KAI_JSON} has no baseline`,
355
+ detail: "it predates the recorded hashes, so this cannot tell your edits from a template change. `kai upgrade` still diffs the project against the template this CLI emits, and will not write without a baseline."
356
+ });
357
+ }
116
358
  } else {
117
359
  findings.push({
118
360
  severity: "info",
@@ -123,7 +365,7 @@ function diagnose(input) {
123
365
  if (declared !== void 0) {
124
366
  const files = sourceFiles(join(input.cwd, "src"));
125
367
  const contents = readAll(files);
126
- const referencing = contents.filter((text) => text.includes(KIT)).length;
368
+ const referencing = contents.filter((c) => c.text.includes(KIT)).length;
127
369
  if (files.length > 0 && referencing === 0) {
128
370
  findings.push({
129
371
  severity: "warn",
@@ -133,7 +375,24 @@ function diagnose(input) {
133
375
  } else if (referencing > 0) {
134
376
  findings.push({ severity: "ok", title: `${referencing} file(s) under src/ reference ${KIT}` });
135
377
  }
136
- const styled = contents.some((text) => /theme\.tokens\.css|theme\.css|solid\.css/.test(text));
378
+ const hitByRule = /* @__PURE__ */ new Map();
379
+ for (const { file, text } of contents) {
380
+ for (const rule of matchRules(text)) {
381
+ if (!hitByRule.has(rule.id)) hitByRule.set(rule.id, { rule, files: [] });
382
+ hitByRule.get(rule.id).files.push(relative(input.cwd, file));
383
+ }
384
+ }
385
+ for (const { rule, files: hits } of hitByRule.values()) {
386
+ const shown = hits.slice(0, 3).join(", ");
387
+ const more = hits.length > 3 ? ` (and ${hits.length - 3} more file(s))` : "";
388
+ findings.push({
389
+ severity: "warn",
390
+ title: `${rule.title} — ${hits.length} file(s) under src/`,
391
+ detail: `${shown}${more}
392
+ ${rule.fix}`
393
+ });
394
+ }
395
+ const styled = contents.some((c) => /theme\.tokens\.css|theme\.css|solid\.css/.test(c.text));
137
396
  if (files.length > 0 && !styled) {
138
397
  findings.push({
139
398
  severity: "info",
@@ -152,11 +411,13 @@ function diagnose(input) {
152
411
  );
153
412
  return findings;
154
413
  }
155
- function exitCodeFor(findings) {
156
- return findings.some((f) => f.severity === "error") ? 1 : 0;
414
+ function exitCodeFor(findings, { strict = false } = {}) {
415
+ if (findings.some((f) => f.severity === "error")) return 1;
416
+ if (strict && findings.some((f) => f.severity === "warn")) return 1;
417
+ return 0;
157
418
  }
158
419
  const MARK = { ok: "✓", info: "·", warn: "!", error: "✗" };
159
- function render(findings, out = console.log) {
420
+ function render(findings, out = console.log, { strict = false } = {}) {
160
421
  for (const finding of findings) {
161
422
  out(`${MARK[finding.severity]} ${finding.title}`);
162
423
  if (finding.detail) out(` ${finding.detail}`);
@@ -165,9 +426,9 @@ function render(findings, out = console.log) {
165
426
  const warns = findings.filter((f) => f.severity === "warn").length;
166
427
  out("");
167
428
  out(
168
- errors > 0 ? `✗ kai doctor: ${errors} problem(s)${warns > 0 ? `, ${warns} warning(s)` : ""}.` : `✓ kai doctor: no problems${warns > 0 ? `, ${warns} warning(s)` : ""}.`
429
+ errors > 0 ? `✗ kai doctor: ${errors} problem(s)${warns > 0 ? `, ${warns} warning(s)` : ""}.` : strict && warns > 0 ? `✗ kai doctor --strict: no problems, but ${warns} warning(s) fail this run.` : `✓ kai doctor: no problems${warns > 0 ? `, ${warns} warning(s)` : ""}.`
169
430
  );
170
- return exitCodeFor(findings);
431
+ return exitCodeFor(findings, { strict });
171
432
  }
172
433
  function cliVersion() {
173
434
  try {
@@ -180,13 +441,14 @@ async function runDoctor(argv = [], io = {}) {
180
441
  const findings = diagnose({
181
442
  cwd: io.cwd ?? process.cwd(),
182
443
  cliVersion: cliVersion(),
183
- builtAgainstKit: "0.34.0"
444
+ builtAgainstKit: "0.35.0"
184
445
  });
446
+ const strict = argv.includes("--strict");
185
447
  if (argv.includes("--json")) {
186
- (io.out ?? console.log)(JSON.stringify({ findings }, null, 2));
187
- return findings.some((f) => f.severity === "error") ? 1 : 0;
448
+ (io.out ?? console.log)(JSON.stringify({ findings, strict }, null, 2));
449
+ return exitCodeFor(findings, { strict });
188
450
  }
189
- return render(findings, io.out);
451
+ return render(findings, io.out, { strict });
190
452
  }
191
453
  export {
192
454
  runDoctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitn.ai/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "The kai command line for @kitn.ai/ui: scaffold a project or add a block to one, diagnose its wiring, and run the construct dev/eject/compile tooling.",
@@ -56,7 +56,7 @@
56
56
  "lint:cli-invocations": "node scripts/lint-cli-invocations.mjs --self-test && node scripts/lint-cli-invocations.mjs"
57
57
  },
58
58
  "dependencies": {
59
- "create-kai": "^0.7.0",
59
+ "create-kai": "^0.8.0",
60
60
  "zod": "^4.4.3"
61
61
  },
62
62
  "devDependencies": {