typeclaw 0.3.1 → 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 +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- 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/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/config/config.ts +75 -0
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +45 -5
- 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 +110 -3
- 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 +388 -5
- 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/typeclaw.schema.json +254 -1
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
export type InstallResult = { ok: true } | { ok: false; reason: string }
|
|
2
2
|
|
|
3
|
+
export type InstallRunnerOptions = {
|
|
4
|
+
// Append `--force` to the bun install argv to bypass the cache for
|
|
5
|
+
// `file:` / `link:` deps. Bun treats name+version of a `file:` dep as a
|
|
6
|
+
// cache hit even after the source on disk has changed, so changes to a
|
|
7
|
+
// locally-linked typeclaw never propagate into <agent>/node_modules until
|
|
8
|
+
// either the typeclaw version is bumped or the install is forced.
|
|
9
|
+
force?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
// Signature for the function `runInit` uses to materialize the agent folder's
|
|
4
13
|
// dependencies. Exposed as a named type so callers (and tests) can pass their
|
|
5
14
|
// own stub without re-declaring the shape, mirroring `HatchRunner` and
|
|
6
15
|
// `KakaotalkAuthRunner` in `./index.ts`.
|
|
7
|
-
export type InstallRunner = (cwd: string) => Promise<InstallResult>
|
|
16
|
+
export type InstallRunner = (cwd: string, opts?: InstallRunnerOptions) => Promise<InstallResult>
|
|
8
17
|
|
|
9
|
-
export async function runBunInstall(cwd: string): Promise<InstallResult> {
|
|
18
|
+
export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): Promise<InstallResult> {
|
|
10
19
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
11
20
|
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
12
21
|
try {
|
|
@@ -21,7 +30,12 @@ export async function runBunInstall(cwd: string): Promise<InstallResult> {
|
|
|
21
30
|
// the bug are non-trivial. Hoisted is the fallback strategy bun shipped
|
|
22
31
|
// before 1.3 — slightly slower for huge monorepos, indistinguishable
|
|
23
32
|
// for an agent folder, and not affected by the bug.
|
|
24
|
-
|
|
33
|
+
//
|
|
34
|
+
// `--force` is conditional: it bypasses the package cache so file:/link:
|
|
35
|
+
// deps re-copy their current on-disk source into node_modules. Bun's
|
|
36
|
+
// file-dep cache is keyed on name+version, so without --force, edits to
|
|
37
|
+
// a `file:..` typeclaw never reach the container after the first install.
|
|
38
|
+
cmd: opts?.force ? ['bun', 'install', '--linker=hoisted', '--force'] : ['bun', 'install', '--linker=hoisted'],
|
|
25
39
|
cwd,
|
|
26
40
|
stdout: 'pipe',
|
|
27
41
|
stderr: 'pipe',
|
|
@@ -8,6 +8,7 @@ import type { ChannelKind } from './index'
|
|
|
8
8
|
const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
9
9
|
'slack-bot': 'Slack',
|
|
10
10
|
'discord-bot': 'Discord',
|
|
11
|
+
github: 'GitHub',
|
|
11
12
|
'telegram-bot': 'Telegram',
|
|
12
13
|
kakaotalk: 'KakaoTalk',
|
|
13
14
|
}
|
|
@@ -25,9 +26,17 @@ export type RunOwnerClaimOptions = {
|
|
|
25
26
|
// normal hatching path so the TUI still opens — the operator can run
|
|
26
27
|
// `typeclaw role claim` later.
|
|
27
28
|
export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOptions): Promise<void> {
|
|
28
|
-
|
|
29
|
+
// GitHub has no DM affordance, and the github adapter doesn't yet route
|
|
30
|
+
// claim codes (the `typeclaw role claim` CLI explicitly lists only the
|
|
31
|
+
// four chat adapters as --channel values). Owner authorization for github
|
|
32
|
+
// is handled by the `roles.member.match[]` repo allowlist that
|
|
33
|
+
// runAddChannel writes during init. Filtering here keeps the auto-claim
|
|
34
|
+
// working for chat channels mixed with github, and skips the flow
|
|
35
|
+
// entirely when github is the only wired channel.
|
|
36
|
+
const claimable = configuredChannels.filter((c) => c !== 'github')
|
|
37
|
+
if (claimable.length === 0) return
|
|
29
38
|
|
|
30
|
-
const channelList =
|
|
39
|
+
const channelList = claimable.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
|
|
31
40
|
|
|
32
41
|
const proceed = await confirm({
|
|
33
42
|
message: `Claim owner role on ${channelList} now?`,
|
|
@@ -37,7 +37,12 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
37
37
|
},
|
|
38
38
|
trusted: {
|
|
39
39
|
match: [],
|
|
40
|
-
permissions: [
|
|
40
|
+
permissions: [
|
|
41
|
+
CORE_PERMISSIONS.channelRespond,
|
|
42
|
+
CORE_PERMISSIONS.cronSchedule,
|
|
43
|
+
'security.bypass.secretExfilBash',
|
|
44
|
+
'security.bypass.gitExfil',
|
|
45
|
+
],
|
|
41
46
|
},
|
|
42
47
|
member: {
|
|
43
48
|
match: [],
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// error messages with typo suggestions; a single big regex would only ever
|
|
17
17
|
// say "didn't match".
|
|
18
18
|
|
|
19
|
-
export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao'] as const
|
|
19
|
+
export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao', 'github'] as const
|
|
20
20
|
export type Platform = (typeof PLATFORMS)[number]
|
|
21
21
|
|
|
22
22
|
const SUBAGENT_NAME = /^[a-z][a-z0-9-]*$/
|
|
@@ -50,7 +50,7 @@ export type ParseMatchRuleResult = { ok: true; value: MatchRule } | { ok: false;
|
|
|
50
50
|
// this to `author:` only, the JSON schema would reject typos with a generic
|
|
51
51
|
// "did not match pattern" error and the user would lose the actionable hint.
|
|
52
52
|
export const MATCH_RULE_REGEX_SOURCE =
|
|
53
|
-
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
53
|
+
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
54
54
|
|
|
55
55
|
export function parseMatchRule(input: string): ParseMatchRuleResult {
|
|
56
56
|
if (input !== input.trim() || input.length === 0) {
|
|
@@ -138,6 +138,8 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
138
138
|
return { ok: true, value: buildChannelRule(platform, { author }) }
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
if (platform === 'github') return parseGithubChannelScope(rest, author)
|
|
142
|
+
|
|
141
143
|
// Bucket scopes: `dm/*`, `dm/<id>`, `group/*`, `open/*`. Slack's `im` is
|
|
142
144
|
// renamed to `dm`; that mapping is enforced by the legacy-prefix table at
|
|
143
145
|
// the top of parseMatchRule for unprefixed forms — here we just refuse the
|
|
@@ -203,6 +205,26 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
203
205
|
return { ok: true, value: buildChannelRule(platform, { workspace: rest, author }) }
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
function parseGithubChannelScope(rest: string, author: string | undefined): ParseMatchRuleResult {
|
|
209
|
+
const [owner, repo, ...chatParts] = rest.split('/')
|
|
210
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '') {
|
|
211
|
+
return { ok: false, error: "github scope requires 'owner/repo' format" }
|
|
212
|
+
}
|
|
213
|
+
if (repo === '*') {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: `'github:${owner}/*' is not supported; use 'github:${owner}/repo' for a specific repo or 'github:*' for all github events`,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const workspace = `${owner}/${repo}`
|
|
220
|
+
if (chatParts.length === 0) return { ok: true, value: buildChannelRule('github', { workspace, author }) }
|
|
221
|
+
const chat = chatParts.join('/')
|
|
222
|
+
if (chat === '' || chat.includes('/')) {
|
|
223
|
+
return { ok: false, error: "github chat scope must be a single segment like 'issue:42'" }
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, value: buildChannelRule('github', { workspace, chat, author }) }
|
|
226
|
+
}
|
|
227
|
+
|
|
206
228
|
function buildChannelRule(
|
|
207
229
|
platform: Platform,
|
|
208
230
|
parts: {
|
package/src/plugin/define.ts
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import type { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
BuiltinToolRef,
|
|
5
|
+
ContainerCommand,
|
|
6
|
+
DefinedPlugin,
|
|
7
|
+
EitherCommand,
|
|
8
|
+
HostCommand,
|
|
9
|
+
PluginCommand,
|
|
10
|
+
PluginContext,
|
|
11
|
+
PluginExports,
|
|
12
|
+
Subagent,
|
|
13
|
+
Tool,
|
|
14
|
+
} from './types'
|
|
4
15
|
|
|
5
16
|
type DefinePluginSpec<S extends z.ZodType<unknown> | undefined> =
|
|
6
17
|
S extends z.ZodType<infer T>
|
|
7
18
|
? {
|
|
8
19
|
configSchema: S
|
|
9
20
|
permissions?: readonly string[]
|
|
21
|
+
commands?: Record<string, PluginCommand>
|
|
10
22
|
plugin: (ctx: PluginContext<T>) => Promise<PluginExports>
|
|
11
23
|
}
|
|
12
24
|
: {
|
|
13
25
|
permissions?: readonly string[]
|
|
26
|
+
commands?: Record<string, PluginCommand>
|
|
14
27
|
plugin: (ctx: PluginContext<unknown>) => Promise<PluginExports>
|
|
15
28
|
}
|
|
16
29
|
|
|
@@ -28,6 +41,34 @@ export function defineSubagent<P>(subagent: Subagent<P>): Subagent<P> {
|
|
|
28
41
|
return subagent
|
|
29
42
|
}
|
|
30
43
|
|
|
44
|
+
type ContainerCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
|
|
45
|
+
S extends z.ZodObject<z.ZodRawShape>
|
|
46
|
+
? Omit<ContainerCommand<z.infer<S>>, 'args'> & { args: S }
|
|
47
|
+
: Omit<ContainerCommand<unknown>, 'args'> & { args?: undefined }
|
|
48
|
+
|
|
49
|
+
type HostCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
|
|
50
|
+
S extends z.ZodObject<z.ZodRawShape>
|
|
51
|
+
? Omit<HostCommand<z.infer<S>>, 'args'> & { args: S }
|
|
52
|
+
: Omit<HostCommand<unknown>, 'args'> & { args?: undefined }
|
|
53
|
+
|
|
54
|
+
type EitherCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
|
|
55
|
+
S extends z.ZodObject<z.ZodRawShape>
|
|
56
|
+
? Omit<EitherCommand<z.infer<S>>, 'args'> & { args: S }
|
|
57
|
+
: Omit<EitherCommand<unknown>, 'args'> & { args?: undefined }
|
|
58
|
+
|
|
59
|
+
export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
|
|
60
|
+
cmd: ContainerCommandSpec<S>,
|
|
61
|
+
): ContainerCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
|
|
62
|
+
export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
|
|
63
|
+
cmd: HostCommandSpec<S>,
|
|
64
|
+
): HostCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
|
|
65
|
+
export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
|
|
66
|
+
cmd: EitherCommandSpec<S>,
|
|
67
|
+
): EitherCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
|
|
68
|
+
export function defineCommand(cmd: PluginCommand): PluginCommand {
|
|
69
|
+
return cmd
|
|
70
|
+
}
|
|
71
|
+
|
|
31
72
|
export const readTool: BuiltinToolRef = { __builtinTool: 'read' }
|
|
32
73
|
export const bashTool: BuiltinToolRef = { __builtinTool: 'bash' }
|
|
33
74
|
export const editTool: BuiltinToolRef = { __builtinTool: 'edit' }
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export {
|
|
2
2
|
bashTool,
|
|
3
|
-
|
|
3
|
+
defineCommand,
|
|
4
4
|
definePlugin,
|
|
5
5
|
defineSubagent,
|
|
6
|
+
defineTool,
|
|
6
7
|
editTool,
|
|
7
8
|
findTool,
|
|
8
9
|
grepTool,
|
|
@@ -13,14 +14,24 @@ export {
|
|
|
13
14
|
|
|
14
15
|
export type {
|
|
15
16
|
BuiltinToolRef,
|
|
17
|
+
CommandExecResult,
|
|
18
|
+
CommandStreams,
|
|
19
|
+
ContainerCommand,
|
|
20
|
+
ContainerCommandContext,
|
|
16
21
|
ContentPart,
|
|
17
22
|
DefinedPlugin,
|
|
23
|
+
EitherCommand,
|
|
24
|
+
EitherCommandContext,
|
|
18
25
|
HookContext,
|
|
19
26
|
HookName,
|
|
20
27
|
Hooks,
|
|
28
|
+
HostCommand,
|
|
29
|
+
HostCommandContext,
|
|
21
30
|
PluginCheckResult,
|
|
22
31
|
PluginCheckStatus,
|
|
32
|
+
PluginCommand,
|
|
23
33
|
PluginContext,
|
|
34
|
+
CronHandlerContext,
|
|
24
35
|
PluginCronJob,
|
|
25
36
|
PluginDoctorCheck,
|
|
26
37
|
PluginDoctorContext,
|
|
@@ -28,6 +39,7 @@ export type {
|
|
|
28
39
|
PluginExports,
|
|
29
40
|
PluginFixResult,
|
|
30
41
|
PluginFixSuggestion,
|
|
42
|
+
PluginHandlerCronJob,
|
|
31
43
|
PluginLogger,
|
|
32
44
|
PluginPromptCronJob,
|
|
33
45
|
PluginSkill,
|
|
@@ -61,12 +73,15 @@ export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
|
|
|
61
73
|
export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
|
|
62
74
|
export {
|
|
63
75
|
buildPluginCronGlobalId,
|
|
76
|
+
RESERVED_COMMAND_NAMES,
|
|
77
|
+
validateCommandDeclaration,
|
|
64
78
|
type PluginRegistry,
|
|
79
|
+
type RegisteredCommand,
|
|
65
80
|
type RegisteredCronJob,
|
|
66
81
|
type RegisteredDoctorCheck,
|
|
82
|
+
type RegisteredSkillDir,
|
|
83
|
+
type RegisteredSkillEntry,
|
|
67
84
|
type RegisteredSubagent,
|
|
68
85
|
type RegisteredTool,
|
|
69
|
-
type RegisteredSkillEntry,
|
|
70
|
-
type RegisteredSkillDir,
|
|
71
86
|
} from './registry'
|
|
72
87
|
export { createHookBus, type HookBus } from './hooks'
|
package/src/plugin/manager.ts
CHANGED
|
@@ -117,6 +117,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
117
117
|
pluginName: resolved.name,
|
|
118
118
|
logger,
|
|
119
119
|
exports,
|
|
120
|
+
...(resolved.defined.commands !== undefined ? { commands: resolved.defined.commands } : {}),
|
|
120
121
|
registry,
|
|
121
122
|
hooks,
|
|
122
123
|
agentDir: opts.agentDir,
|
|
@@ -166,6 +167,7 @@ export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], regi
|
|
|
166
167
|
`${registry.skills.length} skill(s)`,
|
|
167
168
|
`${registry.skillsDirs.length} skills dir(s)`,
|
|
168
169
|
`${registry.doctorChecks.length} doctor check(s)`,
|
|
170
|
+
`${registry.commands.length} command(s)`,
|
|
169
171
|
].join(', ')
|
|
170
172
|
return `${loaded.length} plugin(s): ${head} [${counts}]`
|
|
171
173
|
}
|
package/src/plugin/registry.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
+
import { BUILTIN_COMMAND_NAMES } from '@/cli/builtins'
|
|
3
4
|
import type { CronJob, PromptJob } from '@/cron'
|
|
4
5
|
|
|
5
6
|
import type { HookBus } from './hooks'
|
|
6
7
|
import type {
|
|
8
|
+
PluginCommand,
|
|
7
9
|
PluginCronJob,
|
|
8
10
|
PluginDoctorCheck,
|
|
9
11
|
PluginExports,
|
|
@@ -12,6 +14,7 @@ import type {
|
|
|
12
14
|
Subagent,
|
|
13
15
|
Tool,
|
|
14
16
|
} from './types'
|
|
17
|
+
import { isPrimitiveZodObject } from './zod-introspect'
|
|
15
18
|
|
|
16
19
|
export type RegisteredTool = { pluginName: string; toolName: string; tool: Tool<any>; logger: PluginLogger }
|
|
17
20
|
export type RegisteredSubagent = { pluginName: string; subagentName: string; subagent: Subagent<any> }
|
|
@@ -25,6 +28,12 @@ export type RegisteredDoctorCheck = {
|
|
|
25
28
|
logger: PluginLogger
|
|
26
29
|
check: PluginDoctorCheck
|
|
27
30
|
}
|
|
31
|
+
export type RegisteredCommand = {
|
|
32
|
+
pluginName: string
|
|
33
|
+
commandName: string
|
|
34
|
+
command: PluginCommand
|
|
35
|
+
logger: PluginLogger
|
|
36
|
+
}
|
|
28
37
|
|
|
29
38
|
export type PluginRegistry = {
|
|
30
39
|
tools: RegisteredTool[]
|
|
@@ -33,18 +42,28 @@ export type PluginRegistry = {
|
|
|
33
42
|
skills: RegisteredSkillEntry[]
|
|
34
43
|
skillsDirs: RegisteredSkillDir[]
|
|
35
44
|
doctorChecks: RegisteredDoctorCheck[]
|
|
45
|
+
commands: RegisteredCommand[]
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
export type RegisterContributionsOptions = {
|
|
39
49
|
pluginName: string
|
|
40
50
|
logger: PluginLogger
|
|
41
51
|
exports: PluginExports
|
|
52
|
+
// Static commands declared on `DefinedPlugin.commands`. Passed alongside
|
|
53
|
+
// `exports` because they live outside the factory's return value.
|
|
54
|
+
commands?: Record<string, PluginCommand>
|
|
42
55
|
registry: PluginRegistry
|
|
43
56
|
hooks: HookBus
|
|
44
57
|
agentDir: string
|
|
45
58
|
pluginConfig: unknown
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
const COMMAND_NAME_REGEX = /^[a-z][a-z0-9-]*$/
|
|
62
|
+
|
|
63
|
+
// CLI subcommands plugins MUST NOT shadow. Derived from BUILTIN_COMMAND_NAMES
|
|
64
|
+
// so cli/index.ts and registry.ts cannot drift apart.
|
|
65
|
+
export const RESERVED_COMMAND_NAMES: ReadonlySet<string> = new Set(BUILTIN_COMMAND_NAMES)
|
|
66
|
+
|
|
48
67
|
export function buildPluginCronGlobalId(pluginName: string, localId: string): string {
|
|
49
68
|
return `__plugin_${pluginName}_${localId}`
|
|
50
69
|
}
|
|
@@ -127,6 +146,19 @@ export function registerContributions(opts: RegisterContributionsOptions): void
|
|
|
127
146
|
registry.doctorChecks.push({ pluginName, checkName, pluginConfig, logger, check })
|
|
128
147
|
}
|
|
129
148
|
}
|
|
149
|
+
|
|
150
|
+
if (opts.commands) {
|
|
151
|
+
for (const [commandName, command] of Object.entries(opts.commands)) {
|
|
152
|
+
validateCommandDeclaration(pluginName, commandName, command)
|
|
153
|
+
const conflict = registry.commands.find((c) => c.commandName === commandName)
|
|
154
|
+
if (conflict) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`plugin ${pluginName}: command "${commandName}" already registered by plugin ${conflict.pluginName}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
registry.commands.push({ pluginName, commandName, command, logger })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
130
162
|
}
|
|
131
163
|
|
|
132
164
|
export function discardRegistrationsBy(pluginName: string, registry: PluginRegistry, hooks: HookBus): void {
|
|
@@ -136,11 +168,20 @@ export function discardRegistrationsBy(pluginName: string, registry: PluginRegis
|
|
|
136
168
|
registry.skills = registry.skills.filter((s) => s.pluginName !== pluginName)
|
|
137
169
|
registry.skillsDirs = registry.skillsDirs.filter((d) => d.pluginName !== pluginName)
|
|
138
170
|
registry.doctorChecks = registry.doctorChecks.filter((d) => d.pluginName !== pluginName)
|
|
171
|
+
registry.commands = registry.commands.filter((c) => c.pluginName !== pluginName)
|
|
139
172
|
hooks.unregisterAll(pluginName)
|
|
140
173
|
}
|
|
141
174
|
|
|
142
175
|
export function emptyRegistry(): PluginRegistry {
|
|
143
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
tools: [],
|
|
178
|
+
subagents: [],
|
|
179
|
+
cronJobs: [],
|
|
180
|
+
skills: [],
|
|
181
|
+
skillsDirs: [],
|
|
182
|
+
doctorChecks: [],
|
|
183
|
+
commands: [],
|
|
184
|
+
}
|
|
144
185
|
}
|
|
145
186
|
|
|
146
187
|
function assertNotEmpty(kind: string, value: string, pluginName: string): void {
|
|
@@ -149,6 +190,36 @@ function assertNotEmpty(kind: string, value: string, pluginName: string): void {
|
|
|
149
190
|
}
|
|
150
191
|
}
|
|
151
192
|
|
|
193
|
+
function assertValidCommandArgsSchema(pluginName: string, commandName: string, command: PluginCommand): void {
|
|
194
|
+
if (command.args === undefined) return
|
|
195
|
+
if (!isPrimitiveZodObject(command.args)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`plugin ${pluginName}: command "${commandName}" args must be a z.object({...}) with primitive (string/number/boolean) leaves`,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Reuses the same checks `registerContributions` runs at boot, so host-stage
|
|
203
|
+
// discovery and runtime registration agree on what is a valid command. Throws
|
|
204
|
+
// a precise error referencing the plugin and command; callers translate the
|
|
205
|
+
// error into a discovery `loadError` rather than failing the whole CLI.
|
|
206
|
+
export function validateCommandDeclaration(pluginName: string, commandName: string, command: PluginCommand): void {
|
|
207
|
+
if (commandName.length === 0) {
|
|
208
|
+
throw new Error(`plugin ${pluginName}: empty command name`)
|
|
209
|
+
}
|
|
210
|
+
if (!COMMAND_NAME_REGEX.test(commandName)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`plugin ${pluginName}: command "${commandName}" does not match ${COMMAND_NAME_REGEX.source} (lowercase letters, digits, dashes; must start with a letter)`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if (RESERVED_COMMAND_NAMES.has(commandName)) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`plugin ${pluginName}: command "${commandName}" shadows a built-in typeclaw subcommand and cannot be registered`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
assertValidCommandArgsSchema(pluginName, commandName, command)
|
|
221
|
+
}
|
|
222
|
+
|
|
152
223
|
function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
|
|
153
224
|
// Plugin-contributed jobs default to `owner` because they are part of the
|
|
154
225
|
// agent's bundled (or operator-installed) runtime, not user-channel
|
|
@@ -171,12 +242,23 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
|
|
|
171
242
|
}
|
|
172
243
|
return job
|
|
173
244
|
}
|
|
245
|
+
if (spec.kind === 'exec') {
|
|
246
|
+
return {
|
|
247
|
+
id: globalId,
|
|
248
|
+
schedule: spec.schedule,
|
|
249
|
+
enabled: spec.enabled ?? true,
|
|
250
|
+
kind: 'exec',
|
|
251
|
+
command: spec.command,
|
|
252
|
+
scheduledByRole,
|
|
253
|
+
...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
174
256
|
return {
|
|
175
257
|
id: globalId,
|
|
176
258
|
schedule: spec.schedule,
|
|
177
259
|
enabled: spec.enabled ?? true,
|
|
178
|
-
kind: '
|
|
179
|
-
|
|
260
|
+
kind: 'handler',
|
|
261
|
+
handler: spec.handler,
|
|
180
262
|
scheduledByRole,
|
|
181
263
|
...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
|
|
182
264
|
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -92,7 +92,58 @@ export type PluginExecCronJob = {
|
|
|
92
92
|
timezone?: string
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
// In-process handler. Invoked directly by the cron consumer; no shell-out, no
|
|
96
|
+
// WS round-trip, no Bun.spawn. Use this when the cron job needs imperative
|
|
97
|
+
// control flow (probe → maybe prompt → write file) and lives in the same
|
|
98
|
+
// plugin as the logic — there is no need to dress the work up as a CLI
|
|
99
|
+
// command just to schedule it from yourself.
|
|
100
|
+
//
|
|
101
|
+
// `handler` is a TypeScript function reference, so this kind CANNOT appear in
|
|
102
|
+
// cron.json (only plugin-contributed cron registrations can carry it). The
|
|
103
|
+
// schema gate (`parseCronFile` in src/cron/schema.ts) keeps user files limited
|
|
104
|
+
// to `prompt` and `exec`.
|
|
105
|
+
export type PluginHandlerCronJob = {
|
|
106
|
+
schedule: string
|
|
107
|
+
kind: 'handler'
|
|
108
|
+
handler: (ctx: CronHandlerContext) => Promise<void>
|
|
109
|
+
enabled?: boolean
|
|
110
|
+
timezone?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type PluginCronJob = PluginPromptCronJob | PluginExecCronJob | PluginHandlerCronJob
|
|
114
|
+
|
|
115
|
+
// Surface passed into a plugin cron handler. Mirrors the LLM-call capabilities
|
|
116
|
+
// of `ContainerCommandContext` (`prompt`, `subagent`, `exec`) without the
|
|
117
|
+
// CLI-shaped fields (stdin/stdout/stderr, args, exit code) because cron has
|
|
118
|
+
// no caller to pipe to.
|
|
119
|
+
//
|
|
120
|
+
// `signal` is reserved for future cancellation and is currently never aborted
|
|
121
|
+
// by the runtime — the consumer matches the existing prompt/exec cron jobs
|
|
122
|
+
// which also let in-flight work finish on container shutdown. The signal IS
|
|
123
|
+
// already threaded into `ctx.prompt` and `ctx.exec`, so the moment the
|
|
124
|
+
// runtime starts aborting it (e.g. via a future graceful-shutdown path),
|
|
125
|
+
// propagation works without handler-author changes. Handler authors who
|
|
126
|
+
// want to respect future cancellation should still check `ctx.signal.aborted`
|
|
127
|
+
// in long-running loops; nothing fires it today.
|
|
128
|
+
//
|
|
129
|
+
// `origin` is a cron-shaped SessionOrigin so any session the handler spawns
|
|
130
|
+
// via `ctx.prompt` carries the cron job's provenance — same role-inheritance
|
|
131
|
+
// semantics as a `kind: 'exec'` job that shells out to `typeclaw <cmd>`.
|
|
132
|
+
export type CronHandlerContext = {
|
|
133
|
+
readonly jobId: string
|
|
134
|
+
// The plugin that registered this cron job (e.g. 'inbox-watch'). Matches
|
|
135
|
+
// `ContainerCommandContext.name`. Useful for log lines that should be
|
|
136
|
+
// attributable to the plugin, not just the cron id.
|
|
137
|
+
readonly name: string
|
|
138
|
+
readonly agentDir: string
|
|
139
|
+
readonly logger: PluginLogger
|
|
140
|
+
readonly signal: AbortSignal
|
|
141
|
+
readonly permissions: PermissionService
|
|
142
|
+
readonly origin: SessionOrigin
|
|
143
|
+
readonly prompt: (text: string) => Promise<string>
|
|
144
|
+
readonly subagent: (name: string, payload?: unknown) => Promise<void>
|
|
145
|
+
readonly exec: (cmd: TemplateStringsArray, ...values: unknown[]) => Promise<CommandExecResult>
|
|
146
|
+
}
|
|
96
147
|
|
|
97
148
|
export type PluginSkill = {
|
|
98
149
|
description: string
|
|
@@ -267,5 +318,91 @@ export type PluginFixResult = {
|
|
|
267
318
|
export type DefinedPlugin<TConfig = never> = {
|
|
268
319
|
readonly configSchema?: z.ZodType<TConfig>
|
|
269
320
|
readonly permissions?: readonly string[]
|
|
321
|
+
// Declared by-value (not built inside the factory) so the host-stage CLI
|
|
322
|
+
// can dispatch commands without booting plugin runtime state.
|
|
323
|
+
readonly commands?: Record<string, PluginCommand>
|
|
270
324
|
readonly plugin: (ctx: PluginContext<TConfig>) => Promise<PluginExports>
|
|
271
325
|
}
|
|
326
|
+
|
|
327
|
+
// `surface` controls where a plugin command may run: `'container'` requires
|
|
328
|
+
// the agent runtime (prompt/subagent/exec); `'host'` runs on the user's
|
|
329
|
+
// machine with no agent runtime; `'either'` accepts the intersection ctx
|
|
330
|
+
// and runs on whichever stage the user invoked it from.
|
|
331
|
+
export type PluginCommand = ContainerCommand | HostCommand | EitherCommand
|
|
332
|
+
|
|
333
|
+
export type ContainerCommand<A = unknown> = {
|
|
334
|
+
readonly surface: 'container'
|
|
335
|
+
readonly description: string
|
|
336
|
+
// v1 constraint: `z.object({...})` with primitive (string/number/boolean/
|
|
337
|
+
// literal/enum) leaves so `--help` can render `--<name>=<type>`.
|
|
338
|
+
readonly args?: z.ZodObject<z.ZodRawShape>
|
|
339
|
+
readonly permissions?: readonly string[]
|
|
340
|
+
// When true, runtime spawns a fresh Bun subprocess instead of dispatching
|
|
341
|
+
// in-process. Costs ~150ms cold-start; trade for isolation from the agent.
|
|
342
|
+
readonly isolated?: boolean
|
|
343
|
+
readonly run: (ctx: ContainerCommandContext, args: A) => Promise<number>
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export type HostCommand<A = unknown> = {
|
|
347
|
+
readonly surface: 'host'
|
|
348
|
+
readonly description: string
|
|
349
|
+
readonly args?: z.ZodObject<z.ZodRawShape>
|
|
350
|
+
readonly run: (ctx: HostCommandContext, args: A) => Promise<number>
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export type EitherCommand<A = unknown> = {
|
|
354
|
+
readonly surface: 'either'
|
|
355
|
+
readonly description: string
|
|
356
|
+
readonly args?: z.ZodObject<z.ZodRawShape>
|
|
357
|
+
readonly run: (ctx: EitherCommandContext, args: A) => Promise<number>
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export type CommandStreams = {
|
|
361
|
+
readonly stdin: ReadableStream<Uint8Array>
|
|
362
|
+
readonly stdout: WritableStream<Uint8Array>
|
|
363
|
+
readonly stderr: WritableStream<Uint8Array>
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export type CommandExecResult = {
|
|
367
|
+
readonly stdout: string
|
|
368
|
+
readonly stderr: string
|
|
369
|
+
readonly exitCode: number
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export type ContainerCommandContext = CommandStreams & {
|
|
373
|
+
// The plugin name (e.g. `'my-utilities'`), NOT the command name. Matches
|
|
374
|
+
// `PluginContext.name`. Use the command's own static name if you need it.
|
|
375
|
+
readonly name: string
|
|
376
|
+
readonly version: string | undefined
|
|
377
|
+
readonly agentDir: string
|
|
378
|
+
readonly logger: PluginLogger
|
|
379
|
+
readonly permissions: PermissionService
|
|
380
|
+
// Caller's origin (cron job, TUI op, parent session). Drives permission
|
|
381
|
+
// resolution inside the command. Dispatcher refuses to run without one.
|
|
382
|
+
readonly origin: SessionOrigin
|
|
383
|
+
readonly signal: AbortSignal
|
|
384
|
+
readonly prompt: (text: string) => Promise<string>
|
|
385
|
+
readonly subagent: (name: string, payload?: unknown) => Promise<void>
|
|
386
|
+
readonly exec: (cmd: TemplateStringsArray, ...values: unknown[]) => Promise<CommandExecResult>
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export type HostCommandContext = CommandStreams & {
|
|
390
|
+
// The plugin name, NOT the command name. See `ContainerCommandContext.name`.
|
|
391
|
+
readonly name: string
|
|
392
|
+
readonly version: string | undefined
|
|
393
|
+
// Host path of the agent folder (e.g. the absolute path to the agent
|
|
394
|
+
// folder), NOT `/agent`.
|
|
395
|
+
readonly agentDir: string
|
|
396
|
+
readonly logger: PluginLogger
|
|
397
|
+
readonly signal: AbortSignal
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export type EitherCommandContext = CommandStreams & {
|
|
401
|
+
// The plugin name, NOT the command name. See `ContainerCommandContext.name`.
|
|
402
|
+
readonly name: string
|
|
403
|
+
readonly version: string | undefined
|
|
404
|
+
// Resolves to `/agent` in container, host path on host — same author code.
|
|
405
|
+
readonly agentDir: string
|
|
406
|
+
readonly logger: PluginLogger
|
|
407
|
+
readonly signal: AbortSignal
|
|
408
|
+
}
|