typeclaw 0.3.1 → 0.5.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.
Files changed (125) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. package/typeclaw.schema.json +311 -26
@@ -0,0 +1,533 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { select, text, isCancel, cancel, log } from '@clack/prompts'
5
+ import { defineCommand } from 'citty'
6
+
7
+ import { loadConfigSync } from '@/config'
8
+ import { resolveHostPort, resolveTuiToken } from '@/container'
9
+ import { findAgentDir, isInitialized } from '@/init'
10
+ import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
11
+ import type { TunnelConfig, TunnelFor, TunnelProvider } from '@/tunnels'
12
+
13
+ import { c, errorLine } from './ui'
14
+
15
+ type AddArgs = {
16
+ name: string
17
+ provider?: string
18
+ forChannel?: string
19
+ forManual?: boolean
20
+ upstreamPort?: string
21
+ externalUrl?: string
22
+ }
23
+
24
+ type RemoveArgs = { name: string }
25
+
26
+ type LiveArgs = { url?: string; timeout?: string }
27
+
28
+ type LogsArgs = LiveArgs & { name: string; follow?: boolean }
29
+
30
+ type LiveResult<T> = { ok: true; value: T } | { ok: false; reason: string }
31
+
32
+ export type TextValidator = (value: string) => string | undefined
33
+
34
+ export type TunnelPrompts = {
35
+ selectProvider: () => Promise<TunnelProvider | symbol>
36
+ selectOwner: () => Promise<'channel' | 'manual' | symbol>
37
+ text: (message: string, validate?: TextValidator) => Promise<string | symbol>
38
+ }
39
+
40
+ const DEFAULT_TIMEOUT_MS = 15_000
41
+
42
+ const defaultPrompts: TunnelPrompts = {
43
+ selectProvider: () =>
44
+ select<TunnelProvider>({
45
+ message: 'Tunnel provider',
46
+ options: [
47
+ { value: 'cloudflare-quick', label: 'Cloudflare Quick Tunnel', hint: 'no signup, URL rotates on restart' },
48
+ { value: 'external', label: 'External URL', hint: 'bring your own reverse proxy' },
49
+ ],
50
+ }),
51
+ selectOwner: () =>
52
+ select<'channel' | 'manual'>({
53
+ message: 'Tunnel owner',
54
+ options: [
55
+ { value: 'channel', label: 'Channel' },
56
+ { value: 'manual', label: 'Manual upstream' },
57
+ ],
58
+ }),
59
+ text: (message, validate) =>
60
+ text({ message, ...(validate !== undefined ? { validate: (v) => validate(v ?? '') } : {}) }),
61
+ }
62
+
63
+ const addSub = defineCommand({
64
+ meta: { name: 'add', description: 'add a public tunnel entry to typeclaw.json' },
65
+ args: {
66
+ name: { type: 'positional', required: true, description: 'tunnel name' },
67
+ provider: { type: 'string', description: 'external | cloudflare-quick' },
68
+ 'for-channel': { type: 'string', description: 'own this tunnel from a channel adapter' },
69
+ 'for-manual': { type: 'boolean', description: 'create a manually-owned tunnel' },
70
+ 'upstream-port': { type: 'string', description: 'container-local upstream port for manual tunnels' },
71
+ 'external-url': { type: 'string', description: 'https URL for provider=external' },
72
+ },
73
+ async run({ args }) {
74
+ const result = await runTunnelAddFlow(ensureAgentDir(), {
75
+ name: String(args.name),
76
+ ...(args.provider !== undefined ? { provider: String(args.provider) } : {}),
77
+ ...(args['for-channel'] !== undefined ? { forChannel: String(args['for-channel']) } : {}),
78
+ ...(args['for-manual'] === true ? { forManual: true } : {}),
79
+ ...(args['upstream-port'] !== undefined ? { upstreamPort: String(args['upstream-port']) } : {}),
80
+ ...(args['external-url'] !== undefined ? { externalUrl: String(args['external-url']) } : {}),
81
+ })
82
+ if (!result.ok) {
83
+ console.error(errorLine(result.reason))
84
+ process.exit(1)
85
+ }
86
+ log.success(`Added tunnel "${result.value.name}" to typeclaw.json.`)
87
+ log.info('Run typeclaw restart to apply.')
88
+ },
89
+ })
90
+
91
+ const listSub = defineCommand({
92
+ meta: { name: 'list', description: 'list live tunnels from the running agent' },
93
+ args: liveArgs(),
94
+ async run({ args }) {
95
+ const result = await fetchTunnelList({ cwd: ensureAgentDir(), ...parseLiveArgs(args as LiveArgs) })
96
+ if (!result.ok) {
97
+ console.error(errorLine(result.reason))
98
+ process.exit(1)
99
+ }
100
+ process.stdout.write(`${formatTunnelList(result.value)}\n`)
101
+ },
102
+ })
103
+
104
+ const statusSub = defineCommand({
105
+ meta: { name: 'status', description: 'show one live tunnel in detail' },
106
+ args: { name: { type: 'positional', required: true, description: 'tunnel name' }, ...liveArgs() },
107
+ async run({ args }) {
108
+ const live = parseLiveArgs(args as LiveArgs)
109
+ const result = await fetchTunnelStatus({ cwd: ensureAgentDir(), name: String(args.name), ...live })
110
+ if (!result.ok) {
111
+ console.error(errorLine(result.reason))
112
+ process.exit(1)
113
+ }
114
+ const logs = await fetchTunnelLogs({ cwd: ensureAgentDir(), name: String(args.name), follow: false, ...live })
115
+ const lines = logs.ok ? logs.value : []
116
+ process.stdout.write(`${formatTunnelStatus(result.value, lines)}\n`)
117
+ },
118
+ })
119
+
120
+ const removeSub = defineCommand({
121
+ meta: { name: 'remove', description: 'remove a manually-owned tunnel from typeclaw.json' },
122
+ args: { name: { type: 'positional', required: true, description: 'tunnel name' } },
123
+ async run({ args }) {
124
+ const result = runTunnelRemoveFlow(ensureAgentDir(), args as RemoveArgs)
125
+ if (!result.ok) {
126
+ console.error(errorLine(result.reason))
127
+ process.exit(1)
128
+ }
129
+ log.success(`Removed tunnel "${args.name}" from typeclaw.json.`)
130
+ log.info('Run typeclaw restart to apply.')
131
+ },
132
+ })
133
+
134
+ const logsSub = defineCommand({
135
+ meta: { name: 'logs', description: 'print or follow a tunnel log ring' },
136
+ args: {
137
+ name: { type: 'positional', required: true, description: 'tunnel name' },
138
+ follow: { type: 'boolean', alias: 'f', description: 'follow new log lines' },
139
+ ...liveArgs(),
140
+ },
141
+ async run({ args }) {
142
+ const live = parseLiveArgs(args as LiveArgs)
143
+ const result = await streamTunnelLogs(
144
+ {
145
+ cwd: ensureAgentDir(),
146
+ name: String(args.name),
147
+ follow: args.follow === true,
148
+ ...live,
149
+ },
150
+ (line) => {
151
+ process.stdout.write(`${line}\n`)
152
+ },
153
+ )
154
+ if (!result.ok) {
155
+ console.error(errorLine(result.reason))
156
+ process.exit(1)
157
+ }
158
+ },
159
+ })
160
+
161
+ export const tunnelCommand = defineCommand({
162
+ meta: { name: 'tunnel', description: 'manage public tunnels for channels and manual upstreams' },
163
+ subCommands: { add: addSub, list: listSub, status: statusSub, remove: removeSub, logs: logsSub },
164
+ })
165
+
166
+ export async function runTunnelAddFlow(
167
+ cwd: string,
168
+ args: AddArgs,
169
+ prompts: TunnelPrompts = defaultPrompts,
170
+ ): Promise<LiveResult<TunnelConfig>> {
171
+ const config = loadConfigSync(cwd)
172
+ if (config.tunnels.some((entry) => entry.name === args.name))
173
+ return { ok: false, reason: `tunnel "${args.name}" already exists` }
174
+
175
+ const provider = await resolveProvider(args.provider, prompts)
176
+ const tunnelFor = await resolveFor(args, prompts)
177
+ let upstreamPort: number | undefined
178
+ if (tunnelFor.kind === 'manual') {
179
+ const raw = args.upstreamPort ?? (await promptText('Upstream port', prompts, validateUpstreamPort))
180
+ const portError = validateUpstreamPort(raw)
181
+ if (portError !== undefined) return { ok: false, reason: `upstream port: ${portError}` }
182
+ upstreamPort = Number(raw)
183
+ }
184
+ let externalUrl: string | undefined
185
+ if (provider === 'external') {
186
+ externalUrl = args.externalUrl ?? (await promptText('External HTTPS URL', prompts, validateHttpsUrl))
187
+ const urlError = validateHttpsUrl(externalUrl)
188
+ if (urlError !== undefined) return { ok: false, reason: `external URL: ${urlError}` }
189
+ }
190
+
191
+ const tunnel: TunnelConfig = {
192
+ name: args.name,
193
+ provider,
194
+ for: tunnelFor,
195
+ ...(externalUrl !== undefined ? { externalUrl } : {}),
196
+ ...(upstreamPort !== undefined ? { upstreamPort } : {}),
197
+ }
198
+ const raw = readRawConfig(cwd)
199
+ raw.tunnels = [...config.tunnels, tunnel]
200
+ if (provider === 'cloudflare-quick') {
201
+ raw.docker = { ...asRecord(raw.docker), file: { ...asRecord(asRecord(raw.docker).file), cloudflared: true } }
202
+ }
203
+ writeRawConfig(cwd, raw)
204
+ loadConfigSync(cwd)
205
+ return { ok: true, value: tunnel }
206
+ }
207
+
208
+ export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{ removed: TunnelConfig }> {
209
+ const config = loadConfigSync(cwd)
210
+ const tunnel = config.tunnels.find((entry) => entry.name === args.name)
211
+ if (tunnel === undefined) return { ok: false, reason: `unknown tunnel: ${args.name}` }
212
+ if (tunnel.for.kind === 'channel') {
213
+ return {
214
+ ok: false,
215
+ reason: `tunnel "${args.name}" is owned by channel "${tunnel.for.name}"; run typeclaw channel remove ${tunnel.for.name}`,
216
+ }
217
+ }
218
+ const raw = readRawConfig(cwd)
219
+ raw.tunnels = config.tunnels.filter((entry) => entry.name !== args.name)
220
+ writeRawConfig(cwd, raw)
221
+ loadConfigSync(cwd)
222
+ return { ok: true, value: { removed: tunnel } }
223
+ }
224
+
225
+ export async function fetchTunnelList(opts: {
226
+ cwd: string
227
+ url?: string
228
+ timeoutMs?: number
229
+ }): Promise<LiveResult<TunnelSnapshot[]>> {
230
+ return withTuiSocket(opts, async (ws, timeoutMs) => {
231
+ const requestId = `tunnel-list-${crypto.randomUUID()}`
232
+ const msg: ClientMessage = { type: 'tunnel_list_request', requestId }
233
+ ws.send(JSON.stringify(msg))
234
+ const reply = await waitForServerMessage(
235
+ ws,
236
+ timeoutMs,
237
+ (m) => m.type === 'tunnel_list_response' && m.requestId === requestId,
238
+ )
239
+ if (reply.type !== 'tunnel_list_response') throw new Error('unreachable')
240
+ return reply.ok ? { ok: true, value: reply.tunnels } : { ok: false, reason: reply.error }
241
+ })
242
+ }
243
+
244
+ export async function fetchTunnelStatus(opts: {
245
+ cwd: string
246
+ name: string
247
+ url?: string
248
+ timeoutMs?: number
249
+ }): Promise<LiveResult<TunnelSnapshot>> {
250
+ return withTuiSocket(opts, async (ws, timeoutMs) => {
251
+ const requestId = `tunnel-status-${crypto.randomUUID()}`
252
+ const msg: ClientMessage = { type: 'tunnel_status_request', requestId, name: opts.name }
253
+ ws.send(JSON.stringify(msg))
254
+ const reply = await waitForServerMessage(
255
+ ws,
256
+ timeoutMs,
257
+ (m) => m.type === 'tunnel_status_response' && m.requestId === requestId,
258
+ )
259
+ if (reply.type !== 'tunnel_status_response') throw new Error('unreachable')
260
+ return reply.ok ? { ok: true, value: reply.tunnel } : { ok: false, reason: reply.error }
261
+ })
262
+ }
263
+
264
+ export async function fetchTunnelLogs(opts: {
265
+ cwd: string
266
+ name: string
267
+ url?: string
268
+ timeoutMs?: number
269
+ follow?: false
270
+ }): Promise<LiveResult<string[]>> {
271
+ const lines: string[] = []
272
+ const result = await streamTunnelLogs({ ...opts, follow: false }, (line) => lines.push(line))
273
+ return result.ok ? { ok: true, value: lines } : result
274
+ }
275
+
276
+ export async function streamTunnelLogs(
277
+ opts: { cwd: string; name: string; url?: string; timeoutMs?: number; follow?: boolean },
278
+ onLine: (line: string) => void,
279
+ ): Promise<LiveResult<void>> {
280
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
281
+ const urlResult = await resolveWsUrl(opts.cwd, opts.url, '/tunnel-logs')
282
+ if (!urlResult.ok) return urlResult
283
+ const ws = new WebSocket(urlResult.value)
284
+ try {
285
+ await waitForOpen(ws, timeoutMs)
286
+ ws.send(JSON.stringify({ type: 'subscribe', name: opts.name, follow: opts.follow === true }))
287
+ return await new Promise<LiveResult<void>>((resolve) => {
288
+ const timer = setTimeout(() => resolve({ ok: false, reason: 'timed out waiting for tunnel logs' }), timeoutMs)
289
+ const onSigint = () => {
290
+ cleanup()
291
+ ws.close()
292
+ resolve({ ok: true, value: undefined })
293
+ }
294
+ const cleanup = () => {
295
+ clearTimeout(timer)
296
+ process.off('SIGINT', onSigint)
297
+ ws.removeEventListener('message', onMessage)
298
+ }
299
+ const onMessage = (event: MessageEvent) => {
300
+ const msg = JSON.parse(String(event.data)) as TunnelLogsServerMessage
301
+ if (msg.type === 'snapshot') for (const line of msg.lines) onLine(line)
302
+ else if (msg.type === 'line') onLine(msg.line)
303
+ else if (msg.type === 'error') {
304
+ cleanup()
305
+ ws.close()
306
+ resolve({ ok: false, reason: msg.message })
307
+ } else if (msg.type === 'end') {
308
+ cleanup()
309
+ ws.close()
310
+ resolve({ ok: true, value: undefined })
311
+ }
312
+ }
313
+ process.once('SIGINT', onSigint)
314
+ ws.addEventListener('message', onMessage)
315
+ })
316
+ } catch (err) {
317
+ ws.close()
318
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
319
+ }
320
+ }
321
+
322
+ export function formatTunnelList(tunnels: readonly TunnelSnapshot[]): string {
323
+ if (tunnels.length === 0) return c.dim('No tunnels configured.')
324
+ const rows = tunnels.map((t) => [
325
+ t.name,
326
+ t.provider,
327
+ formatFor(t.for),
328
+ t.url ?? '-',
329
+ t.status,
330
+ formatLast(t.lastUrlAt),
331
+ ])
332
+ const widths = [4, 8, 3, 3, 6, 12].map((min, i) => Math.max(min, ...rows.map((row) => row[i]!.length)))
333
+ const header = ['NAME', 'PROVIDER', 'FOR', 'URL', 'STATUS', 'LAST-ROTATED']
334
+ .map((h, i) => h.padEnd(widths[i]!))
335
+ .join(' ')
336
+ return [c.dim(header), ...rows.map((row) => row.map((cell, i) => cell.padEnd(widths[i]!)).join(' '))].join('\n')
337
+ }
338
+
339
+ export function formatTunnelStatus(tunnel: TunnelSnapshot, lines: readonly string[]): string {
340
+ const out = [
341
+ `${c.bold(tunnel.name)} ${c.dim(`[${tunnel.provider}]`)}`,
342
+ ` ${c.dim('for ')} ${formatFor(tunnel.for)}`,
343
+ ` ${c.dim('current URL')} ${tunnel.url ?? '-'}`,
344
+ ` ${c.dim('status ')} ${tunnel.status}`,
345
+ ` ${c.dim('lastUrlAt ')} ${formatLast(tunnel.lastUrlAt)}`,
346
+ ` ${c.dim('detail ')} ${tunnel.detail}`,
347
+ ]
348
+ if (lines.length > 0) out.push('', c.dim('Recent logs:'), ...lines.map((line) => ` ${line}`))
349
+ return out.join('\n')
350
+ }
351
+
352
+ function liveArgs() {
353
+ return {
354
+ url: { type: 'string', description: 'agent websocket url' },
355
+ timeout: {
356
+ type: 'string',
357
+ description: 'milliseconds to wait for the agent to respond',
358
+ default: String(DEFAULT_TIMEOUT_MS),
359
+ },
360
+ } as const
361
+ }
362
+
363
+ function parseLiveArgs(args: LiveArgs): { url?: string; timeoutMs: number } {
364
+ const timeoutMs = Number(args.timeout ?? DEFAULT_TIMEOUT_MS)
365
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(`invalid --timeout value: ${args.timeout}`)
366
+ return { ...(args.url !== undefined ? { url: args.url } : {}), timeoutMs }
367
+ }
368
+
369
+ async function resolveProvider(input: string | undefined, prompts: TunnelPrompts): Promise<TunnelProvider> {
370
+ if (input === 'external' || input === 'cloudflare-quick') return input
371
+ if (input !== undefined) throw new Error(`unknown tunnel provider: ${input}`)
372
+ const choice = await prompts.selectProvider()
373
+ if (isCancel(choice)) {
374
+ cancel('Aborted.')
375
+ process.exit(0)
376
+ }
377
+ return choice
378
+ }
379
+
380
+ async function resolveFor(args: AddArgs, prompts: TunnelPrompts): Promise<TunnelFor> {
381
+ if (args.forChannel !== undefined && args.forManual === true)
382
+ throw new Error('choose either --for-channel or --for-manual, not both')
383
+ if (args.forChannel !== undefined) return { kind: 'channel', name: args.forChannel }
384
+ if (args.forManual === true) return { kind: 'manual' }
385
+ const choice = await prompts.selectOwner()
386
+ if (isCancel(choice)) {
387
+ cancel('Aborted.')
388
+ process.exit(0)
389
+ }
390
+ if (choice === 'manual') return { kind: 'manual' }
391
+ return {
392
+ kind: 'channel',
393
+ name: await promptText('Channel name', prompts, validateNonEmpty('Channel name is required')),
394
+ }
395
+ }
396
+
397
+ async function promptText(message: string, prompts: TunnelPrompts, validate?: TextValidator): Promise<string> {
398
+ const value = await prompts.text(message, validate)
399
+ if (isCancel(value)) {
400
+ cancel('Aborted.')
401
+ process.exit(0)
402
+ }
403
+ return String(value)
404
+ }
405
+
406
+ function validateNonEmpty(requiredMessage: string): TextValidator {
407
+ return (value) => (value.trim().length > 0 ? undefined : requiredMessage)
408
+ }
409
+
410
+ function validateUpstreamPort(value: string): string | undefined {
411
+ if (value.trim().length === 0) return 'Upstream port is required'
412
+ const port = Number(value)
413
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return 'Must be an integer between 1 and 65535'
414
+ return undefined
415
+ }
416
+
417
+ function validateHttpsUrl(value: string): string | undefined {
418
+ if (value.trim().length === 0) return 'URL is required'
419
+ if (!value.startsWith('https://')) return 'URL must start with https://'
420
+ try {
421
+ new URL(value)
422
+ return undefined
423
+ } catch {
424
+ return 'Must be a valid URL'
425
+ }
426
+ }
427
+
428
+ function ensureAgentDir(): string {
429
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
430
+ if (!isInitialized(cwd)) {
431
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
432
+ process.exit(1)
433
+ }
434
+ return cwd
435
+ }
436
+
437
+ function readRawConfig(cwd: string): Record<string, unknown> {
438
+ const file = join(cwd, 'typeclaw.json')
439
+ try {
440
+ return JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
441
+ } catch (err) {
442
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT') return {}
443
+ throw err
444
+ }
445
+ }
446
+
447
+ function writeRawConfig(cwd: string, config: Record<string, unknown>): void {
448
+ writeFileSync(join(cwd, 'typeclaw.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8')
449
+ }
450
+
451
+ function asRecord(value: unknown): Record<string, unknown> {
452
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
453
+ }
454
+
455
+ async function withTuiSocket<T>(
456
+ opts: { cwd: string; url?: string; timeoutMs?: number },
457
+ fn: (ws: WebSocket, timeoutMs: number) => Promise<LiveResult<T>>,
458
+ ): Promise<LiveResult<T>> {
459
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
460
+ const url = await resolveWsUrl(opts.cwd, opts.url)
461
+ if (!url.ok) return url
462
+ const ws = new WebSocket(url.value)
463
+ try {
464
+ await waitForOpen(ws, timeoutMs)
465
+ return await fn(ws, timeoutMs)
466
+ } catch (err) {
467
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
468
+ } finally {
469
+ ws.close()
470
+ }
471
+ }
472
+
473
+ async function resolveWsUrl(cwd: string, input?: string, pathname = '/'): Promise<LiveResult<string>> {
474
+ try {
475
+ const url = input === undefined ? new URL(`ws://127.0.0.1:${await resolveHostPort({ cwd })}`) : new URL(input)
476
+ if (input === undefined) {
477
+ const token = await resolveTuiToken({ cwd })
478
+ if (token !== null) url.searchParams.set('token', token)
479
+ }
480
+ url.pathname = pathname
481
+ return { ok: true, value: url.toString() }
482
+ } catch (err) {
483
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
484
+ }
485
+ }
486
+
487
+ function waitForOpen(ws: WebSocket, timeoutMs: number): Promise<void> {
488
+ return new Promise((resolve, reject) => {
489
+ const timer = setTimeout(() => reject(new Error('timed out connecting to agent websocket')), timeoutMs)
490
+ ws.addEventListener(
491
+ 'open',
492
+ () => {
493
+ clearTimeout(timer)
494
+ resolve()
495
+ },
496
+ { once: true },
497
+ )
498
+ ws.addEventListener(
499
+ 'error',
500
+ (err) => {
501
+ clearTimeout(timer)
502
+ reject(err)
503
+ },
504
+ { once: true },
505
+ )
506
+ })
507
+ }
508
+
509
+ function waitForServerMessage(
510
+ ws: WebSocket,
511
+ timeoutMs: number,
512
+ predicate: (msg: ServerMessage) => boolean,
513
+ ): Promise<ServerMessage> {
514
+ return new Promise((resolve, reject) => {
515
+ const timer = setTimeout(() => reject(new Error('timed out waiting for agent response')), timeoutMs)
516
+ const onMessage = (event: MessageEvent) => {
517
+ const msg = JSON.parse(String(event.data)) as ServerMessage
518
+ if (!predicate(msg)) return
519
+ clearTimeout(timer)
520
+ ws.removeEventListener('message', onMessage)
521
+ resolve(msg)
522
+ }
523
+ ws.addEventListener('message', onMessage)
524
+ })
525
+ }
526
+
527
+ function formatFor(value: TunnelFor): string {
528
+ return value.kind === 'channel' ? `channel:${value.name}` : 'manual'
529
+ }
530
+
531
+ function formatLast(value: number | null): string {
532
+ return value === null ? '-' : new Date(value).toISOString()
533
+ }
package/src/cli/ui.ts CHANGED
@@ -106,9 +106,14 @@ export function renderStartSuccess(result: StartLikeResult): string {
106
106
 
107
107
  export type NextStepHint = { label: string; command: string }
108
108
 
109
- export function done(opts: { title: string; hints: NextStepHint[] }): void {
110
- const body = opts.hints.map((h) => `${c.dim(h.label)} ${c.cyan(h.command)}`).join('\n')
111
- note(body, opts.title)
109
+ // `details` goes into the body, not the title: clack's `note()` sizes the
110
+ // box to the title's visual width and never wraps titles, so a long title
111
+ // breaks the layout on narrow terminals. Body content is wrapped to fit.
112
+ export function done(opts: { title: string; details?: string; hints: NextStepHint[] }): void {
113
+ const lines: string[] = []
114
+ if (opts.details !== undefined && opts.details !== '') lines.push(opts.details)
115
+ for (const h of opts.hints) lines.push(`${c.dim(h.label)} ${c.cyan(h.command)}`)
116
+ note(lines.join('\n'), opts.title)
112
117
  outro(c.green('Done.'))
113
118
  }
114
119