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.
- package/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -11
- package/src/agent/index.ts +15 -22
- package/src/agent/loop-guard.ts +170 -0
- package/src/agent/model-fallback.ts +2 -1
- package/src/agent/multimodal/index.ts +1 -1
- package/src/agent/multimodal/look-at.ts +118 -55
- package/src/agent/plugin-tools.ts +57 -0
- package/src/agent/subagents.ts +2 -1
- package/src/agent/system-prompt.ts +39 -26
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/agent/tools/skip-response.ts +24 -32
- package/src/agent/tools/spawn-subagent.ts +2 -0
- package/src/bundled-plugins/reviewer/index.ts +11 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
- package/src/channels/adapters/discord-bot-classify.ts +32 -24
- package/src/channels/adapters/github/inbound.ts +63 -7
- package/src/channels/adapters/github/index.ts +32 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
- package/src/channels/adapters/kakaotalk-classify.ts +8 -1
- package/src/channels/adapters/kakaotalk.ts +19 -11
- package/src/channels/adapters/slack-bot-classify.ts +30 -14
- package/src/channels/adapters/slack-bot.ts +3 -2
- package/src/channels/adapters/telegram-bot-classify.ts +36 -13
- package/src/channels/adapters/telegram-bot.ts +3 -3
- package/src/channels/outbound-flood-filter.ts +57 -0
- package/src/channels/router.ts +114 -15
- package/src/channels/types.ts +52 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/mount.ts +157 -0
- package/src/cli/update.ts +6 -4
- package/src/config/mounts-mutation.ts +161 -0
- package/src/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +24 -7
- package/src/init/hatching.ts +1 -1
- package/src/plugin/index.ts +6 -0
- package/src/plugin/load-skill.ts +99 -0
- package/src/run/bundled-plugins.ts +2 -0
- package/src/run/index.ts +31 -1
- package/src/secrets/claude-credentials-json.ts +129 -0
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-claude-credentials-file.ts +279 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +16 -0
- package/src/server/command-runner.ts +2 -1
- package/src/server/index.ts +3 -2
- package/src/shared/index.ts +7 -1
- package/src/shared/local-time.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
- package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
- 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
|
+
}
|
package/src/doctor/checks.ts
CHANGED
|
@@ -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
|
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -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
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
# auth.json
|
|
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() {
|
package/src/init/hatching.ts
CHANGED
|
@@ -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
|
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
+
}
|