@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 +8 -2
- package/bin/kai.js +2 -0
- package/dist/doctor.es.js +227 -14
- package/package.json +2 -2
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
|
|
36
|
-
kai doctor --json
|
|
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((
|
|
242
|
+
const readAll = (files) => files.flatMap((file) => {
|
|
50
243
|
try {
|
|
51
|
-
return [readFileSync(
|
|
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((
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
59
|
+
"create-kai": "^0.7.1",
|
|
60
60
|
"zod": "^4.4.3"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|