typeclaw 0.1.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,66 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { config, validateConfig } from '@/config'
4
+ import { start, stop } from '@/container'
5
+ import { findAgentDir, isInitialized } from '@/init'
6
+
7
+ import { c, errorLine, renderStartSuccess, spinner } from './ui'
8
+
9
+ export const restartCommand = defineCommand({
10
+ meta: {
11
+ name: 'restart',
12
+ description: 'stop and relaunch the agent container (host stage)',
13
+ },
14
+ args: {
15
+ port: {
16
+ type: 'string',
17
+ description:
18
+ 'preferred host port; if it is already bound, typeclaw allocates a free ephemeral port and reports it',
19
+ default: String(config.port),
20
+ },
21
+ build: {
22
+ type: 'boolean',
23
+ description: 'regenerate the Dockerfile from the latest template and rebuild the image',
24
+ default: false,
25
+ },
26
+ },
27
+ async run({ args }) {
28
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
29
+
30
+ if (!isInitialized(cwd)) {
31
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first.'))
32
+ process.exit(1)
33
+ }
34
+
35
+ const validated = validateConfig(cwd)
36
+ if (!validated.ok) {
37
+ console.error(errorLine(validated.reason))
38
+ process.exit(1)
39
+ }
40
+
41
+ const stopSpin = spinner()
42
+ stopSpin.start('Stopping container...')
43
+ const stopped = await stop({ cwd })
44
+ if (!stopped.ok) {
45
+ stopSpin.error(stopped.reason)
46
+ process.exit(1)
47
+ }
48
+ stopSpin.stop(stopped.running ? `Stopped ${c.cyan(stopped.containerName)}.` : 'Already stopped.')
49
+
50
+ const startSpin = spinner()
51
+ startSpin.start('Starting container...')
52
+ const started = await start({
53
+ cwd,
54
+ preferredHostPort: Number(args.port),
55
+ forceBuild: args.build,
56
+ cliEntry: process.argv[1],
57
+ })
58
+ if (!started.ok) {
59
+ startSpin.error(started.reason)
60
+ process.exit(1)
61
+ }
62
+ startSpin.stop('Started.')
63
+
64
+ console.log(renderStartSuccess(started))
65
+ },
66
+ })
package/src/cli/run.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { CONTAINER_PORT } from '@/container'
4
+ import { isInitialized } from '@/init'
5
+ import { startAgent } from '@/run'
6
+
7
+ export const run = defineCommand({
8
+ meta: {
9
+ name: 'run',
10
+ description: 'run the agent in the foreground (container stage)',
11
+ },
12
+ args: {
13
+ port: {
14
+ type: 'string',
15
+ description: 'port to listen on (defaults to the fixed container-internal port)',
16
+ default: String(CONTAINER_PORT),
17
+ },
18
+ prompt: {
19
+ type: 'positional',
20
+ description: 'initial prompt for the attached tui',
21
+ required: false,
22
+ },
23
+ tui: {
24
+ type: 'boolean',
25
+ description: 'attach a local tui (default: auto, on when stdin is a tty)',
26
+ },
27
+ 'no-tui': {
28
+ type: 'boolean',
29
+ description: 'never attach a local tui, stay headless',
30
+ },
31
+ },
32
+ async run({ args }) {
33
+ if (!isInitialized(process.cwd())) {
34
+ console.error('TypeClaw config file not found. Run `typeclaw init` first.')
35
+ process.exit(1)
36
+ }
37
+
38
+ const attachTui = resolveAttachTui({
39
+ tui: args.tui,
40
+ noTui: args['no-tui'],
41
+ isTTY: Boolean(process.stdin.isTTY),
42
+ })
43
+
44
+ const { tuiPromise, stop } = await startAgent({
45
+ port: Number(args.port),
46
+ attachTui,
47
+ initialPrompt: args.prompt,
48
+ })
49
+
50
+ const onSignal = () => {
51
+ stop()
52
+ process.exit(0)
53
+ }
54
+ process.once('SIGINT', onSignal)
55
+ process.once('SIGTERM', onSignal)
56
+
57
+ if (tuiPromise) {
58
+ await tuiPromise
59
+ stop()
60
+ process.exit(0)
61
+ }
62
+ },
63
+ })
64
+
65
+ function resolveAttachTui({
66
+ tui,
67
+ noTui,
68
+ isTTY,
69
+ }: {
70
+ tui: boolean | undefined
71
+ noTui: boolean | undefined
72
+ isTTY: boolean
73
+ }): boolean {
74
+ if (noTui) return false
75
+ if (tui) return true
76
+ return isTTY
77
+ }
@@ -0,0 +1,33 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { shell } from '@/container'
4
+ import { findAgentDir } from '@/init'
5
+
6
+ import { c, errorLine } from './ui'
7
+
8
+ export const shellCommand = defineCommand({
9
+ meta: {
10
+ name: 'shell',
11
+ description: 'open an interactive shell in the agent container (host stage)',
12
+ },
13
+ args: {
14
+ shell: {
15
+ type: 'string',
16
+ description: 'shell executable to run inside the container',
17
+ default: '/bin/bash',
18
+ },
19
+ },
20
+ async run({ args }) {
21
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
22
+
23
+ console.log(c.cyan(`Attaching ${args.shell} inside the container...`))
24
+
25
+ const result = await shell({ cwd, shell: args.shell })
26
+ if (!result.ok) {
27
+ console.error(errorLine(result.reason))
28
+ process.exit(1)
29
+ }
30
+
31
+ process.exit(result.exitCode)
32
+ },
33
+ })
@@ -0,0 +1,57 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { config, validateConfig } from '@/config'
4
+ import { start } from '@/container'
5
+ import { findAgentDir, isInitialized } from '@/init'
6
+
7
+ import { errorLine, renderStartSuccess, spinner } from './ui'
8
+
9
+ export const startCommand = defineCommand({
10
+ meta: {
11
+ name: 'start',
12
+ description: 'launch the agent container in the background (host stage)',
13
+ },
14
+ args: {
15
+ port: {
16
+ type: 'string',
17
+ description:
18
+ 'preferred host port; if it is already bound, typeclaw allocates a free ephemeral port and reports it',
19
+ default: String(config.port),
20
+ },
21
+ build: {
22
+ type: 'boolean',
23
+ description: 'regenerate the Dockerfile from the latest template and rebuild the image',
24
+ default: false,
25
+ },
26
+ },
27
+ async run({ args }) {
28
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
29
+
30
+ if (!isInitialized(cwd)) {
31
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first.'))
32
+ process.exit(1)
33
+ }
34
+
35
+ const validated = validateConfig(cwd)
36
+ if (!validated.ok) {
37
+ console.error(errorLine(validated.reason))
38
+ process.exit(1)
39
+ }
40
+
41
+ const s = spinner()
42
+ s.start('Starting container...')
43
+ const result = await start({
44
+ cwd,
45
+ preferredHostPort: Number(args.port),
46
+ forceBuild: args.build,
47
+ cliEntry: process.argv[1],
48
+ })
49
+ if (!result.ok) {
50
+ s.error(result.reason)
51
+ process.exit(1)
52
+ }
53
+ s.stop(result.alreadyRunning ? 'Already running.' : 'Started.')
54
+
55
+ console.log(renderStartSuccess(result))
56
+ },
57
+ })
@@ -0,0 +1,178 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ import { defineCommand } from 'citty'
4
+
5
+ import { status as containerStatus, type ContainerStatus } from '@/container'
6
+ import { isDaemonReachable, send } from '@/hostd'
7
+ import type { StatusResult } from '@/hostd'
8
+ import { findAgentDir } from '@/init'
9
+
10
+ export type HostdStatus =
11
+ | { kind: 'unreachable' }
12
+ | { kind: 'not-registered'; reason: string }
13
+ | { kind: 'registered'; cwd: string; forwardedPorts: number[] }
14
+
15
+ export type StatusReport = {
16
+ cwd: string
17
+ container: ContainerStatus
18
+ hostd: HostdStatus
19
+ }
20
+
21
+ export const statusCommand = defineCommand({
22
+ meta: {
23
+ name: 'status',
24
+ description: 'show the agent container and host daemon status (host stage)',
25
+ },
26
+ async run() {
27
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
28
+ const container = await containerStatus({ cwd })
29
+ const hostd = await fetchHostdStatus(container.containerName)
30
+
31
+ const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined
32
+ process.stdout.write(`${formatStatus({ cwd, container, hostd }, { useColor })}\n`)
33
+ },
34
+ })
35
+
36
+ async function fetchHostdStatus(containerName: string): Promise<HostdStatus> {
37
+ if (!(await isDaemonReachable())) return { kind: 'unreachable' }
38
+ const reply = await send({ kind: 'status', containerName })
39
+ if (!reply.ok) return { kind: 'not-registered', reason: reply.reason }
40
+ const parsed = parseStatusResult(reply.result)
41
+ if (!parsed) return { kind: 'not-registered', reason: 'daemon returned malformed status' }
42
+ return { kind: 'registered', cwd: parsed.cwd, forwardedPorts: parsed.forwardedPorts }
43
+ }
44
+
45
+ // Validate the daemon payload at runtime: a drift-respawn race or an older
46
+ // daemon binary can deliver a `StatusResult` without `forwardedPorts`, and the
47
+ // blind `as` cast then crashed the renderer with `undefined.length`. Defaulting
48
+ // the field to `[]` (and rejecting non-string `cwd`) keeps `typeclaw status`
49
+ // usable as a diagnostic when the daemon and CLI have drifted.
50
+ export function parseStatusResult(value: unknown): StatusResult | null {
51
+ if (typeof value !== 'object' || value === null) return null
52
+ const v = value as Record<string, unknown>
53
+ if (typeof v.cwd !== 'string') return null
54
+ const containerName = typeof v.containerName === 'string' ? v.containerName : ''
55
+ const forwardedPorts = Array.isArray(v.forwardedPorts)
56
+ ? v.forwardedPorts.filter((p): p is number => typeof p === 'number' && Number.isFinite(p))
57
+ : []
58
+ return { containerName, cwd: v.cwd, forwardedPorts }
59
+ }
60
+
61
+ export type FormatOptions = { useColor?: boolean }
62
+
63
+ type ColorFn = (s: string) => string
64
+ type Palette = {
65
+ bold: ColorFn
66
+ dim: ColorFn
67
+ green: ColorFn
68
+ yellow: ColorFn
69
+ red: ColorFn
70
+ cyan: ColorFn
71
+ }
72
+
73
+ const identity: ColorFn = (s) => s
74
+ const NO_PALETTE: Palette = {
75
+ bold: identity,
76
+ dim: identity,
77
+ green: identity,
78
+ yellow: identity,
79
+ red: identity,
80
+ cyan: identity,
81
+ }
82
+
83
+ const COLOR_PALETTE: Palette = {
84
+ bold: (s) => styleText('bold', s),
85
+ dim: (s) => styleText('dim', s),
86
+ green: (s) => styleText('green', s),
87
+ yellow: (s) => styleText('yellow', s),
88
+ red: (s) => styleText('red', s),
89
+ cyan: (s) => styleText('cyan', s),
90
+ }
91
+
92
+ export function formatStatus(report: StatusReport, opts: FormatOptions = {}): string {
93
+ const useColor = opts.useColor ?? false
94
+ const p: Palette = useColor ? COLOR_PALETTE : NO_PALETTE
95
+
96
+ const lines: string[] = []
97
+ appendContainerSection(lines, report, p)
98
+ lines.push('')
99
+ appendHostdSection(lines, report, p)
100
+ lines.push('')
101
+ appendForwardingSection(lines, report, p)
102
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
103
+ return lines.join('\n')
104
+ }
105
+
106
+ function appendContainerSection(lines: string[], report: StatusReport, p: Palette): void {
107
+ const container = report.container
108
+ lines.push(`${p.bold('Container')} ${container.containerName}`)
109
+ lines.push(row('cwd', report.cwd))
110
+ lines.push(row('image', container.imageTag))
111
+
112
+ if (container.kind === 'missing') {
113
+ lines.push(row('state', p.dim('missing')))
114
+ return
115
+ }
116
+
117
+ const stateLabel = container.kind === 'running' ? p.green('running') : p.yellow('stopped')
118
+ lines.push(row('state', stateLabel))
119
+ lines.push(row('id', shortId(container.containerId)))
120
+
121
+ if (container.kind === 'running') {
122
+ const port =
123
+ container.hostPort === null ? p.dim('unknown') : formatHostMapping(container.hostBindAddr, container.hostPort, p)
124
+ lines.push(row('port', port))
125
+ }
126
+ }
127
+
128
+ function appendHostdSection(lines: string[], report: StatusReport, p: Palette): void {
129
+ lines.push(p.bold('Host daemon'))
130
+
131
+ switch (report.hostd.kind) {
132
+ case 'unreachable':
133
+ lines.push(row('state', p.dim('unreachable')))
134
+ lines.push(` ${p.dim('Daemon is not running. `typeclaw start` will spawn one.')}`)
135
+ return
136
+ case 'not-registered':
137
+ lines.push(row('state', p.yellow('not registered')))
138
+ lines.push(row('reason', report.hostd.reason))
139
+ return
140
+ case 'registered':
141
+ lines.push(row('state', p.green('registered')))
142
+ return
143
+ }
144
+ }
145
+
146
+ function appendForwardingSection(lines: string[], report: StatusReport, p: Palette): void {
147
+ lines.push(p.bold('Port forwarding'))
148
+
149
+ if (report.hostd.kind !== 'registered') {
150
+ lines.push(` ${p.dim('requires the host daemon')}`)
151
+ return
152
+ }
153
+
154
+ const ports = report.hostd.forwardedPorts
155
+ if (ports.length === 0) {
156
+ lines.push(` ${p.dim('no ports currently forwarded')}`)
157
+ return
158
+ }
159
+
160
+ for (const port of [...ports].sort((a, b) => a - b)) {
161
+ lines.push(` ${p.cyan(`127.0.0.1:${port}`)} ${p.dim('->')} container:${port}`)
162
+ }
163
+ }
164
+
165
+ function row(label: string, value: string): string {
166
+ return ` ${label.padEnd(8)}${value}`
167
+ }
168
+
169
+ function shortId(id: string): string {
170
+ if (id.length === 0) return '-'
171
+ const trimmed = id.startsWith('sha256:') ? id.slice('sha256:'.length) : id
172
+ return trimmed.slice(0, 12)
173
+ }
174
+
175
+ function formatHostMapping(bindAddr: string | null, port: number, p: Palette): string {
176
+ const bind = bindAddr ?? '127.0.0.1'
177
+ return `${p.cyan(`${bind}:${port}`)} ${p.dim('->')} container:${port}`
178
+ }
@@ -0,0 +1,31 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { stop } from '@/container'
4
+ import { findAgentDir } from '@/init'
5
+
6
+ import { c, spinner } from './ui'
7
+
8
+ export const stopCommand = defineCommand({
9
+ meta: {
10
+ name: 'stop',
11
+ description: 'stop the agent container (host stage)',
12
+ },
13
+ async run() {
14
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
15
+
16
+ const s = spinner()
17
+ s.start('Stopping container...')
18
+ const result = await stop({ cwd })
19
+
20
+ if (!result.ok) {
21
+ s.error(result.reason)
22
+ process.exit(1)
23
+ }
24
+
25
+ if (result.running) {
26
+ s.stop(`Stopped ${c.cyan(result.containerName)}.`)
27
+ } else {
28
+ s.stop(c.dim(`Container ${result.containerName} is not running.`))
29
+ }
30
+ },
31
+ })
package/src/cli/tui.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { resolveHostPort } from '@/container'
4
+ import { findAgentDir } from '@/init'
5
+ import { createTui } from '@/tui'
6
+
7
+ export const tui = defineCommand({
8
+ meta: {
9
+ name: 'tui',
10
+ description: 'start the tui client',
11
+ },
12
+ args: {
13
+ prompt: {
14
+ type: 'positional',
15
+ description: 'initial prompt',
16
+ required: false,
17
+ },
18
+ url: {
19
+ type: 'string',
20
+ description:
21
+ "agent websocket url (defaults to ws://localhost:<host port> discovered from the running container's published port)",
22
+ },
23
+ },
24
+ async run({ args }) {
25
+ const url = args.url ?? (await defaultUrl())
26
+ const tui = createTui({ url, initialPrompt: args.prompt })
27
+ await tui.run()
28
+ },
29
+ })
30
+
31
+ async function defaultUrl(): Promise<string> {
32
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
33
+ const port = await resolveHostPort({ cwd })
34
+ return `ws://localhost:${port}`
35
+ }
package/src/cli/ui.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ import { cancel, intro, isCancel, log, note, outro, spinner as clackSpinner } from '@clack/prompts'
4
+
5
+ export { cancel, intro, isCancel, log, note, outro }
6
+
7
+ function colorize(modifier: Parameters<typeof styleText>[0], s: string): string {
8
+ if (!colorsEnabled()) return s
9
+ return styleText(modifier, s)
10
+ }
11
+
12
+ // Re-evaluated per call so tests can mutate NO_COLOR / FORCE_COLOR between
13
+ // cases without stale module-load caching.
14
+ function colorsEnabled(): boolean {
15
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
16
+ if (process.env.FORCE_COLOR === '0') return false
17
+ if (process.env.FORCE_COLOR) return true
18
+ return Boolean(process.stdout.isTTY)
19
+ }
20
+
21
+ export const c = {
22
+ cyan: (s: string) => colorize('cyan', s),
23
+ green: (s: string) => colorize('green', s),
24
+ red: (s: string) => colorize('red', s),
25
+ yellow: (s: string) => colorize('yellow', s),
26
+ dim: (s: string) => colorize('dim', s),
27
+ gray: (s: string) => colorize('gray', s),
28
+ magenta: (s: string) => colorize('magenta', s),
29
+ bold: (s: string) => colorize('bold', s),
30
+ }
31
+
32
+ // OSC 8 hyperlink with plain fallback when colors are off so piped output
33
+ // and non-OSC-8 terminals stay readable.
34
+ export function link(text: string, url: string): string {
35
+ if (!colorsEnabled()) return `${text} (${url})`
36
+ return `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007`
37
+ }
38
+
39
+ export type Spinner = {
40
+ start: (msg?: string) => void
41
+ stop: (msg?: string) => void
42
+ error: (msg?: string) => void
43
+ cancel: (msg?: string) => void
44
+ message: (msg?: string) => void
45
+ }
46
+
47
+ export function spinner(): Spinner {
48
+ const s = clackSpinner()
49
+ return {
50
+ start: (msg) => s.start(msg),
51
+ stop: (msg) => s.stop(msg),
52
+ error: (msg) => s.error(msg),
53
+ cancel: (msg) => s.cancel(msg),
54
+ message: (msg) => s.message(msg),
55
+ }
56
+ }
57
+
58
+ export type StartLikeResult = {
59
+ alreadyRunning?: boolean
60
+ built: boolean
61
+ plan: { containerName: string; imageTag: string }
62
+ hostPort: number
63
+ containerId: string
64
+ hostd: { state: 'registered' } | { state: 'unavailable'; reason: string } | { state: 'disabled' }
65
+ }
66
+
67
+ export function renderStartSuccess(result: StartLikeResult): string {
68
+ const lines: string[] = []
69
+ const name = c.cyan(result.plan.containerName)
70
+ const port = c.green(String(result.hostPort))
71
+
72
+ if (result.alreadyRunning) {
73
+ lines.push(`${c.green('●')} ${name} is already running on host port ${port}.`)
74
+ } else {
75
+ if (result.built) {
76
+ lines.push(`Built image ${c.cyan(result.plan.imageTag)}.`)
77
+ }
78
+ const shortId = result.containerId.slice(0, 12)
79
+ lines.push(`${c.green('●')} ${name} started on host port ${port} ${c.dim(`(${shortId})`)}.`)
80
+ }
81
+
82
+ if (result.hostd.state === 'registered') {
83
+ lines.push(c.dim('Host daemon active.'))
84
+ } else if (result.hostd.state === 'unavailable') {
85
+ lines.push(`${c.yellow('Host daemon unavailable:')} ${result.hostd.reason}`)
86
+ }
87
+
88
+ lines.push('')
89
+ lines.push(`${c.dim('Follow logs:')} ${c.cyan('typeclaw logs -f')}`)
90
+ lines.push(`${c.dim('Attach TUI:')} ${c.cyan('typeclaw tui')}`)
91
+ lines.push(`${c.dim('Stop:')} ${c.cyan('typeclaw stop')}`)
92
+
93
+ return lines.join('\n')
94
+ }
95
+
96
+ export type NextStepHint = { label: string; command: string }
97
+
98
+ export function done(opts: { title: string; hints: NextStepHint[] }): void {
99
+ const body = opts.hints.map((h) => `${c.dim(h.label)} ${c.cyan(h.command)}`).join('\n')
100
+ note(body, opts.title)
101
+ outro(c.green('Done.'))
102
+ }
103
+
104
+ export function errorLine(reason: string): string {
105
+ return `${c.red('✖')} ${reason}`
106
+ }
107
+
108
+ export function successLine(message: string): string {
109
+ return `${c.green('●')} ${message}`
110
+ }
@@ -0,0 +1,74 @@
1
+ export type CommandHandler<Context> = (context: Context, command: ParsedCommand) => Promise<void> | void
2
+
3
+ export type Command<Context> = {
4
+ name: string
5
+ aliases?: readonly string[]
6
+ handler: CommandHandler<Context>
7
+ }
8
+
9
+ export type ParsedCommand = {
10
+ name: string
11
+ args: string
12
+ }
13
+
14
+ export type CommandResult =
15
+ | { kind: 'not-command' }
16
+ | { kind: 'unknown-command'; name: string }
17
+ | { kind: 'handled'; name: string }
18
+
19
+ export type CommandRegistry<Context> = {
20
+ parse: (text: string) => ParsedCommand | null
21
+ has: (name: string) => boolean
22
+ execute: (text: string, context: Context) => Promise<CommandResult>
23
+ }
24
+
25
+ // TODO: Add plugin-contributed commands once the public command context is stable.
26
+
27
+ export function createCommandRegistry<Context>(commands: readonly Command<Context>[]): CommandRegistry<Context> {
28
+ const byName = new Map<string, Command<Context>>()
29
+ for (const command of commands) {
30
+ registerName(byName, command.name, command)
31
+ for (const alias of command.aliases ?? []) {
32
+ registerName(byName, alias, command)
33
+ }
34
+ }
35
+
36
+ return {
37
+ parse: parseCommand,
38
+ has: (name) => byName.has(name.toLowerCase()),
39
+ execute: async (text, context) => {
40
+ const parsed = parseCommand(text)
41
+ if (parsed === null) return { kind: 'not-command' }
42
+ const command = byName.get(parsed.name)
43
+ if (!command) return { kind: 'unknown-command', name: parsed.name }
44
+ await command.handler(context, parsed)
45
+ return { kind: 'handled', name: command.name }
46
+ },
47
+ }
48
+ }
49
+
50
+ export function parseCommand(text: string): ParsedCommand | null {
51
+ const trimmed = text.trim()
52
+ if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null
53
+
54
+ const body = trimmed.slice(1)
55
+ const match = /^(?<name>[a-z][a-z0-9_-]*)(?:\s+(?<args>[\s\S]*))?$/i.exec(body)
56
+ if (!match?.groups) return null
57
+
58
+ return {
59
+ name: match.groups.name!.toLowerCase(),
60
+ args: match.groups.args ?? '',
61
+ }
62
+ }
63
+
64
+ function registerName<Context>(
65
+ byName: Map<string, Command<Context>>,
66
+ rawName: string,
67
+ command: Command<Context>,
68
+ ): void {
69
+ const name = rawName.toLowerCase()
70
+ if (byName.has(name)) {
71
+ throw new Error(`duplicate command: ${name}`)
72
+ }
73
+ byName.set(name, command)
74
+ }