typeclaw 0.1.0 → 0.1.2

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.
Files changed (57) hide show
  1. package/README.md +12 -12
  2. package/package.json +3 -2
  3. package/src/agent/auth.ts +10 -4
  4. package/src/agent/doctor.ts +173 -0
  5. package/src/agent/subagents.ts +24 -2
  6. package/src/bundled-plugins/backup/README.md +81 -0
  7. package/src/bundled-plugins/backup/index.ts +209 -0
  8. package/src/bundled-plugins/backup/runner.ts +231 -0
  9. package/src/bundled-plugins/backup/subagents.ts +200 -0
  10. package/src/bundled-plugins/memory/index.ts +42 -1
  11. package/src/bundled-plugins/security/index.ts +5 -1
  12. package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
  13. package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
  14. package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
  15. package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
  16. package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
  17. package/src/channels/adapters/kakaotalk.ts +58 -3
  18. package/src/channels/router.ts +40 -2
  19. package/src/cli/compose.ts +92 -1
  20. package/src/cli/doctor.ts +100 -0
  21. package/src/cli/index.ts +1 -0
  22. package/src/compose/doctor.ts +141 -0
  23. package/src/compose/index.ts +8 -0
  24. package/src/compose/logs.ts +32 -19
  25. package/src/config/config.ts +20 -0
  26. package/src/container/log-colors.ts +75 -0
  27. package/src/container/log-timestamps.ts +84 -0
  28. package/src/container/logs.ts +71 -5
  29. package/src/container/start.ts +23 -8
  30. package/src/cron/consumer.ts +29 -7
  31. package/src/doctor/checks.ts +426 -0
  32. package/src/doctor/commit.ts +71 -0
  33. package/src/doctor/index.ts +287 -0
  34. package/src/doctor/plugin-bridge.ts +147 -0
  35. package/src/doctor/report.ts +142 -0
  36. package/src/doctor/types.ts +87 -0
  37. package/src/init/cli-version.ts +81 -0
  38. package/src/init/dockerfile.ts +223 -25
  39. package/src/init/ensure-deps.ts +2 -2
  40. package/src/init/index.ts +23 -13
  41. package/src/init/run-bun-install.ts +17 -1
  42. package/src/plugin/hooks.ts +32 -0
  43. package/src/plugin/index.ts +7 -0
  44. package/src/plugin/manager.ts +2 -0
  45. package/src/plugin/registry.ts +32 -3
  46. package/src/plugin/types.ts +65 -0
  47. package/src/run/bundled-plugins.ts +8 -0
  48. package/src/run/index.ts +10 -5
  49. package/src/secrets/env.ts +43 -0
  50. package/src/secrets/index.ts +2 -0
  51. package/src/server/index.ts +103 -5
  52. package/src/shared/index.ts +3 -0
  53. package/src/shared/protocol.ts +22 -0
  54. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
  55. package/src/skills/typeclaw-config/SKILL.md +1 -1
  56. package/tsconfig.json +30 -0
  57. package/typeclaw.schema.json +50 -4
@@ -0,0 +1,287 @@
1
+ import { findAgentDir } from '@/init'
2
+ import type { DoctorCheckPayload } from '@/shared'
3
+
4
+ import { buildStaticChecks } from './checks'
5
+ import { commitAutoFixes, type SpawnGit } from './commit'
6
+ import {
7
+ fetchPluginDoctorChecks as defaultFetchPluginDoctorChecks,
8
+ fetchPluginDoctorFix as defaultFetchPluginDoctorFix,
9
+ type PluginBridgeChecksResult,
10
+ type PluginBridgeFetchChecks,
11
+ type PluginBridgeFetchFix,
12
+ type PluginBridgeFixResult,
13
+ } from './plugin-bridge'
14
+ import type {
15
+ CheckContext,
16
+ CheckResult,
17
+ DoctorCheck,
18
+ DoctorReport,
19
+ DoctorRunResult,
20
+ FixAttempt,
21
+ ReportEntry,
22
+ ReportSummary,
23
+ Severity,
24
+ } from './types'
25
+
26
+ export * from './types'
27
+ export { buildStaticChecks } from './checks'
28
+ export { formatJson, formatReport } from './report'
29
+ export { buildCommitMessage, commitAutoFixes } from './commit'
30
+ export {
31
+ fetchPluginDoctorChecks,
32
+ fetchPluginDoctorFix,
33
+ type PluginBridgeChecksResult,
34
+ type PluginBridgeFixResult,
35
+ } from './plugin-bridge'
36
+
37
+ export type RunDoctorOptions = {
38
+ cwd?: string
39
+ only?: string[]
40
+ fix?: boolean
41
+ staticChecks?: DoctorCheck[]
42
+ fetchPluginChecks?: PluginBridgeFetchChecks
43
+ fetchPluginFix?: PluginBridgeFetchFix
44
+ spawnGit?: SpawnGit
45
+ }
46
+
47
+ export async function runDoctor(opts: RunDoctorOptions = {}): Promise<DoctorRunResult> {
48
+ const cwd = opts.cwd ?? findAgentDir(process.cwd()) ?? process.cwd()
49
+ const hasAgentFolder = findAgentDir(cwd) === cwd
50
+ const ctx: CheckContext = { cwd, hasAgentFolder }
51
+
52
+ const staticChecks = (opts.staticChecks ?? buildStaticChecks()).filter((c) => isAllowed(c, opts.only, c.category))
53
+ const staticResults = await runStaticChecks(staticChecks, ctx)
54
+
55
+ const fetchPluginChecks = opts.fetchPluginChecks ?? defaultFetchPluginDoctorChecks
56
+ const pluginResults = await collectPluginChecks(fetchPluginChecks, ctx, opts.only)
57
+
58
+ const initial = buildReport(ctx, staticResults, pluginResults)
59
+
60
+ if (opts.fix !== true || initial.ok) {
61
+ return { initial }
62
+ }
63
+
64
+ const fetchPluginFix = opts.fetchPluginFix ?? defaultFetchPluginDoctorFix
65
+ const attempts: FixAttempt[] = []
66
+ attempts.push(...(await runStaticFixes(staticResults, ctx)))
67
+ attempts.push(...(await runPluginFixes(pluginResults.entries, fetchPluginFix, ctx)))
68
+
69
+ const commit = hasAgentFolder
70
+ ? await commitAutoFixes({ cwd, attempts, ...(opts.spawnGit !== undefined ? { spawnGit: opts.spawnGit } : {}) })
71
+ : { kind: 'skipped' as const, reason: 'no agent folder; nothing to commit' }
72
+
73
+ const finalStaticResults = await runStaticChecks(staticChecks, ctx)
74
+ const finalPluginResults = await collectPluginChecks(fetchPluginChecks, ctx, opts.only)
75
+ const final = buildReport(ctx, finalStaticResults, finalPluginResults)
76
+
77
+ return { initial, fixAttempts: attempts, commit, final }
78
+ }
79
+
80
+ type StaticResult = {
81
+ check: DoctorCheck
82
+ result: CheckResult
83
+ }
84
+
85
+ type PluginCollect = {
86
+ entries: PluginEntry[]
87
+ reachability: PluginBridgeChecksResult
88
+ }
89
+
90
+ type PluginEntry = {
91
+ payload: DoctorCheckPayload
92
+ }
93
+
94
+ async function runStaticChecks(checks: DoctorCheck[], ctx: CheckContext): Promise<StaticResult[]> {
95
+ const out: StaticResult[] = []
96
+ for (const check of checks) {
97
+ if (check.applies && !check.applies(ctx)) {
98
+ out.push({ check, result: { status: 'skipped', message: 'not applicable' } })
99
+ continue
100
+ }
101
+ try {
102
+ out.push({ check, result: await check.run(ctx) })
103
+ } catch (err) {
104
+ out.push({ check, result: { status: 'error', message: err instanceof Error ? err.message : String(err) } })
105
+ }
106
+ }
107
+ return out
108
+ }
109
+
110
+ async function collectPluginChecks(
111
+ fetcher: PluginBridgeFetchChecks,
112
+ ctx: CheckContext,
113
+ only: string[] | undefined,
114
+ ): Promise<PluginCollect> {
115
+ if (!ctx.hasAgentFolder) {
116
+ return { entries: [], reachability: { kind: 'unreachable', reason: 'no agent folder' } }
117
+ }
118
+ const reachability = await fetcher({ cwd: ctx.cwd })
119
+ if (reachability.kind !== 'ok') return { entries: [], reachability }
120
+ const filtered = reachability.checks.filter((c) => isAllowed({ category: c.category }, only, c.category))
121
+ return { entries: filtered.map((payload) => ({ payload })), reachability }
122
+ }
123
+
124
+ function buildReport(ctx: CheckContext, staticResults: StaticResult[], plugin: PluginCollect): DoctorReport {
125
+ const entries: ReportEntry[] = []
126
+ for (const { check, result } of staticResults) {
127
+ const entry: ReportEntry = {
128
+ name: check.name,
129
+ category: check.category,
130
+ description: check.description,
131
+ source: 'static',
132
+ status: result.status,
133
+ message: result.message,
134
+ }
135
+ if (result.details !== undefined) entry.details = result.details
136
+ if (result.fix !== undefined) {
137
+ entry.fix = { description: result.fix.description, canAutoFix: result.fix.autoFix !== undefined }
138
+ }
139
+ entries.push(entry)
140
+ }
141
+
142
+ if (ctx.hasAgentFolder && plugin.reachability.kind !== 'ok') {
143
+ entries.push(reachabilityNote(plugin.reachability))
144
+ }
145
+
146
+ for (const { payload } of plugin.entries) {
147
+ const entry: ReportEntry = {
148
+ name: payload.checkName,
149
+ category: payload.category,
150
+ description: payload.description,
151
+ source: 'plugin',
152
+ pluginName: payload.pluginName,
153
+ status: payload.status,
154
+ message: payload.message,
155
+ }
156
+ if (payload.details !== undefined) entry.details = payload.details
157
+ if (payload.fix !== undefined) {
158
+ entry.fix = { description: payload.fix.description, canAutoFix: payload.fix.hasApply }
159
+ }
160
+ entries.push(entry)
161
+ }
162
+
163
+ const summary = summarize(entries)
164
+ const ok = summary.error === 0 && summary.warning === 0
165
+ return { cwd: ctx.cwd, hasAgentFolder: ctx.hasAgentFolder, entries, summary, ok }
166
+ }
167
+
168
+ function reachabilityNote(reach: PluginBridgeChecksResult): ReportEntry {
169
+ const message =
170
+ reach.kind === 'unreachable'
171
+ ? `plugin checks deferred: container not reachable (${reach.reason})`
172
+ : reach.kind === 'timeout'
173
+ ? 'plugin checks deferred: container did not respond in time'
174
+ : `plugin checks deferred: ${reach.kind === 'error' ? reach.reason : 'unknown reason'}`
175
+ return {
176
+ name: 'plugin-checks-deferred',
177
+ category: 'container',
178
+ description: 'plugin doctor checks require a running container',
179
+ source: 'static',
180
+ status: 'info',
181
+ message,
182
+ details: ['Run `typeclaw start` then re-run `typeclaw doctor` to include plugin checks.'],
183
+ }
184
+ }
185
+
186
+ function summarize(entries: ReportEntry[]): ReportSummary {
187
+ const summary: ReportSummary = { ok: 0, warning: 0, error: 0, info: 0, skipped: 0 }
188
+ for (const e of entries) {
189
+ summary[e.status as keyof ReportSummary]++
190
+ }
191
+ return summary
192
+ }
193
+
194
+ function isAllowed(target: { category: string }, only: string[] | undefined, category: string): boolean {
195
+ if (!only || only.length === 0) return true
196
+ if (only.includes(target.category) || only.includes(category)) return true
197
+ if (category.startsWith('plugin:')) return only.includes('plugin') || only.includes(category)
198
+ return false
199
+ }
200
+
201
+ async function runStaticFixes(results: StaticResult[], ctx: CheckContext): Promise<FixAttempt[]> {
202
+ const attempts: FixAttempt[] = []
203
+ for (const { check, result } of results) {
204
+ if (result.status === 'ok' || result.status === 'skipped' || result.status === 'info') continue
205
+ const apply = result.fix?.autoFix
206
+ if (!apply) continue
207
+ try {
208
+ const fix = await apply(ctx)
209
+ attempts.push({
210
+ name: check.name,
211
+ source: 'static',
212
+ ok: true,
213
+ summary: fix.summary,
214
+ changedPaths: fix.changedPaths,
215
+ })
216
+ } catch (err) {
217
+ attempts.push({
218
+ name: check.name,
219
+ source: 'static',
220
+ ok: false,
221
+ reason: err instanceof Error ? err.message : String(err),
222
+ })
223
+ }
224
+ }
225
+ return attempts
226
+ }
227
+
228
+ async function runPluginFixes(
229
+ entries: PluginEntry[],
230
+ fetchFix: PluginBridgeFetchFix,
231
+ ctx: CheckContext,
232
+ ): Promise<FixAttempt[]> {
233
+ const attempts: FixAttempt[] = []
234
+ for (const { payload } of entries) {
235
+ if (payload.status === 'ok') continue
236
+ if (payload.fix === undefined || payload.fix.hasApply !== true) continue
237
+ const result = await fetchFix({ cwd: ctx.cwd, checkId: payload.id })
238
+ if (result.kind !== 'ok') {
239
+ attempts.push({
240
+ name: `${payload.pluginName}.${payload.checkName}`,
241
+ source: 'plugin',
242
+ ok: false,
243
+ reason:
244
+ result.kind === 'unreachable'
245
+ ? result.reason
246
+ : result.kind === 'timeout'
247
+ ? 'timeout'
248
+ : (result as { reason: string }).reason,
249
+ })
250
+ continue
251
+ }
252
+ if (result.payload.ok) {
253
+ const accepted = sanitizeChangedPathsForHost(ctx.cwd, result.payload.changedPaths)
254
+ attempts.push({
255
+ name: `${payload.pluginName}.${payload.checkName}`,
256
+ source: 'plugin',
257
+ ok: true,
258
+ summary: result.payload.summary,
259
+ changedPaths: accepted,
260
+ })
261
+ } else {
262
+ attempts.push({
263
+ name: `${payload.pluginName}.${payload.checkName}`,
264
+ source: 'plugin',
265
+ ok: false,
266
+ reason: result.payload.error,
267
+ })
268
+ }
269
+ }
270
+ return attempts
271
+ }
272
+
273
+ // Defense in depth: even though the container-side runner sanitizes
274
+ // changedPaths, re-validate on the host before `git add` so a future protocol
275
+ // change cannot bypass the security boundary by accident.
276
+ function sanitizeChangedPathsForHost(_cwd: string, paths: readonly string[]): string[] {
277
+ const out: string[] = []
278
+ for (const raw of paths) {
279
+ if (typeof raw !== 'string' || raw.length === 0) continue
280
+ if (raw.startsWith('/') || raw.includes('\\')) continue
281
+ if (raw.split('/').includes('..')) continue
282
+ out.push(raw)
283
+ }
284
+ return out
285
+ }
286
+
287
+ export type { Severity }
@@ -0,0 +1,147 @@
1
+ import { resolveHostPort } from '@/container'
2
+ import type { ClientMessage, DoctorCheckPayload, DoctorFixPayload, ServerMessage } from '@/shared'
3
+
4
+ export type PluginBridgeOptions = {
5
+ cwd: string
6
+ url?: string
7
+ timeoutMs?: number
8
+ }
9
+
10
+ export type PluginBridgeFetchChecks = (opts: PluginBridgeOptions) => Promise<PluginBridgeChecksResult>
11
+ export type PluginBridgeFetchFix = (opts: PluginBridgeOptions & { checkId: string }) => Promise<PluginBridgeFixResult>
12
+
13
+ export type PluginBridgeChecksResult =
14
+ | { kind: 'ok'; checks: DoctorCheckPayload[] }
15
+ | { kind: 'unreachable'; reason: string }
16
+ | { kind: 'timeout' }
17
+ | { kind: 'error'; reason: string }
18
+
19
+ export type PluginBridgeFixResult =
20
+ | { kind: 'ok'; payload: DoctorFixPayload }
21
+ | { kind: 'unreachable'; reason: string }
22
+ | { kind: 'timeout' }
23
+ | { kind: 'error'; reason: string }
24
+
25
+ const DEFAULT_TIMEOUT_MS = 15_000
26
+
27
+ export async function fetchPluginDoctorChecks(opts: PluginBridgeOptions): Promise<PluginBridgeChecksResult> {
28
+ const reach = await dial(opts)
29
+ if (reach.kind !== 'ok') return reach
30
+ const { ws, timeoutMs } = reach
31
+ const requestId = randomId()
32
+ try {
33
+ return await withRequest<PluginBridgeChecksResult>(
34
+ ws,
35
+ timeoutMs,
36
+ requestId,
37
+ (msg) => {
38
+ if (msg.type === 'doctor_result' && msg.requestId === requestId) {
39
+ return { kind: 'ok', checks: msg.checks }
40
+ }
41
+ return null
42
+ },
43
+ { type: 'doctor', requestId },
44
+ )
45
+ } finally {
46
+ ws.close()
47
+ }
48
+ }
49
+
50
+ export async function fetchPluginDoctorFix(
51
+ opts: PluginBridgeOptions & { checkId: string },
52
+ ): Promise<PluginBridgeFixResult> {
53
+ const reach = await dial(opts)
54
+ if (reach.kind !== 'ok') return reach
55
+ const { ws, timeoutMs } = reach
56
+ const requestId = randomId()
57
+ try {
58
+ return await withRequest<PluginBridgeFixResult>(
59
+ ws,
60
+ timeoutMs,
61
+ requestId,
62
+ (msg) => {
63
+ if (msg.type === 'doctor_fix_result' && msg.requestId === requestId) {
64
+ return { kind: 'ok', payload: msg.result }
65
+ }
66
+ return null
67
+ },
68
+ { type: 'doctor_fix', requestId, checkId: opts.checkId },
69
+ )
70
+ } finally {
71
+ ws.close()
72
+ }
73
+ }
74
+
75
+ type DialResult = { kind: 'ok'; ws: WebSocket; timeoutMs: number } | { kind: 'unreachable'; reason: string }
76
+
77
+ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
78
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
79
+ let url = opts.url
80
+ if (url === undefined) {
81
+ try {
82
+ const port = await resolveHostPort({ cwd: opts.cwd })
83
+ url = `ws://localhost:${port}`
84
+ } catch (err) {
85
+ return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
86
+ }
87
+ }
88
+ const ws = new WebSocket(url)
89
+ try {
90
+ await new Promise<void>((resolve, reject) => {
91
+ const cleanup = () => {
92
+ ws.removeEventListener('open', onOpen)
93
+ ws.removeEventListener('error', onError)
94
+ }
95
+ const onOpen = () => {
96
+ cleanup()
97
+ resolve()
98
+ }
99
+ const onError = (err: unknown) => {
100
+ cleanup()
101
+ reject(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
102
+ }
103
+ ws.addEventListener('open', onOpen, { once: true })
104
+ ws.addEventListener('error', onError, { once: true })
105
+ })
106
+ } catch (err) {
107
+ return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
108
+ }
109
+ return { kind: 'ok', ws, timeoutMs }
110
+ }
111
+
112
+ async function withRequest<R extends { kind: string }>(
113
+ ws: WebSocket,
114
+ timeoutMs: number,
115
+ _requestId: string,
116
+ match: (msg: ServerMessage) => R | null,
117
+ outgoing: ClientMessage,
118
+ ): Promise<R | { kind: 'timeout' } | { kind: 'error'; reason: string }> {
119
+ ws.send(JSON.stringify(outgoing))
120
+ return new Promise((resolve) => {
121
+ const timer = setTimeout(() => {
122
+ ws.removeEventListener('message', onMessage)
123
+ resolve({ kind: 'timeout' })
124
+ }, timeoutMs)
125
+ const onMessage = (event: MessageEvent) => {
126
+ let msg: ServerMessage
127
+ try {
128
+ msg = JSON.parse(String(event.data)) as ServerMessage
129
+ } catch (err) {
130
+ clearTimeout(timer)
131
+ ws.removeEventListener('message', onMessage)
132
+ resolve({ kind: 'error', reason: err instanceof Error ? err.message : String(err) })
133
+ return
134
+ }
135
+ const result = match(msg)
136
+ if (result === null) return
137
+ clearTimeout(timer)
138
+ ws.removeEventListener('message', onMessage)
139
+ resolve(result)
140
+ }
141
+ ws.addEventListener('message', onMessage)
142
+ })
143
+ }
144
+
145
+ function randomId(): string {
146
+ return `doc-${crypto.randomUUID()}`
147
+ }
@@ -0,0 +1,142 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ import type { DoctorReport, ReportEntry, Severity } from './types'
4
+
5
+ export type FormatOptions = { useColor?: boolean; verbose?: boolean }
6
+
7
+ type ColorFn = (s: string) => string
8
+ type Palette = {
9
+ bold: ColorFn
10
+ dim: ColorFn
11
+ green: ColorFn
12
+ yellow: ColorFn
13
+ red: ColorFn
14
+ cyan: ColorFn
15
+ gray: ColorFn
16
+ }
17
+
18
+ const identity: ColorFn = (s) => s
19
+ const NO_PALETTE: Palette = {
20
+ bold: identity,
21
+ dim: identity,
22
+ green: identity,
23
+ yellow: identity,
24
+ red: identity,
25
+ cyan: identity,
26
+ gray: identity,
27
+ }
28
+
29
+ const COLOR_PALETTE: Palette = {
30
+ bold: (s) => styleText('bold', s),
31
+ dim: (s) => styleText('dim', s),
32
+ green: (s) => styleText('green', s),
33
+ yellow: (s) => styleText('yellow', s),
34
+ red: (s) => styleText('red', s),
35
+ cyan: (s) => styleText('cyan', s),
36
+ gray: (s) => styleText('gray', s),
37
+ }
38
+
39
+ export function formatReport(report: DoctorReport, opts: FormatOptions = {}): string {
40
+ const verbose = opts.verbose ?? false
41
+ const useColor = opts.useColor ?? false
42
+ const p: Palette = useColor ? COLOR_PALETTE : NO_PALETTE
43
+
44
+ const lines: string[] = []
45
+ lines.push(`${p.bold('typeclaw doctor')} ${p.dim(report.cwd)}`)
46
+ lines.push('')
47
+
48
+ const byCategory = groupByCategory(report.entries)
49
+ for (const [category, entries] of byCategory) {
50
+ const worst = worstSeverity(entries.map((e) => e.status))
51
+ lines.push(`${categoryBox(worst, p)} ${p.bold(category)}`)
52
+ for (const entry of entries) {
53
+ lines.push(` ${markerForEntry(entry, p)} ${entry.message}${describeOriginSuffix(entry, p)}`)
54
+ if (verbose) {
55
+ for (const detail of entry.details ?? []) {
56
+ lines.push(` ${p.dim('•')} ${p.dim(detail)}`)
57
+ }
58
+ }
59
+ if (entry.fix !== undefined) {
60
+ const tag = entry.fix.canAutoFix ? p.cyan('→ Fix (auto):') : p.cyan('→ Fix:')
61
+ lines.push(` ${tag} ${entry.fix.description}`)
62
+ }
63
+ }
64
+ lines.push('')
65
+ }
66
+
67
+ lines.push(summaryLine(report, p))
68
+ return lines.join('\n').replace(/\n+$/, '\n').trimEnd()
69
+ }
70
+
71
+ export function formatJson(report: DoctorReport): string {
72
+ return JSON.stringify(report, null, 2)
73
+ }
74
+
75
+ function groupByCategory(entries: ReportEntry[]): Map<string, ReportEntry[]> {
76
+ const m = new Map<string, ReportEntry[]>()
77
+ for (const entry of entries) {
78
+ const arr = m.get(entry.category)
79
+ if (arr) arr.push(entry)
80
+ else m.set(entry.category, [entry])
81
+ }
82
+ return m
83
+ }
84
+
85
+ function worstSeverity(statuses: Severity[]): Severity {
86
+ if (statuses.some((s) => s === 'error')) return 'error'
87
+ if (statuses.some((s) => s === 'warning')) return 'warning'
88
+ if (statuses.some((s) => s === 'info')) return 'info'
89
+ if (statuses.every((s) => s === 'skipped')) return 'skipped'
90
+ return 'ok'
91
+ }
92
+
93
+ function categoryBox(status: Severity, p: Palette): string {
94
+ switch (status) {
95
+ case 'ok':
96
+ return p.green('[✓]')
97
+ case 'warning':
98
+ return p.yellow('[!]')
99
+ case 'error':
100
+ return p.red('[✗]')
101
+ case 'skipped':
102
+ return p.dim('[-]')
103
+ case 'info':
104
+ return p.cyan('[i]')
105
+ }
106
+ }
107
+
108
+ function markerForEntry(entry: ReportEntry, p: Palette): string {
109
+ switch (entry.status) {
110
+ case 'ok':
111
+ return p.green('✓')
112
+ case 'warning':
113
+ return p.yellow('!')
114
+ case 'error':
115
+ return p.red('✗')
116
+ case 'skipped':
117
+ return p.dim('-')
118
+ case 'info':
119
+ return p.cyan('i')
120
+ }
121
+ }
122
+
123
+ function describeOriginSuffix(entry: ReportEntry, p: Palette): string {
124
+ const name = entry.pluginName ? `${entry.name} [plugin:${entry.pluginName}]` : entry.name
125
+ return ` ${p.dim(`(${name})`)}`
126
+ }
127
+
128
+ function summaryLine(report: DoctorReport, p: Palette): string {
129
+ const { ok, warning, error, info, skipped } = report.summary
130
+ const parts: string[] = []
131
+ parts.push(`${ok} ok`)
132
+ if (warning > 0) parts.push(p.yellow(`${warning} warning${warning === 1 ? '' : 's'}`))
133
+ if (error > 0) parts.push(p.red(`${error} error${error === 1 ? '' : 's'}`))
134
+ if (info > 0) parts.push(p.dim(`${info} info`))
135
+ if (skipped > 0) parts.push(p.dim(`${skipped} skipped`))
136
+
137
+ const headLine = `${p.bold('Summary:')} ${parts.join(', ')}`
138
+ if (report.ok) {
139
+ return `${p.green('●')} ${headLine}`
140
+ }
141
+ return `${p.red('●')} ${headLine}`
142
+ }
@@ -0,0 +1,87 @@
1
+ export type Severity = 'ok' | 'warning' | 'error' | 'info' | 'skipped'
2
+
3
+ export type DoctorCategory =
4
+ | 'docker'
5
+ | 'agent-folder'
6
+ | 'config'
7
+ | 'mounts'
8
+ | 'hostd'
9
+ | 'ports'
10
+ | 'container'
11
+ | 'runtime'
12
+ | 'compose'
13
+ | string
14
+
15
+ export type FixResult = {
16
+ summary: string
17
+ changedPaths: string[]
18
+ }
19
+
20
+ export type FixSuggestion = {
21
+ description: string
22
+ autoFix?: (ctx: CheckContext) => Promise<FixResult>
23
+ }
24
+
25
+ export type CheckResult = {
26
+ status: Severity
27
+ message: string
28
+ details?: string[]
29
+ fix?: FixSuggestion
30
+ }
31
+
32
+ export type CheckContext = {
33
+ cwd: string
34
+ hasAgentFolder: boolean
35
+ }
36
+
37
+ export type DoctorCheck = {
38
+ name: string
39
+ category: DoctorCategory
40
+ description: string
41
+ applies?: (ctx: CheckContext) => boolean
42
+ run: (ctx: CheckContext) => Promise<CheckResult>
43
+ }
44
+
45
+ export type ReportEntry = {
46
+ name: string
47
+ category: DoctorCategory
48
+ description: string
49
+ source: 'static' | 'plugin'
50
+ pluginName?: string
51
+ status: Severity
52
+ message: string
53
+ details?: string[]
54
+ fix?: { description: string; canAutoFix: boolean }
55
+ }
56
+
57
+ export type ReportSummary = {
58
+ ok: number
59
+ warning: number
60
+ error: number
61
+ info: number
62
+ skipped: number
63
+ }
64
+
65
+ export type DoctorReport = {
66
+ cwd: string
67
+ hasAgentFolder: boolean
68
+ entries: ReportEntry[]
69
+ summary: ReportSummary
70
+ ok: boolean
71
+ }
72
+
73
+ export type FixAttempt =
74
+ | { name: string; source: 'static' | 'plugin'; ok: true; summary: string; changedPaths: string[] }
75
+ | { name: string; source: 'static' | 'plugin'; ok: false; reason: string }
76
+
77
+ export type CommitOutcome =
78
+ | { kind: 'committed'; commitSha: string; pathsStaged: string[] }
79
+ | { kind: 'skipped'; reason: string }
80
+ | { kind: 'failed'; reason: string }
81
+
82
+ export type DoctorRunResult = {
83
+ initial: DoctorReport
84
+ fixAttempts?: FixAttempt[]
85
+ commit?: CommitOutcome
86
+ final?: DoctorReport
87
+ }