typeclaw 0.12.0 → 0.14.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.
Files changed (62) hide show
  1. package/package.json +1 -1
  2. package/scripts/dump-system-prompt.ts +12 -11
  3. package/src/agent/index.ts +15 -22
  4. package/src/agent/loop-guard.ts +170 -0
  5. package/src/agent/model-fallback.ts +2 -1
  6. package/src/agent/multimodal/index.ts +1 -1
  7. package/src/agent/multimodal/look-at.ts +118 -55
  8. package/src/agent/plugin-tools.ts +57 -0
  9. package/src/agent/subagents.ts +2 -1
  10. package/src/agent/system-prompt.ts +39 -26
  11. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  12. package/src/agent/tools/normalize-ref.ts +11 -0
  13. package/src/agent/tools/skip-response.ts +24 -32
  14. package/src/agent/tools/spawn-subagent.ts +2 -0
  15. package/src/bundled-plugins/reviewer/index.ts +11 -0
  16. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  17. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  18. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  19. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  20. package/src/channels/adapters/github/inbound.ts +63 -7
  21. package/src/channels/adapters/github/index.ts +32 -0
  22. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  23. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  24. package/src/channels/adapters/kakaotalk.ts +19 -11
  25. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  26. package/src/channels/adapters/slack-bot.ts +3 -2
  27. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  28. package/src/channels/adapters/telegram-bot.ts +3 -3
  29. package/src/channels/outbound-flood-filter.ts +57 -0
  30. package/src/channels/router.ts +114 -15
  31. package/src/channels/types.ts +52 -1
  32. package/src/cli/builtins.ts +1 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/mount.ts +157 -0
  35. package/src/cli/update.ts +6 -4
  36. package/src/config/mounts-mutation.ts +161 -0
  37. package/src/doctor/channel-checks.ts +328 -0
  38. package/src/doctor/checks.ts +2 -0
  39. package/src/init/dockerfile.ts +24 -7
  40. package/src/init/hatching.ts +1 -1
  41. package/src/plugin/index.ts +6 -0
  42. package/src/plugin/load-skill.ts +99 -0
  43. package/src/run/bundled-plugins.ts +2 -0
  44. package/src/run/index.ts +31 -1
  45. package/src/secrets/claude-credentials-json.ts +129 -0
  46. package/src/secrets/codex-auth-json.ts +67 -0
  47. package/src/secrets/export-claude-credentials-file.ts +279 -0
  48. package/src/secrets/export-codex-auth-file.ts +243 -0
  49. package/src/secrets/index.ts +16 -0
  50. package/src/server/command-runner.ts +2 -1
  51. package/src/server/index.ts +3 -2
  52. package/src/shared/index.ts +7 -1
  53. package/src/shared/local-time.ts +32 -0
  54. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  55. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  56. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  57. package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
  58. package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
  59. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  60. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  61. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  62. package/src/update/index.ts +95 -26
@@ -0,0 +1,161 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { commitSystemFileSync } from '@/git/system-commit'
5
+
6
+ import {
7
+ configSchema,
8
+ expandMountPath,
9
+ loadConfigSyncOrDefaults,
10
+ mountSchema,
11
+ validateMount,
12
+ type Mount,
13
+ } from './config'
14
+
15
+ const CONFIG_FILE = 'typeclaw.json'
16
+ const MOUNT_TARGET_PREFIX = '/agent/mounts'
17
+
18
+ export type MountListEntry = Mount & {
19
+ resolvedPath: string
20
+ targetPath: string
21
+ status: 'ok' | 'error'
22
+ statusReason?: string
23
+ }
24
+
25
+ export type AddMountOptions = {
26
+ readOnly?: boolean
27
+ description?: string | undefined
28
+ }
29
+
30
+ export type AddMountResult = { ok: true; entry: MountListEntry } | { ok: false; reason: string }
31
+ export type RemoveMountResult = { ok: true; removed: MountListEntry } | { ok: false; reason: string }
32
+
33
+ export function listMounts(cwd: string): MountListEntry[] {
34
+ const mounts = loadConfigSyncOrDefaults(cwd).mounts
35
+ return mounts.map((mount) => toListEntry(mount, cwd))
36
+ }
37
+
38
+ export function addMount(cwd: string, name: string, path: string, options: AddMountOptions = {}): AddMountResult {
39
+ const mount = buildMount(name, path, options)
40
+ if (!mount.ok) return mount
41
+
42
+ const check = validateMount(mount.value, cwd)
43
+ if (!check.ok) return check
44
+
45
+ const parsed = readConfigRecord(cwd)
46
+ if (!parsed.ok) return parsed
47
+
48
+ const current = readMounts(parsed.value)
49
+ if (!current.ok) return current
50
+ if (current.value.some((m) => m.name === mount.value.name)) {
51
+ return {
52
+ ok: false,
53
+ reason: `Mount "${mount.value.name}" already exists. Remove it first with \`typeclaw mount remove ${mount.value.name}\`.`,
54
+ }
55
+ }
56
+
57
+ const next = { ...parsed.value, mounts: [...current.value, mount.value] }
58
+ const write = writeMounts(cwd, next, `mount: add ${mount.value.name}`)
59
+ if (!write.ok) return write
60
+ return { ok: true, entry: toListEntry(mount.value, cwd) }
61
+ }
62
+
63
+ export function removeMount(cwd: string, name: string): RemoveMountResult {
64
+ const trimmed = name.trim()
65
+ if (trimmed.length === 0) return { ok: false, reason: 'Mount name cannot be empty.' }
66
+
67
+ const parsed = readConfigRecord(cwd)
68
+ if (!parsed.ok) return parsed
69
+
70
+ const current = readMounts(parsed.value)
71
+ if (!current.ok) return current
72
+
73
+ const removed = current.value.find((m) => m.name === trimmed)
74
+ if (removed === undefined) return { ok: false, reason: `Mount "${trimmed}" not found in ${CONFIG_FILE}.` }
75
+
76
+ const next = { ...parsed.value, mounts: current.value.filter((m) => m.name !== trimmed) }
77
+ const write = writeMounts(cwd, next, `mount: remove ${trimmed}`)
78
+ if (!write.ok) return write
79
+ return { ok: true, removed: toListEntry(removed, cwd) }
80
+ }
81
+
82
+ function buildMount(
83
+ name: string,
84
+ path: string,
85
+ options: AddMountOptions,
86
+ ): { ok: true; value: Mount } | { ok: false; reason: string } {
87
+ const description = options.description?.trim()
88
+ const raw = {
89
+ name: name.trim(),
90
+ path,
91
+ readOnly: options.readOnly ?? false,
92
+ ...(description !== undefined && description.length > 0 ? { description } : {}),
93
+ }
94
+ const parsed = mountSchema.safeParse(raw)
95
+ if (!parsed.success) {
96
+ return { ok: false, reason: parsed.error.issues.map(formatIssue).join('; ') }
97
+ }
98
+ return { ok: true, value: parsed.data }
99
+ }
100
+
101
+ function readConfigRecord(cwd: string): { ok: true; value: Record<string, unknown> } | { ok: false; reason: string } {
102
+ try {
103
+ const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
104
+ const parsed = JSON.parse(raw) as unknown
105
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
106
+ return { ok: false, reason: `${CONFIG_FILE} must contain a JSON object.` }
107
+ }
108
+ return { ok: true, value: parsed as Record<string, unknown> }
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
111
+ return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
112
+ }
113
+ return { ok: false, reason: `Failed to read ${CONFIG_FILE}: ${(error as Error).message}` }
114
+ }
115
+ }
116
+
117
+ function readMounts(record: Record<string, unknown>): { ok: true; value: Mount[] } | { ok: false; reason: string } {
118
+ const parsed = configSchema.safeParse(record)
119
+ if (!parsed.success) {
120
+ return { ok: false, reason: `${CONFIG_FILE} is invalid: ${parsed.error.issues.map(formatIssue).join('; ')}` }
121
+ }
122
+ return { ok: true, value: parsed.data.mounts }
123
+ }
124
+
125
+ function writeMounts(
126
+ cwd: string,
127
+ record: Record<string, unknown>,
128
+ commitMessage: string,
129
+ ): { ok: true } | { ok: false; reason: string } {
130
+ const parsed = configSchema.safeParse(record)
131
+ if (!parsed.success) {
132
+ return { ok: false, reason: `mounts block would be invalid: ${parsed.error.issues.map(formatIssue).join('; ')}` }
133
+ }
134
+
135
+ try {
136
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(record, null, 2)}\n`)
137
+ } catch (error) {
138
+ return { ok: false, reason: `Failed to write ${CONFIG_FILE}: ${(error as Error).message}` }
139
+ }
140
+
141
+ commitSystemFileSync(cwd, CONFIG_FILE, commitMessage)
142
+ return { ok: true }
143
+ }
144
+
145
+ function toListEntry(mount: Mount, cwd: string): MountListEntry {
146
+ const resolvedPath = expandMountPath(mount.path, cwd)
147
+ const targetPath = `${MOUNT_TARGET_PREFIX}/${mount.name}`
148
+ const check = validateMount(mount, cwd)
149
+ return {
150
+ ...mount,
151
+ resolvedPath,
152
+ targetPath,
153
+ status: check.ok ? 'ok' : 'error',
154
+ ...(!check.ok ? { statusReason: check.reason } : {}),
155
+ }
156
+ }
157
+
158
+ function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
159
+ const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
160
+ return `${path}: ${issue.message}`
161
+ }
@@ -0,0 +1,328 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { type AdapterId, type ChannelsConfig } from '@/channels'
4
+ import { loadConfigSync, validateConfig } from '@/config'
5
+ import { readEnvFile } from '@/init'
6
+ import { SecretsBackend } from '@/secrets'
7
+ import { channelFieldDefaultEnv } from '@/secrets/defaults'
8
+
9
+ import type { CheckContext, CheckResult, DoctorCheck } from './types'
10
+
11
+ // Host-stage channel adapter health checks. These cannot talk to Slack /
12
+ // Discord / Telegram / KakaoTalk / GitHub APIs — that work belongs to the
13
+ // container-stage `start()` preflight on each adapter (see
14
+ // `src/channels/manager.ts` and individual adapters). What doctor CAN do
15
+ // from the host is verify that the credentials the container will look for
16
+ // are actually present and resolvable, so the operator gets a clear,
17
+ // before-`typeclaw start` signal instead of a silent skip in the runtime
18
+ // logs.
19
+ //
20
+ // Every check is gated on `ctx.hasAgentFolder` (typeclaw.json is required to
21
+ // read the channels config) and additionally on the adapter being declared
22
+ // AND enabled in typeclaw.json. A missing or `enabled: false` adapter
23
+ // reports `skipped` so the operator can see the check ran without it
24
+ // turning into noise on minimal setups.
25
+
26
+ export function buildChannelChecks(): DoctorCheck[] {
27
+ return [
28
+ slackBotCredentials(),
29
+ discordBotCredentials(),
30
+ telegramBotCredentials(),
31
+ kakaotalkCredentials(),
32
+ githubCredentials(),
33
+ githubWebhookDelivery(),
34
+ ]
35
+ }
36
+
37
+ function slackBotCredentials(): DoctorCheck {
38
+ return {
39
+ name: 'channel.slack-bot.credentials',
40
+ category: 'channels',
41
+ description: 'slack-bot adapter has SLACK_BOT_TOKEN and SLACK_APP_TOKEN',
42
+ applies: (ctx) => ctx.hasAgentFolder,
43
+ run: (ctx) => runTokenAdapterCheck(ctx, 'slack-bot', ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']),
44
+ }
45
+ }
46
+
47
+ function discordBotCredentials(): DoctorCheck {
48
+ return {
49
+ name: 'channel.discord-bot.credentials',
50
+ category: 'channels',
51
+ description: 'discord-bot adapter has DISCORD_BOT_TOKEN',
52
+ applies: (ctx) => ctx.hasAgentFolder,
53
+ run: (ctx) => runTokenAdapterCheck(ctx, 'discord-bot', ['DISCORD_BOT_TOKEN']),
54
+ }
55
+ }
56
+
57
+ function telegramBotCredentials(): DoctorCheck {
58
+ return {
59
+ name: 'channel.telegram-bot.credentials',
60
+ category: 'channels',
61
+ description: 'telegram-bot adapter has TELEGRAM_BOT_TOKEN',
62
+ applies: (ctx) => ctx.hasAgentFolder,
63
+ run: (ctx) => runTokenAdapterCheck(ctx, 'telegram-bot', ['TELEGRAM_BOT_TOKEN']),
64
+ }
65
+ }
66
+
67
+ function kakaotalkCredentials(): DoctorCheck {
68
+ return {
69
+ name: 'channel.kakaotalk.credentials',
70
+ category: 'channels',
71
+ description: 'kakaotalk adapter has at least one account in secrets.json',
72
+ applies: (ctx) => ctx.hasAgentFolder,
73
+ async run(ctx) {
74
+ const channels = readDeclaredChannels(ctx)
75
+ if (channels === null) return configInvalidResult()
76
+ if (!isAdapterActive(channels, 'kakaotalk')) {
77
+ return { status: 'skipped', message: 'kakaotalk not configured' }
78
+ }
79
+ const block = readChannelsSecrets(ctx)?.kakaotalk
80
+ const accountCount = block?.accounts ? Object.keys(block.accounts).length : 0
81
+ if (accountCount === 0) {
82
+ return {
83
+ status: 'warning',
84
+ message: 'kakaotalk has no accounts in secrets.json',
85
+ details: ['Adapter will start but fail authentication and stay disconnected.'],
86
+ fix: { description: 'Run `typeclaw channel add kakaotalk` to log in an account.' },
87
+ }
88
+ }
89
+ return { status: 'ok', message: `kakaotalk has ${accountCount} account(s)` }
90
+ },
91
+ }
92
+ }
93
+
94
+ function githubCredentials(): DoctorCheck {
95
+ return {
96
+ name: 'channel.github.credentials',
97
+ category: 'channels',
98
+ description: 'github adapter has auth and webhookSecret in secrets.json',
99
+ applies: (ctx) => ctx.hasAgentFolder,
100
+ async run(ctx) {
101
+ const channels = readDeclaredChannels(ctx)
102
+ if (channels === null) return configInvalidResult()
103
+ if (!isAdapterActive(channels, 'github')) {
104
+ return { status: 'skipped', message: 'github not configured' }
105
+ }
106
+ const block = readChannelsSecrets(ctx)?.github
107
+ if (!block) {
108
+ return {
109
+ status: 'error',
110
+ message: 'github secrets missing from secrets.json',
111
+ details: ['Adapter requires both `auth` and `webhookSecret`.'],
112
+ fix: { description: 'Run `typeclaw channel set github` to configure GitHub auth.' },
113
+ }
114
+ }
115
+ const dotEnv = safeReadEnvFile(ctx.cwd)
116
+ const details: string[] = []
117
+ const webhookSecret = resolveSecretHostStage(block.webhookSecret, dotEnv)
118
+ if (webhookSecret === undefined || webhookSecret === '') {
119
+ details.push('webhookSecret is unset (resolves to empty string)')
120
+ }
121
+ if (block.auth.type === 'pat') {
122
+ const token = resolveSecretHostStage(block.auth.token, dotEnv)
123
+ if (token === undefined || token === '') {
124
+ details.push('auth.token (PAT) is unset (resolves to empty string)')
125
+ }
126
+ } else {
127
+ const key = resolveSecretHostStage(block.auth.privateKey, dotEnv)
128
+ if (key === undefined || key === '') {
129
+ details.push('auth.privateKey (App) is unset (resolves to empty string)')
130
+ }
131
+ }
132
+ if (details.length > 0) {
133
+ return {
134
+ status: 'error',
135
+ message: 'github credentials present but some fields resolve to empty',
136
+ details,
137
+ fix: { description: 'Run `typeclaw channel set github` to repopulate the missing fields.' },
138
+ }
139
+ }
140
+ return {
141
+ status: 'ok',
142
+ message: `github ${block.auth.type === 'pat' ? 'PAT' : 'App'} auth + webhookSecret resolved`,
143
+ }
144
+ },
145
+ }
146
+ }
147
+
148
+ function githubWebhookDelivery(): DoctorCheck {
149
+ return {
150
+ name: 'channel.github.webhook-delivery',
151
+ category: 'channels',
152
+ description: 'github webhook delivery has a public URL (webhookUrl or tunnel)',
153
+ applies: (ctx) => ctx.hasAgentFolder,
154
+ async run(ctx) {
155
+ const cfg = safeLoadConfig(ctx)
156
+ if (cfg === null) return configInvalidResult()
157
+ const github = cfg.channels.github
158
+ if (github === undefined || github.enabled === false) {
159
+ return { status: 'skipped', message: 'github not configured' }
160
+ }
161
+ const hasWebhookUrl = typeof github.webhookUrl === 'string' && github.webhookUrl.length > 0
162
+ const hasTunnel = cfg.tunnels.some((t) => t.for.kind === 'channel' && t.for.name === 'github')
163
+ if (hasWebhookUrl || hasTunnel) {
164
+ const source = hasWebhookUrl ? 'webhookUrl' : 'tunnel'
165
+ return { status: 'ok', message: `github webhook delivery configured via ${source}` }
166
+ }
167
+ if (github.repos.length === 0) {
168
+ return {
169
+ status: 'info',
170
+ message: 'github has no webhookUrl or tunnel, and no repos to register',
171
+ details: ['Webhooks will not be auto-registered until either webhookUrl or a tunnel binding is set.'],
172
+ }
173
+ }
174
+ return {
175
+ status: 'warning',
176
+ message: `github lists ${github.repos.length} repo(s) but has no public URL to deliver webhooks to`,
177
+ details: [
178
+ 'Either set `channels.github.webhookUrl` in typeclaw.json,',
179
+ 'or add a `tunnels[]` entry with `for: { kind: "channel", name: "github" }`.',
180
+ ],
181
+ fix: {
182
+ description: 'Configure webhookUrl or a github tunnel; see `typeclaw tunnel add` for managed tunnels.',
183
+ },
184
+ }
185
+ },
186
+ }
187
+ }
188
+
189
+ async function runTokenAdapterCheck(
190
+ ctx: CheckContext,
191
+ adapter: Extract<AdapterId, 'slack-bot' | 'discord-bot' | 'telegram-bot'>,
192
+ envNames: readonly string[],
193
+ ): Promise<CheckResult> {
194
+ const channels = readDeclaredChannels(ctx)
195
+ if (channels === null) return configInvalidResult()
196
+ if (!isAdapterActive(channels, adapter)) {
197
+ return { status: 'skipped', message: `${adapter} not configured` }
198
+ }
199
+ const dotEnv = safeReadEnvFile(ctx.cwd)
200
+ const channelSecrets = readChannelsSecrets(ctx)
201
+ const adapterSecrets = (channelSecrets?.[adapter] ?? {}) as Record<string, unknown>
202
+ const missing: string[] = []
203
+ for (const envName of envNames) {
204
+ if (hasTokenForEnv(adapter, envName, dotEnv, adapterSecrets)) continue
205
+ missing.push(envName)
206
+ }
207
+ if (missing.length > 0) {
208
+ return {
209
+ status: 'warning',
210
+ message: `${adapter} missing credentials: ${missing.join(', ')}`,
211
+ details: [
212
+ 'Adapter will be skipped at start until credentials are present.',
213
+ 'Resolution order: process.env wins over .env file value over secrets.json value.',
214
+ ],
215
+ fix: { description: 'Run `typeclaw channel set ' + adapter + '`, or add the env vars to .env.' },
216
+ }
217
+ }
218
+ return { status: 'ok', message: `${adapter} credentials present` }
219
+ }
220
+
221
+ // hasTokenForEnv resolves a single env-var-style credential the same way the
222
+ // runtime does, plus one host-stage-specific source: process.env > .env file >
223
+ // secrets.json. Empty strings count as unset, matching `src/secrets/resolve.ts`.
224
+ function hasTokenForEnv(
225
+ adapter: AdapterId,
226
+ envName: string,
227
+ dotEnv: Map<string, string>,
228
+ adapterSecrets: Record<string, unknown>,
229
+ ): boolean {
230
+ const fromProcess = process.env[envName]
231
+ if (fromProcess !== undefined && fromProcess !== '') return true
232
+ const fromDotEnv = dotEnv.get(envName)
233
+ if (fromDotEnv !== undefined && fromDotEnv !== '') return true
234
+ const fieldName = fieldNameForEnv(adapter, envName)
235
+ if (fieldName === undefined) return false
236
+ const secret = adapterSecrets[fieldName]
237
+ if (!isSecretShape(secret)) return false
238
+ const resolved = resolveSecretHostStage(secret, dotEnv, envName)
239
+ return resolved !== undefined && resolved !== ''
240
+ }
241
+
242
+ // resolveSecretHostStage mirrors `resolveSecret` precedence but adds a .env
243
+ // lookup before falling through to process.env. Doctor runs on the host and
244
+ // never executes the container, so .env values are not in process.env. For
245
+ // Secrets bound to a custom env var (e.g. `{ env: 'MY_TOKEN' }`), the runtime
246
+ // would resolve via process.env.MY_TOKEN inside the container — on the host
247
+ // that yields undefined even when the value is sitting in .env. So look up
248
+ // the custom env name in the parsed .env map first.
249
+ function resolveSecretHostStage(
250
+ secret: { value?: string; env?: string },
251
+ dotEnv: Map<string, string>,
252
+ defaultEnv?: string,
253
+ ): string | undefined {
254
+ const envName = secret.env ?? defaultEnv
255
+ if (envName !== undefined) {
256
+ const fromProcess = process.env[envName]
257
+ if (fromProcess !== undefined && fromProcess !== '') return fromProcess
258
+ const fromDotEnv = dotEnv.get(envName)
259
+ if (fromDotEnv !== undefined && fromDotEnv !== '') return fromDotEnv
260
+ }
261
+ return secret.value
262
+ }
263
+
264
+ function fieldNameForEnv(adapter: AdapterId, envName: string): string | undefined {
265
+ // Reverse-lookup using channelFieldDefaultEnv: scan the small set of known
266
+ // fields per adapter for the one whose default env matches. The set is
267
+ // tiny (1-2 entries) so the linear scan is fine.
268
+ const candidates: Record<string, readonly string[]> = {
269
+ 'slack-bot': ['botToken', 'appToken'],
270
+ 'discord-bot': ['token'],
271
+ 'telegram-bot': ['token'],
272
+ }
273
+ const fields = candidates[adapter]
274
+ if (!fields) return undefined
275
+ for (const field of fields) {
276
+ if (channelFieldDefaultEnv(adapter, field) === envName) return field
277
+ }
278
+ return undefined
279
+ }
280
+
281
+ function isSecretShape(value: unknown): value is { value?: string; env?: string } {
282
+ if (typeof value !== 'object' || value === null) return false
283
+ const obj = value as Record<string, unknown>
284
+ const hasValue = typeof obj['value'] === 'string'
285
+ const hasEnv = typeof obj['env'] === 'string'
286
+ return hasValue || hasEnv
287
+ }
288
+
289
+ function readDeclaredChannels(ctx: CheckContext): ChannelsConfig | null {
290
+ const cfg = safeLoadConfig(ctx)
291
+ return cfg?.channels ?? null
292
+ }
293
+
294
+ function safeLoadConfig(ctx: CheckContext): ReturnType<typeof loadConfigSync> | null {
295
+ const result = validateConfig(ctx.cwd)
296
+ if (!result.ok) return null
297
+ try {
298
+ return loadConfigSync(ctx.cwd)
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
304
+ function safeReadEnvFile(cwd: string): Map<string, string> {
305
+ try {
306
+ return readEnvFile(cwd)
307
+ } catch {
308
+ return new Map()
309
+ }
310
+ }
311
+
312
+ function readChannelsSecrets(ctx: CheckContext): ReturnType<SecretsBackend['tryReadChannelsSync']> {
313
+ try {
314
+ return new SecretsBackend(join(ctx.cwd, 'secrets.json')).tryReadChannelsSync()
315
+ } catch {
316
+ return null
317
+ }
318
+ }
319
+
320
+ function isAdapterActive(channels: ChannelsConfig, adapter: AdapterId): boolean {
321
+ const slot = channels[adapter]
322
+ if (slot === undefined) return false
323
+ return slot.enabled !== false
324
+ }
325
+
326
+ function configInvalidResult(): CheckResult {
327
+ return { status: 'skipped', message: 'config invalid (covered by config.valid)' }
328
+ }
@@ -21,6 +21,7 @@ import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
21
21
  import { detectMissingDeps } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
 
24
+ import { buildChannelChecks } from './channel-checks'
24
25
  import type { DoctorCheck } from './types'
25
26
 
26
27
  export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
@@ -40,6 +41,7 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
40
41
  hostdRegistration(),
41
42
  containerState(dockerExec),
42
43
  containerHostPort(),
44
+ ...buildChannelChecks(),
43
45
  ]
44
46
  }
45
47
 
@@ -1,4 +1,9 @@
1
1
  import type { DockerfileConfig, DockerfileFeatureToggle } from '@/config/config'
2
+ import {
3
+ CLAUDE_CREDENTIALS_FILE_NAME,
4
+ CLAUDE_CREDENTIALS_RELATIVE_PATH,
5
+ CLAUDE_DEFAULT_CONFIG_DIR_NAME,
6
+ } from '@/secrets/export-claude-credentials-file'
2
7
 
3
8
  import { GHCR_BASE_IMAGE_REPO } from './cli-version'
4
9
 
@@ -283,13 +288,19 @@ set -eu
283
288
  # no inbound exposure anyway.
284
289
  # link_persistent_home_files symlinks credential files that tools write
285
290
  # to $HOME into a bind-mounted location so they survive container
286
- # restarts. The canonical case is Codex CLI's ~/.codex/auth.json: codex
287
- # rewrites the file in place to rotate OAuth tokens, and the official
288
- # CI/CD guidance is to persist auth.json so refresh-token state
289
- # compounds across runs. The container's $HOME (/root by default) lives
290
- # on Docker's writable overlay and is wiped on every \`stop\`+\`start\`
291
- # cycle, so without this symlink the operator would have to re-paste
292
- # auth.json after every restart.
291
+ # restarts. The container's $HOME (/root by default) lives on Docker's
292
+ # writable overlay and is wiped on every \`stop\`+\`start\` cycle, so
293
+ # without this symlink the operator would have to re-paste credentials
294
+ # after every restart.
295
+ #
296
+ # Two files are linked today, both following the same contract:
297
+ # - ~/.codex/auth.json Codex CLI rotates OAuth tokens in place by
298
+ # rewriting auth.json with refreshed credentials.
299
+ # - $CLAUDE_CONFIG_DIR/.credentials.json, or ~/.claude/.credentials.json
300
+ # by default — Claude Code rotates OAuth tokens in place by rewriting
301
+ # .credentials.json on every successful refresh (anthropics/claude-code
302
+ # #53063). Linux/Windows path; macOS uses the Keychain entry "Claude
303
+ # Code-credentials" with the same JSON shape.
293
304
  #
294
305
  # The persist root lives under /agent/.typeclaw/home/ (bind-mounted
295
306
  # from the agent folder via the -v <cwd>:/agent flag in start.ts).
@@ -329,6 +340,12 @@ link_persistent_home_files() {
329
340
  persist_root="\${TYPECLAW_PERSIST_HOME_ROOT:-/agent/.typeclaw/home}"
330
341
  mkdir -p "$persist_root/.codex" "$HOME/.codex"
331
342
  ln -sfn "$persist_root/.codex/auth.json" "$HOME/.codex/auth.json"
343
+ claude_config_dir="\${CLAUDE_CONFIG_DIR:-}"
344
+ if [ -z "$claude_config_dir" ]; then
345
+ claude_config_dir="$HOME/${CLAUDE_DEFAULT_CONFIG_DIR_NAME}"
346
+ fi
347
+ mkdir -p "$persist_root/${CLAUDE_DEFAULT_CONFIG_DIR_NAME}" "$claude_config_dir"
348
+ ln -sfn "$persist_root/${CLAUDE_CREDENTIALS_RELATIVE_PATH}" "$claude_config_dir/${CLAUDE_CREDENTIALS_FILE_NAME}"
332
349
  }
333
350
 
334
351
  start_xvfb() {
@@ -38,7 +38,7 @@ Routing answers:
38
38
  1. \`write\` your name into \`IDENTITY.md\` (a first-person one-liner is fine: "I am <name>.").
39
39
  2. ${q1AliasStep} The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
40
40
  2. **Q2 — the user's name.** Ask what to call them. After the answer: \`write\` it to both \`IDENTITY.md\` and \`USER.md\`.
41
- 3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`.
41
+ 3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or is in Korean asking for 친근/귀엽/다정한 tone, append a line to \`SOUL.md\` like: \`I lean on kaomojis like (◕‿◕✿) and (。・ω・。) to carry warmth — emojis still welcome when they actually mean something, but kaomojis lead.\` This makes the bundled \`typeclaw-kaomoji\` skill auto-load later when you need it. Do not force this line if the user asked for a neutral, professional, or terse tone.
42
42
 
43
43
  **Do not ask what they want you to do, what project you'll work on, or why they installed you.** That reveals itself when they give you a real task. Probing here makes the tool feel heavy for someone just trying it out.
44
44
 
@@ -75,6 +75,12 @@ export type { PermissionService } from '@/permissions'
75
75
  export type { LoadPluginEntryFn, ResolvedPlugin } from './loader'
76
76
  export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
77
77
  export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
78
+ export {
79
+ createLoadSkillTool,
80
+ type CreateLoadSkillToolOptions,
81
+ type LoadableSkill,
82
+ type LoadSkillArgs,
83
+ } from './load-skill'
78
84
  export {
79
85
  buildPluginCronGlobalId,
80
86
  RESERVED_COMMAND_NAMES,
@@ -0,0 +1,99 @@
1
+ import { z } from 'zod'
2
+
3
+ import { defineTool } from './define'
4
+ import type { Tool } from './types'
5
+
6
+ // One unit of curated skill content a subagent can load on demand. The
7
+ // `name` becomes a value in the tool's `name` enum; `description` is what
8
+ // the model sees in the tool description block so it can decide which
9
+ // skill to load WITHOUT having to load it first; `content` is the body
10
+ // returned by the tool when the model picks this skill.
11
+ export type LoadableSkill = {
12
+ name: string
13
+ description: string
14
+ content: string
15
+ }
16
+
17
+ export type CreateLoadSkillToolOptions = {
18
+ skills: readonly LoadableSkill[]
19
+ // Override the tool's top-level description. Defaults to a generic
20
+ // explanation of how the tool works followed by the per-skill menu.
21
+ // Plugins can pass a more specific framing (e.g. "Load a review skill
22
+ // …") so the subagent's instructions line up with the tool name.
23
+ description?: string
24
+ }
25
+
26
+ export type LoadSkillArgs = { name: string }
27
+
28
+ // Build a typed `load_skill` tool a subagent can call to fetch the body
29
+ // of a curated skill on demand. The factory closes over the `skills`
30
+ // list so:
31
+ // - the `name` parameter is a Zod enum narrowed to exactly the skill
32
+ // names the caller supplied (typo-resistant; the model sees the
33
+ // allowed values in the tool's JSON Schema),
34
+ // - the tool's `description` lists every skill's name + description so
35
+ // the model can choose the right one BEFORE calling the tool, paying
36
+ // only one tool-call's worth of context for the body it actually
37
+ // needs,
38
+ // - unknown names are rejected by parameter validation, not by the
39
+ // handler — the runtime returns the validation error before
40
+ // `execute` runs.
41
+ //
42
+ // This is the runtime-loaded counterpart to TypeClaw's startup-time
43
+ // skill discovery (`additionalSkillPaths` in `src/agent/index.ts`).
44
+ // Subagents bypass the file-based resource loader, so the startup path
45
+ // is unavailable to them; this factory gives plugin authors a typed way
46
+ // to expose curated skills to their subagents via `customTools`.
47
+ export function createLoadSkillTool(options: CreateLoadSkillToolOptions): Tool<LoadSkillArgs> {
48
+ const { skills } = options
49
+
50
+ if (skills.length === 0) {
51
+ throw new Error('createLoadSkillTool: `skills` must contain at least one entry')
52
+ }
53
+
54
+ const seen = new Set<string>()
55
+ for (const skill of skills) {
56
+ if (skill.name.length === 0) {
57
+ throw new Error('createLoadSkillTool: skill name must be non-empty')
58
+ }
59
+ if (seen.has(skill.name)) {
60
+ throw new Error(`createLoadSkillTool: duplicate skill name ${JSON.stringify(skill.name)}`)
61
+ }
62
+ seen.add(skill.name)
63
+ }
64
+
65
+ // z.enum requires a `[string, ...string[]]` tuple. Build it from the
66
+ // skill list so the JSON Schema surfaced to the model lists exactly
67
+ // the allowed values.
68
+ const names = skills.map((s) => s.name) as [string, ...string[]]
69
+
70
+ const description = options.description ?? buildDefaultDescription(skills)
71
+
72
+ return defineTool<LoadSkillArgs>({
73
+ description,
74
+ parameters: z.object({
75
+ name: z.enum(names).describe('The name of the skill to load. Must match one of the available skills.'),
76
+ }),
77
+ async execute(args) {
78
+ const skill = skills.find((s) => s.name === args.name)
79
+ if (skill === undefined) {
80
+ // Defensive: Zod enum validation should have rejected this
81
+ // before reaching the handler. Surface a clear message anyway.
82
+ const available = names.join(', ')
83
+ throw new Error(`Unknown skill ${JSON.stringify(args.name)}. Available skills: ${available}.`)
84
+ }
85
+ return {
86
+ content: [{ type: 'text' as const, text: skill.content }],
87
+ details: { name: skill.name, contentBytes: skill.content.length },
88
+ }
89
+ },
90
+ })
91
+ }
92
+
93
+ function buildDefaultDescription(skills: readonly LoadableSkill[]): string {
94
+ const menu = skills.map((s) => `- \`${s.name}\` — ${s.description}`).join('\n')
95
+ return `Load a curated skill by name. Returns the full skill body as text so you can apply it to the current task. Call this when you have identified which skill matches the task; do NOT load multiple skills speculatively.
96
+
97
+ Available skills:
98
+ ${menu}`
99
+ }