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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- 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
|
+
}
|
package/src/cli/shell.ts
ADDED
|
@@ -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
|
+
})
|
package/src/cli/start.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/stop.ts
ADDED
|
@@ -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
|
+
}
|