typeclaw 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +209 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +190 -61
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +57 -45
package/src/init/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type KnownProviderId,
|
|
13
13
|
} from '@/config/providers'
|
|
14
14
|
import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
|
|
15
|
+
import { commitSystemFile } from '@/git/system-commit'
|
|
15
16
|
import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
16
17
|
import { createTui } from '@/tui'
|
|
17
18
|
|
|
@@ -81,7 +82,15 @@ export type InitStepEvent =
|
|
|
81
82
|
// portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
|
|
82
83
|
// programmatic callers that never want a daemon), `start()` skips the daemon
|
|
83
84
|
// path entirely and the container runs unmanaged.
|
|
84
|
-
export type HatchRunner = (options: {
|
|
85
|
+
export type HatchRunner = (options: {
|
|
86
|
+
cwd: string
|
|
87
|
+
port: number
|
|
88
|
+
cliEntry?: string
|
|
89
|
+
// Set when the wizard wired at least one channel adapter, so the runner
|
|
90
|
+
// can offer to run `typeclaw role claim` after the container is ready.
|
|
91
|
+
// Empty / undefined means "no channels — skip the claim flow".
|
|
92
|
+
configuredChannels?: readonly ChannelKind[]
|
|
93
|
+
}) => Promise<HatchingResult>
|
|
85
94
|
|
|
86
95
|
export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
|
|
87
96
|
|
|
@@ -101,16 +110,27 @@ export type InitOptions = {
|
|
|
101
110
|
// defaults to the api-key path with `apiKey` (legacy field, still
|
|
102
111
|
// supported for backwards compat with the old `runInit` signature).
|
|
103
112
|
llmAuth?: LLMAuth
|
|
113
|
+
// Optional second model + auth, written as `models.vision` when the
|
|
114
|
+
// default model is text-only. Auth is reused from the default path
|
|
115
|
+
// when both refer to the same provider; the wizard enforces this
|
|
116
|
+
// pairing rule, so by the time we get here `visionAuth` is either
|
|
117
|
+
// (a) absent, or (b) the right auth for `visionModel`'s provider.
|
|
118
|
+
visionModel?: KnownModelRef
|
|
119
|
+
visionAuth?: LLMAuth
|
|
104
120
|
apiKey?: string
|
|
105
121
|
discordBotToken?: string
|
|
106
|
-
discordAllowAll?: boolean
|
|
107
122
|
slackBotToken?: string
|
|
108
123
|
slackAppToken?: string
|
|
109
|
-
slackAllowAll?: boolean
|
|
110
124
|
telegramBotToken?: string
|
|
111
|
-
|
|
125
|
+
// When reusing existing channel credentials from a pre-init `secrets.json`,
|
|
126
|
+
// the CLI passes `with<Adapter>: true` without a corresponding token so the
|
|
127
|
+
// scaffolded `typeclaw.json` wires the adapter while `writeSecrets` leaves
|
|
128
|
+
// the existing slot in `secrets.json#channels` untouched. Defaults below
|
|
129
|
+
// mirror the legacy derivation (`<token> !== undefined && !== ''`).
|
|
130
|
+
withDiscord?: boolean
|
|
131
|
+
withSlack?: boolean
|
|
132
|
+
withTelegram?: boolean
|
|
112
133
|
withKakaotalk?: boolean
|
|
113
|
-
kakaotalkAllowAll?: boolean
|
|
114
134
|
runKakaotalkAuth?: KakaotalkAuthRunner
|
|
115
135
|
onProgress?: (event: InitStepEvent) => void
|
|
116
136
|
runHatching?: HatchRunner
|
|
@@ -129,15 +149,16 @@ export async function runInit({
|
|
|
129
149
|
apiKey,
|
|
130
150
|
llmAuth,
|
|
131
151
|
model = DEFAULT_MODEL_REF,
|
|
152
|
+
visionModel,
|
|
153
|
+
visionAuth,
|
|
132
154
|
discordBotToken,
|
|
133
|
-
discordAllowAll = true,
|
|
134
155
|
slackBotToken,
|
|
135
156
|
slackAppToken,
|
|
136
|
-
slackAllowAll = true,
|
|
137
157
|
telegramBotToken,
|
|
138
|
-
|
|
158
|
+
withDiscord,
|
|
159
|
+
withSlack,
|
|
160
|
+
withTelegram,
|
|
139
161
|
withKakaotalk = false,
|
|
140
|
-
kakaotalkAllowAll = false,
|
|
141
162
|
runKakaotalkAuth,
|
|
142
163
|
onProgress,
|
|
143
164
|
runHatching = defaultRunHatching,
|
|
@@ -180,20 +201,36 @@ export async function runInit({
|
|
|
180
201
|
}
|
|
181
202
|
}
|
|
182
203
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
204
|
+
// When the vision profile uses a different provider than the default, its
|
|
205
|
+
// OAuth login runs here too, before any file write. Same-provider vision
|
|
206
|
+
// reuses the default auth (no separate login). API-key vision auth is
|
|
207
|
+
// captured in memory and persisted by writeSecrets() below.
|
|
208
|
+
if (
|
|
209
|
+
visionAuth !== undefined &&
|
|
210
|
+
visionAuth.kind === 'oauth' &&
|
|
211
|
+
visionModel !== undefined &&
|
|
212
|
+
providerForModelRef(visionModel) !== providerForModelRef(model)
|
|
213
|
+
) {
|
|
214
|
+
emit({ step: 'oauth-login', phase: 'start' })
|
|
215
|
+
await mkdir(cwd, { recursive: true })
|
|
216
|
+
const result = await visionAuth.runLogin({ cwd, model: visionModel })
|
|
217
|
+
emit({ step: 'oauth-login', phase: 'done', result })
|
|
218
|
+
if (!result.ok) {
|
|
219
|
+
throw new Error(`OAuth login failed: ${result.reason}`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const wantsDiscord = withDiscord ?? (discordBotToken !== undefined && discordBotToken !== '')
|
|
224
|
+
const wantsSlack = withSlack ?? (slackBotToken !== undefined && slackBotToken !== '')
|
|
225
|
+
const wantsTelegram = withTelegram ?? (telegramBotToken !== undefined && telegramBotToken !== '')
|
|
186
226
|
emit({ step: 'scaffold', phase: 'start' })
|
|
187
227
|
await scaffold(cwd, {
|
|
188
228
|
model,
|
|
229
|
+
...(visionModel !== undefined ? { visionModel } : {}),
|
|
189
230
|
withDiscord: wantsDiscord,
|
|
190
|
-
discordAllowAll,
|
|
191
231
|
withSlack: wantsSlack,
|
|
192
|
-
slackAllowAll,
|
|
193
232
|
withTelegram: wantsTelegram,
|
|
194
|
-
telegramAllowAll,
|
|
195
233
|
withKakaotalk,
|
|
196
|
-
kakaotalkAllowAll,
|
|
197
234
|
})
|
|
198
235
|
// Only write the LLM API key on the api-key path. OAuth providers persist
|
|
199
236
|
// their credentials to secrets.json (via the OAuth login step above); writing
|
|
@@ -201,6 +238,9 @@ export async function runInit({
|
|
|
201
238
|
await writeSecrets(cwd, {
|
|
202
239
|
model,
|
|
203
240
|
apiKey: resolvedAuth.kind === 'api-key' ? resolvedAuth.apiKey : undefined,
|
|
241
|
+
...(visionModel !== undefined && visionAuth?.kind === 'api-key'
|
|
242
|
+
? { visionModel, visionApiKey: visionAuth.apiKey }
|
|
243
|
+
: {}),
|
|
204
244
|
discordBotToken,
|
|
205
245
|
slackBotToken,
|
|
206
246
|
slackAppToken,
|
|
@@ -235,8 +275,19 @@ export async function runInit({
|
|
|
235
275
|
const git = await initGitRepo(cwd)
|
|
236
276
|
emit({ step: 'git', phase: 'done', result: git })
|
|
237
277
|
|
|
278
|
+
const configuredChannels: ChannelKind[] = []
|
|
279
|
+
if (wantsDiscord) configuredChannels.push('discord-bot')
|
|
280
|
+
if (wantsSlack) configuredChannels.push('slack-bot')
|
|
281
|
+
if (wantsTelegram) configuredChannels.push('telegram-bot')
|
|
282
|
+
if (withKakaotalk) configuredChannels.push('kakaotalk')
|
|
283
|
+
|
|
238
284
|
emit({ step: 'hatching', phase: 'start' })
|
|
239
|
-
const hatching = await runHatching({
|
|
285
|
+
const hatching = await runHatching({
|
|
286
|
+
cwd,
|
|
287
|
+
port: config.port,
|
|
288
|
+
...(cliEntry !== undefined ? { cliEntry } : {}),
|
|
289
|
+
...(configuredChannels.length > 0 ? { configuredChannels } : {}),
|
|
290
|
+
})
|
|
240
291
|
emit({ step: 'hatching', phase: 'done', result: hatching })
|
|
241
292
|
}
|
|
242
293
|
|
|
@@ -250,16 +301,20 @@ export async function defaultRunHatching({
|
|
|
250
301
|
cwd,
|
|
251
302
|
port,
|
|
252
303
|
cliEntry,
|
|
304
|
+
configuredChannels,
|
|
253
305
|
startContainer = start,
|
|
254
306
|
tui: tuiFactory = createTui,
|
|
255
307
|
waitForAgent: waitForAgentFn = waitForAgent,
|
|
308
|
+
runClaim = defaultRunClaim,
|
|
256
309
|
}: {
|
|
257
310
|
cwd: string
|
|
258
311
|
port: number
|
|
259
312
|
cliEntry?: string
|
|
313
|
+
configuredChannels?: readonly ChannelKind[]
|
|
260
314
|
startContainer?: typeof start
|
|
261
315
|
tui?: typeof createTui
|
|
262
316
|
waitForAgent?: typeof waitForAgent
|
|
317
|
+
runClaim?: ClaimRunner
|
|
263
318
|
}): Promise<HatchingResult> {
|
|
264
319
|
try {
|
|
265
320
|
const launch = await startContainer({
|
|
@@ -276,6 +331,11 @@ export async function defaultRunHatching({
|
|
|
276
331
|
|
|
277
332
|
await waitForAgentFn(`http://127.0.0.1:${hostPort}`, { timeoutMs: 30_000 })
|
|
278
333
|
|
|
334
|
+
if (configuredChannels !== undefined && configuredChannels.length > 0) {
|
|
335
|
+
const url = buildTuiUrl(hostPort, launch.tuiToken)
|
|
336
|
+
await runClaim({ url, configuredChannels })
|
|
337
|
+
}
|
|
338
|
+
|
|
279
339
|
const tui = tuiFactory({
|
|
280
340
|
url: buildTuiUrl(hostPort, launch.tuiToken),
|
|
281
341
|
initialPrompt: HATCHING_PROMPT,
|
|
@@ -287,6 +347,13 @@ export async function defaultRunHatching({
|
|
|
287
347
|
}
|
|
288
348
|
}
|
|
289
349
|
|
|
350
|
+
export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
|
|
351
|
+
|
|
352
|
+
const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
|
|
353
|
+
const { runOwnerClaim } = await import('./run-owner-claim')
|
|
354
|
+
await runOwnerClaim({ url, configuredChannels })
|
|
355
|
+
}
|
|
356
|
+
|
|
290
357
|
function buildTuiUrl(hostPort: number, token: string | null): string {
|
|
291
358
|
const url = new URL(`ws://127.0.0.1:${hostPort}`)
|
|
292
359
|
if (token !== null) url.searchParams.set('token', token)
|
|
@@ -372,14 +439,11 @@ export async function isHatched(dir: string): Promise<boolean> {
|
|
|
372
439
|
|
|
373
440
|
export type ScaffoldOptions = {
|
|
374
441
|
model?: KnownModelRef
|
|
442
|
+
visionModel?: KnownModelRef
|
|
375
443
|
withDiscord?: boolean
|
|
376
|
-
discordAllowAll?: boolean
|
|
377
444
|
withSlack?: boolean
|
|
378
|
-
slackAllowAll?: boolean
|
|
379
445
|
withTelegram?: boolean
|
|
380
|
-
telegramAllowAll?: boolean
|
|
381
446
|
withKakaotalk?: boolean
|
|
382
|
-
kakaotalkAllowAll?: boolean
|
|
383
447
|
}
|
|
384
448
|
|
|
385
449
|
export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
|
|
@@ -392,30 +456,22 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
392
456
|
// immediately populated, so packages/ is the only one that needs this.
|
|
393
457
|
await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
|
|
394
458
|
|
|
395
|
-
// Only fields without sensible defaults elsewhere are emitted
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
// has to maintain in sync with the source of truth.
|
|
459
|
+
// Only fields without sensible defaults elsewhere are emitted. Everything
|
|
460
|
+
// with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
|
|
461
|
+
// `memory.*`) is omitted to keep the scaffold minimal — duplicating defaults
|
|
462
|
+
// here would mean every schema change has to be mirrored in two places, and
|
|
463
|
+
// users would feel obligated to maintain values they never set.
|
|
464
|
+
const models: Record<string, KnownModelRef> = { default: options.model ?? DEFAULT_MODEL_REF }
|
|
465
|
+
if (options.visionModel !== undefined) models.vision = options.visionModel
|
|
403
466
|
const config: Record<string, unknown> = {
|
|
404
467
|
$schema: './node_modules/typeclaw/typeclaw.schema.json',
|
|
405
|
-
|
|
406
|
-
network: { blockInternal: true },
|
|
407
|
-
}
|
|
408
|
-
const channels: Record<string, { allow: string[] }> = {}
|
|
409
|
-
if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
|
|
410
|
-
if (options.withSlack) channels['slack-bot'] = { allow: options.slackAllowAll === false ? [] : ['*'] }
|
|
411
|
-
if (options.withTelegram) channels['telegram-bot'] = { allow: options.telegramAllowAll === false ? [] : ['*'] }
|
|
412
|
-
if (options.withKakaotalk) {
|
|
413
|
-
// KakaoTalk involves a personal account, so we default to a tighter
|
|
414
|
-
// allow list (DMs only) than Slack/Discord/Telegram which scope to a
|
|
415
|
-
// workspace the user explicitly admitted the bot into. The user can
|
|
416
|
-
// broaden to `kakao:*` later by editing typeclaw.json.
|
|
417
|
-
channels.kakaotalk = { allow: options.kakaotalkAllowAll === true ? ['kakao:*'] : ['kakao:dm/*'] }
|
|
468
|
+
models,
|
|
418
469
|
}
|
|
470
|
+
const channels: Record<string, Record<string, never>> = {}
|
|
471
|
+
if (options.withDiscord) channels['discord-bot'] = {}
|
|
472
|
+
if (options.withSlack) channels['slack-bot'] = {}
|
|
473
|
+
if (options.withTelegram) channels['telegram-bot'] = {}
|
|
474
|
+
if (options.withKakaotalk) channels.kakaotalk = {}
|
|
419
475
|
if (Object.keys(channels).length > 0) config.channels = channels
|
|
420
476
|
await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
421
477
|
|
|
@@ -578,6 +634,8 @@ export async function writeSecrets(
|
|
|
578
634
|
{
|
|
579
635
|
model = DEFAULT_MODEL_REF,
|
|
580
636
|
apiKey,
|
|
637
|
+
visionModel,
|
|
638
|
+
visionApiKey,
|
|
581
639
|
discordBotToken,
|
|
582
640
|
slackBotToken,
|
|
583
641
|
slackAppToken,
|
|
@@ -586,6 +644,8 @@ export async function writeSecrets(
|
|
|
586
644
|
model?: KnownModelRef
|
|
587
645
|
// Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
|
|
588
646
|
apiKey?: string
|
|
647
|
+
visionModel?: KnownModelRef
|
|
648
|
+
visionApiKey?: string
|
|
589
649
|
discordBotToken?: string
|
|
590
650
|
slackBotToken?: string
|
|
591
651
|
slackAppToken?: string
|
|
@@ -594,8 +654,20 @@ export async function writeSecrets(
|
|
|
594
654
|
): Promise<void> {
|
|
595
655
|
const providerId = providerForModelRef(model)
|
|
596
656
|
const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
|
|
597
|
-
|
|
598
|
-
|
|
657
|
+
const wantsDefaultKey = apiKey !== undefined && apiKeyEnv !== null
|
|
658
|
+
const visionProviderId = visionModel !== undefined ? providerForModelRef(visionModel) : null
|
|
659
|
+
const wantsVisionKey =
|
|
660
|
+
visionModel !== undefined &&
|
|
661
|
+
visionApiKey !== undefined &&
|
|
662
|
+
visionProviderId !== providerId &&
|
|
663
|
+
visionProviderId !== null &&
|
|
664
|
+
KNOWN_PROVIDERS[visionProviderId].apiKeyEnv !== null
|
|
665
|
+
if (wantsDefaultKey || wantsVisionKey) {
|
|
666
|
+
const secretsStore = createSecretsStoreForAgent(join(root, 'secrets.json'))
|
|
667
|
+
if (wantsDefaultKey) secretsStore.set(providerId, { type: 'api_key', key: apiKey! })
|
|
668
|
+
if (wantsVisionKey) {
|
|
669
|
+
secretsStore.set(visionProviderId, { type: 'api_key', key: visionApiKey! })
|
|
670
|
+
}
|
|
599
671
|
}
|
|
600
672
|
|
|
601
673
|
const channelTokens: Record<string, Record<string, Secret>> = {}
|
|
@@ -630,6 +702,70 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
|
|
|
630
702
|
return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
|
|
631
703
|
}
|
|
632
704
|
|
|
705
|
+
// Detects whether the requested channel already has usable credentials in
|
|
706
|
+
// `secrets.json#channels`, so the init wizard can offer to reuse them
|
|
707
|
+
// instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
|
|
708
|
+
// returns `true` only when ALL fields the adapter needs are present in a
|
|
709
|
+
// shape `hydrateChannelEnvFromSecrets` would inject at runtime — both the
|
|
710
|
+
// `{ value }` form and the `{ env }` env-binding form count, matching the
|
|
711
|
+
// runtime resolution rules in `src/secrets/resolve.ts`. Partial slots (e.g.
|
|
712
|
+
// `slack-bot` with `botToken` but no `appToken`) return `false` so the
|
|
713
|
+
// missing field gets filled in by the normal prompt.
|
|
714
|
+
//
|
|
715
|
+
// KakaoTalk reuse is stricter: a usable block requires both a complete
|
|
716
|
+
// account (currentAccount + matching entry in accounts) AND the renewal
|
|
717
|
+
// fields (email + encryptedPassword) the hostd renewal cron needs to mint
|
|
718
|
+
// fresh tokens unattended (`src/secrets/kakao-renewal.ts`). Without those,
|
|
719
|
+
// the saved `oauth_token` will work only until KakaoTalk's ~7-day TTL
|
|
720
|
+
// expires, after which the user has to run `typeclaw channel reauth
|
|
721
|
+
// kakaotalk` anyway — better to re-auth now during init.
|
|
722
|
+
export async function hasExistingChannelSecrets(
|
|
723
|
+
root: string,
|
|
724
|
+
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
|
|
725
|
+
): Promise<boolean> {
|
|
726
|
+
const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
|
|
727
|
+
if (channels === null) return false
|
|
728
|
+
switch (channel) {
|
|
729
|
+
case 'discord':
|
|
730
|
+
return hasSecretField(channels['discord-bot'], 'token')
|
|
731
|
+
case 'slack':
|
|
732
|
+
return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
|
|
733
|
+
case 'telegram':
|
|
734
|
+
return hasSecretField(channels['telegram-bot'], 'token')
|
|
735
|
+
case 'kakaotalk': {
|
|
736
|
+
const block = channels.kakaotalk
|
|
737
|
+
if (!isObjectRecord(block)) return false
|
|
738
|
+
const current = (block as { currentAccount?: unknown }).currentAccount
|
|
739
|
+
if (typeof current !== 'string' || current.length === 0) return false
|
|
740
|
+
const accounts = (block as { accounts?: unknown }).accounts
|
|
741
|
+
if (!isObjectRecord(accounts)) return false
|
|
742
|
+
const account = accounts[current]
|
|
743
|
+
if (!isObjectRecord(account)) return false
|
|
744
|
+
const email = (account as { email?: unknown }).email
|
|
745
|
+
const encryptedPassword = (account as { encryptedPassword?: unknown }).encryptedPassword
|
|
746
|
+
return typeof email === 'string' && email.length > 0 && isObjectRecord(encryptedPassword)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Accepts either the `{ value }` form (resolves to a literal at runtime) or
|
|
752
|
+
// the `{ env }` form (resolves at runtime by reading `process.env[<env>]`).
|
|
753
|
+
// String shorthand is sugar for `{ value }`. The schema already rejects
|
|
754
|
+
// empty strings via `z.string().min(1)`, so the length checks here are
|
|
755
|
+
// defense-in-depth against forward-compat shape drift.
|
|
756
|
+
function hasSecretField(slot: unknown, field: string): boolean {
|
|
757
|
+
if (!isObjectRecord(slot)) return false
|
|
758
|
+
const secret = slot[field]
|
|
759
|
+
if (typeof secret === 'string') return secret.length > 0
|
|
760
|
+
if (isObjectRecord(secret)) {
|
|
761
|
+
const value = (secret as { value?: unknown }).value
|
|
762
|
+
if (typeof value === 'string' && value.length > 0) return true
|
|
763
|
+
const env = (secret as { env?: unknown }).env
|
|
764
|
+
if (typeof env === 'string' && env.length > 0) return true
|
|
765
|
+
}
|
|
766
|
+
return false
|
|
767
|
+
}
|
|
768
|
+
|
|
633
769
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
634
770
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
635
771
|
}
|
|
@@ -689,7 +825,6 @@ export type AddChannelStepEvent =
|
|
|
689
825
|
// from prompts; tests build them inline.
|
|
690
826
|
export type AddChannelOptions = {
|
|
691
827
|
cwd: string
|
|
692
|
-
allowAll?: boolean
|
|
693
828
|
onProgress?: (event: AddChannelStepEvent) => void
|
|
694
829
|
} & (
|
|
695
830
|
| { channel: 'discord-bot'; discordBotToken: string }
|
|
@@ -717,7 +852,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
717
852
|
}
|
|
718
853
|
|
|
719
854
|
emit({ step: 'config', phase: 'start' })
|
|
720
|
-
await mergeChannelIntoConfig(options.cwd, options.channel
|
|
855
|
+
await mergeChannelIntoConfig(options.cwd, options.channel)
|
|
721
856
|
emit({ step: 'config', phase: 'done' })
|
|
722
857
|
|
|
723
858
|
emit({ step: 'secrets', phase: 'start' })
|
|
@@ -726,14 +861,13 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
726
861
|
await appendChannelSecrets(options.cwd, options.channel, tokens)
|
|
727
862
|
}
|
|
728
863
|
emit({ step: 'secrets', phase: 'done' })
|
|
729
|
-
}
|
|
730
864
|
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
865
|
+
// Commit the typeclaw.json change so the agent folder isn't silently
|
|
866
|
+
// dirty after `typeclaw channel add`. Same `commitSystemFile` contract as
|
|
867
|
+
// every other host-side rewrite: no-op outside a git repo, when Bun is
|
|
868
|
+
// unavailable, or when the file is clean. secrets.json is gitignored, so
|
|
869
|
+
// only typeclaw.json is named here.
|
|
870
|
+
await commitSystemFile(options.cwd, CONFIG_FILE, `channel: add ${options.channel}`)
|
|
737
871
|
}
|
|
738
872
|
|
|
739
873
|
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
@@ -774,7 +908,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
|
|
|
774
908
|
return present
|
|
775
909
|
}
|
|
776
910
|
|
|
777
|
-
async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind
|
|
911
|
+
async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promise<void> {
|
|
778
912
|
const path = join(cwd, CONFIG_FILE)
|
|
779
913
|
let parsed: Record<string, unknown>
|
|
780
914
|
try {
|
|
@@ -798,23 +932,18 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAl
|
|
|
798
932
|
// Defense in depth — the CLI already filters configured channels out of
|
|
799
933
|
// the picker and rejects them as the positional arg. Hitting this branch
|
|
800
934
|
// means a programmatic caller passed a duplicate; better to fail loudly
|
|
801
|
-
// than silently overwrite the user's existing
|
|
935
|
+
// than silently overwrite the user's existing config.
|
|
802
936
|
throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
|
|
803
937
|
}
|
|
804
938
|
|
|
805
939
|
parsed.channels = {
|
|
806
940
|
...existingChannels,
|
|
807
|
-
[channel]: {
|
|
941
|
+
[channel]: {},
|
|
808
942
|
}
|
|
809
943
|
|
|
810
944
|
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
811
945
|
}
|
|
812
946
|
|
|
813
|
-
function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
|
|
814
|
-
if (channel === 'kakaotalk') return allowAll ? ['kakao:*'] : ['kakao:dm/*']
|
|
815
|
-
return allowAll ? ['*'] : []
|
|
816
|
-
}
|
|
817
|
-
|
|
818
947
|
// Writes per-adapter field values into `secrets.json#channels.<adapter>`.
|
|
819
948
|
// Refuses to overwrite existing fields: if the user already has e.g.
|
|
820
949
|
// `botToken` recorded (from a prior `channel add` whose follow-up steps
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createRequire } from 'node:module'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import { containerNameFromCwd } from '@/container'
|
|
5
|
+
import { keysDir } from '@/hostd/paths'
|
|
6
|
+
import { encrypt } from '@/secrets/encryption'
|
|
4
7
|
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
8
|
+
import { createKeyStore, type KeyStore } from '@/secrets/keys'
|
|
5
9
|
|
|
6
10
|
export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
|
|
7
11
|
|
|
@@ -15,6 +19,13 @@ export type KakaotalkLoginInput = {
|
|
|
15
19
|
agentDir: string
|
|
16
20
|
callbacks: KakaotalkLoginCallbacks
|
|
17
21
|
loginFlow?: LoginFlowFn
|
|
22
|
+
// Test seam: inject a custom keystore (typically pointing at a tmpdir).
|
|
23
|
+
// Production uses ~/.typeclaw/keys/<containerName>.key.
|
|
24
|
+
keyStore?: KeyStore
|
|
25
|
+
// Test seam: override the containerName used to bind the encrypted
|
|
26
|
+
// password's AAD. Production derives it from basename(agentDir) via
|
|
27
|
+
// containerNameFromCwd to match what `typeclaw start` registers with hostd.
|
|
28
|
+
containerName?: string
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export type LoginFlowOptions = {
|
|
@@ -82,6 +93,10 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
|
|
|
82
93
|
|
|
83
94
|
const now = new Date().toISOString()
|
|
84
95
|
const accountId = result.credentials.user_id || 'default'
|
|
96
|
+
const containerName = input.containerName ?? containerNameFromCwd(resolve(input.agentDir))
|
|
97
|
+
const keyStore = input.keyStore ?? createKeyStore({ keysDir: keysDir() })
|
|
98
|
+
const key = await keyStore.ensure(containerName)
|
|
99
|
+
const encryptedPassword = encrypt(input.password, key, { containerName, accountId })
|
|
85
100
|
await credManager.setAccount({
|
|
86
101
|
account_id: accountId,
|
|
87
102
|
oauth_token: result.credentials.access_token,
|
|
@@ -92,6 +107,8 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
|
|
|
92
107
|
auth_method: 'login',
|
|
93
108
|
created_at: now,
|
|
94
109
|
updated_at: now,
|
|
110
|
+
email: input.email,
|
|
111
|
+
encryptedPassword,
|
|
95
112
|
})
|
|
96
113
|
await credManager.setCurrentAccount(accountId)
|
|
97
114
|
await credManager.clearPendingLogin()
|
package/src/init/models-dev.ts
CHANGED
|
@@ -14,6 +14,11 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
|
|
|
14
14
|
// entries are surfaced regardless of upstream membership.
|
|
15
15
|
'openai-codex': 'openai',
|
|
16
16
|
fireworks: 'fireworks-ai',
|
|
17
|
+
zai: 'zai',
|
|
18
|
+
// zai-coding (GLM Coding Plan) is a billing surface, not a separate model
|
|
19
|
+
// catalog. models.dev tracks the underlying model metadata under `zai`,
|
|
20
|
+
// so we route lookups there. The curated entries still get surfaced.
|
|
21
|
+
'zai-coding': 'zai',
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export type ModelOption = {
|
|
@@ -25,6 +30,13 @@ export type ModelOption = {
|
|
|
25
30
|
reasoning: boolean
|
|
26
31
|
contextWindow: number | null
|
|
27
32
|
curated: boolean
|
|
33
|
+
// True iff the model accepts image input. Sourced from the curated
|
|
34
|
+
// `Model.input` array (which is the source of truth — pi-ai consumes it
|
|
35
|
+
// directly) with a fallback to models.dev's `modalities.input` when the
|
|
36
|
+
// curated entry omits the field. The init wizard uses this to decide
|
|
37
|
+
// whether to prompt for a separate `vision` profile after the user picks
|
|
38
|
+
// a text-only `default` model.
|
|
39
|
+
supportsVision: boolean
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
type ModelsDevModel = {
|
|
@@ -115,7 +127,10 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
|
|
|
115
127
|
const modelId = ref.slice(slash + 1)
|
|
116
128
|
const provider = KNOWN_PROVIDERS[providerId]
|
|
117
129
|
const curatedModel = (
|
|
118
|
-
provider.models as Record<
|
|
130
|
+
provider.models as Record<
|
|
131
|
+
string,
|
|
132
|
+
{ name: string; contextWindow?: number; reasoning?: boolean; input?: ReadonlyArray<string> }
|
|
133
|
+
>
|
|
119
134
|
)[modelId]
|
|
120
135
|
return {
|
|
121
136
|
ref,
|
|
@@ -126,5 +141,15 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
|
|
|
126
141
|
reasoning: opts.upstream?.reasoning ?? curatedModel?.reasoning ?? false,
|
|
127
142
|
contextWindow: opts.upstream?.limit?.context ?? curatedModel?.contextWindow ?? null,
|
|
128
143
|
curated: opts.curated,
|
|
144
|
+
supportsVision: resolveSupportsVision(curatedModel?.input, opts.upstream?.modalities?.input),
|
|
129
145
|
}
|
|
130
146
|
}
|
|
147
|
+
|
|
148
|
+
function resolveSupportsVision(
|
|
149
|
+
curatedInput: ReadonlyArray<string> | undefined,
|
|
150
|
+
upstreamInput: ReadonlyArray<string> | undefined,
|
|
151
|
+
): boolean {
|
|
152
|
+
if (curatedInput !== undefined) return curatedInput.includes('image')
|
|
153
|
+
if (upstreamInput !== undefined) return upstreamInput.includes('image')
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { confirm, isCancel, log, note, spinner } from '@clack/prompts'
|
|
2
|
+
|
|
3
|
+
import { c } from '@/cli/ui'
|
|
4
|
+
import { runClaimSession } from '@/role-claim'
|
|
5
|
+
|
|
6
|
+
import type { ChannelKind } from './index'
|
|
7
|
+
|
|
8
|
+
const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
9
|
+
'slack-bot': 'Slack',
|
|
10
|
+
'discord-bot': 'Discord',
|
|
11
|
+
'telegram-bot': 'Telegram',
|
|
12
|
+
kakaotalk: 'KakaoTalk',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000
|
|
16
|
+
|
|
17
|
+
export type RunOwnerClaimOptions = {
|
|
18
|
+
url: string
|
|
19
|
+
configuredChannels: readonly ChannelKind[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Drives the post-hatching claim flow: ask the operator whether to pair now,
|
|
23
|
+
// run the claim handshake against the running container, print the result.
|
|
24
|
+
// Aborts (kind: 'cancel' or a clack cancel) drop straight back into the
|
|
25
|
+
// normal hatching path so the TUI still opens — the operator can run
|
|
26
|
+
// `typeclaw role claim` later.
|
|
27
|
+
export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOptions): Promise<void> {
|
|
28
|
+
if (configuredChannels.length === 0) return
|
|
29
|
+
|
|
30
|
+
const channelList = configuredChannels.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
|
|
31
|
+
|
|
32
|
+
const proceed = await confirm({
|
|
33
|
+
message: `Claim owner role on ${channelList} now?`,
|
|
34
|
+
initialValue: true,
|
|
35
|
+
})
|
|
36
|
+
if (isCancel(proceed) || proceed === false) {
|
|
37
|
+
log.info(`Skipping. Run ${c.bold('typeclaw role claim')} later when you're ready.`)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const s = spinner()
|
|
42
|
+
s.start('Generating your claim code...')
|
|
43
|
+
|
|
44
|
+
const result = await runClaimSession({
|
|
45
|
+
url,
|
|
46
|
+
role: 'owner',
|
|
47
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
48
|
+
onStarted: (payload) => {
|
|
49
|
+
const expiresInMin = Math.max(1, Math.round((payload.expiresAt - Date.now()) / 60_000))
|
|
50
|
+
s.stop('Code ready.')
|
|
51
|
+
note(
|
|
52
|
+
[
|
|
53
|
+
`Open ${channelList} and DM your bot with this code:`,
|
|
54
|
+
'',
|
|
55
|
+
` ${c.bold(payload.code)}`,
|
|
56
|
+
'',
|
|
57
|
+
`(expires in ~${expiresInMin}m)`,
|
|
58
|
+
].join('\n'),
|
|
59
|
+
'Claim your owner role',
|
|
60
|
+
)
|
|
61
|
+
s.start('Waiting for your DM...')
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (result.kind === 'completed') {
|
|
66
|
+
s.stop(c.green(`Paired as owner.`))
|
|
67
|
+
log.info(`Match rule added to typeclaw.json#roles.owner.match: ${c.bold(result.payload.matchRule)}`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (result.kind === 'error') {
|
|
71
|
+
s.stop(c.red(`Claim failed: ${result.payload.reason}`))
|
|
72
|
+
log.info(`You can retry with ${c.bold('typeclaw role claim')} anytime.`)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
|
|
76
|
+
log.info(`Run ${c.bold('typeclaw role claim')} when you're ready.`)
|
|
77
|
+
}
|