typeclaw 0.1.2 → 0.1.3

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 (42) hide show
  1. package/README.md +4 -0
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  7. package/src/agent/tools/channel-history.ts +10 -1
  8. package/src/agent/tools/channel-log.ts +32 -0
  9. package/src/agent/tools/channel-reply.ts +18 -1
  10. package/src/agent/tools/channel-send.ts +13 -1
  11. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  12. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  13. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  14. package/src/channels/adapters/kakaotalk.ts +25 -16
  15. package/src/channels/manager.ts +47 -38
  16. package/src/cli/channel.ts +3 -3
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/init.ts +2 -1
  19. package/src/cli/ui.ts +11 -0
  20. package/src/config/config.ts +15 -4
  21. package/src/container/start.ts +90 -1
  22. package/src/hostd/daemon.ts +28 -3
  23. package/src/hostd/protocol.ts +7 -0
  24. package/src/init/auto-upgrade.ts +368 -0
  25. package/src/init/dockerfile.ts +25 -14
  26. package/src/init/index.ts +123 -77
  27. package/src/init/kakaotalk-auth.ts +9 -3
  28. package/src/init/run-bun-install.ts +34 -0
  29. package/src/run/bundled-plugins.ts +7 -0
  30. package/src/run/index.ts +9 -0
  31. package/src/secrets/defaults.ts +67 -0
  32. package/src/secrets/hydrate.ts +99 -0
  33. package/src/secrets/index.ts +6 -12
  34. package/src/secrets/kakao-store.ts +129 -0
  35. package/src/secrets/migrate-kakaotalk.ts +82 -0
  36. package/src/secrets/migrate.ts +5 -4
  37. package/src/secrets/resolve.ts +57 -0
  38. package/src/secrets/schema.ts +162 -42
  39. package/src/secrets/storage.ts +253 -47
  40. package/src/skills/typeclaw-config/SKILL.md +47 -8
  41. package/typeclaw.schema.json +36 -2
  42. package/src/secrets/env.ts +0 -43
@@ -0,0 +1,129 @@
1
+ import type { KakaoAccountCredentials, KakaoConfig } from 'agent-messenger/kakaotalk'
2
+ import type { PendingLoginState } from 'agent-messenger/kakaotalk'
3
+
4
+ import { sendHttp } from '@/hostd/client'
5
+
6
+ import { type KakaoChannelBlock, kakaoChannelBlockSchema } from './schema'
7
+ import { SecretsBackend } from './storage'
8
+
9
+ export type SecretsKakaoCredentialStoreOptions =
10
+ | { mode: 'host'; secretsPath: string }
11
+ | { mode: 'container'; secretsPath: string; hostdUrl: string; restartToken: string; containerName: string }
12
+
13
+ const EMPTY_BLOCK: KakaoChannelBlock = { currentAccount: null, accounts: {} }
14
+
15
+ export class SecretsKakaoCredentialStore {
16
+ private readonly backend: SecretsBackend
17
+ private writeChain: Promise<void> = Promise.resolve()
18
+
19
+ constructor(private readonly options: SecretsKakaoCredentialStoreOptions) {
20
+ this.backend = new SecretsBackend(options.secretsPath)
21
+ }
22
+
23
+ async load(): Promise<KakaoConfig> {
24
+ return toKakaoConfig(this.readBlock())
25
+ }
26
+
27
+ async save(config: KakaoConfig): Promise<void> {
28
+ await this.writeBlock(() => fromKakaoConfig(config))
29
+ }
30
+
31
+ async getAccount(id?: string): Promise<KakaoAccountCredentials | null> {
32
+ const config = await this.load()
33
+ if (id) return config.accounts[id] ?? null
34
+ if (!config.current_account) return null
35
+ return config.accounts[config.current_account] ?? null
36
+ }
37
+
38
+ async setAccount(account: KakaoAccountCredentials): Promise<void> {
39
+ await this.writeBlock((block) => {
40
+ const accounts = { ...block.accounts, [account.account_id]: account }
41
+ return { ...block, currentAccount: block.currentAccount ?? account.account_id, accounts }
42
+ })
43
+ }
44
+
45
+ async removeAccount(id: string): Promise<void> {
46
+ await this.writeBlock((block) => {
47
+ const accounts = { ...block.accounts }
48
+ delete accounts[id]
49
+ const currentAccount = block.currentAccount === id ? (Object.keys(accounts)[0] ?? null) : block.currentAccount
50
+ return { ...block, currentAccount, accounts }
51
+ })
52
+ }
53
+
54
+ async listAccounts(): Promise<Array<KakaoAccountCredentials & { is_current: boolean }>> {
55
+ const config = await this.load()
56
+ return Object.values(config.accounts).map((account) => ({
57
+ ...account,
58
+ is_current: account.account_id === config.current_account,
59
+ }))
60
+ }
61
+
62
+ async setCurrentAccount(id: string): Promise<void> {
63
+ await this.writeBlock((block) => ({ ...block, currentAccount: id }))
64
+ }
65
+
66
+ async savePendingLogin(state: PendingLoginState): Promise<void> {
67
+ await this.writeBlock((block) => ({ ...block, pendingLogin: state }))
68
+ }
69
+
70
+ async loadPendingLogin(): Promise<PendingLoginState | null> {
71
+ return this.readBlock().pendingLogin ?? null
72
+ }
73
+
74
+ async clearPendingLogin(): Promise<void> {
75
+ await this.writeBlock((block) => {
76
+ const { pendingLogin: _pendingLogin, ...next } = block
77
+ return next
78
+ })
79
+ }
80
+
81
+ private readBlock(): KakaoChannelBlock {
82
+ const channels =
83
+ this.options.mode === 'container' ? this.backend.tryReadChannelsSync() : this.backend.readChannelsSync()
84
+ const raw = channels?.kakaotalk
85
+ return parseBlock(raw)
86
+ }
87
+
88
+ private async writeBlock(update: (current: KakaoChannelBlock) => KakaoChannelBlock): Promise<void> {
89
+ return this.enqueueWrite(async () => {
90
+ if (this.options.mode === 'container') {
91
+ const next = update(this.readBlock())
92
+ const response = await sendHttp(
93
+ {
94
+ kind: 'secrets-patch',
95
+ containerName: this.options.containerName,
96
+ patch: { channels: { kakaotalk: next } },
97
+ },
98
+ { url: this.options.hostdUrl, token: this.options.restartToken },
99
+ )
100
+ if (!response.ok) throw new Error(`secrets-patch failed: ${response.reason}`)
101
+ return
102
+ }
103
+
104
+ await this.backend.updateChannelsAsync(async (channels) => {
105
+ const next = { ...channels, kakaotalk: update(parseBlock(channels.kakaotalk)) }
106
+ return { result: undefined, next }
107
+ })
108
+ })
109
+ }
110
+
111
+ private enqueueWrite(op: () => Promise<void>): Promise<void> {
112
+ const next = this.writeChain.then(op, op)
113
+ this.writeChain = next.catch(() => {})
114
+ return next
115
+ }
116
+ }
117
+
118
+ function parseBlock(value: unknown): KakaoChannelBlock {
119
+ if (value === undefined) return EMPTY_BLOCK
120
+ return kakaoChannelBlockSchema.parse(value)
121
+ }
122
+
123
+ function toKakaoConfig(block: KakaoChannelBlock): KakaoConfig {
124
+ return { current_account: block.currentAccount, accounts: block.accounts }
125
+ }
126
+
127
+ function fromKakaoConfig(config: KakaoConfig): KakaoChannelBlock {
128
+ return { currentAccount: config.current_account, accounts: config.accounts }
129
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { rename } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ import { KakaoCredentialManager } from 'agent-messenger/kakaotalk'
6
+ import type { KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
7
+
8
+ import { type KakaoChannelBlock, kakaoChannelBlockSchema } from './schema'
9
+ import { SecretsBackend } from './storage'
10
+
11
+ const KAKAO_CONFIG_DIR = join('workspace', '.agent-messenger')
12
+ const CREDENTIALS_FILE = 'kakaotalk-credentials.json'
13
+ const PENDING_LOGIN_FILE = 'kakaotalk-pending-login.json'
14
+
15
+ export type KakaotalkCredentialMigrationResult = { promoted: boolean }
16
+
17
+ export async function migrateKakaotalkCredentials(agentDir: string): Promise<KakaotalkCredentialMigrationResult> {
18
+ const configDir = join(agentDir, KAKAO_CONFIG_DIR)
19
+ const credentialsPath = join(configDir, CREDENTIALS_FILE)
20
+ const pendingLoginPath = join(configDir, PENDING_LOGIN_FILE)
21
+ if (!existsSync(credentialsPath) && !existsSync(pendingLoginPath)) return { promoted: false }
22
+
23
+ const secretsPath = join(agentDir, 'secrets.json')
24
+ const legacy = new KakaoCredentialManager(configDir)
25
+ const config = await legacy.load()
26
+ const pendingLogin = await legacy.loadPendingLogin()
27
+ if (Object.keys(config.accounts).length === 0 && pendingLogin === null) return { promoted: false }
28
+
29
+ const backend = new SecretsBackend(secretsPath)
30
+ const result = await backend.updateChannelsAsync(async (channels) => {
31
+ const existing = parseExistingBlock(channels.kakaotalk)
32
+ const next = mergeLegacyBlock(existing, config, pendingLogin)
33
+ if (next === existing) return { result: { promoted: false, renameCredentials: false, renamePending: false } }
34
+
35
+ return {
36
+ result: {
37
+ promoted: true,
38
+ renameCredentials: isEmptyBlock(existing) && Object.keys(config.accounts).length > 0,
39
+ renamePending: pendingLogin !== null && existing?.pendingLogin === undefined,
40
+ },
41
+ next: { ...channels, kakaotalk: next },
42
+ }
43
+ })
44
+ if (!result.promoted) return { promoted: false }
45
+
46
+ if (result.renameCredentials) await renameIfPresent(credentialsPath, `${credentialsPath}.migrated`)
47
+ if (result.renamePending) await renameIfPresent(pendingLoginPath, `${pendingLoginPath}.migrated`)
48
+ return { promoted: true }
49
+ }
50
+
51
+ function parseExistingBlock(value: unknown): KakaoChannelBlock | null {
52
+ if (value === undefined) return null
53
+ return kakaoChannelBlockSchema.parse(value)
54
+ }
55
+
56
+ function isEmptyBlock(block: KakaoChannelBlock | null): boolean {
57
+ return (
58
+ block === null ||
59
+ (block.currentAccount === null && Object.keys(block.accounts).length === 0 && block.pendingLogin === undefined)
60
+ )
61
+ }
62
+
63
+ function mergeLegacyBlock(
64
+ existing: KakaoChannelBlock | null,
65
+ config: KakaoConfig,
66
+ pendingLogin: PendingLoginState | null,
67
+ ): KakaoChannelBlock | null {
68
+ if (existing === null || isEmptyBlock(existing)) {
69
+ return {
70
+ currentAccount: config.current_account,
71
+ accounts: config.accounts,
72
+ ...(pendingLogin ? { pendingLogin } : {}),
73
+ }
74
+ }
75
+ if (pendingLogin === null || existing.pendingLogin !== undefined) return existing
76
+ return { ...existing, pendingLogin }
77
+ }
78
+
79
+ async function renameIfPresent(from: string, to: string): Promise<void> {
80
+ if (!existsSync(from)) return
81
+ await rename(from, to)
82
+ }
@@ -70,9 +70,10 @@ function renameWithRaceFallback(from: string, to: string): void {
70
70
  }
71
71
  }
72
72
 
73
- // "Empty envelope" = no actual credentials. Parsed shape with empty llm and
74
- // empty channels. We do NOT try to be clever about "approximately empty"
75
- // exact emptiness is the only safe auto-delete / auto-overwrite case.
73
+ // "Empty envelope" = no actual credentials. parseSecretsFile normalises both
74
+ // legacy v1 and current v2 to a v2-shaped SecretsFile, so we only check the
75
+ // v2 fields. We do NOT try to be clever about "approximately empty" — exact
76
+ // emptiness is the only safe auto-delete / auto-overwrite case.
76
77
  function isEmptyEnvelope(path: string): boolean {
77
78
  let raw: string
78
79
  try {
@@ -91,5 +92,5 @@ function isEmptyEnvelope(path: string): boolean {
91
92
 
92
93
  const result = parseSecretsFile(parsed)
93
94
  if (!result.ok) return false
94
- return Object.keys(result.file.llm).length === 0 && Object.keys(result.file.channels).length === 0
95
+ return Object.keys(result.file.providers).length === 0 && Object.keys(result.file.channels).length === 0
95
96
  }
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod'
2
+
3
+ // A Secret is the on-disk shape for any env-injectable credential field.
4
+ // String shorthand is sugar for `{ value }`. The schema normalises to the
5
+ // object form at parse time so consumers only ever handle one shape, but
6
+ // writers MAY emit the string shorthand for the common no-custom-env case
7
+ // to keep `secrets.json` terse.
8
+ //
9
+ // Empty objects `{}` are rejected because they carry no information — the
10
+ // resolver would always return undefined for them and the file would silently
11
+ // fail to provide credentials at boot.
12
+ const secretObjectSchema = z
13
+ .object({
14
+ value: z.string().min(1).optional(),
15
+ env: z.string().min(1).optional(),
16
+ })
17
+ .refine((s) => s.value !== undefined || s.env !== undefined, {
18
+ message: 'Secret object must have at least one of `value` or `env`',
19
+ })
20
+
21
+ export const secretFieldSchema = z
22
+ .union([z.string().min(1), secretObjectSchema])
23
+ .transform((v) => (typeof v === 'string' ? { value: v } : v))
24
+
25
+ export type Secret = z.infer<typeof secretFieldSchema>
26
+
27
+ // Env-wins resolution. The single place env-vs-file precedence lives.
28
+ //
29
+ // Precedence (highest to lowest):
30
+ // 1. process.env[secret.env] — explicit binding wins
31
+ // 2. process.env[defaultEnv] — canonical env-var-name fallback
32
+ // 3. secret.value — on-disk value
33
+ // 4. undefined — caller decides (missing-credential error)
34
+ //
35
+ // Empty-string env values are treated as unset, matching the existing
36
+ // hydrate.ts policy (`env[key] !== '' `). This keeps `unset` and `set to ""`
37
+ // behaviorally identical for credentials, which is what every shell ecosystem
38
+ // converges on.
39
+ export function resolveSecret(
40
+ secret: Secret,
41
+ defaultEnv: string | undefined,
42
+ env: NodeJS.ProcessEnv,
43
+ ): string | undefined {
44
+ const envName = secret.env ?? defaultEnv
45
+ if (envName !== undefined) {
46
+ const fromEnv = env[envName]
47
+ if (fromEnv !== undefined && fromEnv !== '') return fromEnv
48
+ }
49
+ return secret.value
50
+ }
51
+
52
+ // Returns the env-var name that resolveSecret would consult for a given
53
+ // Secret + default. Used by doctor / diagnostics to report "if you want to
54
+ // override this, set $envName". Does NOT consult process.env — pure mapping.
55
+ export function effectiveEnvName(secret: Secret, defaultEnv: string | undefined): string | undefined {
56
+ return secret.env ?? defaultEnv
57
+ }
@@ -1,72 +1,192 @@
1
1
  import { z } from 'zod'
2
2
 
3
- // The api_key shape exactly matches pi-coding-agent's ApiKeyCredential. We
4
- // re-state it here (rather than import the upstream type into the schema)
5
- // because Zod schemas are the source of truth for validation and JSON Schema
6
- // generation, and pi-coding-agent does not export Zod schemas.
7
- const llmApiKeyCredentialSchema = z.object({
3
+ import { CHANNEL_ENV_TO_FIELD } from './defaults'
4
+ import { secretFieldSchema, type Secret } from './resolve'
5
+
6
+ // providers.<id> for api-key credentials: the `key` field is a Secret (string
7
+ // shorthand or `{ value?, env? }` object). resolveSecret turns this into a
8
+ // flat string at read time so AuthStorage (which expects `key: string`)
9
+ // stays happy. OAuth credentials carry stateful refresh/access tokens that
10
+ // are not env-injectable, so they pass through unchanged via catchall.
11
+ const apiKeyProviderSchema = z.object({
8
12
  type: z.literal('api_key'),
9
- key: z.string().min(1),
13
+ key: secretFieldSchema,
10
14
  })
11
15
 
12
- // pi-coding-agent's OAuth credential carries provider-specific fields
13
- // (access, refresh, expires, plus arbitrary upstream additions). We accept
14
- // them as a passthrough object so future upstream additions don't break parse.
15
- // Upstream is the runtime authority on OAuth shape; our job here is only to
16
- // route the slice safely through the file envelope.
17
- const llmOAuthCredentialSchema = z
16
+ const oauthProviderSchema = z
18
17
  .object({
19
18
  type: z.literal('oauth'),
20
19
  })
21
20
  .catchall(z.unknown())
22
21
 
23
- export const llmCredentialSchema = z.discriminatedUnion('type', [llmApiKeyCredentialSchema, llmOAuthCredentialSchema])
22
+ export const providerCredentialSchema = z.discriminatedUnion('type', [apiKeyProviderSchema, oauthProviderSchema])
23
+
24
+ export const providersSchema = z.record(z.string(), providerCredentialSchema)
25
+
26
+ // Per-adapter channel slots use named fields (`botToken`, `appToken`, `token`)
27
+ // instead of env-var-name keys. The Secret union per field carries the env-var
28
+ // override. Unknown adapter ids pass through via catchall so a future
29
+ // plugin-contributed adapter doesn't fail validation.
30
+ const slackBotChannelSchema = z.object({
31
+ botToken: secretFieldSchema.optional(),
32
+ appToken: secretFieldSchema.optional(),
33
+ })
34
+
35
+ const discordBotChannelSchema = z.object({
36
+ token: secretFieldSchema.optional(),
37
+ })
38
+
39
+ const telegramBotChannelSchema = z.object({
40
+ token: secretFieldSchema.optional(),
41
+ })
42
+
43
+ export const kakaoAccountRecordSchema = z.object({
44
+ account_id: z.string(),
45
+ oauth_token: z.string(),
46
+ user_id: z.string(),
47
+ refresh_token: z.string().optional(),
48
+ device_uuid: z.string(),
49
+ device_type: z.union([z.literal('pc'), z.literal('tablet')]),
50
+ auth_method: z.union([z.literal('login'), z.literal('extract')]).optional(),
51
+ created_at: z.string(),
52
+ updated_at: z.string(),
53
+ })
54
+
55
+ export const kakaoPendingLoginRecordSchema = z.object({
56
+ device_uuid: z.string(),
57
+ device_type: z.union([z.literal('pc'), z.literal('tablet')]),
58
+ email: z.string(),
59
+ created_at: z.string(),
60
+ })
24
61
 
25
- // Map keyed by provider id ("openai", "openai-codex", "fireworks", ...).
26
- // Exactly the shape pi-coding-agent persists today as the entire secrets file.
27
- export const llmCredentialsSchema = z.record(z.string(), llmCredentialSchema)
62
+ export const kakaoChannelBlockSchema = z.object({
63
+ currentAccount: z.string().nullable(),
64
+ accounts: z.record(z.string(), kakaoAccountRecordSchema),
65
+ pendingLogin: kakaoPendingLoginRecordSchema.optional(),
66
+ })
28
67
 
29
- // Empty channels schema today; channel adapter tokens (Slack/Discord/Telegram)
30
- // will move here in a follow-up plan. The catchall keeps forward compatibility
31
- // when a future TypeClaw reads a file written by an even-newer TypeClaw.
32
- export const channelsSchema = z.object({}).catchall(z.unknown())
68
+ export const channelsSchema = z
69
+ .object({
70
+ 'slack-bot': slackBotChannelSchema.optional(),
71
+ 'discord-bot': discordBotChannelSchema.optional(),
72
+ 'telegram-bot': telegramBotChannelSchema.optional(),
73
+ kakaotalk: kakaoChannelBlockSchema.optional(),
74
+ })
75
+ .catchall(z.unknown())
76
+
77
+ // version 2 = providers.* with Secret-typed api-key.key + per-adapter
78
+ // channel field shapes. version 1 = the previous shape (flat `llm.*`, channel
79
+ // slots keyed by env-var name). Legacy v1 input is upgraded transparently by
80
+ // parseSecretsFile; the first write persists v2.
81
+ export const SECRETS_FILE_VERSION = 2
33
82
 
34
83
  export const secretsFileSchema = z.object({
35
84
  $schema: z.string().optional(),
36
- version: z.literal(1),
37
- llm: llmCredentialsSchema.default({}),
85
+ version: z.literal(SECRETS_FILE_VERSION),
86
+ providers: providersSchema.default({}),
38
87
  channels: channelsSchema.default({}),
39
88
  })
40
89
 
41
- export type LlmCredential = z.infer<typeof llmCredentialSchema>
42
- export type LlmCredentials = z.infer<typeof llmCredentialsSchema>
90
+ export type ProviderCredential = z.infer<typeof providerCredentialSchema>
91
+ export type Providers = z.infer<typeof providersSchema>
92
+ export type Channels = z.infer<typeof channelsSchema>
93
+ export type KakaoAccountRecord = z.infer<typeof kakaoAccountRecordSchema>
94
+ export type PendingLoginRecord = z.infer<typeof kakaoPendingLoginRecordSchema>
95
+ export type KakaoChannelBlock = z.infer<typeof kakaoChannelBlockSchema>
43
96
  export type SecretsFile = z.infer<typeof secretsFileSchema>
44
97
 
45
98
  export type ParseSecretsResult = { ok: true; file: SecretsFile } | { ok: false; reason: string }
46
99
 
47
- // parseSecretsFile recognises two shapes:
48
- // 1. The new envelope: { version: 1, llm: {...}, channels: {...} }
49
- // 2. The legacy flat shape pi-coding-agent writes today: a top-level
50
- // Record<string, AuthCredential>. Treated as { version: 1, llm: <flat>,
51
- // channels: {} } so existing OAuth users transparently upgrade on the
52
- // next write that goes through this code path.
100
+ // parseSecretsFile recognises three shapes, in priority order:
101
+ // 1. The v2 envelope (current): { version: 2, providers, channels }
102
+ // 2. The v1 envelope (legacy): { version: 1, llm, channels } where channel
103
+ // slots are keyed by env-var name. Both `llm` and `channels` get
104
+ // reshaped llm -> providers, env-keyed channel slots -> field-keyed.
105
+ // 3. The pre-envelope flat shape (very legacy): Record<string, AuthCredential>
106
+ // at top level. Treated as { version: 2, providers: <flat>, channels: {} }
107
+ // so existing OAuth users transparently upgrade.
53
108
  //
54
- // An empty object {} is a legitimate legacy state a freshly-created
55
- // secrets file with no providers logged in yet. It upgrades cleanly to the
56
- // new envelope with empty llm and channels.
109
+ // Every legacy upgrade produces a v2-shaped SecretsFile in memory; the next
110
+ // write persists v2 to disk. The legacy branches stay forever as a quiet
111
+ // compatibility seam only the v2 form is documented.
57
112
  export function parseSecretsFile(raw: unknown): ParseSecretsResult {
58
- const direct = secretsFileSchema.safeParse(raw)
59
- if (direct.success) return { ok: true, file: direct.data }
113
+ const v2 = secretsFileSchema.safeParse(raw)
114
+ if (v2.success) return { ok: true, file: v2.data }
115
+
116
+ const v1 = legacyV1Schema.safeParse(raw)
117
+ if (v1.success) return { ok: true, file: upgradeV1ToV2(v1.data) }
118
+
119
+ const flat = legacyFlatProviderSchema.safeParse(raw)
120
+ if (flat.success) {
121
+ return { ok: true, file: upgradeV1ToV2({ version: 1, llm: flat.data, channels: {} }) }
122
+ }
60
123
 
61
- const legacy = llmCredentialsSchema.safeParse(raw)
62
- if (legacy.success) {
63
- return { ok: true, file: { version: 1, llm: legacy.data, channels: {} } }
124
+ return { ok: false, reason: v2.error.issues.map(formatIssue).join('; ') }
125
+ }
126
+
127
+ // Legacy v1 schema: `llm` (flat string-key) and `channels` (env-var-keyed
128
+ // flat map per adapter). Used only for upgrade reads; never written.
129
+ const legacyV1ApiKeySchema = z.object({
130
+ type: z.literal('api_key'),
131
+ key: z.string().min(1),
132
+ })
133
+
134
+ const legacyV1OAuthSchema = z
135
+ .object({
136
+ type: z.literal('oauth'),
137
+ })
138
+ .catchall(z.unknown())
139
+
140
+ const legacyV1CredentialSchema = z.discriminatedUnion('type', [legacyV1ApiKeySchema, legacyV1OAuthSchema])
141
+
142
+ const legacyV1LlmSchema = z.record(z.string(), legacyV1CredentialSchema)
143
+
144
+ const legacyV1ChannelsSchema = z.record(z.string(), z.record(z.string(), z.string()))
145
+
146
+ const legacyV1Schema = z.object({
147
+ $schema: z.string().optional(),
148
+ version: z.literal(1),
149
+ llm: legacyV1LlmSchema.default({}),
150
+ channels: legacyV1ChannelsSchema.default({}),
151
+ })
152
+
153
+ const legacyFlatProviderSchema = z.record(z.string(), legacyV1CredentialSchema)
154
+
155
+ function upgradeV1ToV2(legacy: z.infer<typeof legacyV1Schema>): SecretsFile {
156
+ const providers: Providers = {}
157
+ for (const [providerId, cred] of Object.entries(legacy.llm)) {
158
+ if (cred.type === 'api_key') {
159
+ providers[providerId] = { type: 'api_key', key: { value: cred.key } }
160
+ } else {
161
+ providers[providerId] = cred
162
+ }
64
163
  }
65
164
 
66
- // Neither shape matched. We surface the new-shape error because that's the
67
- // target the user is presumably moving toward; the legacy path is a quiet
68
- // compatibility seam, not a documented format.
69
- return { ok: false, reason: direct.error.issues.map(formatIssue).join('; ') }
165
+ const channels: Channels = {}
166
+ for (const [adapterId, envKeyedSlot] of Object.entries(legacy.channels)) {
167
+ const upgradedSlot: Record<string, Secret> = {}
168
+ for (const [envKey, value] of Object.entries(envKeyedSlot)) {
169
+ const mapping = CHANNEL_ENV_TO_FIELD[envKey]
170
+ if (mapping && mapping.adapterId === adapterId) {
171
+ upgradedSlot[mapping.fieldName] = { value }
172
+ } else {
173
+ // Unknown env-var-name key on a known adapter, or an adapter we don't
174
+ // recognise: pass through verbatim under the original key. Better to
175
+ // preserve user data than drop it; the catchall on channelsSchema
176
+ // makes this safe.
177
+ upgradedSlot[envKey] = { value }
178
+ }
179
+ }
180
+ channels[adapterId] = upgradedSlot
181
+ }
182
+
183
+ const result: SecretsFile = {
184
+ version: SECRETS_FILE_VERSION,
185
+ providers,
186
+ channels,
187
+ }
188
+ if (legacy.$schema !== undefined) result.$schema = legacy.$schema
189
+ return result
70
190
  }
71
191
 
72
192
  function formatIssue(issue: { path: PropertyKey[]; message: string }): string {