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,244 @@
1
+ import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
2
+ import type { ClientMessage, ServerMessage } from '@/shared'
3
+
4
+ export type ContainerCommandResult = { ok: true; exitCode: number } | { ok: false; exitCode: number; message: string }
5
+
6
+ export type ContainerProxyOptions = {
7
+ agentDir: string
8
+ commandName: string
9
+ args: unknown
10
+ isolated?: boolean
11
+ stdin?: ReadableStream<Uint8Array>
12
+ stdout?: WritableStream<Uint8Array>
13
+ stderr?: WritableStream<Uint8Array>
14
+ abortSignal?: AbortSignal
15
+ // Explicit parent-origin override. When unset the proxy reads
16
+ // process.env.TYPECLAW_PARENT_ORIGIN_JSON. Tests pass this directly to
17
+ // avoid mutating process.env.
18
+ parentOriginJson?: string
19
+ // Override hooks for tests. When unset, the live host port + token resolvers
20
+ // are used. The websocketFactory is also pluggable so tests can drive a
21
+ // fake server without binding to a real port.
22
+ resolveUrl?: (opts: { agentDir: string }) => Promise<{ url: string } | { error: string }>
23
+ websocketFactory?: (url: string) => WebSocketLike
24
+ }
25
+
26
+ export type WebSocketLike = {
27
+ send: (data: string) => void
28
+ close: () => void
29
+ addEventListener: (
30
+ event: 'open' | 'message' | 'close' | 'error',
31
+ listener: (event: { data?: unknown; code?: number; reason?: string }) => void,
32
+ ) => void
33
+ }
34
+
35
+ export async function proxyContainerCommand(opts: ContainerProxyOptions): Promise<ContainerCommandResult> {
36
+ const urlResolution =
37
+ opts.resolveUrl !== undefined
38
+ ? await opts.resolveUrl({ agentDir: opts.agentDir })
39
+ : await resolveUrlFromDocker(opts.agentDir)
40
+ if ('error' in urlResolution) {
41
+ return { ok: false, exitCode: 2, message: urlResolution.error }
42
+ }
43
+
44
+ const callId = crypto.randomUUID()
45
+ const ws =
46
+ opts.websocketFactory !== undefined
47
+ ? opts.websocketFactory(urlResolution.url)
48
+ : (new WebSocket(urlResolution.url) as unknown as WebSocketLike)
49
+
50
+ let opened = false
51
+ await new Promise<void>((resolve, reject) => {
52
+ const timer = setTimeout(() => {
53
+ try {
54
+ ws.close()
55
+ } catch {
56
+ // Ignore close failures during connect timeout — the original error is
57
+ // already propagating through reject().
58
+ }
59
+ reject(new Error('timed out connecting to agent container WebSocket'))
60
+ }, 5_000)
61
+ ws.addEventListener('open', () => {
62
+ clearTimeout(timer)
63
+ opened = true
64
+ resolve()
65
+ })
66
+ ws.addEventListener('error', (event) => {
67
+ clearTimeout(timer)
68
+ reject(new Error(String((event as { message?: string }).message ?? 'websocket error')))
69
+ })
70
+ ws.addEventListener('close', () => {
71
+ if (!opened) {
72
+ clearTimeout(timer)
73
+ reject(new Error('websocket closed before open'))
74
+ }
75
+ })
76
+ })
77
+
78
+ return new Promise<ContainerCommandResult>((resolve) => {
79
+ let settled = false
80
+ let finalErrorMessage: string | undefined
81
+
82
+ const settle = (result: ContainerCommandResult) => {
83
+ if (settled) return
84
+ settled = true
85
+ try {
86
+ ws.close()
87
+ } catch {
88
+ // Already closed.
89
+ }
90
+ resolve(result)
91
+ }
92
+
93
+ ws.addEventListener('message', (event) => {
94
+ const raw = event.data
95
+ if (typeof raw !== 'string') return
96
+ let parsed: ServerMessage
97
+ try {
98
+ parsed = JSON.parse(raw) as ServerMessage
99
+ } catch {
100
+ return
101
+ }
102
+ if (!('callId' in parsed) || parsed.callId !== callId) return
103
+
104
+ if (parsed.type === 'command_stdout' && opts.stdout) {
105
+ void writeChunkBase64(opts.stdout, parsed.chunk)
106
+ return
107
+ }
108
+ if (parsed.type === 'command_stderr' && opts.stderr) {
109
+ void writeChunkBase64(opts.stderr, parsed.chunk)
110
+ return
111
+ }
112
+ if (parsed.type === 'command_error') {
113
+ finalErrorMessage = parsed.message
114
+ return
115
+ }
116
+ if (parsed.type === 'command_exit') {
117
+ if (finalErrorMessage !== undefined) {
118
+ settle({ ok: false, exitCode: parsed.code, message: finalErrorMessage })
119
+ } else {
120
+ settle({ ok: true, exitCode: parsed.code })
121
+ }
122
+ return
123
+ }
124
+ })
125
+
126
+ ws.addEventListener('close', () => {
127
+ if (!settled) {
128
+ settle({ ok: false, exitCode: 1, message: finalErrorMessage ?? 'websocket closed before command_exit' })
129
+ }
130
+ })
131
+ ws.addEventListener('error', (event) => {
132
+ if (!settled) {
133
+ const msg = String((event as { message?: string }).message ?? 'websocket error')
134
+ settle({ ok: false, exitCode: 1, message: msg })
135
+ }
136
+ })
137
+
138
+ if (opts.abortSignal !== undefined) {
139
+ const onAbort = () => {
140
+ try {
141
+ const abortFrame: ClientMessage = {
142
+ type: 'command_abort',
143
+ callId,
144
+ reason: opts.abortSignal?.reason instanceof Error ? opts.abortSignal.reason.message : 'aborted',
145
+ }
146
+ ws.send(JSON.stringify(abortFrame))
147
+ } catch {
148
+ // Best-effort abort; if the send fails the server will close anyway.
149
+ }
150
+ }
151
+ if (opts.abortSignal.aborted) onAbort()
152
+ else opts.abortSignal.addEventListener('abort', onAbort, { once: true })
153
+ }
154
+
155
+ // Forward TYPECLAW_PARENT_ORIGIN_JSON verbatim when the surrounding
156
+ // process set it (e.g. a cron exec runner that injected the cron job's
157
+ // origin into the subprocess env). The server uses this as the
158
+ // command's spawnedByOrigin so permission resolution chases through
159
+ // to the parent role instead of defaulting to synthetic-owner.
160
+ const parentOriginJson = opts.parentOriginJson ?? process.env.TYPECLAW_PARENT_ORIGIN_JSON
161
+ const exec: ClientMessage = {
162
+ type: 'exec_command',
163
+ callId,
164
+ name: opts.commandName,
165
+ args: opts.args,
166
+ ...(opts.isolated !== undefined ? { isolated: opts.isolated } : {}),
167
+ ...(parentOriginJson !== undefined && parentOriginJson !== '' ? { parentOriginJson } : {}),
168
+ }
169
+ ws.send(JSON.stringify(exec))
170
+
171
+ if (opts.stdin !== undefined) {
172
+ pumpStdin(opts.stdin, (chunk) => {
173
+ const frame: ClientMessage = { type: 'command_stdin', callId, chunk: encodeBase64(chunk) }
174
+ ws.send(JSON.stringify(frame))
175
+ })
176
+ .then(() => {
177
+ const end: ClientMessage = { type: 'command_stdin_end', callId }
178
+ ws.send(JSON.stringify(end))
179
+ })
180
+ .catch((err: unknown) => {
181
+ // Local stdin error: tell the server to abandon the in-flight
182
+ // command so it doesn't wait forever for command_stdin_end, and
183
+ // settle the host-side promise with a clear error. Without this
184
+ // .catch the rejection was silent and the command hung.
185
+ const reason = err instanceof Error ? err.message : String(err)
186
+ try {
187
+ const abortFrame: ClientMessage = {
188
+ type: 'command_abort',
189
+ callId,
190
+ reason: `local stdin error: ${reason}`,
191
+ }
192
+ ws.send(JSON.stringify(abortFrame))
193
+ } catch {
194
+ // ws may have already closed; the close handler will settle below.
195
+ }
196
+ settle({ ok: false, exitCode: 1, message: `local stdin error: ${reason}` })
197
+ })
198
+ }
199
+ })
200
+ }
201
+
202
+ async function resolveUrlFromDocker(agentDir: string): Promise<{ url: string } | { error: string }> {
203
+ const running = await requireContainerRunning({ cwd: agentDir })
204
+ if (!running.ok) {
205
+ return { error: `${running.reason}; start it with \`typeclaw start\`` }
206
+ }
207
+ const port = await resolveHostPort({ cwd: agentDir })
208
+ const token = await resolveTuiToken({ cwd: agentDir })
209
+ // The dedicated /commands path skips TUI session bootstrap on the server,
210
+ // saving an AgentSession creation per command invocation. Same auth as
211
+ // the root /` TUI path; both are owner-equivalent.
212
+ const url = new URL(`ws://127.0.0.1:${port}/commands`)
213
+ if (token !== null) url.searchParams.set('token', token)
214
+ return { url: url.toString() }
215
+ }
216
+
217
+ async function pumpStdin(stream: ReadableStream<Uint8Array>, send: (chunk: Uint8Array) => void): Promise<void> {
218
+ const reader = stream.getReader()
219
+ try {
220
+ while (true) {
221
+ const next = await reader.read()
222
+ if (next.done) return
223
+ send(next.value)
224
+ }
225
+ } finally {
226
+ reader.releaseLock()
227
+ }
228
+ }
229
+
230
+ async function writeChunkBase64(stream: WritableStream<Uint8Array>, chunkBase64: string): Promise<void> {
231
+ const bytes = Uint8Array.from(atob(chunkBase64), (c) => c.charCodeAt(0))
232
+ const writer = stream.getWriter()
233
+ try {
234
+ await writer.write(bytes)
235
+ } finally {
236
+ writer.releaseLock()
237
+ }
238
+ }
239
+
240
+ function encodeBase64(bytes: Uint8Array): string {
241
+ let s = ''
242
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] ?? 0)
243
+ return btoa(s)
244
+ }
@@ -0,0 +1,173 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { requireContainerRunning } from '@/container'
4
+ import { fetchCronList, type CronListBridgeResult } from '@/cron/bridge'
5
+ import { findAgentDir } from '@/init'
6
+ import type { CronListEntryPayload } from '@/shared'
7
+
8
+ import { c, errorLine } from './ui'
9
+
10
+ const listSub = defineCommand({
11
+ meta: {
12
+ name: 'list',
13
+ description: 'list all cron jobs (user-authored + plugin-contributed) registered in the running agent',
14
+ },
15
+ args: {
16
+ json: {
17
+ type: 'boolean',
18
+ description: 'emit the cron list as JSON',
19
+ default: false,
20
+ },
21
+ url: {
22
+ type: 'string',
23
+ description:
24
+ "agent websocket url (defaults to ws://127.0.0.1:<host port> discovered from the running container's published port)",
25
+ },
26
+ timeout: {
27
+ type: 'string',
28
+ description: 'milliseconds to wait for the agent to respond',
29
+ default: '15000',
30
+ },
31
+ },
32
+ async run({ args }) {
33
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
34
+ const timeoutMs = Number(args.timeout)
35
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
36
+ console.error(errorLine(`invalid --timeout value: ${args.timeout}`))
37
+ process.exit(1)
38
+ }
39
+
40
+ let url: string | undefined = args.url
41
+ if (url === undefined) {
42
+ const precheck = await requireContainerRunning({ cwd })
43
+ if (!precheck.ok) {
44
+ console.error(errorLine(precheck.reason))
45
+ process.exit(1)
46
+ }
47
+ }
48
+
49
+ const result = await fetchCronList({ cwd, timeoutMs, ...(url !== undefined ? { url } : {}) })
50
+
51
+ if (args.json) {
52
+ process.stdout.write(`${JSON.stringify(toJsonShape(result), null, 2)}\n`)
53
+ process.exit(result.kind === 'ok' ? 0 : 1)
54
+ }
55
+
56
+ if (result.kind !== 'ok') {
57
+ console.error(errorLine(describeFailure(result)))
58
+ process.exit(1)
59
+ }
60
+
61
+ process.stdout.write(`${formatList(result.jobs, result.nowMs)}\n`)
62
+ },
63
+ })
64
+
65
+ export const cronCommand = defineCommand({
66
+ meta: {
67
+ name: 'cron',
68
+ description: 'inspect cron jobs registered in the running agent (user-authored + plugin-contributed)',
69
+ },
70
+ subCommands: {
71
+ list: listSub,
72
+ },
73
+ })
74
+
75
+ export function describeFailure(result: Exclude<CronListBridgeResult, { kind: 'ok' }>): string {
76
+ switch (result.kind) {
77
+ case 'unreachable':
78
+ return `cannot reach the agent: ${result.reason}`
79
+ case 'timeout':
80
+ return 'timed out waiting for the agent to respond'
81
+ case 'error':
82
+ return result.reason
83
+ }
84
+ }
85
+
86
+ function toJsonShape(result: CronListBridgeResult): unknown {
87
+ if (result.kind === 'ok') {
88
+ return { ok: true, nowMs: result.nowMs, jobs: result.jobs }
89
+ }
90
+ return { ok: false, reason: describeFailure(result) }
91
+ }
92
+
93
+ export function formatList(jobs: readonly CronListEntryPayload[], nowMs: number): string {
94
+ if (jobs.length === 0) {
95
+ return c.dim('No cron jobs registered.')
96
+ }
97
+
98
+ const lines: string[] = []
99
+ lines.push(c.bold(`${jobs.length} cron job(s):`))
100
+ lines.push('')
101
+ for (const job of jobs) {
102
+ lines.push(formatEntry(job, nowMs))
103
+ lines.push('')
104
+ }
105
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
106
+ return lines.join('\n')
107
+ }
108
+
109
+ function formatEntry(job: CronListEntryPayload, nowMs: number): string {
110
+ const lines: string[] = []
111
+ const sourceLabel =
112
+ job.source.kind === 'user' ? c.cyan('user') : c.magenta(`plugin:${job.source.pluginName}.${job.source.localId}`)
113
+ const enabledBadge = job.enabled ? '' : ` ${c.yellow('(disabled)')}`
114
+ const kindBadge = c.dim(`[${job.kind}]`)
115
+ lines.push(`${c.bold(displayId(job))} ${kindBadge} ${sourceLabel}${enabledBadge}`)
116
+
117
+ const tz = job.timezone !== undefined ? ` ${c.dim(`(${job.timezone})`)}` : ''
118
+ lines.push(` ${c.dim('schedule')} ${job.schedule}${tz}`)
119
+
120
+ if (job.nextFireMs === null) {
121
+ const why = job.scheduleError !== undefined ? `: ${job.scheduleError}` : ''
122
+ lines.push(` ${c.dim('next ')} ${c.red('invalid schedule')}${why}`)
123
+ } else {
124
+ lines.push(` ${c.dim('next ')} ${formatNextFire(job.nextFireMs, nowMs)}`)
125
+ }
126
+
127
+ if (job.scheduledByRole !== undefined) {
128
+ lines.push(` ${c.dim('role ')} ${job.scheduledByRole}`)
129
+ }
130
+
131
+ if (job.kind === 'prompt') {
132
+ if (job.subagent !== undefined) {
133
+ lines.push(` ${c.dim('subagent')} ${job.subagent}`)
134
+ }
135
+ if (job.prompt !== undefined && job.subagent === undefined) {
136
+ lines.push(` ${c.dim('prompt ')} ${truncate(job.prompt, 80)}`)
137
+ }
138
+ } else if (job.command !== undefined) {
139
+ lines.push(` ${c.dim('command ')} ${job.command.join(' ')}`)
140
+ }
141
+
142
+ return lines.join('\n')
143
+ }
144
+
145
+ function displayId(job: CronListEntryPayload): string {
146
+ if (job.source.kind === 'plugin') {
147
+ return `${job.source.pluginName}.${job.source.localId}`
148
+ }
149
+ return job.id
150
+ }
151
+
152
+ export function formatNextFire(nextFireMs: number, nowMs: number): string {
153
+ const iso = new Date(nextFireMs).toISOString()
154
+ const diffMs = nextFireMs - nowMs
155
+ return `${iso} ${c.dim(`(${formatDuration(diffMs)})`)}`
156
+ }
157
+
158
+ export function formatDuration(diffMs: number): string {
159
+ if (diffMs <= 0) return 'now'
160
+ const seconds = Math.round(diffMs / 1000)
161
+ if (seconds < 60) return `in ${seconds}s`
162
+ const minutes = Math.round(seconds / 60)
163
+ if (minutes < 60) return `in ${minutes}m`
164
+ const hours = Math.round(minutes / 60)
165
+ if (hours < 48) return `in ${hours}h`
166
+ const days = Math.round(hours / 24)
167
+ return `in ${days}d`
168
+ }
169
+
170
+ function truncate(s: string, max: number): string {
171
+ if (s.length <= max) return s
172
+ return `${s.slice(0, max - 1)}…`
173
+ }
@@ -0,0 +1,150 @@
1
+ import { z } from 'zod'
2
+
3
+ import {
4
+ type EitherCommand,
5
+ type EitherCommandContext,
6
+ type HostCommand,
7
+ type HostCommandContext,
8
+ type PluginCommand,
9
+ } from '@/plugin'
10
+ import { coerceFlag } from '@/plugin/zod-introspect'
11
+
12
+ export type HostRunOptions = {
13
+ agentDir: string
14
+ pluginName: string
15
+ pluginVersion: string | undefined
16
+ command: HostCommand | EitherCommand
17
+ rawArgs: readonly string[]
18
+ signal: AbortSignal
19
+ stdin: ReadableStream<Uint8Array>
20
+ stdout: WritableStream<Uint8Array>
21
+ stderr: WritableStream<Uint8Array>
22
+ }
23
+
24
+ export type HostRunResult = { ok: true; exitCode: number } | { ok: false; exitCode: number; message: string }
25
+
26
+ export async function runHostCommand(opts: HostRunOptions): Promise<HostRunResult> {
27
+ const argsParse = parseArgs(opts.command, opts.rawArgs)
28
+ if (!argsParse.ok) {
29
+ return { ok: false, exitCode: 2, message: argsParse.message }
30
+ }
31
+
32
+ const logger = makeCommandLogger(opts.pluginName, opts.stderr)
33
+ const ctxBase = {
34
+ name: opts.pluginName,
35
+ version: opts.pluginVersion,
36
+ agentDir: opts.agentDir,
37
+ logger,
38
+ signal: opts.signal,
39
+ stdin: opts.stdin,
40
+ stdout: opts.stdout,
41
+ stderr: opts.stderr,
42
+ }
43
+
44
+ try {
45
+ if (opts.command.surface === 'host') {
46
+ const ctx: HostCommandContext = ctxBase
47
+ const code = await opts.command.run(ctx, argsParse.value)
48
+ return { ok: true, exitCode: code }
49
+ }
50
+ const ctx: EitherCommandContext = ctxBase
51
+ const code = await opts.command.run(ctx, argsParse.value)
52
+ return { ok: true, exitCode: code }
53
+ } catch (err) {
54
+ const detail = err instanceof Error ? err.message : String(err)
55
+ return { ok: false, exitCode: 1, message: detail }
56
+ }
57
+ }
58
+
59
+ type ArgsParseResult = { ok: true; value: unknown } | { ok: false; message: string }
60
+
61
+ export function parseArgs(command: PluginCommand, rawArgs: readonly string[]): ArgsParseResult {
62
+ if (command.args === undefined) {
63
+ if (rawArgs.length > 0) {
64
+ return { ok: false, message: `command accepts no arguments but received: ${rawArgs.join(' ')}` }
65
+ }
66
+ return { ok: true, value: undefined }
67
+ }
68
+
69
+ const tokenized = tokenizeFlags(rawArgs)
70
+ if (!tokenized.ok) return tokenized
71
+
72
+ const coerced = coerceAgainstSchema(command.args, tokenized.flags)
73
+ if (!coerced.ok) return coerced
74
+
75
+ const parsed = command.args.safeParse(coerced.value)
76
+ if (!parsed.success) {
77
+ const message = parsed.error.issues
78
+ .map((i) => `${i.path.length > 0 ? i.path.join('.') : '<root>'}: ${i.message}`)
79
+ .join('; ')
80
+ return { ok: false, message }
81
+ }
82
+ return { ok: true, value: parsed.data }
83
+ }
84
+
85
+ type TokenizeResult = { ok: true; flags: Record<string, string | true> } | { ok: false; message: string }
86
+
87
+ // Parses `--key=value` / `--key value` / `--key` (boolean) into a flat map.
88
+ // Positional args are not supported in v1 (constrained by the z.object args
89
+ // shape). Unknown flags surface as Zod errors downstream.
90
+ function tokenizeFlags(rawArgs: readonly string[]): TokenizeResult {
91
+ const flags: Record<string, string | true> = {}
92
+ for (let i = 0; i < rawArgs.length; i++) {
93
+ const arg = rawArgs[i]
94
+ if (arg === undefined) continue
95
+ if (!arg.startsWith('--')) {
96
+ return { ok: false, message: `unexpected positional argument: ${arg}` }
97
+ }
98
+ const stripped = arg.slice(2)
99
+ const eq = stripped.indexOf('=')
100
+ if (eq >= 0) {
101
+ const key = stripped.slice(0, eq)
102
+ const value = stripped.slice(eq + 1)
103
+ flags[key] = value
104
+ continue
105
+ }
106
+ const key = stripped
107
+ const next = rawArgs[i + 1]
108
+ if (next !== undefined && !next.startsWith('--')) {
109
+ flags[key] = next
110
+ i++
111
+ } else {
112
+ flags[key] = true
113
+ }
114
+ }
115
+ return { ok: true, flags }
116
+ }
117
+
118
+ function coerceAgainstSchema(
119
+ schema: z.ZodObject<z.ZodRawShape>,
120
+ flags: Record<string, string | true>,
121
+ ): { ok: true; value: Record<string, unknown> } | { ok: false; message: string } {
122
+ const shape = schema.shape as Record<string, unknown>
123
+ const out: Record<string, unknown> = {}
124
+ for (const [key, raw] of Object.entries(flags)) {
125
+ const leaf = shape[key]
126
+ if (leaf === undefined) {
127
+ return { ok: false, message: `unknown flag: --${key}` }
128
+ }
129
+ try {
130
+ out[key] = coerceFlag(leaf, raw, key)
131
+ } catch (err) {
132
+ const detail = err instanceof Error ? err.message : String(err)
133
+ return { ok: false, message: detail }
134
+ }
135
+ }
136
+ return { ok: true, value: out }
137
+ }
138
+
139
+ function makeCommandLogger(pluginName: string, stderr: WritableStream<Uint8Array>) {
140
+ const writer = stderr.getWriter()
141
+ const encoder = new TextEncoder()
142
+ const write = (level: string, msg: string) => {
143
+ void writer.write(encoder.encode(`[command:${pluginName}] ${level}: ${msg}\n`))
144
+ }
145
+ return {
146
+ info: (msg: string) => write('info', msg),
147
+ warn: (msg: string) => write('warn', msg),
148
+ error: (msg: string) => write('error', msg),
149
+ }
150
+ }
package/src/cli/index.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  import { defineCommand, runMain } from 'citty'
4
4
 
5
5
  import { CLI_VERSION } from '../init/cli-version'
6
+ import { BUILTIN_COMMAND_NAMES } from './builtins'
7
+ import { dispatchPluginCommand, type PluginCommandDispatchOutcome } from './plugin-commands-dispatch'
6
8
 
7
9
  const main = defineCommand({
8
10
  meta: {
@@ -23,6 +25,8 @@ const main = defineCommand({
23
25
  shell: () => import('./shell').then((m) => m.shellCommand),
24
26
  compose: () => import('./compose').then((m) => m.composeCommand),
25
27
  channel: () => import('./channel').then((m) => m.channelCommand),
28
+ cron: () => import('./cron').then((m) => m.cronCommand),
29
+ tunnel: () => import('./tunnel').then((m) => m.tunnelCommand),
26
30
  role: () => import('./role').then((m) => m.roleCommand),
27
31
  provider: () => import('./provider').then((m) => m.providerCommand),
28
32
  model: () => import('./model').then((m) => m.modelCommand),
@@ -32,4 +36,41 @@ const main = defineCommand({
32
36
  },
33
37
  })
34
38
 
35
- runMain(main)
39
+ await runWithPluginDispatch()
40
+
41
+ async function runWithPluginDispatch(): Promise<void> {
42
+ const argv = process.argv.slice(2)
43
+ const first = argv[0]
44
+
45
+ if (first === '--help' || first === '-h') {
46
+ // citty calls process.exit() after rendering help, so anything we print
47
+ // AFTER `runMain(main)` is never reached. Print the plugin commands
48
+ // section first; citty's own help follows and the user reads top-down.
49
+ const { renderPluginCommandsSection } = await import('./plugin-command-help')
50
+ const { discoverCommands } = await import('./plugin-commands')
51
+ const discovery = await discoverCommands({ cwd: process.cwd() })
52
+ const section = renderPluginCommandsSection(discovery.commands)
53
+ if (section !== null) process.stdout.write(`${section}\n\n`)
54
+ await runMain(main)
55
+ return
56
+ }
57
+
58
+ if (
59
+ first !== undefined &&
60
+ !first.startsWith('-') &&
61
+ !BUILTIN_COMMAND_NAMES.includes(first as (typeof BUILTIN_COMMAND_NAMES)[number])
62
+ ) {
63
+ const outcome = await dispatchPluginCommand({ name: first, rawArgs: argv.slice(1), cwd: process.cwd() })
64
+ if (outcome.kind === 'dispatched') {
65
+ process.exit(outcome.exitCode)
66
+ }
67
+ if (outcome.kind === 'error') {
68
+ process.stderr.write(`${outcome.message}\n`)
69
+ process.exit(outcome.exitCode)
70
+ }
71
+ // outcome.kind === 'not-found' → fall through to citty for unknown-command error
72
+ }
73
+ await runMain(main)
74
+ }
75
+
76
+ export type { PluginCommandDispatchOutcome }