typeclaw 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/secrets.schema.json +113 -0
- package/src/agent/index.ts +149 -30
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +70 -35
- package/src/bundled-plugins/security/index.ts +3 -2
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +286 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +28 -2
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +256 -27
- package/src/cli/model.ts +4 -2
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/cli/usage.ts +30 -2
- package/src/config/config.ts +90 -4
- package/src/config/reloadable.ts +22 -4
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +62 -6
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +119 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +393 -15
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +35 -4
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
- package/typeclaw.schema.json +254 -1
|
@@ -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
|
+
}
|
package/src/cli/cron.ts
ADDED
|
@@ -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
|
-
|
|
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 }
|