typeclaw 0.1.3 → 0.1.5
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 +1 -1
- package/package.json +1 -1
- package/src/bundled-plugins/memory/README.md +8 -8
- package/src/bundled-plugins/memory/dreaming.ts +117 -1
- package/src/cli/init.ts +35 -6
- package/src/cli/reload.ts +6 -3
- package/src/cli/tui.ts +6 -3
- package/src/config/config.ts +162 -17
- package/src/config/index.ts +8 -1
- package/src/container/index.ts +3 -1
- package/src/container/port.ts +10 -0
- package/src/container/start.ts +54 -10
- package/src/doctor/checks.ts +8 -28
- package/src/doctor/commit.ts +44 -3
- package/src/doctor/plugin-bridge.ts +46 -3
- package/src/init/dockerfile.ts +62 -4
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +31 -24
- package/src/reload/client.ts +25 -1
- package/src/run/index.ts +13 -1
- package/src/secrets/storage.ts +15 -0
- package/src/server/index.ts +80 -64
- package/src/skills/typeclaw-config/SKILL.md +70 -52
- package/src/skills/typeclaw-memory/SKILL.md +8 -8
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/typeclaw.schema.json +91 -54
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ The bundled `memory` plugin turns lived experience into reusable knowledge. No m
|
|
|
43
43
|
|
|
44
44
|
1. **Observe.** After every idle turn, a `memory-logger` subagent reads the transcript and appends notable fragments to `memory/yyyy-MM-dd.md`. Cheap, frequent, lossy by design.
|
|
45
45
|
2. **Dream.** On a cron schedule (default 4am), a `dreaming` subagent consolidates daily streams into `MEMORY.md`, and — when it spots a procedure worth remembering — writes it as **muscle memory**: a new skill at `memory/skills/<name>/SKILL.md`.
|
|
46
|
-
3. **Apply.** Tomorrow's prompt sees the updated `MEMORY.md`. Muscle-memory skills sit alongside bundled and user-installed ones, loaded on demand. Every dream is
|
|
46
|
+
3. **Apply.** Tomorrow's prompt sees the updated `MEMORY.md`. Muscle-memory skills sit alongside bundled and user-installed ones, loaded on demand. Every dream is committed with a one-line summary — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮` — so growth is auditable.
|
|
47
47
|
|
|
48
48
|
See [`src/bundled-plugins/memory/README.md`](./src/bundled-plugins/memory/README.md) for the full contract.
|
|
49
49
|
|
package/package.json
CHANGED
|
@@ -27,14 +27,14 @@ All fields are **restart-required** — the plugin reads them once at boot.
|
|
|
27
27
|
|
|
28
28
|
## What it contributes
|
|
29
29
|
|
|
30
|
-
| Kind | Name | Notes
|
|
31
|
-
| -------- | -------------------------- |
|
|
32
|
-
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.md`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file.
|
|
33
|
-
| Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and `
|
|
34
|
-
| Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`.
|
|
35
|
-
| Hook | `session.prompt` | Appends the rendered memory section (`# Memory`, `MEMORY.md`, undreamed stream tails) to `event.prompt`.
|
|
36
|
-
| Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`.
|
|
37
|
-
| Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away).
|
|
30
|
+
| Kind | Name | Notes |
|
|
31
|
+
| -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.md`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file. |
|
|
33
|
+
| Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and commits the result with a summary message (`dream: <summary> <emoji>`, e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`). Coalesced per `agentDir`. |
|
|
34
|
+
| Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
|
|
35
|
+
| Hook | `session.prompt` | Appends the rendered memory section (`# Memory`, `MEMORY.md`, undreamed stream tails) to `event.prompt`. |
|
|
36
|
+
| Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`. |
|
|
37
|
+
| Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away). |
|
|
38
38
|
|
|
39
39
|
## Files on disk
|
|
40
40
|
|
|
@@ -183,8 +183,10 @@ export async function commitMemorySnapshot(cwd: string): Promise<void> {
|
|
|
183
183
|
return
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
const message = await buildCommitMessage(bun, cwd, staged)
|
|
187
|
+
|
|
186
188
|
const commit = bun.spawn({
|
|
187
|
-
cmd: ['git', 'commit', '-m',
|
|
189
|
+
cmd: ['git', 'commit', '-m', message, '--only', '--', ...staged],
|
|
188
190
|
cwd,
|
|
189
191
|
stdout: 'pipe',
|
|
190
192
|
stderr: 'pipe',
|
|
@@ -194,6 +196,120 @@ export async function commitMemorySnapshot(cwd: string): Promise<void> {
|
|
|
194
196
|
await applySkipWorktree(bun, cwd)
|
|
195
197
|
}
|
|
196
198
|
|
|
199
|
+
// Pool of emojis sampled into every dream commit. The pool is small and
|
|
200
|
+
// thematically coherent (sleep + cognition) so `git log --oneline` reads like a
|
|
201
|
+
// dream journal. Exported for tests.
|
|
202
|
+
export const DREAM_EMOJI_POOL = ['💤', '🌙', '⭐', '🛌', '😴', '🧠', '💭', '🔮'] as const
|
|
203
|
+
export type DreamEmoji = (typeof DREAM_EMOJI_POOL)[number]
|
|
204
|
+
|
|
205
|
+
// Random pick is deliberate (not seeded). Independent draw per commit gives the
|
|
206
|
+
// log surface maximum visual variety; correctness does not depend on the
|
|
207
|
+
// emoji.
|
|
208
|
+
function pickDreamEmoji(): DreamEmoji {
|
|
209
|
+
const i = Math.floor(Math.random() * DREAM_EMOJI_POOL.length)
|
|
210
|
+
return DREAM_EMOJI_POOL[i] ?? DREAM_EMOJI_POOL[0]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build `dream: <summary> <emoji>` from what is actually staged in the
|
|
214
|
+
// snapshot. The summary is derived from the staged diff (ground truth of what
|
|
215
|
+
// is being committed), not from the handler's intent — so a partial commit
|
|
216
|
+
// reports honestly.
|
|
217
|
+
//
|
|
218
|
+
// Classification:
|
|
219
|
+
// - `N fragments` when daily-stream files (memory/yyyy-MM-dd.md) added lines
|
|
220
|
+
// - `+ new skill 'x'` / `+ N new skills` when memory/skills/<name>/SKILL.md
|
|
221
|
+
// paths are newly added in this commit (status A, not M)
|
|
222
|
+
// - `MEMORY.md only` when only MEMORY.md changed
|
|
223
|
+
// - `watermarks only` as the fallback (e.g. only .dreaming-state.json moved)
|
|
224
|
+
export async function buildCommitMessage(
|
|
225
|
+
bun: { spawn: typeof Bun.spawn },
|
|
226
|
+
cwd: string,
|
|
227
|
+
staged: string[],
|
|
228
|
+
emojiPicker: () => DreamEmoji = pickDreamEmoji,
|
|
229
|
+
): Promise<string> {
|
|
230
|
+
const summary = await buildDreamSummary(bun, cwd, staged)
|
|
231
|
+
return `dream: ${summary} ${emojiPicker()}`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const STREAM_FILE_RELATIVE = /^memory\/\d{4}-\d{2}-\d{2}\.md$/
|
|
235
|
+
const SKILL_FILE_RELATIVE = /^memory\/skills\/([^/]+)\/SKILL\.md$/
|
|
236
|
+
|
|
237
|
+
async function buildDreamSummary(bun: { spawn: typeof Bun.spawn }, cwd: string, staged: string[]): Promise<string> {
|
|
238
|
+
// numstat: `<added>\t<deleted>\t<path>` per line. Use NUL-terminated so paths
|
|
239
|
+
// with whitespace round-trip; -z switches the record separator to NUL.
|
|
240
|
+
const numstat = bun.spawn({
|
|
241
|
+
cmd: ['git', 'diff', '--cached', '--numstat', '-z', '--', ...staged],
|
|
242
|
+
cwd,
|
|
243
|
+
stdout: 'pipe',
|
|
244
|
+
stderr: 'pipe',
|
|
245
|
+
})
|
|
246
|
+
const raw = await new Response(numstat.stdout).text()
|
|
247
|
+
if ((await numstat.exited) !== 0) return 'snapshot'
|
|
248
|
+
|
|
249
|
+
let fragmentLines = 0
|
|
250
|
+
let touchedMemoryMd = false
|
|
251
|
+
for (const record of raw.split('\0')) {
|
|
252
|
+
if (record.length === 0) continue
|
|
253
|
+
// Each record is `<added>\t<deleted>\t<path>`; binary files report `-`
|
|
254
|
+
// instead of integers — treat those as 0 since memory artifacts are text.
|
|
255
|
+
const [addedStr = '', , path = ''] = record.split('\t')
|
|
256
|
+
const added = Number.parseInt(addedStr, 10)
|
|
257
|
+
if (!Number.isFinite(added)) continue
|
|
258
|
+
if (path === 'MEMORY.md') {
|
|
259
|
+
touchedMemoryMd = true
|
|
260
|
+
} else if (STREAM_FILE_RELATIVE.test(path)) {
|
|
261
|
+
fragmentLines += added
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Newly-added muscle-memory skills (status A). Refinements (status M) are
|
|
266
|
+
// not announced — they ride under the fragment count.
|
|
267
|
+
const newSkills = await listNewlyAddedSkills(bun, cwd, staged)
|
|
268
|
+
|
|
269
|
+
const parts: string[] = []
|
|
270
|
+
if (fragmentLines > 0) {
|
|
271
|
+
parts.push(`${fragmentLines} fragment${fragmentLines === 1 ? '' : 's'}`)
|
|
272
|
+
} else if (touchedMemoryMd && newSkills.length === 0) {
|
|
273
|
+
parts.push('MEMORY.md only')
|
|
274
|
+
}
|
|
275
|
+
if (newSkills.length === 1) {
|
|
276
|
+
parts.push(`new skill '${newSkills[0]}'`)
|
|
277
|
+
} else if (newSkills.length > 1) {
|
|
278
|
+
parts.push(`${newSkills.length} new skills`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (parts.length === 0) return 'watermarks only'
|
|
282
|
+
return parts.join(' + ')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function listNewlyAddedSkills(
|
|
286
|
+
bun: { spawn: typeof Bun.spawn },
|
|
287
|
+
cwd: string,
|
|
288
|
+
staged: string[],
|
|
289
|
+
): Promise<string[]> {
|
|
290
|
+
const proc = bun.spawn({
|
|
291
|
+
cmd: ['git', 'diff', '--cached', '--name-status', '-z', '--', ...staged],
|
|
292
|
+
cwd,
|
|
293
|
+
stdout: 'pipe',
|
|
294
|
+
stderr: 'pipe',
|
|
295
|
+
})
|
|
296
|
+
const raw = await new Response(proc.stdout).text()
|
|
297
|
+
if ((await proc.exited) !== 0) return []
|
|
298
|
+
|
|
299
|
+
// `--name-status -z` interleaves status and path as separate NUL records:
|
|
300
|
+
// `A\0path\0M\0other\0...`. Pair them up.
|
|
301
|
+
const tokens = raw.split('\0').filter((t) => t.length > 0)
|
|
302
|
+
const names: string[] = []
|
|
303
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
304
|
+
const status = tokens[i] ?? ''
|
|
305
|
+
const path = tokens[i + 1] ?? ''
|
|
306
|
+
if (status !== 'A') continue
|
|
307
|
+
const match = SKILL_FILE_RELATIVE.exec(path)
|
|
308
|
+
if (match) names.push(match[1] ?? '')
|
|
309
|
+
}
|
|
310
|
+
return names.filter((n) => n.length > 0)
|
|
311
|
+
}
|
|
312
|
+
|
|
197
313
|
async function listTrackedSnapshotFiles(bun: { spawn: typeof Bun.spawn }, cwd: string): Promise<string[]> {
|
|
198
314
|
const ls = bun.spawn({
|
|
199
315
|
cmd: ['git', 'ls-files', '-z', '--', ...SNAPSHOT_PATHS],
|
package/src/cli/init.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
findAgentDir,
|
|
14
14
|
isDirectoryNonEmpty,
|
|
15
15
|
isHatched,
|
|
16
|
+
readExistingProviderApiKey,
|
|
16
17
|
runInit,
|
|
17
18
|
type InitStep,
|
|
18
19
|
type InitStepEvent,
|
|
@@ -64,10 +65,11 @@ export const init = defineCommand({
|
|
|
64
65
|
const selectedModel = await pickModel()
|
|
65
66
|
const provider = KNOWN_PROVIDERS[selectedModel.providerId]
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
+
const existingApiKey = await readExistingProviderApiKey(cwd, selectedModel.providerId)
|
|
69
|
+
const llmAuth = await collectLLMAuth(provider, existingApiKey)
|
|
68
70
|
|
|
69
71
|
const channelChoice = await select({
|
|
70
|
-
message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + .
|
|
72
|
+
message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + secrets.json)',
|
|
71
73
|
options: [
|
|
72
74
|
{ value: 'slack', label: 'Slack' },
|
|
73
75
|
{ value: 'discord', label: 'Discord' },
|
|
@@ -435,21 +437,36 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
|
|
|
435
437
|
}
|
|
436
438
|
|
|
437
439
|
// Resolves how the user wants to authenticate to the chosen provider:
|
|
438
|
-
// - api-key only (e.g. Fireworks): prompt for the key, write to .
|
|
440
|
+
// - api-key only (e.g. Fireworks): prompt for the key, write to secrets.json.
|
|
439
441
|
// - oauth only (e.g. openai-codex): run the browser flow inline, write
|
|
440
442
|
// secrets.json. No API key prompt at all.
|
|
441
443
|
// - both supported (no providers ship this today, but Anthropic will when
|
|
442
444
|
// wired): ask "API key or OAuth?" first, then dispatch to the chosen path.
|
|
443
|
-
async function collectLLMAuth(
|
|
445
|
+
async function collectLLMAuth(
|
|
446
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
447
|
+
existingApiKey: string | null,
|
|
448
|
+
): Promise<LLMAuth> {
|
|
444
449
|
const supportsApiKey = providerSupportsApiKey(provider)
|
|
445
450
|
const supportsOAuth = providerSupportsOAuth(provider)
|
|
446
451
|
|
|
452
|
+
const existingKeyDecision = await decideExistingApiKeyReuse(provider, existingApiKey, (message) =>
|
|
453
|
+
confirm({ message, initialValue: true }),
|
|
454
|
+
)
|
|
455
|
+
if (existingKeyDecision === 'cancel') {
|
|
456
|
+
cancel('Aborted.')
|
|
457
|
+
process.exit(0)
|
|
458
|
+
}
|
|
459
|
+
if (existingKeyDecision === 'reuse' && existingApiKey !== null) {
|
|
460
|
+
log.info(`Using existing ${provider.name} API key from secrets.json.`)
|
|
461
|
+
return { kind: 'api-key', apiKey: existingApiKey }
|
|
462
|
+
}
|
|
463
|
+
|
|
447
464
|
let method: 'api-key' | 'oauth'
|
|
448
465
|
if (supportsApiKey && supportsOAuth) {
|
|
449
466
|
const choice = await select<'api-key' | 'oauth'>({
|
|
450
467
|
message: `How do you want to authenticate to ${provider.name}?`,
|
|
451
468
|
options: [
|
|
452
|
-
{ value: 'api-key', label: 'API key', hint:
|
|
469
|
+
{ value: 'api-key', label: 'API key', hint: 'saved to secrets.json' },
|
|
453
470
|
{ value: 'oauth', label: 'OAuth (browser login)', hint: 'saved to secrets.json' },
|
|
454
471
|
],
|
|
455
472
|
initialValue: 'api-key',
|
|
@@ -467,7 +484,7 @@ async function collectLLMAuth(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId
|
|
|
467
484
|
|
|
468
485
|
if (method === 'api-key') {
|
|
469
486
|
const apiKey = await password({
|
|
470
|
-
message: `Put your ${provider.name} API key (will be saved to .
|
|
487
|
+
message: `Put your ${provider.name} API key (will be saved to secrets.json)`,
|
|
471
488
|
validate: (value) => (value && value.length > 0 ? undefined : 'API key is required'),
|
|
472
489
|
})
|
|
473
490
|
if (isCancel(apiKey)) {
|
|
@@ -480,6 +497,18 @@ async function collectLLMAuth(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId
|
|
|
480
497
|
return { kind: 'oauth', runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)) }
|
|
481
498
|
}
|
|
482
499
|
|
|
500
|
+
export async function decideExistingApiKeyReuse(
|
|
501
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
502
|
+
existingApiKey: string | null,
|
|
503
|
+
askReuse: (message: string) => Promise<unknown>,
|
|
504
|
+
): Promise<'reuse' | 'prompt' | 'cancel'> {
|
|
505
|
+
if (!providerSupportsApiKey(provider) || existingApiKey === null) return 'prompt'
|
|
506
|
+
|
|
507
|
+
const reuse = await askReuse(`Reuse existing ${provider.name} API key from secrets.json?`)
|
|
508
|
+
if (isCancel(reuse)) return 'cancel'
|
|
509
|
+
return reuse === true ? 'reuse' : 'prompt'
|
|
510
|
+
}
|
|
511
|
+
|
|
483
512
|
// Wraps the OAuth lifecycle into the same clack idiom the rest of the wizard
|
|
484
513
|
// uses: a spinner over the "waiting for login" period, with onAuth printing
|
|
485
514
|
// the URL the user needs to open and onPrompt falling back to a `text`
|
package/src/cli/reload.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
|
-
import { resolveHostPort } from '@/container'
|
|
3
|
+
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
5
|
import { requestReload, type ReloadResult } from '@/reload'
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ export const reload = defineCommand({
|
|
|
15
15
|
url: {
|
|
16
16
|
type: 'string',
|
|
17
17
|
description:
|
|
18
|
-
"agent websocket url (defaults to ws://
|
|
18
|
+
"agent websocket url (defaults to ws://127.0.0.1:<host port> discovered from the running container's published port)",
|
|
19
19
|
},
|
|
20
20
|
timeout: {
|
|
21
21
|
type: 'string',
|
|
@@ -64,5 +64,8 @@ export const reload = defineCommand({
|
|
|
64
64
|
async function defaultUrl(): Promise<string> {
|
|
65
65
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
66
66
|
const port = await resolveHostPort({ cwd })
|
|
67
|
-
|
|
67
|
+
const token = await resolveTuiToken({ cwd })
|
|
68
|
+
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
69
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
70
|
+
return url.toString()
|
|
68
71
|
}
|
package/src/cli/tui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
|
-
import { resolveHostPort } from '@/container'
|
|
3
|
+
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
5
|
import { createTui } from '@/tui'
|
|
6
6
|
|
|
@@ -18,7 +18,7 @@ export const tui = defineCommand({
|
|
|
18
18
|
url: {
|
|
19
19
|
type: 'string',
|
|
20
20
|
description:
|
|
21
|
-
"agent websocket url (defaults to ws://
|
|
21
|
+
"agent websocket url (defaults to ws://127.0.0.1:<host port> discovered from the running container's published port)",
|
|
22
22
|
},
|
|
23
23
|
},
|
|
24
24
|
async run({ args }) {
|
|
@@ -31,5 +31,8 @@ export const tui = defineCommand({
|
|
|
31
31
|
async function defaultUrl(): Promise<string> {
|
|
32
32
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
33
33
|
const port = await resolveHostPort({ cwd })
|
|
34
|
-
|
|
34
|
+
const token = await resolveTuiToken({ cwd })
|
|
35
|
+
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
36
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
37
|
+
return url.toString()
|
|
35
38
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs'
|
|
1
|
+
import { accessSync, constants as fsConstants, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
4
4
|
|
|
@@ -75,7 +75,7 @@ const dockerfileFeatureSchema = z.union([
|
|
|
75
75
|
])
|
|
76
76
|
|
|
77
77
|
// `default(() => ({}))` paired with field-level defaults is the idiom that
|
|
78
|
-
// makes both `
|
|
78
|
+
// makes both `docker.file: {}` and an omitted `docker.file` key resolve to the
|
|
79
79
|
// SAME fully-populated object. A plain `.default({})` would short-circuit the
|
|
80
80
|
// inner field defaults when the key is omitted, leaving downstream code with
|
|
81
81
|
// `{ append: undefined, tmux: undefined, ... }` and a `lines.length` crash.
|
|
@@ -92,17 +92,61 @@ export const dockerfileSchema = dockerfileObjectSchema.default(() => dockerfileO
|
|
|
92
92
|
export type DockerfileConfig = z.infer<typeof dockerfileSchema>
|
|
93
93
|
export type DockerfileFeatureToggle = z.infer<typeof dockerfileFeatureSchema>
|
|
94
94
|
|
|
95
|
+
// The `docker` namespace nests Docker-related blocks under one top-level key
|
|
96
|
+
// so future extensions (e.g. `docker.compose`, `docker.buildArgs`) have a home
|
|
97
|
+
// without polluting the root. Today the only inhabitant is `docker.file`,
|
|
98
|
+
// which holds the same shape that used to live at top-level `dockerfile`.
|
|
99
|
+
// One-time migration (see `migrateLegacyConfigShape`) rewrites the old
|
|
100
|
+
// top-level key into the new path on first load.
|
|
101
|
+
export const dockerSchema = z
|
|
102
|
+
.object({
|
|
103
|
+
file: dockerfileSchema,
|
|
104
|
+
})
|
|
105
|
+
.default(() => ({ file: dockerfileObjectSchema.parse({}) }))
|
|
106
|
+
|
|
107
|
+
export type DockerConfig = z.infer<typeof dockerSchema>
|
|
108
|
+
|
|
95
109
|
const gitignoreLineSchema = z.string().refine((line) => !/[\r\n]/.test(line), {
|
|
96
|
-
message: '
|
|
110
|
+
message: 'git.ignore.append entries must be single gitignore lines; split multiline patterns into array entries',
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const gitignoreObjectSchema = z.object({
|
|
114
|
+
append: z.array(gitignoreLineSchema).default([]),
|
|
97
115
|
})
|
|
98
116
|
|
|
99
|
-
export const gitignoreSchema =
|
|
117
|
+
export const gitignoreSchema = gitignoreObjectSchema.default(() => gitignoreObjectSchema.parse({}))
|
|
118
|
+
|
|
119
|
+
export type GitignoreConfig = z.infer<typeof gitignoreSchema>
|
|
120
|
+
|
|
121
|
+
// Same rationale as `dockerSchema`: a `git` namespace today carries `git.ignore`
|
|
122
|
+
// and leaves room for future siblings (e.g. `git.attributes`). The one-time
|
|
123
|
+
// migration also handles the rename of legacy top-level `gitignore`.
|
|
124
|
+
export const gitSchema = z
|
|
100
125
|
.object({
|
|
101
|
-
|
|
126
|
+
ignore: gitignoreSchema,
|
|
102
127
|
})
|
|
103
|
-
.default({
|
|
128
|
+
.default(() => ({ ignore: gitignoreObjectSchema.parse({}) }))
|
|
104
129
|
|
|
105
|
-
export type
|
|
130
|
+
export type GitConfig = z.infer<typeof gitSchema>
|
|
131
|
+
|
|
132
|
+
const IPV4_CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/
|
|
133
|
+
|
|
134
|
+
const ipv4CidrSchema = z.string().refine(
|
|
135
|
+
(value) => {
|
|
136
|
+
const match = IPV4_CIDR_PATTERN.exec(value)
|
|
137
|
+
if (!match) return false
|
|
138
|
+
const octets = [match[1], match[2], match[3], match[4]].map(Number)
|
|
139
|
+
if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
|
|
140
|
+
if (match[5] !== undefined) {
|
|
141
|
+
const prefix = Number(match[5])
|
|
142
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
|
|
143
|
+
}
|
|
144
|
+
return true
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
message: 'network.allow entries must be IPv4 addresses or CIDR ranges (e.g. "10.0.0.0/16", "10.0.0.2")',
|
|
148
|
+
},
|
|
149
|
+
)
|
|
106
150
|
|
|
107
151
|
// `blockInternal` is the kill-switch for the container-stage egress filter
|
|
108
152
|
// installed by Dockerfile entrypoint shim: when true, the container is granted
|
|
@@ -124,11 +168,38 @@ export type GitignoreConfig = z.infer<typeof gitignoreSchema>
|
|
|
124
168
|
// field is discoverable in fresh `typeclaw.json` files. Loopback traffic
|
|
125
169
|
// (`-o lo`) is always allowed by the shim, so `bun run dev` and local APIs
|
|
126
170
|
// on `localhost` / `127.0.0.1` are unaffected.
|
|
171
|
+
//
|
|
172
|
+
// `autoAllowResolvers` (default `true`) makes the shim narrowly carve out
|
|
173
|
+
// the container's DNS resolvers — every `nameserver` line in
|
|
174
|
+
// `/etc/resolv.conf` gets a `udp/tcp --dport 53` ACCEPT inserted BEFORE the
|
|
175
|
+
// REJECT rules. This fixes the canonical EC2/GCE/Azure footgun: cloud VPC
|
|
176
|
+
// resolvers live inside RFC1918 (e.g. AWS VPC DNS at `10.0.0.2`), so
|
|
177
|
+
// `blockInternal: true` would otherwise kill every DNS lookup the agent
|
|
178
|
+
// makes. The carve-out is scoped to port 53 only — a compromised agent
|
|
179
|
+
// cannot reach the resolver host on any other port. On a laptop where
|
|
180
|
+
// `/etc/resolv.conf` points at a public resolver (1.1.1.1, 8.8.8.8), the
|
|
181
|
+
// generated ACCEPT rules are no-ops because public IPs are not in the
|
|
182
|
+
// block list to begin with. Opt-out (`false`) is for users who explicitly
|
|
183
|
+
// configure DNS via `.env` (e.g. `DOCKER_DNS=1.1.1.1`) and want a fully
|
|
184
|
+
// closed filter.
|
|
185
|
+
//
|
|
186
|
+
// `allow` is the power-user escape hatch: an explicit list of IPv4 CIDRs
|
|
187
|
+
// or bare IPv4 addresses that punch through the block list wholesale (all
|
|
188
|
+
// ports, all protocols). Use case: VPC-private services the agent must
|
|
189
|
+
// reach by IP — internal APIs, RDS endpoints, VPC interface endpoints for
|
|
190
|
+
// S3/Bedrock. Each entry inserts an unscoped `iptables -A OUTPUT -d <cidr>
|
|
191
|
+
// -j ACCEPT` before the REJECT rules. IPv4 only: the carve-out is for
|
|
192
|
+
// destinations the operator names explicitly, and every cloud VPC we
|
|
193
|
+
// support is IPv4-routable. Validation at parse time rejects non-CIDR
|
|
194
|
+
// strings, IPv6 forms, and out-of-range octets so a typo in
|
|
195
|
+
// `typeclaw.json` surfaces immediately instead of at container boot.
|
|
127
196
|
export const networkSchema = z
|
|
128
197
|
.object({
|
|
129
198
|
blockInternal: z.boolean().default(true),
|
|
199
|
+
autoAllowResolvers: z.boolean().default(true),
|
|
200
|
+
allow: z.array(ipv4CidrSchema).default([]),
|
|
130
201
|
})
|
|
131
|
-
.default({ blockInternal: true })
|
|
202
|
+
.default({ blockInternal: true, autoAllowResolvers: true, allow: [] })
|
|
132
203
|
|
|
133
204
|
export type NetworkConfig = z.infer<typeof networkSchema>
|
|
134
205
|
|
|
@@ -152,8 +223,8 @@ export const configSchema = z
|
|
|
152
223
|
channels: channelsSchema,
|
|
153
224
|
portForward: portForwardSchema,
|
|
154
225
|
network: networkSchema,
|
|
155
|
-
|
|
156
|
-
|
|
226
|
+
docker: dockerSchema,
|
|
227
|
+
git: gitSchema,
|
|
157
228
|
})
|
|
158
229
|
.catchall(z.unknown())
|
|
159
230
|
|
|
@@ -243,8 +314,8 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
|
|
|
243
314
|
channels: 'applied',
|
|
244
315
|
portForward: 'restart-required',
|
|
245
316
|
network: 'restart-required',
|
|
246
|
-
|
|
247
|
-
|
|
317
|
+
'docker.file': 'restart-required',
|
|
318
|
+
'git.ignore': 'restart-required',
|
|
248
319
|
}
|
|
249
320
|
|
|
250
321
|
// Stable JSON for value comparison. Fields are small JSON-shaped objects, so
|
|
@@ -307,8 +378,8 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
|
|
|
307
378
|
'channels',
|
|
308
379
|
'portForward',
|
|
309
380
|
'network',
|
|
310
|
-
'
|
|
311
|
-
'
|
|
381
|
+
'docker',
|
|
382
|
+
'git',
|
|
312
383
|
])
|
|
313
384
|
const result: Record<string, unknown> = {}
|
|
314
385
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
@@ -330,7 +401,8 @@ export function loadPluginConfigsSync(cwd: string): Record<string, unknown> {
|
|
|
330
401
|
} catch {
|
|
331
402
|
return {}
|
|
332
403
|
}
|
|
333
|
-
|
|
404
|
+
const migrated = migrateLegacyConfigShape(json)
|
|
405
|
+
return extractPluginConfigs(migrated.json)
|
|
334
406
|
}
|
|
335
407
|
|
|
336
408
|
export function loadConfigSync(cwd: string): Config {
|
|
@@ -349,13 +421,81 @@ export function loadConfigSync(cwd: string): Config {
|
|
|
349
421
|
throw new Error(`${CONFIG_FILE} is not valid JSON: ${detail}`)
|
|
350
422
|
}
|
|
351
423
|
|
|
352
|
-
const
|
|
424
|
+
const migrated = migrateLegacyConfigShape(json)
|
|
425
|
+
if (migrated.changed) {
|
|
426
|
+
persistMigratedConfig(cwd, migrated.json)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const result = configSchema.safeParse(migrated.json)
|
|
353
430
|
if (!result.success) {
|
|
354
431
|
throw new Error(`${CONFIG_FILE} is invalid: ${formatZodError(result.error)}`)
|
|
355
432
|
}
|
|
356
433
|
return result.data
|
|
357
434
|
}
|
|
358
435
|
|
|
436
|
+
// One-shot rename of legacy top-level `dockerfile` / `gitignore` keys into the
|
|
437
|
+
// nested `docker.file` / `git.ignore` shape introduced for namespace
|
|
438
|
+
// extensibility (`docker.compose`, `git.attributes`, etc. land here later
|
|
439
|
+
// without a second migration). Called from every entry point that reads
|
|
440
|
+
// `typeclaw.json` so the rest of the pipeline only ever sees the new shape.
|
|
441
|
+
//
|
|
442
|
+
// Precedence when both legacy and new keys coexist: the new shape wins and
|
|
443
|
+
// the legacy key is dropped silently. Two ways this happens in practice:
|
|
444
|
+
// 1. User hand-edited the new shape after auto-migration but forgot to
|
|
445
|
+
// delete the legacy key.
|
|
446
|
+
// 2. Two `typeclaw start` invocations raced on a stale checkout.
|
|
447
|
+
// Either way, the new shape is the source of truth — losing the legacy
|
|
448
|
+
// duplicate is the right call because it would otherwise be shadowed at
|
|
449
|
+
// parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
|
|
450
|
+
export function migrateLegacyConfigShape(json: unknown): { json: unknown; changed: boolean } {
|
|
451
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
452
|
+
return { json, changed: false }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const obj = json as Record<string, unknown>
|
|
456
|
+
const hasLegacyDockerfile = 'dockerfile' in obj
|
|
457
|
+
const hasLegacyGitignore = 'gitignore' in obj
|
|
458
|
+
if (!hasLegacyDockerfile && !hasLegacyGitignore) {
|
|
459
|
+
return { json, changed: false }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const next: Record<string, unknown> = { ...obj }
|
|
463
|
+
if (hasLegacyDockerfile) {
|
|
464
|
+
const legacy = next.dockerfile
|
|
465
|
+
delete next.dockerfile
|
|
466
|
+
if (!('docker' in next)) {
|
|
467
|
+
next.docker = { file: legacy }
|
|
468
|
+
} else if (isPlainObject(next.docker) && !('file' in next.docker)) {
|
|
469
|
+
next.docker = { ...next.docker, file: legacy }
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (hasLegacyGitignore) {
|
|
473
|
+
const legacy = next.gitignore
|
|
474
|
+
delete next.gitignore
|
|
475
|
+
if (!('git' in next)) {
|
|
476
|
+
next.git = { ignore: legacy }
|
|
477
|
+
} else if (isPlainObject(next.git) && !('ignore' in next.git)) {
|
|
478
|
+
next.git = { ...next.git, ignore: legacy }
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return { json: next, changed: true }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
485
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function persistMigratedConfig(cwd: string, json: unknown): void {
|
|
489
|
+
try {
|
|
490
|
+
writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(json, null, 2)}\n`)
|
|
491
|
+
} catch {
|
|
492
|
+
// Best-effort write-back: the migration is also applied in-memory on every
|
|
493
|
+
// load, so a read-only filesystem (e.g. snapshotted CI checkout) just
|
|
494
|
+
// means the rewrite retries next start. Surfacing the error would brick
|
|
495
|
+
// load paths the user didn't ask to mutate.
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
359
499
|
export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
|
|
360
500
|
|
|
361
501
|
// Missing file → ok (matches `loadMounts` in src/container/up.ts; `isInitialized`
|
|
@@ -384,7 +524,12 @@ export function validateConfig(cwd: string): ValidateConfigResult {
|
|
|
384
524
|
return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${detail}` }
|
|
385
525
|
}
|
|
386
526
|
|
|
387
|
-
const
|
|
527
|
+
const migrated = migrateLegacyConfigShape(json)
|
|
528
|
+
if (migrated.changed) {
|
|
529
|
+
persistMigratedConfig(cwd, migrated.json)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const result = configSchema.safeParse(migrated.json)
|
|
388
533
|
if (!result.success) {
|
|
389
534
|
return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
|
|
390
535
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
export {
|
|
2
2
|
config,
|
|
3
3
|
configSchema,
|
|
4
|
+
dockerSchema,
|
|
5
|
+
dockerfileSchema,
|
|
4
6
|
expandMountPath,
|
|
5
7
|
extractPluginConfigs,
|
|
6
8
|
getConfig,
|
|
9
|
+
gitSchema,
|
|
10
|
+
gitignoreSchema,
|
|
7
11
|
loadConfigSync,
|
|
8
12
|
loadPluginConfigsSync,
|
|
13
|
+
migrateLegacyConfigShape,
|
|
9
14
|
mountSchema,
|
|
10
|
-
dockerfileSchema,
|
|
11
15
|
portForwardSchema,
|
|
12
16
|
reloadConfig,
|
|
13
17
|
resolveModel,
|
|
@@ -16,7 +20,10 @@ export {
|
|
|
16
20
|
type Config,
|
|
17
21
|
type ConfigChange,
|
|
18
22
|
type ConfigReloadDiff,
|
|
23
|
+
type DockerConfig,
|
|
19
24
|
type DockerfileConfig,
|
|
25
|
+
type GitConfig,
|
|
26
|
+
type GitignoreConfig,
|
|
20
27
|
type Mount,
|
|
21
28
|
type PortForward,
|
|
22
29
|
type ValidateConfigResult,
|
package/src/container/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { logs, planLogs, type LogsPlan, type LogsResult } from './logs'
|
|
2
|
-
export { CONTAINER_PORT, findFreePort, resolveHostPort } from './port'
|
|
2
|
+
export { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, resolveHostPort, resolveTuiToken } from './port'
|
|
3
3
|
export { planShell, shell, type ShellPlan, type ShellResult } from './shell'
|
|
4
4
|
export { status, type ContainerStatus, type StatusOptions } from './status'
|
|
5
5
|
export {
|
|
@@ -17,6 +17,8 @@ export {
|
|
|
17
17
|
} from './shared'
|
|
18
18
|
export {
|
|
19
19
|
planStart,
|
|
20
|
+
refreshDockerfile,
|
|
21
|
+
refreshGitignore,
|
|
20
22
|
start,
|
|
21
23
|
type HostDaemonStatus,
|
|
22
24
|
type PlanStartOptions,
|
package/src/container/port.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { containerNameFromCwd, defaultDockerExec, type DockerExec } from './shar
|
|
|
13
13
|
// works: containers started before this change used `-p 8973:8973`, and after
|
|
14
14
|
// the upgrade `docker port <name> 8973/tcp` still resolves correctly.
|
|
15
15
|
export const CONTAINER_PORT = 8973
|
|
16
|
+
export const TUI_TOKEN_LABEL = 'dev.typeclaw.tui-token'
|
|
16
17
|
|
|
17
18
|
// Asks the kernel for a free TCP port. When `preferred` is supplied, tries
|
|
18
19
|
// that port first; if it's already bound, falls back to a kernel-assigned
|
|
@@ -102,6 +103,15 @@ export async function resolveHostPort(options: ResolveHostPortOptions): Promise<
|
|
|
102
103
|
return loadConfigSync(options.cwd).port
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
export async function resolveTuiToken(options: { cwd: string; exec?: DockerExec }): Promise<string | null> {
|
|
107
|
+
const exec = options.exec ?? defaultDockerExec
|
|
108
|
+
const containerName = containerNameFromCwd(options.cwd)
|
|
109
|
+
const result = await exec(['inspect', '--format', `{{ index .Config.Labels "${TUI_TOKEN_LABEL}" }}`, containerName])
|
|
110
|
+
if (result.exitCode !== 0) return null
|
|
111
|
+
const token = result.stdout.trim()
|
|
112
|
+
return token.length > 0 && token !== '<no value>' ? token : null
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
async function queryDockerHostPort(exec: DockerExec, containerName: string): Promise<number | null> {
|
|
106
116
|
const result = await exec(['port', containerName, `${CONTAINER_PORT}/tcp`])
|
|
107
117
|
if (result.exitCode !== 0) return null
|