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
@@ -14,16 +14,29 @@ export type OAuthLoginResult = { ok: true } | { ok: false; reason: string }
14
14
  export type OAuthLoginRunner = (options: { cwd: string; model: KnownModelRef }) => Promise<OAuthLoginResult>
15
15
 
16
16
  // Wrap pi-ai's OAuth callbacks so the CLI doesn't have to know about the
17
- // upstream callback shape. The CLI only sees three lifecycle events:
17
+ // upstream callback shape. The CLI sees four lifecycle events:
18
18
  // (1) onAuth(url) — print the URL the user must visit
19
19
  // (2) onProgress(message) — show waiting/finalizing status
20
20
  // (3) onPrompt(prompt) — ask the user for a manual code if the browser flow
21
- // can't reach the local callback server. Most users won't see this; it
22
- // fires when they paste the post-redirect URL by hand.
21
+ // can't reach the local callback server. Fires only after the local
22
+ // server gave up (bind error -> waitForCode resolves null).
23
+ // (4) onManualCodeInput() — concurrent paste input that RACES the local
24
+ // callback server. Required for cross-device flows: pi-ai's openai-codex
25
+ // OAuth hardcodes redirect_uri=http://localhost:1455/auth/callback, which
26
+ // resolves to the *browser's* machine. When the user runs `typeclaw init`
27
+ // over SSH or on a remote dev box and completes login on a different
28
+ // laptop, the browser callback never reaches the CLI's local server and
29
+ // waitForCode() hangs forever — so onPrompt would never fire either.
30
+ // onManualCodeInput is the upstream-supported escape hatch: it shows a
31
+ // paste field IMMEDIATELY alongside the URL, and whichever path lands a
32
+ // code first wins. parseAuthorizationInput on the upstream side accepts
33
+ // the full redirect URL, the bare `code=...&state=...` query string, or
34
+ // just the code value.
23
35
  export type OAuthCallbacks = {
24
36
  onAuth: (url: string, instructions?: string) => void
25
37
  onProgress?: (message: string) => void
26
38
  onPrompt: (message: string, placeholder?: string) => Promise<string | null>
39
+ onManualCodeInput?: () => Promise<string>
27
40
  }
28
41
 
29
42
  // Default runner: real OAuth flow against pi-ai. Tests inject a stub to skip
@@ -50,6 +63,7 @@ export function makeOAuthLoginRunner(callbacks: OAuthCallbacks): OAuthLoginRunne
50
63
  }
51
64
  return value
52
65
  },
66
+ onManualCodeInput: callbacks.onManualCodeInput,
53
67
  })
54
68
  return { ok: true }
55
69
  } catch (error) {
@@ -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
- cmd: ['bun', 'install', '--linker=hoisted'],
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
- if (configuredChannels.length === 0) return
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 = configuredChannels.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
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?`,
@@ -25,6 +25,21 @@ export type BuiltinRoleSpec = {
25
25
  readonly permissions: readonly string[]
26
26
  }
27
27
 
28
+ // Owner carries low + medium tier strings explicitly AND the wildcard
29
+ // sentinel. The sentinel expands to plugin-contributed `security.bypass.*`
30
+ // strings minus the security plugin's `ownerWildcardExclusions` (today:
31
+ // `security.bypass.high` plus high-tier per-guard strings). Net effect:
32
+ // owner auto-bypasses every low- and medium-tier guard, and high-tier
33
+ // guards require per-call ack from owner too (the audience-leak rule —
34
+ // owner-in-public-channel must not silently post credentials).
35
+ //
36
+ // Trusted carries only `security.bypass.low`. Trusted does NOT carry the
37
+ // pre-PR per-guard grants (`bypassSecretExfilBash`, `bypassGitExfil`):
38
+ // those guards are medium/high under the audience-leak axis and per-guard
39
+ // grants would re-introduce exactly the bypass holes the tier system
40
+ // exists to prevent. Operators who want the pre-PR ergonomics can add the
41
+ // per-guard strings explicitly to `roles.trusted.permissions[]` in
42
+ // typeclaw.json — that path stays alive forever.
28
43
  export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
29
44
  owner: {
30
45
  match: [{ kind: 'tui' }],
@@ -32,12 +47,14 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
32
47
  CORE_PERMISSIONS.channelRespond,
33
48
  CORE_PERMISSIONS.cronSchedule,
34
49
  CORE_PERMISSIONS.cronModify,
50
+ 'security.bypass.low',
51
+ 'security.bypass.medium',
35
52
  OWNER_SECURITY_WILDCARD,
36
53
  ],
37
54
  },
38
55
  trusted: {
39
56
  match: [],
40
- permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.secretExfilBash'],
57
+ permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.low'],
41
58
  },
42
59
  member: {
43
60
  match: [],
@@ -49,11 +66,21 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
49
66
  },
50
67
  }
51
68
 
69
+ // Expands the owner wildcard sentinel against plugin-contributed
70
+ // `security.bypass.*` strings. `wildcardExclusions` is an optional set of
71
+ // permission strings the sentinel must NOT expand to — used by the
72
+ // bundled security plugin to exclude `security.bypass.high` AND the
73
+ // per-guard strings for high-tier guards, so the wildcard does not
74
+ // auto-grant audience-leak bypass to owner. Explicit operator grants of
75
+ // those strings in `roles.owner.permissions[]` still take effect (they
76
+ // flow through the non-sentinel branch).
52
77
  export function expandOwnerWildcard(
53
78
  ownerPermissions: readonly string[],
54
79
  pluginContributed: readonly string[],
80
+ wildcardExclusions: readonly string[] = [],
55
81
  ): readonly string[] {
56
- const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.'))
82
+ const excludeSet = new Set(wildcardExclusions)
83
+ const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.') && !excludeSet.has(p))
57
84
  const out: string[] = []
58
85
  for (const p of ownerPermissions) {
59
86
  if (p === OWNER_SECURITY_WILDCARD) {
@@ -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: {
@@ -38,6 +38,12 @@ type ResolvedRole = {
38
38
  export type CreatePermissionServiceOptions = {
39
39
  roles?: RolesConfig
40
40
  pluginPermissions?: readonly string[]
41
+ // Permission strings that the owner wildcard sentinel must NOT
42
+ // auto-expand to. Today populated from the bundled security plugin's
43
+ // high-tier list so audience-leak guards do not get auto-granted to
44
+ // owner. Generic by design — any future plugin could contribute
45
+ // exclusions through the plugin manager. See expandOwnerWildcard.
46
+ ownerWildcardExclusions?: readonly string[]
41
47
  }
42
48
 
43
49
  // Returns warnings for user-declared `permissions[]` strings that aren't
@@ -97,7 +103,8 @@ function levenshtein(a: string, b: string): number {
97
103
 
98
104
  export function createPermissionService(opts: CreatePermissionServiceOptions = {}): PermissionService {
99
105
  const pluginPermissions = opts.pluginPermissions ?? []
100
- let resolved = buildRoleTable(opts.roles ?? {}, pluginPermissions)
106
+ const ownerWildcardExclusions = opts.ownerWildcardExclusions ?? []
107
+ let resolved = buildRoleTable(opts.roles ?? {}, pluginPermissions, ownerWildcardExclusions)
101
108
  let byName = new Map(resolved.map((r) => [r.name, r]))
102
109
 
103
110
  function resolveRole(origin: SessionOrigin | undefined): string {
@@ -139,36 +146,46 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
139
146
  return { role: name, permissions: role?.permissions ?? [] }
140
147
  },
141
148
  replaceRoles(roles) {
142
- resolved = buildRoleTable(roles ?? {}, pluginPermissions)
149
+ resolved = buildRoleTable(roles ?? {}, pluginPermissions, ownerWildcardExclusions)
143
150
  byName = new Map(resolved.map((r) => [r.name, r]))
144
151
  },
145
152
  }
146
153
  }
147
154
 
148
- function buildRoleTable(roles: RolesConfig, pluginPermissions: readonly string[]): ResolvedRole[] {
155
+ function buildRoleTable(
156
+ roles: RolesConfig,
157
+ pluginPermissions: readonly string[],
158
+ ownerWildcardExclusions: readonly string[],
159
+ ): ResolvedRole[] {
149
160
  const out: ResolvedRole[] = []
150
161
  const seen = new Set<string>()
151
162
 
152
163
  for (const name of Object.keys(roles)) {
153
164
  if (seen.has(name)) continue
154
165
  seen.add(name)
155
- out.push(resolveOne(name, roles[name], pluginPermissions))
166
+ out.push(resolveOne(name, roles[name], pluginPermissions, ownerWildcardExclusions))
156
167
  }
157
168
 
158
169
  for (const name of BUILTIN_ROLE_NAMES) {
159
170
  if (seen.has(name)) continue
160
- out.push(resolveOne(name, undefined, pluginPermissions))
171
+ out.push(resolveOne(name, undefined, pluginPermissions, ownerWildcardExclusions))
161
172
  }
162
173
 
163
174
  return out
164
175
  }
165
176
 
166
- function resolveOne(name: string, user: RoleConfig | undefined, pluginPermissions: readonly string[]): ResolvedRole {
177
+ function resolveOne(
178
+ name: string,
179
+ user: RoleConfig | undefined,
180
+ pluginPermissions: readonly string[],
181
+ ownerWildcardExclusions: readonly string[],
182
+ ): ResolvedRole {
167
183
  if (isBuiltinRoleName(name)) {
168
184
  const builtin = BUILTIN_ROLES[name]
169
185
  const match = [...builtin.match, ...(user?.match ?? [])]
170
186
  const rawPerms = user?.permissions !== undefined ? user.permissions : [...builtin.permissions]
171
- const permissions = name === 'owner' ? expandOwnerWildcard(rawPerms, pluginPermissions) : rawPerms
187
+ const permissions =
188
+ name === 'owner' ? expandOwnerWildcard(rawPerms, pluginPermissions, ownerWildcardExclusions) : rawPerms
172
189
  return { name, match, permissions }
173
190
  }
174
191
  return {
@@ -5,6 +5,7 @@ import type { MatchRule, Platform } from './match-rule'
5
5
  const ADAPTER_TO_PLATFORM: Record<AdapterId, Platform> = {
6
6
  'slack-bot': 'slack',
7
7
  'discord-bot': 'discord',
8
+ github: 'github',
8
9
  'telegram-bot': 'telegram',
9
10
  kakaotalk: 'kakao',
10
11
  }
@@ -1,16 +1,31 @@
1
1
  import type { z } from 'zod'
2
2
 
3
- import type { BuiltinToolRef, DefinedPlugin, PluginContext, PluginExports, Subagent, Tool } from './types'
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
+ ownerWildcardExclusions?: readonly string[]
22
+ commands?: Record<string, PluginCommand>
10
23
  plugin: (ctx: PluginContext<T>) => Promise<PluginExports>
11
24
  }
12
25
  : {
13
26
  permissions?: readonly string[]
27
+ ownerWildcardExclusions?: readonly string[]
28
+ commands?: Record<string, PluginCommand>
14
29
  plugin: (ctx: PluginContext<unknown>) => Promise<PluginExports>
15
30
  }
16
31
 
@@ -28,6 +43,34 @@ export function defineSubagent<P>(subagent: Subagent<P>): Subagent<P> {
28
43
  return subagent
29
44
  }
30
45
 
46
+ type ContainerCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
47
+ S extends z.ZodObject<z.ZodRawShape>
48
+ ? Omit<ContainerCommand<z.infer<S>>, 'args'> & { args: S }
49
+ : Omit<ContainerCommand<unknown>, 'args'> & { args?: undefined }
50
+
51
+ type HostCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
52
+ S extends z.ZodObject<z.ZodRawShape>
53
+ ? Omit<HostCommand<z.infer<S>>, 'args'> & { args: S }
54
+ : Omit<HostCommand<unknown>, 'args'> & { args?: undefined }
55
+
56
+ type EitherCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
57
+ S extends z.ZodObject<z.ZodRawShape>
58
+ ? Omit<EitherCommand<z.infer<S>>, 'args'> & { args: S }
59
+ : Omit<EitherCommand<unknown>, 'args'> & { args?: undefined }
60
+
61
+ export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
62
+ cmd: ContainerCommandSpec<S>,
63
+ ): ContainerCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
64
+ export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
65
+ cmd: HostCommandSpec<S>,
66
+ ): HostCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
67
+ export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
68
+ cmd: EitherCommandSpec<S>,
69
+ ): EitherCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
70
+ export function defineCommand(cmd: PluginCommand): PluginCommand {
71
+ return cmd
72
+ }
73
+
31
74
  export const readTool: BuiltinToolRef = { __builtinTool: 'read' }
32
75
  export const bashTool: BuiltinToolRef = { __builtinTool: 'bash' }
33
76
  export const editTool: BuiltinToolRef = { __builtinTool: 'edit' }
@@ -1,8 +1,9 @@
1
1
  export {
2
2
  bashTool,
3
- defineTool,
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'
@@ -56,9 +56,11 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
56
56
  ]
57
57
 
58
58
  const declaredPermissions = collectDeclaredPermissions(allPlugins)
59
+ const ownerWildcardExclusions = collectOwnerWildcardExclusions(allPlugins)
59
60
  const permissions = createPermissionService({
60
61
  ...(opts.roles !== undefined ? { roles: opts.roles } : {}),
61
62
  pluginPermissions: declaredPermissions,
63
+ ownerWildcardExclusions,
62
64
  })
63
65
 
64
66
  // Non-fatal: surface user-declared `permissions[]` strings that aren't in
@@ -117,6 +119,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
117
119
  pluginName: resolved.name,
118
120
  logger,
119
121
  exports,
122
+ ...(resolved.defined.commands !== undefined ? { commands: resolved.defined.commands } : {}),
120
123
  registry,
121
124
  hooks,
122
125
  agentDir: opts.agentDir,
@@ -157,6 +160,18 @@ function collectDeclaredPermissions(
157
160
  return out
158
161
  }
159
162
 
163
+ function collectOwnerWildcardExclusions(
164
+ plugins: readonly { entry: string; resolved: ResolvedPlugin }[],
165
+ ): readonly string[] {
166
+ const out: string[] = []
167
+ for (const { resolved } of plugins) {
168
+ for (const perm of resolved.defined.ownerWildcardExclusions ?? []) {
169
+ if (!out.includes(perm)) out.push(perm)
170
+ }
171
+ }
172
+ return out
173
+ }
174
+
160
175
  export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], registry: PluginRegistry): string {
161
176
  const head = loaded.map((p) => (p.version !== undefined ? `${p.name} v${p.version}` : p.name)).join(', ')
162
177
  const counts = [
@@ -166,6 +181,7 @@ export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], regi
166
181
  `${registry.skills.length} skill(s)`,
167
182
  `${registry.skillsDirs.length} skills dir(s)`,
168
183
  `${registry.doctorChecks.length} doctor check(s)`,
184
+ `${registry.commands.length} command(s)`,
169
185
  ].join(', ')
170
186
  return `${loaded.length} plugin(s): ${head} [${counts}]`
171
187
  }
@@ -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 { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [], doctorChecks: [] }
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: 'exec',
179
- command: spec.command,
260
+ kind: 'handler',
261
+ handler: spec.handler,
180
262
  scheduledByRole,
181
263
  ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
182
264
  }