@kitn.ai/cli 0.1.0 → 0.3.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
@@ -32,10 +32,16 @@ npx -y @kitn.ai/cli add support-widget # no install at all
32
32
  ## doctor
33
33
 
34
34
  ```bash
35
- kai doctor # human-readable
36
- kai doctor --json # the findings, for a CI job or an agent
35
+ kai doctor # human-readable
36
+ kai doctor --json # the findings, for a CI job or an agent
37
+ kai doctor --strict # warnings fail the run too, for CI
37
38
  ```
38
39
 
40
+ It also runs the MCP `debug` tool's rule set over your own source files — the forty-odd classic
41
+ kai-* mistakes (an array prop set as an HTML attribute, a wrong import path, and so on) — reporting
42
+ each matched rule with the files it matched and the fix. Those are warnings by default, since a rule
43
+ matches a PATTERN and a doc example can look like the mistake; `--strict` makes them fail.
44
+
39
45
  It reports the CLI version and the kit it was built against, the kit range this project declares
40
46
  versus the version actually installed, whether `kai.json` is present, whether anything under `src/`
41
47
  references the kit, whether a kit stylesheet is referenced, and whether the MCP package is
package/bin/kai.js CHANGED
@@ -40,6 +40,8 @@ Usage
40
40
  kai add --list print the blocks this release ships
41
41
 
42
42
  kai doctor diagnose this project's kit wiring, versions and registration
43
+ kai doctor --strict the same, but warnings fail the run (for CI)
44
+ kai doctor --json the findings, for a CI job or an agent
43
45
 
44
46
  kai mcp run the MCP server for AI coding harnesses (@kitn.ai/mcp)
45
47
  kai dev <construct.json> live preview with reload-on-edit
package/dist/doctor.es.js CHANGED
@@ -1,5 +1,198 @@
1
1
  import { readFileSync, statSync, readdirSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, relative } from "node:path";
3
+ const RULES = [
4
+ {
5
+ // Rule 1 — array/object data set as an HTML attribute
6
+ // Source: for-ai-agents.mdx §1; context7.json rule 2
7
+ id: "array-as-attribute",
8
+ test: (t) => /\b(messages|models|context|suggestions|triggers)\s*=\s*["']/.test(t),
9
+ title: "Array/object prop set as an HTML attribute (silent failure)",
10
+ 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.",
11
+ 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```"
12
+ },
13
+ {
14
+ // Rule 2 — in-place mutation → no re-render
15
+ // Source: for-ai-agents.mdx §3; context7.json rule 4
16
+ id: "in-place-mutation",
17
+ 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),
18
+ title: "In-place mutation does not trigger a re-render",
19
+ 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.",
20
+ 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"
21
+ },
22
+ {
23
+ // Rule 3 — listening for events on a parent / wrong element
24
+ // Source: for-ai-agents.mdx §2; context7.json rule 3
25
+ id: "event-bubbling",
26
+ test: (t) => /event.*not\s+fir|not\s+fir.*event|listen.*document|document.*listen|listen.*parent|parent.*listen|event.*bubbl/i.test(
27
+ t
28
+ ),
29
+ title: "Listening for events on the wrong element (events are non-bubbling)",
30
+ 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.",
31
+ 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`."
32
+ },
33
+ {
34
+ // Rule 4 — wrong element prefix kitn-
35
+ // Source: context7.json rule 1
36
+ id: "wrong-prefix",
37
+ test: (t) => /\bkitn-/.test(t),
38
+ title: "Wrong element prefix `kitn-` (should be `kai-`)",
39
+ 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.",
40
+ 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```"
41
+ },
42
+ {
43
+ // Rule 6 — web components not registered / renders nothing (React #1 failure)
44
+ // Source: field-test reports; for-ai-agents.mdx §"Import order matters"
45
+ id: "web-components-not-registered",
46
+ test: (t) => {
47
+ 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))
48
+ return true;
49
+ if (/\b(empty|blank)\b/.test(t) && /render|element|component|kai-|<[a-z]+-|shadow/.test(t))
50
+ return true;
51
+ if (/doesn'?t\s+render|won'?t\s+render/.test(t) && /kai-|element|component|custom.?element/.test(t))
52
+ return true;
53
+ return false;
54
+ },
55
+ title: "Web components not registered — renders nothing / empty box",
56
+ 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`.",
57
+ 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\"."
58
+ },
59
+ {
60
+ // Rule 7 — tsc errors inside node_modules/@kitn.ai/ui/src (SolidJS source pulled in)
61
+ // Source: field-test reports; packaging gap (tracked upstream)
62
+ id: "tsc-source-pull",
63
+ 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(
64
+ t
65
+ ),
66
+ title: "tsc errors inside node_modules/@kitn.ai/ui/src (SolidJS source compiled under React)",
67
+ 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.",
68
+ 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.)'
69
+ },
70
+ {
71
+ // Rule 8 — fetch('/api/chat') 404 in a Vite SPA (no server-side routes)
72
+ // Source: field-test reports; common scaffold confusion
73
+ id: "vite-api-404",
74
+ test: (t) => {
75
+ if (/\/api\/chat/.test(t) && /\b404\b|not\s+found/i.test(t)) return true;
76
+ if (/\bvite\b/.test(t) && /api\s+route|route\s+handler|\bPOST\b.*not\s+work/.test(t)) return true;
77
+ if (/next\.?js.*route|route.*next\.?js/.test(t) && /\bvite\b/.test(t)) return true;
78
+ return false;
79
+ },
80
+ title: "fetch('/api/chat') 404 — Vite SPA has no server-side API routes",
81
+ 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.",
82
+ 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```"
83
+ },
84
+ {
85
+ // Rule 9 — reduce bundle size / footprint / "how much does @kitn.ai/ui add"
86
+ // Source: dist/web-components/<file>.js per-web-component exports; dist/autoloader.js
87
+ id: "bundle-footprint",
88
+ 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(
89
+ t
90
+ ),
91
+ title: "Reducing bundle footprint — three load modes",
92
+ 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.",
93
+ 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\"."
94
+ },
95
+ {
96
+ // Rule 5 — SSR / server component / document is not defined
97
+ // Source: for-ai-agents.mdx (client-only import); context7.json rule 2 (property rule requires DOM)
98
+ id: "ssr-server-component",
99
+ test: (t) => {
100
+ 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;
101
+ if (/hydration/.test(t) && /kai|web.?component|custom.?element|<[a-z]+-/.test(t)) return true;
102
+ return false;
103
+ },
104
+ title: "SSR / server-side rendering — web components require the browser DOM",
105
+ 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.',
106
+ 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```"
107
+ },
108
+ {
109
+ // Rule 10 — toast() is the imperative API; there is no <kai-toast> to place
110
+ // Source: src/primitives/toast-store.ts (the `toast` fn + auto-mounted region)
111
+ id: "toast-imperative",
112
+ test: (t) => {
113
+ if (/<kai-toast(-region)?\b/.test(t)) return true;
114
+ 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))
115
+ return true;
116
+ return false;
117
+ },
118
+ title: "Toast is an imperative call — `toast('…')`, not a `<kai-toast>` you place",
119
+ 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.",
120
+ 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."
121
+ },
122
+ {
123
+ // Rule 11 — dismissed cards are DEFERRED (reopenable stub), not deleted
124
+ // Source: src/primitives/card-recovery.ts (dismissRecovery) + the dismissed stub
125
+ id: "card-dismiss-deferred",
126
+ test: (t) => {
127
+ const cardCtx = /\bcard(s)?\b|envelope|kai-card|kai-cards|kai-confirm|kai-choice|kai-tasks|kai-form|generative.?ui|resolution|dismissRecovery/i;
128
+ if (!cardCtx.test(t)) return false;
129
+ return /dismiss|reopen|re-?open|\bundo\b|disappear|remove.*card|card.*(gone|remove|delete|vanish)|filter.*out|stub/i.test(t);
130
+ },
131
+ title: "Dismissed cards are DEFERRED (a reopenable stub), not deleted",
132
+ 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.",
133
+ 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."
134
+ },
135
+ {
136
+ // Rule 12 — kai-compare contract: two candidates, JS data prop, stream both, terminal pick
137
+ // Source: src/web-components/compare/compare.tsx + src/components/response/response-compare-types.ts
138
+ id: "compare-contract",
139
+ test: (t) => {
140
+ if (/<kai-compare\b|kai-compare-select|ResponseCompareData|response.?compare/i.test(t)) return true;
141
+ 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))
142
+ return true;
143
+ return false;
144
+ },
145
+ title: "`kai-compare` — two candidates, `data` as a JS property, terminal pick",
146
+ 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.",
147
+ 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`."
148
+ },
149
+ {
150
+ // Rule 13 — kai-composer is the bare editor; attachments + send live on kai-prompt-input
151
+ // Source: src/web-components/composer/composer.tsx (element doc), src/web-components/prompt/prompt-input.tsx,
152
+ // apps/docs components/composer.mdx (the taxonomy sentence). Rung-6 F-43.
153
+ id: "composer-attachments",
154
+ test: (t) => {
155
+ if (!/kai-composer|composer\b/i.test(t)) return false;
156
+ return /attach|paperclip|file.?upload|upload|send.?button|toolbar|staging|staged/i.test(t);
157
+ },
158
+ title: "`<kai-composer>` is the bare editor — attachments and the send button live on `<kai-prompt-input>`",
159
+ 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.",
160
+ 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)."
161
+ },
162
+ {
163
+ // Rule 14 — blob: attachment URLs: the wire refuses them; use a data: URI
164
+ // Source: src/primitives/attachment-types.ts (AttachmentData.url doc),
165
+ // src/web-components/prompt/default-input.tsx (readAsDataUrl), src/wire/files.ts (the refusal),
166
+ // apps/docs patterns/attachments-flow.mdx. Rung-6 F-44; the defect PR #186 shipped.
167
+ id: "attachment-blob-url",
168
+ test: (t) => {
169
+ if (/createObjectURL/.test(t) && /attachment|type:\s*'file'|filename|kai|@kitn/i.test(t)) return true;
170
+ if (/blob:/.test(t) && /attachment|toOpenAIMessages|toAnthropicMessages|wire|kai|@kitn/i.test(t)) return true;
171
+ return false;
172
+ },
173
+ title: "Never `URL.createObjectURL` in `AttachmentData.url` — read the file as a `data:` URI",
174
+ 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.",
175
+ 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."
176
+ },
177
+ {
178
+ // Rule 15 — mock tool calls: MockTurn.toolCalls scripts them; the HOST resolves them
179
+ // Source: src/state/mock.ts (MockTurn/MockToolCall), src/wire/chunk.ts (ModelTurn.toolCalls),
180
+ // src/state/stream.ts (upsertTool). Rung-6 F-46/F-47/F-52.
181
+ id: "mock-tool-calls",
182
+ test: (t) => {
183
+ const toolContext = /tool.?call|toolCalls|input-available/i.test(t);
184
+ if (/createMockResponder|mock responder|MockTurn/i.test(t) && toolContext) return true;
185
+ if (toolContext && /\bmock\b/i.test(t) && /kai|@kitn|stream|responder/i.test(t)) return true;
186
+ return false;
187
+ },
188
+ title: "Scripting mock tool calls — `MockTurn.toolCalls`; the HOST resolves the announced call",
189
+ 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.",
190
+ 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."
191
+ }
192
+ ];
193
+ function matchRules(text) {
194
+ return RULES.filter((rule) => rule.test(text));
195
+ }
3
196
  const KIT = "@kitn.ai/ui";
4
197
  const MCP = "@kitn.ai/mcp";
5
198
  const KAI_JSON = "kai.json";
@@ -46,9 +239,9 @@ function sourceFiles(dir, limit = 400) {
46
239
  if (statSync(dir, { throwIfNoEntry: false })?.isDirectory()) walk(dir);
47
240
  return out;
48
241
  }
49
- const readAll = (files) => files.flatMap((f) => {
242
+ const readAll = (files) => files.flatMap((file) => {
50
243
  try {
51
- return [readFileSync(f, "utf8")];
244
+ return [{ file, text: readFileSync(file, "utf8") }];
52
245
  } catch {
53
246
  return [];
54
247
  }
@@ -123,7 +316,7 @@ function diagnose(input) {
123
316
  if (declared !== void 0) {
124
317
  const files = sourceFiles(join(input.cwd, "src"));
125
318
  const contents = readAll(files);
126
- const referencing = contents.filter((text) => text.includes(KIT)).length;
319
+ const referencing = contents.filter((c) => c.text.includes(KIT)).length;
127
320
  if (files.length > 0 && referencing === 0) {
128
321
  findings.push({
129
322
  severity: "warn",
@@ -133,7 +326,24 @@ function diagnose(input) {
133
326
  } else if (referencing > 0) {
134
327
  findings.push({ severity: "ok", title: `${referencing} file(s) under src/ reference ${KIT}` });
135
328
  }
136
- const styled = contents.some((text) => /theme\.tokens\.css|theme\.css|solid\.css/.test(text));
329
+ const hitByRule = /* @__PURE__ */ new Map();
330
+ for (const { file, text } of contents) {
331
+ for (const rule of matchRules(text)) {
332
+ if (!hitByRule.has(rule.id)) hitByRule.set(rule.id, { rule, files: [] });
333
+ hitByRule.get(rule.id).files.push(relative(input.cwd, file));
334
+ }
335
+ }
336
+ for (const { rule, files: hits } of hitByRule.values()) {
337
+ const shown = hits.slice(0, 3).join(", ");
338
+ const more = hits.length > 3 ? ` (and ${hits.length - 3} more file(s))` : "";
339
+ findings.push({
340
+ severity: "warn",
341
+ title: `${rule.title} — ${hits.length} file(s) under src/`,
342
+ detail: `${shown}${more}
343
+ ${rule.fix}`
344
+ });
345
+ }
346
+ const styled = contents.some((c) => /theme\.tokens\.css|theme\.css|solid\.css/.test(c.text));
137
347
  if (files.length > 0 && !styled) {
138
348
  findings.push({
139
349
  severity: "info",
@@ -152,11 +362,13 @@ function diagnose(input) {
152
362
  );
153
363
  return findings;
154
364
  }
155
- function exitCodeFor(findings) {
156
- return findings.some((f) => f.severity === "error") ? 1 : 0;
365
+ function exitCodeFor(findings, { strict = false } = {}) {
366
+ if (findings.some((f) => f.severity === "error")) return 1;
367
+ if (strict && findings.some((f) => f.severity === "warn")) return 1;
368
+ return 0;
157
369
  }
158
370
  const MARK = { ok: "✓", info: "·", warn: "!", error: "✗" };
159
- function render(findings, out = console.log) {
371
+ function render(findings, out = console.log, { strict = false } = {}) {
160
372
  for (const finding of findings) {
161
373
  out(`${MARK[finding.severity]} ${finding.title}`);
162
374
  if (finding.detail) out(` ${finding.detail}`);
@@ -165,9 +377,9 @@ function render(findings, out = console.log) {
165
377
  const warns = findings.filter((f) => f.severity === "warn").length;
166
378
  out("");
167
379
  out(
168
- errors > 0 ? `✗ kai doctor: ${errors} problem(s)${warns > 0 ? `, ${warns} warning(s)` : ""}.` : `✓ kai doctor: no problems${warns > 0 ? `, ${warns} warning(s)` : ""}.`
380
+ 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
381
  );
170
- return exitCodeFor(findings);
382
+ return exitCodeFor(findings, { strict });
171
383
  }
172
384
  function cliVersion() {
173
385
  try {
@@ -180,13 +392,14 @@ async function runDoctor(argv = [], io = {}) {
180
392
  const findings = diagnose({
181
393
  cwd: io.cwd ?? process.cwd(),
182
394
  cliVersion: cliVersion(),
183
- builtAgainstKit: "0.33.0"
395
+ builtAgainstKit: "0.35.0"
184
396
  });
397
+ const strict = argv.includes("--strict");
185
398
  if (argv.includes("--json")) {
186
- (io.out ?? console.log)(JSON.stringify({ findings }, null, 2));
187
- return findings.some((f) => f.severity === "error") ? 1 : 0;
399
+ (io.out ?? console.log)(JSON.stringify({ findings, strict }, null, 2));
400
+ return exitCodeFor(findings, { strict });
188
401
  }
189
- return render(findings, io.out);
402
+ return render(findings, io.out, { strict });
190
403
  }
191
404
  export {
192
405
  runDoctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitn.ai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.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.6.0",
59
+ "create-kai": "^0.7.1",
60
60
  "zod": "^4.4.3"
61
61
  },
62
62
  "devDependencies": {