typeclaw 0.1.1 → 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.
- package/README.md +12 -12
- package/package.json +1 -1
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/channels/router.ts +29 -0
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +1 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +20 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +23 -8
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +223 -25
- package/src/init/index.ts +18 -10
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +8 -0
- package/src/run/index.ts +10 -5
- package/src/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/typeclaw.schema.json +50 -0
package/src/container/start.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { configSchema, expandMountPath, type Config } from '@/config/config'
|
|
|
7
7
|
import { send as sendToDaemon } from '@/hostd/client'
|
|
8
8
|
import type { HttpInfoResult } from '@/hostd/protocol'
|
|
9
9
|
import { ensureDaemon } from '@/hostd/spawn'
|
|
10
|
+
import { resolveBaseImageVersion } from '@/init/cli-version'
|
|
10
11
|
import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
|
|
11
12
|
import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
|
|
12
13
|
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
@@ -142,7 +143,6 @@ export async function start({
|
|
|
142
143
|
// one-shot and idempotent — once `workspaces` is set, refreshPackageJson
|
|
143
144
|
// is a no-op, so users who never edit their agent folder pay zero cost on
|
|
144
145
|
// subsequent starts and users who customized `workspaces` are not clobbered.
|
|
145
|
-
await refreshDockerfile(cwd)
|
|
146
146
|
await refreshGitignore(cwd)
|
|
147
147
|
const pkgRefresh = await refreshPackageJson(cwd)
|
|
148
148
|
await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
|
|
@@ -161,6 +161,11 @@ export async function start({
|
|
|
161
161
|
return { ok: false, reason: `dependency install failed: ${deps.reason}` }
|
|
162
162
|
}
|
|
163
163
|
await commitSystemFile(cwd, DEPENDENCY_FILES, 'Update dependencies')
|
|
164
|
+
// Dockerfile refresh AFTER ensureDeps so the version pin in the FROM
|
|
165
|
+
// line resolves against the agent's installed node_modules/typeclaw —
|
|
166
|
+
// ensures the base image's CLI version matches the runtime the
|
|
167
|
+
// container will actually load.
|
|
168
|
+
await refreshDockerfile(cwd)
|
|
164
169
|
|
|
165
170
|
if (state.exists) {
|
|
166
171
|
// Container holds the name but is not running. Without `--rm`, this is
|
|
@@ -315,7 +320,8 @@ export async function planStart({
|
|
|
315
320
|
const imageTag = imageTagFromCwd(cwd)
|
|
316
321
|
|
|
317
322
|
const devSourcePath = await detectDevSource(cwd)
|
|
318
|
-
const
|
|
323
|
+
const cfg = await loadTypeclawConfig(cwd)
|
|
324
|
+
const mounts = cfg.mounts
|
|
319
325
|
|
|
320
326
|
// No `--rm`: a crashed container's logs MUST survive past exit so users can
|
|
321
327
|
// debug the failure. `typeclaw stop` removes the container explicitly, and
|
|
@@ -324,6 +330,17 @@ export async function planStart({
|
|
|
324
330
|
// a running container or one the user has not started again yet.
|
|
325
331
|
const runArgs = ['run', '-d', '--name', containerName, '-p', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
|
|
326
332
|
|
|
333
|
+
// Network egress filter: when `typeclaw.json#network.blockInternal` is true,
|
|
334
|
+
// grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
|
|
335
|
+
// install iptables OUTPUT rules. The shim drops the capability from the
|
|
336
|
+
// bounding set via setpriv before exec'ing the agent — see the shim source
|
|
337
|
+
// in src/init/dockerfile.ts for the full handoff. The `-e` flag is what
|
|
338
|
+
// tells the shim to take the on-path; absent or set to anything other than
|
|
339
|
+
// "1", the shim is a no-op.
|
|
340
|
+
if (cfg.network.blockInternal) {
|
|
341
|
+
runArgs.push('--cap-add=NET_ADMIN', '-e', 'TYPECLAW_NETWORK_BLOCK_INTERNAL=1')
|
|
342
|
+
}
|
|
343
|
+
|
|
327
344
|
if (hostdControl) {
|
|
328
345
|
runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
|
|
329
346
|
}
|
|
@@ -386,7 +403,10 @@ export async function planStart({
|
|
|
386
403
|
|
|
387
404
|
export async function refreshDockerfile(cwd: string): Promise<void> {
|
|
388
405
|
const cfg = await loadTypeclawConfig(cwd)
|
|
389
|
-
await writeFile(
|
|
406
|
+
await writeFile(
|
|
407
|
+
join(cwd, DOCKERFILE),
|
|
408
|
+
buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) }),
|
|
409
|
+
)
|
|
390
410
|
}
|
|
391
411
|
|
|
392
412
|
export async function refreshGitignore(cwd: string): Promise<void> {
|
|
@@ -576,11 +596,6 @@ async function detectDevSource(cwd: string): Promise<string | null> {
|
|
|
576
596
|
// folder mid-init). Anything else — malformed JSON, schema-invalid config,
|
|
577
597
|
// invalid mount entry — must surface so the user sees they configured a mount
|
|
578
598
|
// that won't be applied.
|
|
579
|
-
async function loadMounts(cwd: string): Promise<Config['mounts']> {
|
|
580
|
-
const cfg = await loadTypeclawConfig(cwd)
|
|
581
|
-
return cfg.mounts
|
|
582
|
-
}
|
|
583
|
-
|
|
584
599
|
async function loadTypeclawConfig(cwd: string): Promise<Config> {
|
|
585
600
|
return configSchema.parse(await loadConfigJson(cwd))
|
|
586
601
|
}
|
package/src/cron/consumer.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
+
import type { SessionOrigin } from '@/agent/session-origin'
|
|
1
2
|
import type { HookBus } from '@/plugin'
|
|
2
3
|
import type { Stream, Unsubscribe } from '@/stream'
|
|
3
4
|
|
|
4
5
|
import type { CronJob, ExecJob, PromptJob } from './schema'
|
|
5
6
|
|
|
6
|
-
// `hooks`, `sessionId`, and `getTranscriptPath` are optional so
|
|
7
|
-
// stay one-liners. When present, the consumer fires
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
7
|
+
// `hooks`, `sessionId`, `agentDir`, and `getTranscriptPath` are optional so
|
|
8
|
+
// test fakes can stay one-liners. When present, the consumer fires
|
|
9
|
+
// `session.turn.start`/`session.turn.end` around `prompt()`, then
|
|
10
|
+
// `session.idle` after, then `session.end` on dispose — mirroring the
|
|
11
|
+
// lifecycle signals the TUI server emits in `src/server/index.ts`. Without
|
|
12
|
+
// this the bundled memory plugin's debounced `memory-logger` never spawns for
|
|
13
|
+
// cron prompt jobs (it only wakes on `session.idle`), and the bundled backup
|
|
14
|
+
// plugin's turn counter would miss cron-driven activity.
|
|
12
15
|
export type CronSession = {
|
|
13
16
|
prompt: (text: string) => Promise<void>
|
|
14
17
|
dispose?: () => void
|
|
15
18
|
hooks?: HookBus
|
|
16
19
|
sessionId?: string
|
|
20
|
+
agentDir?: string
|
|
17
21
|
getTranscriptPath?: () => string | undefined
|
|
22
|
+
origin?: SessionOrigin
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export type CronConsumerLogger = {
|
|
@@ -102,8 +107,25 @@ async function runPrompt(
|
|
|
102
107
|
return
|
|
103
108
|
}
|
|
104
109
|
const session = await createSessionForCron(job)
|
|
110
|
+
const turnEvent =
|
|
111
|
+
session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
|
|
112
|
+
? {
|
|
113
|
+
sessionId: session.sessionId,
|
|
114
|
+
agentDir: session.agentDir,
|
|
115
|
+
...(session.origin !== undefined ? { origin: session.origin } : {}),
|
|
116
|
+
}
|
|
117
|
+
: undefined
|
|
105
118
|
try {
|
|
106
|
-
|
|
119
|
+
if (session.hooks && turnEvent !== undefined) {
|
|
120
|
+
await session.hooks.runSessionTurnStart(turnEvent)
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await session.prompt(job.prompt)
|
|
124
|
+
} finally {
|
|
125
|
+
if (session.hooks && turnEvent !== undefined) {
|
|
126
|
+
await session.hooks.runSessionTurnEnd(turnEvent)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
107
129
|
if (session.hooks && session.sessionId !== undefined) {
|
|
108
130
|
await session.hooks.runSessionIdle({
|
|
109
131
|
sessionId: session.sessionId,
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join, relative } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { loadConfigSync, validateConfig } from '@/config'
|
|
7
|
+
import {
|
|
8
|
+
checkDockerAvailable,
|
|
9
|
+
containerNameFromCwd,
|
|
10
|
+
defaultDockerExec,
|
|
11
|
+
imageTagFromCwd,
|
|
12
|
+
inspectContainer,
|
|
13
|
+
resolveHostPort,
|
|
14
|
+
type DockerExec,
|
|
15
|
+
} from '@/container'
|
|
16
|
+
import { isDaemonReachable, send } from '@/hostd'
|
|
17
|
+
import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
|
|
18
|
+
import { detectMissingDeps } from '@/init/ensure-deps'
|
|
19
|
+
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
20
|
+
|
|
21
|
+
import type { DoctorCheck } from './types'
|
|
22
|
+
|
|
23
|
+
const REQUIRED_DIRS = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
|
|
24
|
+
|
|
25
|
+
export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
|
|
26
|
+
const dockerExec = opts.dockerExec ?? defaultDockerExec
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
dockerDaemon(dockerExec),
|
|
30
|
+
bunRuntime(),
|
|
31
|
+
agentFolderInitialized(),
|
|
32
|
+
agentFolderRequiredDirs(),
|
|
33
|
+
agentFolderDockerfileTemplate(),
|
|
34
|
+
agentFolderGitignoreTemplate(),
|
|
35
|
+
agentFolderNodeModules(),
|
|
36
|
+
agentFolderEnvFile(),
|
|
37
|
+
agentFolderGitRepo(),
|
|
38
|
+
configValid(),
|
|
39
|
+
hostdHomeWritable(),
|
|
40
|
+
hostdReachable(),
|
|
41
|
+
hostdRegistration(),
|
|
42
|
+
containerState(dockerExec),
|
|
43
|
+
containerHostPort(),
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dockerDaemon(exec: DockerExec): DoctorCheck {
|
|
48
|
+
return {
|
|
49
|
+
name: 'docker.daemon-reachable',
|
|
50
|
+
category: 'docker',
|
|
51
|
+
description: 'Docker daemon is reachable',
|
|
52
|
+
async run() {
|
|
53
|
+
const result = await checkDockerAvailable(exec)
|
|
54
|
+
if (result.ok) return { status: 'ok', message: 'docker info responded' }
|
|
55
|
+
return {
|
|
56
|
+
status: 'error',
|
|
57
|
+
message: result.reason === 'binary-missing' ? 'docker binary missing on $PATH' : 'docker daemon down',
|
|
58
|
+
details: [result.detail],
|
|
59
|
+
fix:
|
|
60
|
+
result.reason === 'binary-missing'
|
|
61
|
+
? { description: 'Install Docker (Docker Desktop, OrbStack, or docker-ce).' }
|
|
62
|
+
: { description: 'Start the Docker daemon (Docker Desktop, OrbStack, or `systemctl start docker`).' },
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bunRuntime(): DoctorCheck {
|
|
69
|
+
return {
|
|
70
|
+
name: 'runtime.bun-available',
|
|
71
|
+
category: 'runtime',
|
|
72
|
+
description: 'Bun runtime is available',
|
|
73
|
+
async run() {
|
|
74
|
+
const bun = (globalThis as { Bun?: unknown }).Bun
|
|
75
|
+
if (bun === undefined) {
|
|
76
|
+
return {
|
|
77
|
+
status: 'error',
|
|
78
|
+
message: 'Bun runtime is not available',
|
|
79
|
+
fix: { description: 'Install Bun (https://bun.sh) and ensure the typeclaw CLI runs under it.' },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { status: 'ok', message: `Bun ${process.versions.bun ?? 'present'}` }
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function agentFolderInitialized(): DoctorCheck {
|
|
88
|
+
return {
|
|
89
|
+
name: 'agent-folder.is-initialized',
|
|
90
|
+
category: 'agent-folder',
|
|
91
|
+
description: 'agent folder contains typeclaw.json',
|
|
92
|
+
async run(ctx) {
|
|
93
|
+
if (ctx.hasAgentFolder) return { status: 'ok', message: 'typeclaw.json present' }
|
|
94
|
+
return {
|
|
95
|
+
status: 'info',
|
|
96
|
+
message: 'no typeclaw.json found in or above current directory',
|
|
97
|
+
details: ['Host-stage checks unrelated to the agent folder still ran.'],
|
|
98
|
+
fix: { description: 'Run `typeclaw init` in the directory you want to use as an agent folder.' },
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function agentFolderRequiredDirs(): DoctorCheck {
|
|
105
|
+
return {
|
|
106
|
+
name: 'agent-folder.required-dirs',
|
|
107
|
+
category: 'agent-folder',
|
|
108
|
+
description: 'required agent directories exist',
|
|
109
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
110
|
+
async run(ctx) {
|
|
111
|
+
const missing = REQUIRED_DIRS.filter((d) => !existsSync(join(ctx.cwd, d)))
|
|
112
|
+
if (missing.length === 0) return { status: 'ok', message: 'all required directories present' }
|
|
113
|
+
return {
|
|
114
|
+
status: 'warning',
|
|
115
|
+
message: `${missing.length} required ${missing.length === 1 ? 'directory' : 'directories'} missing`,
|
|
116
|
+
details: missing.map((d) => `missing: ${d}/`),
|
|
117
|
+
fix: {
|
|
118
|
+
description: `Create the missing directories (${missing.map((d) => `${d}/`).join(', ')}).`,
|
|
119
|
+
autoFix: async () => {
|
|
120
|
+
for (const d of missing) {
|
|
121
|
+
mkdirSync(join(ctx.cwd, d), { recursive: true })
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
summary: `created ${missing.map((d) => `${d}/`).join(', ')}`,
|
|
125
|
+
changedPaths: missing.map((d) => `${d}/`),
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function agentFolderDockerfileTemplate(): DoctorCheck {
|
|
135
|
+
return {
|
|
136
|
+
name: 'agent-folder.dockerfile-managed',
|
|
137
|
+
category: 'agent-folder',
|
|
138
|
+
description: 'Dockerfile matches the typeclaw template',
|
|
139
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
140
|
+
async run(ctx) {
|
|
141
|
+
const dockerfilePath = join(ctx.cwd, DOCKERFILE)
|
|
142
|
+
const expected = buildExpectedDockerfile(ctx.cwd)
|
|
143
|
+
if (expected === null) {
|
|
144
|
+
return { status: 'info', message: 'config invalid; cannot compute expected Dockerfile' }
|
|
145
|
+
}
|
|
146
|
+
const actual = await safeRead(dockerfilePath)
|
|
147
|
+
if (actual === expected) return { status: 'ok', message: 'Dockerfile matches template' }
|
|
148
|
+
return {
|
|
149
|
+
status: 'warning',
|
|
150
|
+
message: actual === null ? 'Dockerfile missing' : 'Dockerfile diverges from template',
|
|
151
|
+
details: ['The Dockerfile is regenerated on every `typeclaw start`, so a divergent file will be overwritten.'],
|
|
152
|
+
fix: {
|
|
153
|
+
description: 'Regenerate the Dockerfile from the typeclaw template.',
|
|
154
|
+
autoFix: async () => {
|
|
155
|
+
await writeAtomic(dockerfilePath, expected)
|
|
156
|
+
return { summary: 'refreshed Dockerfile from template', changedPaths: [DOCKERFILE] }
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function agentFolderGitignoreTemplate(): DoctorCheck {
|
|
165
|
+
return {
|
|
166
|
+
name: 'agent-folder.gitignore-managed',
|
|
167
|
+
category: 'agent-folder',
|
|
168
|
+
description: '.gitignore matches the typeclaw template',
|
|
169
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
170
|
+
async run(ctx) {
|
|
171
|
+
const gitignorePath = join(ctx.cwd, GITIGNORE_FILE)
|
|
172
|
+
const expected = buildExpectedGitignore(ctx.cwd)
|
|
173
|
+
if (expected === null) {
|
|
174
|
+
return { status: 'info', message: 'config invalid; cannot compute expected .gitignore' }
|
|
175
|
+
}
|
|
176
|
+
const actual = await safeRead(gitignorePath)
|
|
177
|
+
if (actual === expected) return { status: 'ok', message: '.gitignore matches template' }
|
|
178
|
+
return {
|
|
179
|
+
status: 'warning',
|
|
180
|
+
message: actual === null ? '.gitignore missing' : '.gitignore diverges from template',
|
|
181
|
+
fix: {
|
|
182
|
+
description: 'Regenerate .gitignore from the typeclaw template.',
|
|
183
|
+
autoFix: async () => {
|
|
184
|
+
await writeAtomic(gitignorePath, expected)
|
|
185
|
+
return { summary: 'refreshed .gitignore from template', changedPaths: [GITIGNORE_FILE] }
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function agentFolderNodeModules(): DoctorCheck {
|
|
194
|
+
return {
|
|
195
|
+
name: 'agent-folder.node-modules-complete',
|
|
196
|
+
category: 'agent-folder',
|
|
197
|
+
description: 'node_modules satisfies package.json dependencies',
|
|
198
|
+
applies: (ctx) => ctx.hasAgentFolder && existsSync(join(ctx.cwd, 'package.json')),
|
|
199
|
+
async run(ctx) {
|
|
200
|
+
const missing = await detectMissingDeps(ctx.cwd)
|
|
201
|
+
if (missing.length === 0) return { status: 'ok', message: 'node_modules complete' }
|
|
202
|
+
return {
|
|
203
|
+
status: 'error',
|
|
204
|
+
message: `${missing.length} package(s) missing from node_modules`,
|
|
205
|
+
details: missing.map((m) => `missing: ${m}`),
|
|
206
|
+
fix: { description: 'Run `bun install` inside the agent folder.' },
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function agentFolderEnvFile(): DoctorCheck {
|
|
213
|
+
return {
|
|
214
|
+
name: 'agent-folder.env-file',
|
|
215
|
+
category: 'agent-folder',
|
|
216
|
+
description: '.env file is present',
|
|
217
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
218
|
+
async run(ctx) {
|
|
219
|
+
if (existsSync(join(ctx.cwd, '.env'))) return { status: 'ok', message: '.env present' }
|
|
220
|
+
return {
|
|
221
|
+
status: 'warning',
|
|
222
|
+
message: '.env is missing',
|
|
223
|
+
details: ['Channels and external API integrations will not have their secrets injected.'],
|
|
224
|
+
fix: { description: 'Create a .env file with the credentials your agent needs.' },
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function agentFolderGitRepo(): DoctorCheck {
|
|
231
|
+
return {
|
|
232
|
+
name: 'agent-folder.git-repo',
|
|
233
|
+
category: 'agent-folder',
|
|
234
|
+
description: 'agent folder is a git repo',
|
|
235
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
236
|
+
async run(ctx) {
|
|
237
|
+
if (existsSync(join(ctx.cwd, '.git'))) return { status: 'ok', message: '.git present' }
|
|
238
|
+
return {
|
|
239
|
+
status: 'warning',
|
|
240
|
+
message: 'agent folder is not a git repo',
|
|
241
|
+
details: ['typeclaw doctor --fix cannot commit changes without a git repo.'],
|
|
242
|
+
fix: { description: 'Run `git init` in the agent folder.' },
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function configValid(): DoctorCheck {
|
|
249
|
+
return {
|
|
250
|
+
name: 'config.valid',
|
|
251
|
+
category: 'config',
|
|
252
|
+
description: 'typeclaw.json is valid and mounts are accessible',
|
|
253
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
254
|
+
async run(ctx) {
|
|
255
|
+
const result = validateConfig(ctx.cwd)
|
|
256
|
+
if (result.ok) return { status: 'ok', message: 'typeclaw.json valid; mounts accessible' }
|
|
257
|
+
return {
|
|
258
|
+
status: 'error',
|
|
259
|
+
message: result.reason,
|
|
260
|
+
fix: { description: 'Edit typeclaw.json to resolve the validation error above.' },
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hostdHomeWritable(): DoctorCheck {
|
|
267
|
+
return {
|
|
268
|
+
name: 'hostd.home-writable',
|
|
269
|
+
category: 'hostd',
|
|
270
|
+
description: 'hostd home (~/.typeclaw/) is writable',
|
|
271
|
+
async run() {
|
|
272
|
+
const home = process.env.TYPECLAW_HOME ?? join(homedir(), '.typeclaw')
|
|
273
|
+
try {
|
|
274
|
+
mkdirSync(home, { recursive: true })
|
|
275
|
+
return { status: 'ok', message: `${home} writable` }
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return {
|
|
278
|
+
status: 'error',
|
|
279
|
+
message: `cannot create ${home}: ${err instanceof Error ? err.message : String(err)}`,
|
|
280
|
+
fix: { description: 'Ensure your home directory is writable, or set TYPECLAW_HOME to an alternate path.' },
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function hostdReachable(): DoctorCheck {
|
|
288
|
+
return {
|
|
289
|
+
name: 'hostd.reachable',
|
|
290
|
+
category: 'hostd',
|
|
291
|
+
description: 'host daemon is reachable over the Unix socket',
|
|
292
|
+
async run() {
|
|
293
|
+
const reachable = await isDaemonReachable()
|
|
294
|
+
if (reachable) return { status: 'ok', message: 'daemon socket replied to list RPC' }
|
|
295
|
+
return {
|
|
296
|
+
status: 'info',
|
|
297
|
+
message: 'host daemon is not running',
|
|
298
|
+
details: [
|
|
299
|
+
'This is normal when no agent has been started yet. `typeclaw start` will spawn the daemon on demand.',
|
|
300
|
+
],
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hostdRegistration(): DoctorCheck {
|
|
307
|
+
return {
|
|
308
|
+
name: 'hostd.registration',
|
|
309
|
+
category: 'hostd',
|
|
310
|
+
description: 'agent container is registered with hostd',
|
|
311
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
312
|
+
async run(ctx) {
|
|
313
|
+
if (!(await isDaemonReachable())) {
|
|
314
|
+
return { status: 'skipped', message: 'hostd unreachable (covered by hostd.reachable)' }
|
|
315
|
+
}
|
|
316
|
+
const containerName = containerNameFromCwd(ctx.cwd)
|
|
317
|
+
const reply = await send({ kind: 'status', containerName })
|
|
318
|
+
if (reply.ok) return { status: 'ok', message: 'registered with hostd' }
|
|
319
|
+
return {
|
|
320
|
+
status: 'info',
|
|
321
|
+
message: 'agent is not registered with hostd',
|
|
322
|
+
details: [reply.reason],
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function containerState(exec: DockerExec): DoctorCheck {
|
|
329
|
+
return {
|
|
330
|
+
name: 'container.state',
|
|
331
|
+
category: 'container',
|
|
332
|
+
description: 'agent container Docker state',
|
|
333
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
334
|
+
async run(ctx) {
|
|
335
|
+
const available = await checkDockerAvailable(exec)
|
|
336
|
+
if (!available.ok) {
|
|
337
|
+
return { status: 'skipped', message: 'docker unavailable (covered by docker.daemon-reachable)' }
|
|
338
|
+
}
|
|
339
|
+
const name = containerNameFromCwd(ctx.cwd)
|
|
340
|
+
const state = await inspectContainer(name)
|
|
341
|
+
if (!state.exists) {
|
|
342
|
+
return {
|
|
343
|
+
status: 'info',
|
|
344
|
+
message: `container ${name} does not exist`,
|
|
345
|
+
details: [`expected image tag: ${imageTagFromCwd(ctx.cwd)}`],
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (state.running) return { status: 'ok', message: `container ${name} is running` }
|
|
349
|
+
return {
|
|
350
|
+
status: 'warning',
|
|
351
|
+
message: `container ${name} is stopped`,
|
|
352
|
+
fix: { description: 'Run `typeclaw start` to bring the container back up.' },
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function containerHostPort(): DoctorCheck {
|
|
359
|
+
return {
|
|
360
|
+
name: 'container.host-port-resolves',
|
|
361
|
+
category: 'container',
|
|
362
|
+
description: 'running container exposes its host port',
|
|
363
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
364
|
+
async run(ctx) {
|
|
365
|
+
const name = containerNameFromCwd(ctx.cwd)
|
|
366
|
+
const state = await inspectContainer(name)
|
|
367
|
+
if (!state.exists || !state.running) {
|
|
368
|
+
return { status: 'skipped', message: 'container not running' }
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const port = await resolveHostPort({ cwd: ctx.cwd })
|
|
372
|
+
return { status: 'ok', message: `host port ${port} -> container` }
|
|
373
|
+
} catch (err) {
|
|
374
|
+
return {
|
|
375
|
+
status: 'warning',
|
|
376
|
+
message: `cannot resolve host port: ${err instanceof Error ? err.message : String(err)}`,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function buildExpectedDockerfile(cwd: string): string | null {
|
|
384
|
+
try {
|
|
385
|
+
const cfg = loadConfigStrictForTemplate(cwd)
|
|
386
|
+
if (cfg === null) return null
|
|
387
|
+
return buildDockerfile(cfg.dockerfile)
|
|
388
|
+
} catch {
|
|
389
|
+
return null
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function buildExpectedGitignore(cwd: string): string | null {
|
|
394
|
+
try {
|
|
395
|
+
const cfg = loadConfigStrictForTemplate(cwd)
|
|
396
|
+
if (cfg === null) return null
|
|
397
|
+
return buildGitignore(cfg.gitignore)
|
|
398
|
+
} catch {
|
|
399
|
+
return null
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function loadConfigStrictForTemplate(
|
|
404
|
+
cwd: string,
|
|
405
|
+
): { dockerfile: Parameters<typeof buildDockerfile>[0]; gitignore: Parameters<typeof buildGitignore>[0] } | null {
|
|
406
|
+
const result = validateConfig(cwd)
|
|
407
|
+
if (!result.ok) return null
|
|
408
|
+
const cfg = loadConfigSync(cwd)
|
|
409
|
+
return { dockerfile: cfg.dockerfile, gitignore: cfg.gitignore }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function safeRead(path: string): Promise<string | null> {
|
|
413
|
+
try {
|
|
414
|
+
return await readFile(path, 'utf8')
|
|
415
|
+
} catch {
|
|
416
|
+
return null
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function writeAtomic(path: string, content: string): Promise<void> {
|
|
421
|
+
await writeFile(path, content)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function relativeToCwd(cwd: string, path: string): string {
|
|
425
|
+
return relative(cwd, path) || '.'
|
|
426
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { CommitOutcome, FixAttempt } from './types'
|
|
5
|
+
|
|
6
|
+
export type CommitOptions = {
|
|
7
|
+
cwd: string
|
|
8
|
+
attempts: FixAttempt[]
|
|
9
|
+
spawnGit?: SpawnGit
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type GitResult = { exitCode: number; stdout: string; stderr: string }
|
|
13
|
+
export type SpawnGit = (args: string[], cwd: string) => Promise<GitResult>
|
|
14
|
+
|
|
15
|
+
export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcome> {
|
|
16
|
+
const successes = opts.attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
|
|
17
|
+
if (successes.length === 0) {
|
|
18
|
+
return { kind: 'skipped', reason: 'no successful auto-fixes' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pathsStaged = uniqueSorted(successes.flatMap((a) => a.changedPaths))
|
|
22
|
+
if (pathsStaged.length === 0) {
|
|
23
|
+
return { kind: 'skipped', reason: 'auto-fixes reported no changed paths' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!existsSync(join(opts.cwd, '.git'))) {
|
|
27
|
+
return { kind: 'skipped', reason: 'agent folder is not a git repo (.git missing)' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const spawnGit = opts.spawnGit ?? defaultSpawnGit
|
|
31
|
+
|
|
32
|
+
const add = await spawnGit(['add', '--', ...pathsStaged], opts.cwd)
|
|
33
|
+
if (add.exitCode !== 0) {
|
|
34
|
+
return { kind: 'failed', reason: `git add failed: ${add.stderr.trim() || `exit ${add.exitCode}`}` }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const message = buildCommitMessage(opts.attempts)
|
|
38
|
+
const commit = await spawnGit(['commit', '-m', message], opts.cwd)
|
|
39
|
+
if (commit.exitCode !== 0) {
|
|
40
|
+
return { kind: 'failed', reason: `git commit failed: ${commit.stderr.trim() || `exit ${commit.exitCode}`}` }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sha = await spawnGit(['rev-parse', 'HEAD'], opts.cwd)
|
|
44
|
+
const commitSha = sha.exitCode === 0 ? sha.stdout.trim() : ''
|
|
45
|
+
return { kind: 'committed', commitSha, pathsStaged }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildCommitMessage(attempts: FixAttempt[]): string {
|
|
49
|
+
const successes = attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
|
|
50
|
+
const subject = `typeclaw doctor: auto-fix ${successes.length} issue${successes.length === 1 ? '' : 's'}`
|
|
51
|
+
const body = successes.map((a) => `- [${a.source}] ${a.name}: ${a.summary}`).join('\n')
|
|
52
|
+
return `${subject}\n\n${body}\n`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const defaultSpawnGit: SpawnGit = async (args, cwd) => {
|
|
56
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
57
|
+
if (!bun) return { exitCode: -1, stdout: '', stderr: 'bun runtime not available' }
|
|
58
|
+
try {
|
|
59
|
+
const proc = bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
60
|
+
const exitCode = await proc.exited
|
|
61
|
+
const stdout = await new Response(proc.stdout).text()
|
|
62
|
+
const stderr = await new Response(proc.stderr).text()
|
|
63
|
+
return { exitCode, stdout, stderr }
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function uniqueSorted(values: string[]): string[] {
|
|
70
|
+
return [...new Set(values)].sort()
|
|
71
|
+
}
|