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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { commitSystemFileSync } from '@/git/system-commit'
|
|
5
|
+
|
|
6
|
+
import { configSchema, loadConfigSync, validateConfig } from './config'
|
|
7
|
+
import {
|
|
8
|
+
KNOWN_PROVIDERS,
|
|
9
|
+
listKnownModelRefs,
|
|
10
|
+
providerForModelRef,
|
|
11
|
+
type KnownModelRef,
|
|
12
|
+
type KnownProviderId,
|
|
13
|
+
} from './providers'
|
|
14
|
+
import { isProviderConfigured } from './providers-mutation'
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILE = 'typeclaw.json'
|
|
17
|
+
|
|
18
|
+
export type ModelProfileEntry = {
|
|
19
|
+
profile: string
|
|
20
|
+
ref: KnownModelRef
|
|
21
|
+
providerId: KnownProviderId
|
|
22
|
+
isDefault: boolean
|
|
23
|
+
credentialStatus: 'available' | 'missing-credentials'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
|
|
27
|
+
|
|
28
|
+
export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
|
|
29
|
+
const models = loadConfigSync(cwd).models
|
|
30
|
+
const out: ModelProfileEntry[] = []
|
|
31
|
+
for (const [profile, ref] of Object.entries(models)) {
|
|
32
|
+
const providerId = providerForModelRef(ref)
|
|
33
|
+
out.push({
|
|
34
|
+
profile,
|
|
35
|
+
ref,
|
|
36
|
+
providerId,
|
|
37
|
+
isDefault: profile === 'default',
|
|
38
|
+
credentialStatus: hasUsableCredential(cwd, providerId, env) ? 'available' : 'missing-credentials',
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
// `default` always first; remaining profiles alphabetical so output is stable.
|
|
42
|
+
out.sort((a, b) => {
|
|
43
|
+
if (a.isDefault) return -1
|
|
44
|
+
if (b.isDefault) return 1
|
|
45
|
+
return a.profile.localeCompare(b.profile)
|
|
46
|
+
})
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function listAvailableModelRefs(): KnownModelRef[] {
|
|
51
|
+
return listKnownModelRefs()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isKnownModelRef(value: string): value is KnownModelRef {
|
|
55
|
+
return (listKnownModelRefs() as ReadonlyArray<string>).includes(value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `set` is the canonical mutation for both creating a new profile and updating
|
|
59
|
+
// an existing one (mirrors how `models.<profile>` works in the schema).
|
|
60
|
+
// Refuses unknown model refs and providers without credentials (unless
|
|
61
|
+
// `force: true`) so a write can't leave the agent in a state where the next
|
|
62
|
+
// session start crashes with a missing-credential error.
|
|
63
|
+
export type SetProfileOptions = {
|
|
64
|
+
force?: boolean
|
|
65
|
+
env?: NodeJS.ProcessEnv
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function setProfile(
|
|
69
|
+
cwd: string,
|
|
70
|
+
profile: string,
|
|
71
|
+
ref: string,
|
|
72
|
+
options: SetProfileOptions = {},
|
|
73
|
+
): ModelMutationResult {
|
|
74
|
+
const trimmed = profile.trim()
|
|
75
|
+
if (trimmed.length === 0) {
|
|
76
|
+
return { ok: false, reason: 'Profile name cannot be empty.' }
|
|
77
|
+
}
|
|
78
|
+
if (!isKnownModelRef(ref)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: `Unknown model "${ref}". Run \`typeclaw model list --available\` to see valid options.`,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const providerId = providerForModelRef(ref)
|
|
85
|
+
if (options.force !== true && !hasUsableCredential(cwd, providerId, options.env ?? process.env)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: `Provider "${providerId}" has no credentials. Run \`typeclaw provider add ${providerId}\` first, or pass --force to write anyway.`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const existingBefore = readModelsRaw(cwd)
|
|
93
|
+
const verb = existingBefore !== null && trimmed in existingBefore ? 'set' : 'add'
|
|
94
|
+
return writeProfile(cwd, trimmed, ref, `model: ${verb} ${trimmed} → ${ref}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `add` is just `set` with a uniqueness guard; users who want "update" should
|
|
98
|
+
// reach for `set`. Keeping it separate so the CLI can route distinct error
|
|
99
|
+
// messages without leaking force-overwrite as a happy path.
|
|
100
|
+
export function addProfile(
|
|
101
|
+
cwd: string,
|
|
102
|
+
profile: string,
|
|
103
|
+
ref: string,
|
|
104
|
+
options: SetProfileOptions = {},
|
|
105
|
+
): ModelMutationResult {
|
|
106
|
+
const existing = readModelsRaw(cwd)
|
|
107
|
+
if (existing !== null && profile in existing) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: `Profile "${profile}" already exists. Use \`typeclaw model set ${profile} ${ref}\` to update it.`,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return setProfile(cwd, profile, ref, options)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// `default` is required by the schema (`modelsSchema.refine`). Removing it
|
|
117
|
+
// would make the file unparseable, so we reject with a precise hint instead of
|
|
118
|
+
// letting the next `validateConfig` failure confuse the user. To change the
|
|
119
|
+
// default model, the user runs `typeclaw model set default <ref>`.
|
|
120
|
+
export function removeProfile(cwd: string, profile: string): ModelMutationResult {
|
|
121
|
+
if (profile === 'default') {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason:
|
|
125
|
+
'Cannot remove the `default` profile. Use `typeclaw model set default <ref>` to change the default model.',
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const existing = readModelsRaw(cwd)
|
|
129
|
+
if (existing === null) {
|
|
130
|
+
return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
|
|
131
|
+
}
|
|
132
|
+
if (!(profile in existing)) {
|
|
133
|
+
return { ok: false, reason: `Profile "${profile}" not found in ${CONFIG_FILE}.` }
|
|
134
|
+
}
|
|
135
|
+
const next = { ...existing }
|
|
136
|
+
delete next[profile]
|
|
137
|
+
return writeModels(cwd, next, `model: remove ${profile}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeProfile(cwd: string, profile: string, ref: KnownModelRef, message: string): ModelMutationResult {
|
|
141
|
+
const existing = readModelsRaw(cwd)
|
|
142
|
+
const next = existing === null ? { default: ref } : { ...existing, [profile]: ref }
|
|
143
|
+
if (existing === null && profile !== 'default') {
|
|
144
|
+
next.default = ref
|
|
145
|
+
}
|
|
146
|
+
return writeModels(cwd, next, message)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeModels(cwd: string, models: Record<string, string>, commitMessage: string): ModelMutationResult {
|
|
150
|
+
const path = join(cwd, CONFIG_FILE)
|
|
151
|
+
let parsed: Record<string, unknown>
|
|
152
|
+
try {
|
|
153
|
+
const raw = readFileSync(path, 'utf8')
|
|
154
|
+
parsed = JSON.parse(raw) as Record<string, unknown>
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
157
|
+
return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
|
|
158
|
+
}
|
|
159
|
+
return { ok: false, reason: `Failed to read ${CONFIG_FILE}: ${(error as Error).message}` }
|
|
160
|
+
}
|
|
161
|
+
parsed.models = models
|
|
162
|
+
const check = configSchema.safeParse(parsed)
|
|
163
|
+
if (!check.success) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: `models block would be invalid: ${check.error.issues.map((i) => i.message).join('; ')}`,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return { ok: false, reason: `Failed to write ${CONFIG_FILE}: ${(error as Error).message}` }
|
|
173
|
+
}
|
|
174
|
+
// Final schema-pass for parity with every other host-side mutation that runs
|
|
175
|
+
// through validateConfig. Mount checks etc. should never fail here because
|
|
176
|
+
// we only touched `models`, but if the file was already in a bad state we
|
|
177
|
+
// want to surface that instead of leaving the user wondering why `reload`
|
|
178
|
+
// fails.
|
|
179
|
+
const validation = validateConfig(cwd)
|
|
180
|
+
if (!validation.ok) {
|
|
181
|
+
return { ok: false, reason: validation.reason }
|
|
182
|
+
}
|
|
183
|
+
// Auto-commit so the agent folder is never silently dirty after a CLI
|
|
184
|
+
// config mutation. Same pattern as `persistMigratedConfig` and cron
|
|
185
|
+
// migrations: `commitSystemFileSync` no-ops on non-git folders, missing
|
|
186
|
+
// Bun, and clean files, so callers outside a git repo pay zero cost.
|
|
187
|
+
commitSystemFileSync(cwd, CONFIG_FILE, commitMessage)
|
|
188
|
+
return { ok: true }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readModelsRaw(cwd: string): Record<string, string> | null {
|
|
192
|
+
try {
|
|
193
|
+
const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
|
|
194
|
+
const parsed = JSON.parse(raw) as { models?: Record<string, string> }
|
|
195
|
+
return parsed.models ?? null
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
198
|
+
throw error
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hasUsableCredential(cwd: string, providerId: KnownProviderId, env: NodeJS.ProcessEnv): boolean {
|
|
203
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
204
|
+
if (provider.apiKeyEnv !== null) {
|
|
205
|
+
const fromEnv = env[provider.apiKeyEnv]
|
|
206
|
+
if (fromEnv !== undefined && fromEnv !== '') return true
|
|
207
|
+
}
|
|
208
|
+
return isProviderConfigured(cwd, providerId)
|
|
209
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { SecretsBackend, type Secret } from '@/secrets'
|
|
4
|
+
import { providerKeyDefaultEnv } from '@/secrets/defaults'
|
|
5
|
+
import type { ProviderCredential, Providers } from '@/secrets/schema'
|
|
6
|
+
|
|
7
|
+
import { type Models, loadConfigSync } from './config'
|
|
8
|
+
import { KNOWN_PROVIDERS, type KnownModelRef, type KnownProviderId, providerForModelRef } from './providers'
|
|
9
|
+
|
|
10
|
+
// Where a configured credential resolves from at runtime. Reported by
|
|
11
|
+
// `typeclaw provider list` so users can tell whether their key is coming from
|
|
12
|
+
// `secrets.json#providers.<id>.key.value`, from `process.env.<API_KEY_ENV>`, or
|
|
13
|
+
// from an explicit `{ env: 'CUSTOM_NAME' }` binding. Drives the post-mutation
|
|
14
|
+
// hints (e.g. "key is env-overridden — `provider remove` will not unset env").
|
|
15
|
+
export type CredentialSource =
|
|
16
|
+
| { kind: 'file' }
|
|
17
|
+
| { kind: 'env-only'; envName: string }
|
|
18
|
+
| { kind: 'env-overridden'; envName: string }
|
|
19
|
+
| { kind: 'oauth' }
|
|
20
|
+
|
|
21
|
+
export type ConfiguredProvider = {
|
|
22
|
+
id: KnownProviderId | string
|
|
23
|
+
known: boolean
|
|
24
|
+
type: 'api_key' | 'oauth' | 'unknown'
|
|
25
|
+
source: CredentialSource
|
|
26
|
+
envName: string | undefined
|
|
27
|
+
referencedByProfiles: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ProviderAddCredential =
|
|
31
|
+
| { type: 'api_key'; key: string; envBinding?: string | undefined }
|
|
32
|
+
| { type: 'env-binding'; envBinding: string }
|
|
33
|
+
|
|
34
|
+
export type ProviderMutationResult = { ok: true } | { ok: false; reason: string }
|
|
35
|
+
|
|
36
|
+
const SECRETS_FILE = 'secrets.json'
|
|
37
|
+
|
|
38
|
+
export function listConfiguredProviders(cwd: string, env: NodeJS.ProcessEnv = process.env): ConfiguredProvider[] {
|
|
39
|
+
const backend = new SecretsBackend(join(cwd, SECRETS_FILE))
|
|
40
|
+
const providers = backend.tryReadProvidersSync()
|
|
41
|
+
const models = readModelsOrNull(cwd)
|
|
42
|
+
const referencedByProfiles = buildProviderReferenceMap(models)
|
|
43
|
+
|
|
44
|
+
const ids = new Set<string>([...Object.keys(providers), ...Object.keys(KNOWN_PROVIDERS)])
|
|
45
|
+
const out: ConfiguredProvider[] = []
|
|
46
|
+
for (const id of ids) {
|
|
47
|
+
const credential = providers[id]
|
|
48
|
+
const known = id in KNOWN_PROVIDERS
|
|
49
|
+
if (credential === undefined) {
|
|
50
|
+
// Known provider with no file entry. Surface it only when an env var
|
|
51
|
+
// makes it usable; otherwise it's not "configured" and shouldn't appear
|
|
52
|
+
// in the list (would clutter output for the 5+ known providers users
|
|
53
|
+
// haven't touched).
|
|
54
|
+
const envName = providerKeyDefaultEnv(id)
|
|
55
|
+
if (envName !== undefined && readEnvKey(env, envName) !== undefined) {
|
|
56
|
+
out.push({
|
|
57
|
+
id: id as KnownProviderId,
|
|
58
|
+
known,
|
|
59
|
+
type: 'api_key',
|
|
60
|
+
source: { kind: 'env-only', envName },
|
|
61
|
+
envName,
|
|
62
|
+
referencedByProfiles: referencedByProfiles.get(id) ?? [],
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
out.push({
|
|
68
|
+
id,
|
|
69
|
+
known,
|
|
70
|
+
type: credentialType(credential),
|
|
71
|
+
source: credentialSource(id, credential, env),
|
|
72
|
+
envName: effectiveEnvName(id, credential),
|
|
73
|
+
referencedByProfiles: referencedByProfiles.get(id) ?? [],
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
out.sort((a, b) => a.id.localeCompare(b.id))
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isProviderConfigured(cwd: string, providerId: string): boolean {
|
|
81
|
+
const backend = new SecretsBackend(join(cwd, SECRETS_FILE))
|
|
82
|
+
return providerId in backend.tryReadProvidersSync()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Refuses to overwrite an existing provider — callers must use `setProvider`
|
|
86
|
+
// for the rotate path. Keeps the "I'm adding fresh credentials" intent
|
|
87
|
+
// distinct from the "I'm rotating an existing key" intent at the file-write
|
|
88
|
+
// boundary, so an `add` typo can't silently displace a working key.
|
|
89
|
+
export function addProvider(
|
|
90
|
+
cwd: string,
|
|
91
|
+
providerId: KnownProviderId,
|
|
92
|
+
credential: ProviderAddCredential,
|
|
93
|
+
): ProviderMutationResult {
|
|
94
|
+
if (isProviderConfigured(cwd, providerId)) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: `Provider "${providerId}" is already configured in secrets.json. Use \`typeclaw provider set\` to rotate its credentials.`,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return writeApiKeyCredential(cwd, providerId, credential)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function setProvider(
|
|
104
|
+
cwd: string,
|
|
105
|
+
providerId: KnownProviderId,
|
|
106
|
+
credential: ProviderAddCredential,
|
|
107
|
+
): ProviderMutationResult {
|
|
108
|
+
return writeApiKeyCredential(cwd, providerId, credential)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Refuses removal when any model profile in typeclaw.json references the
|
|
112
|
+
// provider — clearing the credential out from under an active profile would
|
|
113
|
+
// crash the next session start with a missing-credential error. Returns the
|
|
114
|
+
// list of offending profiles so the CLI can name them in the error message.
|
|
115
|
+
export type ProviderRemovalResult =
|
|
116
|
+
| { ok: true; existed: boolean }
|
|
117
|
+
| { ok: false; reason: 'referenced'; profiles: string[] }
|
|
118
|
+
|
|
119
|
+
export function removeProvider(
|
|
120
|
+
cwd: string,
|
|
121
|
+
providerId: string,
|
|
122
|
+
options: { force?: boolean } = {},
|
|
123
|
+
): ProviderRemovalResult {
|
|
124
|
+
if (options.force !== true) {
|
|
125
|
+
const profiles = findModelsReferencingProvider(cwd, providerId)
|
|
126
|
+
if (profiles.length > 0) {
|
|
127
|
+
return { ok: false, reason: 'referenced', profiles }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const backend = new SecretsBackend(join(cwd, SECRETS_FILE))
|
|
131
|
+
const existed = backend.removeProviderCredentialSync(providerId)
|
|
132
|
+
return { ok: true, existed }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function findModelsReferencingProvider(cwd: string, providerId: string): string[] {
|
|
136
|
+
const models = readModelsOrNull(cwd)
|
|
137
|
+
if (models === null) return []
|
|
138
|
+
const out: string[] = []
|
|
139
|
+
for (const [profile, ref] of Object.entries(models)) {
|
|
140
|
+
if (refTargetsProvider(ref, providerId)) out.push(profile)
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writeApiKeyCredential(
|
|
146
|
+
cwd: string,
|
|
147
|
+
providerId: KnownProviderId,
|
|
148
|
+
credential: ProviderAddCredential,
|
|
149
|
+
): ProviderMutationResult {
|
|
150
|
+
if (!(providerId in KNOWN_PROVIDERS)) {
|
|
151
|
+
return { ok: false, reason: `Unknown provider "${providerId}".` }
|
|
152
|
+
}
|
|
153
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
154
|
+
if (provider.apiKeyEnv === null) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: `Provider "${providerId}" does not support api-key authentication. Use \`typeclaw provider add ${providerId} --oauth\` instead.`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const secret = buildSecret(credential)
|
|
161
|
+
if (secret === null) {
|
|
162
|
+
return { ok: false, reason: 'API key cannot be empty.' }
|
|
163
|
+
}
|
|
164
|
+
const backend = new SecretsBackend(join(cwd, SECRETS_FILE))
|
|
165
|
+
backend.writeProviderCredentialSync(providerId, { type: 'api_key', key: secret })
|
|
166
|
+
return { ok: true }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildSecret(credential: ProviderAddCredential): Secret | null {
|
|
170
|
+
if (credential.type === 'env-binding') {
|
|
171
|
+
return { env: credential.envBinding }
|
|
172
|
+
}
|
|
173
|
+
if (credential.key.length === 0) return null
|
|
174
|
+
if (credential.envBinding !== undefined && credential.envBinding.length > 0) {
|
|
175
|
+
return { value: credential.key, env: credential.envBinding }
|
|
176
|
+
}
|
|
177
|
+
return { value: credential.key }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function credentialType(credential: ProviderCredential): 'api_key' | 'oauth' | 'unknown' {
|
|
181
|
+
if (credential.type === 'api_key') return 'api_key'
|
|
182
|
+
if (credential.type === 'oauth') return 'oauth'
|
|
183
|
+
return 'unknown'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function credentialSource(
|
|
187
|
+
providerId: string,
|
|
188
|
+
credential: ProviderCredential,
|
|
189
|
+
env: NodeJS.ProcessEnv,
|
|
190
|
+
): CredentialSource {
|
|
191
|
+
if (credential.type === 'oauth') return { kind: 'oauth' }
|
|
192
|
+
if (credential.type !== 'api_key') return { kind: 'file' }
|
|
193
|
+
const envName = credential.key.env ?? providerKeyDefaultEnv(providerId)
|
|
194
|
+
if (envName !== undefined && readEnvKey(env, envName) !== undefined) {
|
|
195
|
+
if (credential.key.value === undefined) return { kind: 'env-only', envName }
|
|
196
|
+
return { kind: 'env-overridden', envName }
|
|
197
|
+
}
|
|
198
|
+
return { kind: 'file' }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function effectiveEnvName(providerId: string, credential: ProviderCredential): string | undefined {
|
|
202
|
+
if (credential.type !== 'api_key') return undefined
|
|
203
|
+
return credential.key.env ?? providerKeyDefaultEnv(providerId)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readEnvKey(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
|
207
|
+
const value = env[key]
|
|
208
|
+
if (value === undefined || value === '') return undefined
|
|
209
|
+
return value
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildProviderReferenceMap(models: Models | null): Map<string, string[]> {
|
|
213
|
+
const out = new Map<string, string[]>()
|
|
214
|
+
if (models === null) return out
|
|
215
|
+
for (const [profile, ref] of Object.entries(models)) {
|
|
216
|
+
const providerId = safeProviderForRef(ref)
|
|
217
|
+
if (providerId === null) continue
|
|
218
|
+
const existing = out.get(providerId) ?? []
|
|
219
|
+
existing.push(profile)
|
|
220
|
+
out.set(providerId, existing)
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function refTargetsProvider(ref: string, providerId: string): boolean {
|
|
226
|
+
return ref.startsWith(`${providerId}/`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function safeProviderForRef(ref: KnownModelRef): KnownProviderId | null {
|
|
230
|
+
try {
|
|
231
|
+
return providerForModelRef(ref)
|
|
232
|
+
} catch {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readModelsOrNull(cwd: string): Models | null {
|
|
238
|
+
try {
|
|
239
|
+
return loadConfigSync(cwd).models
|
|
240
|
+
} catch {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export type ProviderListEntry = ConfiguredProvider
|
|
246
|
+
|
|
247
|
+
export type ProvidersSnapshot = {
|
|
248
|
+
providers: Providers
|
|
249
|
+
configuredIds: string[]
|
|
250
|
+
}
|
package/src/config/providers.ts
CHANGED
|
@@ -22,8 +22,9 @@ type KnownProvider = {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// Curated allowlist of providers + models that are wired into the agent
|
|
25
|
-
// runtime. The values here back the Zod enum on
|
|
26
|
-
// model the user can put in `typeclaw.json`
|
|
25
|
+
// runtime. The values here back the Zod enum on every entry in
|
|
26
|
+
// `configSchema.models`, so any model the user can put in `typeclaw.json`
|
|
27
|
+
// (under any profile name) MUST appear here verbatim. The
|
|
27
28
|
// init-time picker may surface additional models from models.dev, but it
|
|
28
29
|
// resolves them through this list before scaffolding (anything missing falls
|
|
29
30
|
// back to a curated default).
|
|
@@ -187,6 +188,144 @@ export const KNOWN_PROVIDERS = {
|
|
|
187
188
|
},
|
|
188
189
|
},
|
|
189
190
|
},
|
|
191
|
+
// Z.AI (ZhipuAI / BigModel) general pay-as-you-go API. OpenAI-compatible
|
|
192
|
+
// (Bearer auth + /chat/completions shape), so models go through pi-ai's
|
|
193
|
+
// `openai-completions` adapter with a custom baseUrl — same trick as
|
|
194
|
+
// Fireworks. Costs and context windows mirror docs.z.ai/guides/overview/
|
|
195
|
+
// pricing as of 2026-05-15.
|
|
196
|
+
//
|
|
197
|
+
// The split with `zai-coding` below mirrors how we model `openai` /
|
|
198
|
+
// `openai-codex`: same upstream vendor, two distinct billing surfaces
|
|
199
|
+
// (paygo vs subscription), two distinct base URLs, two distinct env vars
|
|
200
|
+
// so a user can hold both keys simultaneously without collisions.
|
|
201
|
+
zai: {
|
|
202
|
+
id: 'zai',
|
|
203
|
+
name: 'Z.AI',
|
|
204
|
+
baseUrl: 'https://api.z.ai/api/paas/v4',
|
|
205
|
+
auth: ['api-key'],
|
|
206
|
+
apiKeyEnv: 'ZAI_API_KEY',
|
|
207
|
+
oauthProviderId: null,
|
|
208
|
+
models: {
|
|
209
|
+
'glm-4.5-air': {
|
|
210
|
+
id: 'glm-4.5-air',
|
|
211
|
+
name: 'GLM-4.5-Air',
|
|
212
|
+
api: 'openai-completions',
|
|
213
|
+
provider: 'zai',
|
|
214
|
+
baseUrl: 'https://api.z.ai/api/paas/v4',
|
|
215
|
+
reasoning: true,
|
|
216
|
+
input: ['text'],
|
|
217
|
+
cost: { input: 0.2, output: 1.1, cacheRead: 0, cacheWrite: 0 },
|
|
218
|
+
contextWindow: 128000,
|
|
219
|
+
maxTokens: 96000,
|
|
220
|
+
},
|
|
221
|
+
'glm-4.6': {
|
|
222
|
+
id: 'glm-4.6',
|
|
223
|
+
name: 'GLM-4.6',
|
|
224
|
+
api: 'openai-completions',
|
|
225
|
+
provider: 'zai',
|
|
226
|
+
baseUrl: 'https://api.z.ai/api/paas/v4',
|
|
227
|
+
reasoning: true,
|
|
228
|
+
input: ['text'],
|
|
229
|
+
cost: { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 },
|
|
230
|
+
contextWindow: 200000,
|
|
231
|
+
maxTokens: 128000,
|
|
232
|
+
},
|
|
233
|
+
'glm-4.7': {
|
|
234
|
+
id: 'glm-4.7',
|
|
235
|
+
name: 'GLM-4.7',
|
|
236
|
+
api: 'openai-completions',
|
|
237
|
+
provider: 'zai',
|
|
238
|
+
baseUrl: 'https://api.z.ai/api/paas/v4',
|
|
239
|
+
reasoning: true,
|
|
240
|
+
input: ['text'],
|
|
241
|
+
cost: { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 },
|
|
242
|
+
contextWindow: 200000,
|
|
243
|
+
maxTokens: 128000,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
// Z.AI GLM Coding Plan subscription. Same vendor, same key format, but a
|
|
248
|
+
// distinct base URL (/api/coding/paas/v4) and a separate billing surface
|
|
249
|
+
// — using a Coding Plan key against the paygo endpoint returns error 1113
|
|
250
|
+
// ("insufficient balance"). Distinct env var (`ZAI_CODING_API_KEY`) so a
|
|
251
|
+
// user can hold both a paygo and a Coding Plan key on different accounts.
|
|
252
|
+
//
|
|
253
|
+
// Model lineup is exactly the five models the Coding Plan docs name as
|
|
254
|
+
// "All plans support" plus GLM-5 (Pro/Max only per docs). Listing other
|
|
255
|
+
// GLM models here would silently bill against the wrong surface.
|
|
256
|
+
'zai-coding': {
|
|
257
|
+
id: 'zai-coding',
|
|
258
|
+
name: 'Z.AI (GLM Coding Plan)',
|
|
259
|
+
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
|
260
|
+
auth: ['api-key'],
|
|
261
|
+
apiKeyEnv: 'ZAI_CODING_API_KEY',
|
|
262
|
+
oauthProviderId: null,
|
|
263
|
+
models: {
|
|
264
|
+
'glm-4.5-air': {
|
|
265
|
+
id: 'glm-4.5-air',
|
|
266
|
+
name: 'GLM-4.5-Air',
|
|
267
|
+
api: 'openai-completions',
|
|
268
|
+
provider: 'zai-coding',
|
|
269
|
+
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
|
270
|
+
reasoning: true,
|
|
271
|
+
input: ['text'],
|
|
272
|
+
cost: { input: 0.2, output: 1.1, cacheRead: 0, cacheWrite: 0 },
|
|
273
|
+
contextWindow: 128000,
|
|
274
|
+
maxTokens: 96000,
|
|
275
|
+
},
|
|
276
|
+
'glm-4.7': {
|
|
277
|
+
id: 'glm-4.7',
|
|
278
|
+
name: 'GLM-4.7',
|
|
279
|
+
api: 'openai-completions',
|
|
280
|
+
provider: 'zai-coding',
|
|
281
|
+
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
|
282
|
+
reasoning: true,
|
|
283
|
+
input: ['text'],
|
|
284
|
+
cost: { input: 0.6, output: 2.2, cacheRead: 0, cacheWrite: 0 },
|
|
285
|
+
contextWindow: 200000,
|
|
286
|
+
maxTokens: 128000,
|
|
287
|
+
},
|
|
288
|
+
// GLM-5 access is Pro/Max tier only per docs.z.ai/devpack — Lite
|
|
289
|
+
// subscribers will see a quota error. We still list it because we
|
|
290
|
+
// can't introspect plan tier from the key alone.
|
|
291
|
+
'glm-5': {
|
|
292
|
+
id: 'glm-5',
|
|
293
|
+
name: 'GLM-5',
|
|
294
|
+
api: 'openai-completions',
|
|
295
|
+
provider: 'zai-coding',
|
|
296
|
+
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
|
297
|
+
reasoning: true,
|
|
298
|
+
input: ['text'],
|
|
299
|
+
cost: { input: 1.0, output: 3.2, cacheRead: 0, cacheWrite: 0 },
|
|
300
|
+
contextWindow: 200000,
|
|
301
|
+
maxTokens: 128000,
|
|
302
|
+
},
|
|
303
|
+
'glm-5-turbo': {
|
|
304
|
+
id: 'glm-5-turbo',
|
|
305
|
+
name: 'GLM-5-Turbo',
|
|
306
|
+
api: 'openai-completions',
|
|
307
|
+
provider: 'zai-coding',
|
|
308
|
+
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
|
309
|
+
reasoning: true,
|
|
310
|
+
input: ['text'],
|
|
311
|
+
cost: { input: 1.2, output: 4.0, cacheRead: 0, cacheWrite: 0 },
|
|
312
|
+
contextWindow: 200000,
|
|
313
|
+
maxTokens: 128000,
|
|
314
|
+
},
|
|
315
|
+
'glm-5.1': {
|
|
316
|
+
id: 'glm-5.1',
|
|
317
|
+
name: 'GLM-5.1',
|
|
318
|
+
api: 'openai-completions',
|
|
319
|
+
provider: 'zai-coding',
|
|
320
|
+
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
|
321
|
+
reasoning: true,
|
|
322
|
+
input: ['text'],
|
|
323
|
+
cost: { input: 1.4, output: 4.4, cacheRead: 0, cacheWrite: 0 },
|
|
324
|
+
contextWindow: 200000,
|
|
325
|
+
maxTokens: 128000,
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
190
329
|
} as const satisfies Record<string, KnownProvider>
|
|
191
330
|
|
|
192
331
|
export type KnownProviderId = keyof typeof KNOWN_PROVIDERS
|
package/src/config/reloadable.ts
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
|
+
import type { PermissionService } from '@/permissions'
|
|
1
2
|
import type { Reloadable, ReloadResult } from '@/reload'
|
|
2
3
|
|
|
3
|
-
import { type ConfigReloadDiff, reloadConfig, validateConfig } from './config'
|
|
4
|
+
import { getConfig, type ConfigReloadDiff, reloadConfig, validateConfig } from './config'
|
|
4
5
|
|
|
5
6
|
export type CreateConfigReloadableOptions = {
|
|
6
7
|
cwd: string
|
|
8
|
+
// Optional hook fired after a successful reload so the live permission
|
|
9
|
+
// service can rebuild its resolved role table from the new roles config.
|
|
10
|
+
// This is what makes `roles.<name>.match` edits (typeclaw role claim,
|
|
11
|
+
// hand-edits) take effect without a container restart. `roles.<name>.permissions`
|
|
12
|
+
// changes still require a restart — see FIELD_EFFECTS in config.ts.
|
|
13
|
+
permissions?: PermissionService
|
|
7
14
|
}
|
|
8
15
|
|
|
9
|
-
export function createConfigReloadable({ cwd }: CreateConfigReloadableOptions): Reloadable {
|
|
16
|
+
export function createConfigReloadable({ cwd, permissions }: CreateConfigReloadableOptions): Reloadable {
|
|
10
17
|
return {
|
|
11
18
|
scope: 'config',
|
|
12
19
|
description: 'typeclaw.json runtime config',
|
|
13
|
-
reload: async () => doReload(cwd),
|
|
20
|
+
reload: async () => doReload(cwd, permissions),
|
|
14
21
|
}
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
async function doReload(cwd: string): Promise<ReloadResult> {
|
|
24
|
+
async function doReload(cwd: string, permissions: PermissionService | undefined): Promise<ReloadResult> {
|
|
18
25
|
// Mount accessibility belongs to the validation surface, not loadConfigSync —
|
|
19
26
|
// validateConfig is the single gate that every host-side caller goes through.
|
|
20
27
|
// Run it before swapping the live config pointer so a mount that vanished
|
|
@@ -34,6 +41,10 @@ async function doReload(cwd: string): Promise<ReloadResult> {
|
|
|
34
41
|
return { scope: 'config', ok: false, reason: message }
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
if (permissions !== undefined && diff.applied.some((c) => c.path === 'roles.match')) {
|
|
45
|
+
permissions.replaceRoles(getConfig().roles)
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
return {
|
|
38
49
|
scope: 'config',
|
|
39
50
|
ok: true,
|
package/src/container/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { logs, planLogs, type LogsPlan, type LogsResult } from './logs'
|
|
2
2
|
export { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, resolveHostPort, resolveTuiToken } from './port'
|
|
3
|
+
export {
|
|
4
|
+
requireContainerRunning,
|
|
5
|
+
type RequireContainerRunningOptions,
|
|
6
|
+
type RequireContainerRunningResult,
|
|
7
|
+
} from './require-running'
|
|
3
8
|
export { planShell, shell, type ShellPlan, type ShellResult } from './shell'
|
|
4
9
|
export { status, type ContainerStatus, type StatusOptions } from './status'
|
|
5
10
|
export {
|