typeclaw 0.1.1 → 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 (74) hide show
  1. package/README.md +16 -12
  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/doctor.ts +173 -0
  7. package/src/agent/subagents.ts +24 -2
  8. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  9. package/src/agent/tools/channel-history.ts +10 -1
  10. package/src/agent/tools/channel-log.ts +32 -0
  11. package/src/agent/tools/channel-reply.ts +18 -1
  12. package/src/agent/tools/channel-send.ts +13 -1
  13. package/src/bundled-plugins/backup/README.md +81 -0
  14. package/src/bundled-plugins/backup/index.ts +209 -0
  15. package/src/bundled-plugins/backup/runner.ts +231 -0
  16. package/src/bundled-plugins/backup/subagents.ts +200 -0
  17. package/src/bundled-plugins/memory/index.ts +42 -1
  18. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  19. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  20. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  21. package/src/channels/adapters/kakaotalk.ts +25 -16
  22. package/src/channels/manager.ts +47 -38
  23. package/src/channels/router.ts +29 -0
  24. package/src/cli/channel.ts +3 -3
  25. package/src/cli/compose.ts +92 -1
  26. package/src/cli/doctor.ts +100 -0
  27. package/src/cli/index.ts +4 -0
  28. package/src/cli/init.ts +2 -1
  29. package/src/cli/ui.ts +11 -0
  30. package/src/compose/doctor.ts +141 -0
  31. package/src/compose/index.ts +8 -0
  32. package/src/compose/logs.ts +32 -19
  33. package/src/config/config.ts +31 -0
  34. package/src/container/log-colors.ts +75 -0
  35. package/src/container/log-timestamps.ts +84 -0
  36. package/src/container/logs.ts +71 -5
  37. package/src/container/start.ts +113 -9
  38. package/src/cron/consumer.ts +29 -7
  39. package/src/doctor/checks.ts +426 -0
  40. package/src/doctor/commit.ts +71 -0
  41. package/src/doctor/index.ts +287 -0
  42. package/src/doctor/plugin-bridge.ts +147 -0
  43. package/src/doctor/report.ts +142 -0
  44. package/src/doctor/types.ts +87 -0
  45. package/src/hostd/daemon.ts +28 -3
  46. package/src/hostd/protocol.ts +7 -0
  47. package/src/init/auto-upgrade.ts +368 -0
  48. package/src/init/cli-version.ts +81 -0
  49. package/src/init/dockerfile.ts +234 -25
  50. package/src/init/index.ts +141 -87
  51. package/src/init/kakaotalk-auth.ts +9 -3
  52. package/src/init/run-bun-install.ts +34 -0
  53. package/src/plugin/hooks.ts +32 -0
  54. package/src/plugin/index.ts +7 -0
  55. package/src/plugin/manager.ts +2 -0
  56. package/src/plugin/registry.ts +32 -3
  57. package/src/plugin/types.ts +65 -0
  58. package/src/run/bundled-plugins.ts +15 -0
  59. package/src/run/index.ts +19 -5
  60. package/src/secrets/defaults.ts +67 -0
  61. package/src/secrets/hydrate.ts +99 -0
  62. package/src/secrets/index.ts +6 -12
  63. package/src/secrets/kakao-store.ts +129 -0
  64. package/src/secrets/migrate-kakaotalk.ts +82 -0
  65. package/src/secrets/migrate.ts +5 -4
  66. package/src/secrets/resolve.ts +57 -0
  67. package/src/secrets/schema.ts +162 -42
  68. package/src/secrets/storage.ts +253 -47
  69. package/src/server/index.ts +103 -5
  70. package/src/shared/index.ts +3 -0
  71. package/src/shared/protocol.ts +22 -0
  72. package/src/skills/typeclaw-config/SKILL.md +48 -9
  73. package/typeclaw.schema.json +84 -0
  74. package/src/secrets/env.ts +0 -43
@@ -7,6 +7,8 @@ import type { Socket, UnixSocketListener } from 'bun'
7
7
  import type { PortForward } from '@/config'
8
8
  import { defaultDockerExec, type DockerExec } from '@/container'
9
9
  import type { PortForwardEvent } from '@/portbroker'
10
+ import { kakaoChannelBlockSchema } from '@/secrets/schema'
11
+ import { SecretsBackend } from '@/secrets/storage'
10
12
 
11
13
  import { isDaemonReachable } from './client'
12
14
  import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
@@ -16,6 +18,7 @@ import type {
16
18
  Request,
17
19
  Response as RpcResponse,
18
20
  RestartResult,
21
+ SecretsPatchResult,
19
22
  ShutdownResult,
20
23
  StatusResult,
21
24
  VersionResult,
@@ -351,6 +354,26 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
351
354
  return { ok: true, result }
352
355
  }
353
356
 
357
+ const handleSecretsPatch = async (req: {
358
+ containerName: string
359
+ patch: { channels: { kakaotalk: unknown } }
360
+ }): Promise<RpcResponse> =>
361
+ runSerially(req.containerName, async () => {
362
+ const cwd = cwds.get(req.containerName)
363
+ if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
364
+ const parsed = kakaoChannelBlockSchema.safeParse(req.patch?.channels?.kakaotalk)
365
+ if (!parsed.success) {
366
+ return { ok: false, reason: parsed.error.issues.map((issue) => issue.message).join('; ') }
367
+ }
368
+ const backend = new SecretsBackend(join(cwd, 'secrets.json'))
369
+ await backend.updateChannelsAsync(async (channels) => ({
370
+ result: undefined,
371
+ next: { ...channels, kakaotalk: parsed.data },
372
+ }))
373
+ const result: SecretsPatchResult = { containerName: req.containerName, patched: true }
374
+ return { ok: true, result }
375
+ })
376
+
354
377
  const handleHttpInfo = (): RpcResponse => {
355
378
  const result: HttpInfoResult = { port: httpPort }
356
379
  return { ok: true, result }
@@ -392,6 +415,8 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
392
415
  return handleStatus(req)
393
416
  case 'restart':
394
417
  return handleRestart(req)
418
+ case 'secrets-patch':
419
+ return handleSecretsPatch(req)
395
420
  case 'http-info':
396
421
  return handleHttpInfo()
397
422
  case 'version':
@@ -454,13 +479,13 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
454
479
  } catch {
455
480
  return json({ ok: false, reason: 'invalid request json' }, 400)
456
481
  }
457
- if (rpc.kind !== 'restart') {
458
- return json({ ok: false, reason: 'http transport only supports restart' }, 403)
482
+ if (rpc.kind !== 'restart' && rpc.kind !== 'secrets-patch') {
483
+ return json({ ok: false, reason: 'http transport only supports restart and secrets-patch' }, 403)
459
484
  }
460
485
  if (restartTokens.get(rpc.containerName) !== token) {
461
486
  return json({ ok: false, reason: 'invalid restart token' }, 403)
462
487
  }
463
- return json(await handleRestart(rpc))
488
+ return json(rpc.kind === 'restart' ? await handleRestart(rpc) : await handleSecretsPatch(rpc))
464
489
  }
465
490
 
466
491
  const httpHostname = opts.httpHost ?? '0.0.0.0'
@@ -1,4 +1,5 @@
1
1
  import type { PortForward } from '@/config'
2
+ import type { KakaoChannelBlock } from '@/secrets/schema'
2
3
 
3
4
  export type Request =
4
5
  | {
@@ -14,6 +15,7 @@ export type Request =
14
15
  | { kind: 'list' }
15
16
  | { kind: 'status'; containerName: string }
16
17
  | { kind: 'restart'; containerName: string; build?: boolean }
18
+ | { kind: 'secrets-patch'; containerName: string; patch: { channels: { kakaotalk: KakaoChannelBlock } } }
17
19
  | { kind: 'http-info' }
18
20
  | { kind: 'version' }
19
21
  | { kind: 'shutdown' }
@@ -35,6 +37,11 @@ export type RestartResult = {
35
37
  scheduled: true
36
38
  }
37
39
 
40
+ export type SecretsPatchResult = {
41
+ containerName: string
42
+ patched: true
43
+ }
44
+
38
45
  export type HttpInfoResult = {
39
46
  port: number
40
47
  }
@@ -0,0 +1,368 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ import { resolveScaffoldVersion } from './cli-version'
6
+
7
+ const PACKAGE_FILE = 'package.json'
8
+ const TYPECLAW = 'typeclaw'
9
+
10
+ // Two semver quirks drive every branch in this module:
11
+ //
12
+ // 1. Pre-1.0 caret: `^0.1.0` resolves to `>=0.1.0 <0.2.0`. A CLI bump from
13
+ // 0.1.x to 0.2.0 falls OUT of the agent's range, so we must rewrite the
14
+ // spec for those crossings.
15
+ //
16
+ // 2. `bun install` honors the lockfile: when the lockfile entry already
17
+ // satisfies the declared spec, `bun install` is a no-op even if a newer
18
+ // in-range version exists upstream. To actually upgrade an in-range dep
19
+ // we MUST use `bun update <pkg> --latest`. See src/init/run-bun-install.ts.
20
+ //
21
+ // The decision matrix anchors on the INSTALLED version (the truth), not the
22
+ // declared range floor (a promise the agent may not yet have kept).
23
+
24
+ export type AutoUpgradeOutcome =
25
+ | { kind: 'skipped-dev-mode' }
26
+ | { kind: 'skipped-no-dep' }
27
+ | { kind: 'skipped-non-release-spec'; declared: string }
28
+ | { kind: 'skipped-already-running' }
29
+ | { kind: 'up-to-date'; installedVersion: string }
30
+ | { kind: 'exact-pin-respected'; declared: string; cliVersion: string }
31
+ | { kind: 'spec-rewritten'; from: string; to: string; cliVersion: string }
32
+ | { kind: 'reinstall-needed'; from: string; to: string }
33
+
34
+ export type AutoUpgradeOptions = {
35
+ cwd: string
36
+ // Test seam: lets tests simulate dev-mode (null) and arbitrary release
37
+ // versions without depending on the test runner's actual CLI version.
38
+ scaffoldVersion?: string | null
39
+ }
40
+
41
+ export async function autoUpgradeTypeclawDep(options: AutoUpgradeOptions): Promise<AutoUpgradeOutcome> {
42
+ const { cwd } = options
43
+ const scaffold = options.scaffoldVersion !== undefined ? options.scaffoldVersion : resolveScaffoldVersion()
44
+ if (scaffold === null) return { kind: 'skipped-dev-mode' }
45
+
46
+ const cliVersion = stripCaret(scaffold)
47
+ if (cliVersion === null) return { kind: 'skipped-dev-mode' }
48
+
49
+ const pkg = await readAgentPackageJson(cwd)
50
+ if (pkg === null) return { kind: 'skipped-no-dep' }
51
+
52
+ const declared = pkg.parsed.dependencies?.[TYPECLAW]
53
+ if (typeof declared !== 'string') return { kind: 'skipped-no-dep' }
54
+
55
+ const declaredKind = classifyDepSpec(declared)
56
+ if (declaredKind.kind === 'non-release') {
57
+ return { kind: 'skipped-non-release-spec', declared }
58
+ }
59
+
60
+ const installed = readInstalledTypeclawVersion(cwd)
61
+
62
+ // "Upgrade only, never downgrade" — anchored on the INSTALLED version
63
+ // (the truth), with the declared range floor used ONLY when nothing is
64
+ // installed yet (best proxy for "what would land if bun install ran").
65
+ //
66
+ // Anchoring on `installed` first closes the half-applied-rewrite hole:
67
+ // a previous start may have written ^0.2.0 to package.json but failed
68
+ // its install, leaving node_modules at 0.1.x. The declared floor would
69
+ // wrongly say "up-to-date"; the installed version correctly says "retry."
70
+ if (installed !== null && compareReleaseVersions(installed, cliVersion) >= 0) {
71
+ return { kind: 'up-to-date', installedVersion: installed }
72
+ }
73
+ if (installed === null && declaredKind.kind !== 'exact') {
74
+ const declaredFloor = formatTriple(declaredKind.version)
75
+ if (compareReleaseVersions(declaredFloor, cliVersion) >= 0) {
76
+ return { kind: 'up-to-date', installedVersion: declaredFloor }
77
+ }
78
+ }
79
+
80
+ if (declaredKind.kind === 'exact') {
81
+ const declaredVersion = formatTriple(declaredKind.version)
82
+ // Exact pin matches CLI but installed is stale (or missing): we still
83
+ // need to install. The user wrote the right spec — they just haven't
84
+ // materialized it yet. Return reinstall-needed; caller will run
85
+ // `bun update typeclaw --latest` against that exact spec.
86
+ if (declaredVersion === cliVersion) {
87
+ return { kind: 'reinstall-needed', from: installed ?? '<missing>', to: cliVersion }
88
+ }
89
+ // Exact pin diverges from CLI. User intent wins; we warn but never
90
+ // rewrite. If installed is ALSO ahead of CLI (e.g. exact pin 0.1.5,
91
+ // CLI 0.1.2), the up-to-date check above already returned.
92
+ return { kind: 'exact-pin-respected', declared, cliVersion }
93
+ }
94
+
95
+ if (!rangeSatisfies(declaredKind, cliVersion)) {
96
+ const newSpec = `^${cliVersion}`
97
+ await writeDepSpec(cwd, pkg.raw, pkg.parsed, newSpec)
98
+ return { kind: 'spec-rewritten', from: declared, to: newSpec, cliVersion }
99
+ }
100
+
101
+ // Declared range includes CLI. Three sub-cases:
102
+ // - installed === null: fresh agent, nothing on disk yet. ensureDeps
103
+ // will install for the missing-dep reason; nothing for us to add.
104
+ // - installed > CLI but in range: we already returned up-to-date above.
105
+ // - installed < CLI: force an upgrade via `bun update typeclaw --latest`.
106
+ if (installed === null) {
107
+ return { kind: 'up-to-date', installedVersion: cliVersion }
108
+ }
109
+ return { kind: 'reinstall-needed', from: installed, to: cliVersion }
110
+ }
111
+
112
+ export function outcomeForcesInstall(outcome: AutoUpgradeOutcome): boolean {
113
+ return outcome.kind === 'spec-rewritten' || outcome.kind === 'reinstall-needed'
114
+ }
115
+
116
+ // The version we expect to find in node_modules/typeclaw after the
117
+ // auto-upgrade-triggered install completes. Callers use this to verify
118
+ // the install actually moved the on-disk version (not just resolved the
119
+ // lockfile). Returns null when no install was forced — verification is
120
+ // skipped on no-op outcomes.
121
+ export function expectedInstalledAfterUpgrade(outcome: AutoUpgradeOutcome): string | null {
122
+ if (outcome.kind === 'spec-rewritten') return outcome.cliVersion
123
+ if (outcome.kind === 'reinstall-needed') return outcome.to
124
+ return null
125
+ }
126
+
127
+ export function describeAutoUpgrade(outcome: AutoUpgradeOutcome): string {
128
+ switch (outcome.kind) {
129
+ case 'spec-rewritten':
130
+ return `Upgrading agent typeclaw ${outcome.from} → ${outcome.to} to match CLI`
131
+ case 'reinstall-needed':
132
+ return `Upgrading agent typeclaw ${outcome.from} → ${outcome.to} to match CLI`
133
+ case 'exact-pin-respected':
134
+ return `Agent typeclaw is exact-pinned to ${outcome.declared}; CLI is ${outcome.cliVersion}. Not upgrading (remove the exact pin to allow auto-upgrade).`
135
+ default:
136
+ return ''
137
+ }
138
+ }
139
+
140
+ export function readInstalledTypeclawVersionFromAgent(cwd: string): string | null {
141
+ return readInstalledTypeclawVersion(cwd)
142
+ }
143
+
144
+ type ParsedPackage = {
145
+ raw: string
146
+ parsed: { dependencies?: Record<string, string> } & Record<string, unknown>
147
+ }
148
+
149
+ async function readAgentPackageJson(cwd: string): Promise<ParsedPackage | null> {
150
+ const path = join(cwd, PACKAGE_FILE)
151
+ if (!existsSync(path)) return null
152
+ let raw: string
153
+ try {
154
+ raw = await readFile(path, 'utf8')
155
+ } catch {
156
+ return null
157
+ }
158
+ let parsed: unknown
159
+ try {
160
+ parsed = JSON.parse(raw)
161
+ } catch {
162
+ return null
163
+ }
164
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
165
+ return { raw, parsed: parsed as ParsedPackage['parsed'] }
166
+ }
167
+
168
+ function readInstalledTypeclawVersion(cwd: string): string | null {
169
+ const path = join(cwd, 'node_modules', TYPECLAW, PACKAGE_FILE)
170
+ if (!existsSync(path)) return null
171
+ let raw: string
172
+ try {
173
+ raw = readFileSync(path, 'utf8')
174
+ } catch {
175
+ return null
176
+ }
177
+ try {
178
+ const parsed = JSON.parse(raw) as { version?: string }
179
+ if (typeof parsed.version === 'string' && isReleaseVersion(parsed.version)) return parsed.version
180
+ } catch {}
181
+ return null
182
+ }
183
+
184
+ type DepSpecKind =
185
+ | { kind: 'exact'; version: [number, number, number]; raw: string }
186
+ | { kind: 'caret'; version: [number, number, number] }
187
+ | { kind: 'tilde'; version: [number, number, number] }
188
+ | { kind: 'non-release' }
189
+
190
+ function classifyDepSpec(spec: string): DepSpecKind {
191
+ const trimmed = spec.trim()
192
+ const exactMatch = trimmed.match(/^=?(\d+)\.(\d+)\.(\d+)$/)
193
+ if (exactMatch) {
194
+ const [, a, b, c] = exactMatch
195
+ return { kind: 'exact', version: parseTriple(a!, b!, c!), raw: trimmed }
196
+ }
197
+ const caretMatch = trimmed.match(/^\^(\d+)\.(\d+)\.(\d+)$/)
198
+ if (caretMatch) {
199
+ const [, a, b, c] = caretMatch
200
+ return { kind: 'caret', version: parseTriple(a!, b!, c!) }
201
+ }
202
+ const tildeMatch = trimmed.match(/^~(\d+)\.(\d+)\.(\d+)$/)
203
+ if (tildeMatch) {
204
+ const [, a, b, c] = tildeMatch
205
+ return { kind: 'tilde', version: parseTriple(a!, b!, c!) }
206
+ }
207
+ return { kind: 'non-release' }
208
+ }
209
+
210
+ function parseTriple(a: string, b: string, c: string): [number, number, number] {
211
+ return [Number.parseInt(a, 10), Number.parseInt(b, 10), Number.parseInt(c, 10)]
212
+ }
213
+
214
+ function formatTriple(v: [number, number, number]): string {
215
+ return `${v[0]}.${v[1]}.${v[2]}`
216
+ }
217
+
218
+ // npm/bun caret+tilde semantics, narrowed to plain X.Y.Z bases:
219
+ // ^0.1.2 → >=0.1.2 <0.2.0 (pre-1.0 caret pins minor — the bug we fix)
220
+ // ^1.2.3 → >=1.2.3 <2.0.0
221
+ // ~0.1.2 → >=0.1.2 <0.2.0
222
+ // ~1.2.3 → >=1.2.3 <1.3.0
223
+ function rangeSatisfies(range: Exclude<DepSpecKind, { kind: 'exact' | 'non-release' }>, version: string): boolean {
224
+ const v = parseVersion(version)
225
+ if (v === null) return false
226
+ const [base, ceiling] = rangeBounds(range)
227
+ return compareTriples(v, base) >= 0 && compareTriples(v, ceiling) < 0
228
+ }
229
+
230
+ function rangeBounds(
231
+ range: Exclude<DepSpecKind, { kind: 'exact' | 'non-release' }>,
232
+ ): [[number, number, number], [number, number, number]] {
233
+ const [maj, min, pat] = range.version
234
+ if (range.kind === 'caret') {
235
+ if (maj > 0)
236
+ return [
237
+ [maj, min, pat],
238
+ [maj + 1, 0, 0],
239
+ ]
240
+ if (min > 0)
241
+ return [
242
+ [maj, min, pat],
243
+ [maj, min + 1, 0],
244
+ ]
245
+ return [
246
+ [maj, min, pat],
247
+ [maj, min, pat + 1],
248
+ ]
249
+ }
250
+ return [
251
+ [maj, min, pat],
252
+ [maj, min + 1, 0],
253
+ ]
254
+ }
255
+
256
+ function parseVersion(version: string): [number, number, number] | null {
257
+ const m = version.match(/^(\d+)\.(\d+)\.(\d+)$/)
258
+ if (!m) return null
259
+ const [, a, b, c] = m
260
+ return parseTriple(a!, b!, c!)
261
+ }
262
+
263
+ function compareTriples(a: [number, number, number], b: [number, number, number]): number {
264
+ for (let i = 0; i < 3; i++) {
265
+ const ai = a[i]!
266
+ const bi = b[i]!
267
+ if (ai !== bi) return ai - bi
268
+ }
269
+ return 0
270
+ }
271
+
272
+ function compareReleaseVersions(a: string, b: string): number {
273
+ const av = parseVersion(a)
274
+ const bv = parseVersion(b)
275
+ if (av === null || bv === null) return 0
276
+ return compareTriples(av, bv)
277
+ }
278
+
279
+ function stripCaret(scaffold: string): string | null {
280
+ const m = scaffold.match(/^\^?(\d+\.\d+\.\d+)$/)
281
+ return m ? (m[1] ?? null) : null
282
+ }
283
+
284
+ function isReleaseVersion(version: string): boolean {
285
+ return /^\d+\.\d+\.\d+$/.test(version)
286
+ }
287
+
288
+ async function writeDepSpec(cwd: string, raw: string, parsed: ParsedPackage['parsed'], newSpec: string): Promise<void> {
289
+ // Scoped edit: replace the typeclaw spec ONLY inside the dependencies
290
+ // object. The previous implementation used `raw.replace(/"typeclaw":.../)`
291
+ // unscoped, which would silently rewrite devDependencies.typeclaw if it
292
+ // appeared before dependencies.typeclaw in the file (the original spec
293
+ // never moves). We slice the dependencies object's textual range, edit
294
+ // inside it, then splice back to preserve whitespace, key order, and
295
+ // trailing newline. If the slice fails (unusual JSON shape), fall back
296
+ // to a full JSON round-trip — formatting churn is acceptable; silently
297
+ // updating the wrong key is not.
298
+ const scoped = sliceDependenciesRange(raw, parsed)
299
+ if (scoped !== null) {
300
+ const { start, end } = scoped
301
+ const block = raw.slice(start, end)
302
+ const replaced = block.replace(
303
+ /("typeclaw"\s*:\s*)"[^"]+"/,
304
+ (_m, prefix: string) => `${prefix}${JSON.stringify(newSpec)}`,
305
+ )
306
+ if (replaced !== block) {
307
+ await writeFile(join(cwd, PACKAGE_FILE), `${raw.slice(0, start)}${replaced}${raw.slice(end)}`)
308
+ return
309
+ }
310
+ }
311
+ const deps = { ...parsed.dependencies, [TYPECLAW]: newSpec }
312
+ const next = { ...parsed, dependencies: deps }
313
+ const indent = detectIndent(raw)
314
+ await writeFile(join(cwd, PACKAGE_FILE), `${JSON.stringify(next, null, indent)}\n`)
315
+ }
316
+
317
+ // Returns the [start, end) byte range of the "dependencies" object's value
318
+ // in `raw`, or null if it can't be located unambiguously. Uses a brace-
319
+ // counting tokenizer that respects string literals so a `dependencies` key
320
+ // inside a string value (e.g. inside a `description`) cannot fool it.
321
+ function sliceDependenciesRange(raw: string, parsed: ParsedPackage['parsed']): { start: number; end: number } | null {
322
+ if (parsed.dependencies === undefined || parsed.dependencies === null) return null
323
+ const keyMatch = raw.match(/"dependencies"\s*:\s*\{/)
324
+ if (!keyMatch || keyMatch.index === undefined) return null
325
+ const startOfOpenBrace = keyMatch.index + keyMatch[0].length - 1
326
+ const closeBrace = findMatchingCloseBrace(raw, startOfOpenBrace)
327
+ if (closeBrace === null) return null
328
+ return { start: startOfOpenBrace, end: closeBrace + 1 }
329
+ }
330
+
331
+ function findMatchingCloseBrace(raw: string, openIndex: number): number | null {
332
+ let depth = 0
333
+ let inString = false
334
+ let escape = false
335
+ for (let i = openIndex; i < raw.length; i++) {
336
+ const ch = raw[i]
337
+ if (escape) {
338
+ escape = false
339
+ continue
340
+ }
341
+ if (inString) {
342
+ if (ch === '\\') escape = true
343
+ else if (ch === '"') inString = false
344
+ continue
345
+ }
346
+ if (ch === '"') {
347
+ inString = true
348
+ continue
349
+ }
350
+ if (ch === '{') depth++
351
+ else if (ch === '}') {
352
+ depth--
353
+ if (depth === 0) return i
354
+ }
355
+ }
356
+ return null
357
+ }
358
+
359
+ function detectIndent(raw: string): number | string {
360
+ // Default to 2 — matches `JSON.stringify(_, _, 2)` behavior and the
361
+ // project's existing scaffold style. Only override when we can see a
362
+ // clear non-2 indent on the first indented line.
363
+ const match = raw.match(/\n([\t ]+)\S/)
364
+ if (!match) return 2
365
+ const sample = match[1]!
366
+ if (sample.startsWith('\t')) return '\t'
367
+ return sample.length
368
+ }
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ // Single source of truth for "what version of typeclaw is this agent on,
5
+ // and where does that mean we should pin the base image / write the dep
6
+ // spec." Sync I/O at module load — relative paths are stable in both a dev
7
+ // checkout and a real install, so the parent-walk an earlier draft used
8
+ // was unnecessary side effect. See AGENTS.md "Rules of thumb" for the
9
+ // install-vs-dev distinction this module encodes.
10
+
11
+ export const GHCR_BASE_IMAGE_REPO = 'ghcr.io/typeclaw/typeclaw-base'
12
+
13
+ const CLI_PACKAGE_JSON_PATH = join(import.meta.dir, '..', '..', 'package.json')
14
+
15
+ const cliPkg = JSON.parse(readFileSync(CLI_PACKAGE_JSON_PATH, 'utf8')) as { name?: string; version?: string }
16
+ if (cliPkg.name !== 'typeclaw' || typeof cliPkg.version !== 'string') {
17
+ throw new Error(`Expected typeclaw package.json at ${CLI_PACKAGE_JSON_PATH}, got name=${cliPkg.name}`)
18
+ }
19
+
20
+ export const CLI_VERSION = cliPkg.version
21
+
22
+ const NODE_MODULES_SEGMENT = `${join('/', 'node_modules', '/')}`
23
+
24
+ function isInstalledCli(): boolean {
25
+ return CLI_PACKAGE_JSON_PATH.includes(NODE_MODULES_SEGMENT)
26
+ }
27
+
28
+ // `^X.Y.Z` when the invoking CLI is itself an installed copy of typeclaw
29
+ // (suitable for writing into a freshly-scaffolded agent's package.json),
30
+ // `null` when the CLI is running from the source repo (caller falls back
31
+ // to `file:` so the agent tracks the local checkout).
32
+ export function resolveScaffoldVersion(): string | null {
33
+ if (!isInstalledCli()) return null
34
+ return `^${CLI_VERSION}`
35
+ }
36
+
37
+ // The version of typeclaw the AGENT will actually run inside the container.
38
+ // Prefers `<agent>/node_modules/typeclaw/package.json#version` because that
39
+ // is what the bind-mount exposes to the container at /agent/node_modules,
40
+ // and we want the base image's CLI version to match the runtime's. Falls
41
+ // back to parsing the agent's `dependencies.typeclaw` spec for fresh inits
42
+ // where `bun install` hasn't run yet, and to `null` when neither maps to
43
+ // a release version (dev mode, ranges, dist-tags, etc.).
44
+ export function resolveBaseImageVersion(agentDir: string): string | null {
45
+ return readInstalledTypeclawVersion(agentDir) ?? readVersionFromDepSpec(agentDir)
46
+ }
47
+
48
+ function readInstalledTypeclawVersion(agentDir: string): string | null {
49
+ try {
50
+ const raw = readFileSync(join(agentDir, 'node_modules', 'typeclaw', 'package.json'), 'utf8')
51
+ const parsed = JSON.parse(raw) as { version?: string }
52
+ if (typeof parsed.version === 'string' && isReleaseVersion(parsed.version)) return parsed.version
53
+ } catch {}
54
+ return null
55
+ }
56
+
57
+ function readVersionFromDepSpec(agentDir: string): string | null {
58
+ try {
59
+ const raw = readFileSync(join(agentDir, 'package.json'), 'utf8')
60
+ const parsed = JSON.parse(raw) as { dependencies?: Record<string, string> }
61
+ const spec = parsed.dependencies?.typeclaw
62
+ if (typeof spec !== 'string') return null
63
+ return extractReleaseVersionFromSpec(spec)
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+
69
+ // Accept only specs that name an exact release version we can map 1:1 to a
70
+ // GHCR tag (`X.Y.Z`, `^X.Y.Z`, `~X.Y.Z`, `=X.Y.Z`). Reject ranges, `latest`,
71
+ // `*`, dist-tags, `workspace:` / `git:` / `portal:` / `npm:` aliases. Being
72
+ // strict here delays versioned pinning rather than silently picking the
73
+ // wrong tag — the installed-typeclaw check above is the primary path.
74
+ function extractReleaseVersionFromSpec(spec: string): string | null {
75
+ const match = spec.trim().match(/^[\^~=]?(\d+\.\d+\.\d+)$/)
76
+ return match ? (match[1] ?? null) : null
77
+ }
78
+
79
+ function isReleaseVersion(version: string): boolean {
80
+ return /^\d+\.\d+\.\d+$/.test(version)
81
+ }