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,786 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
import { config, configSchema, type Config } from '@/config'
|
|
7
|
+
import { DEFAULT_MODEL_REF, KNOWN_PROVIDERS, providerForModelRef, type KnownModelRef } from '@/config/providers'
|
|
8
|
+
import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
|
|
9
|
+
import { createTui } from '@/tui'
|
|
10
|
+
|
|
11
|
+
import { buildDockerfile, DOCKERFILE } from './dockerfile'
|
|
12
|
+
import { buildGitignore, GITIGNORE_FILE } from './gitignore'
|
|
13
|
+
import { HATCHING_PROMPT } from './hatching'
|
|
14
|
+
import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
|
|
15
|
+
import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
16
|
+
import { runBunInstall, type InstallResult } from './run-bun-install'
|
|
17
|
+
|
|
18
|
+
export { runBunInstall, type InstallResult } from './run-bun-install'
|
|
19
|
+
|
|
20
|
+
export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
21
|
+
|
|
22
|
+
const CONFIG_FILE = 'typeclaw.json'
|
|
23
|
+
const CRON_FILE = 'cron.json'
|
|
24
|
+
const SECRETS_FILE = '.env'
|
|
25
|
+
const PACKAGE_FILE = 'package.json'
|
|
26
|
+
|
|
27
|
+
const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
|
|
28
|
+
|
|
29
|
+
// `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
|
|
30
|
+
// Reusable systems the agent builds — including custom plugins wired into
|
|
31
|
+
// typeclaw.json — live there as standalone packages, while one-off scripts
|
|
32
|
+
// stay in `workspace/`. The directory is scaffolded empty so the layout is
|
|
33
|
+
// discoverable on day one; a `.gitkeep` is written below so it survives the
|
|
34
|
+
// initial commit.
|
|
35
|
+
const DIRECTORIES = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
|
|
36
|
+
|
|
37
|
+
export type GitInitResult = { ok: true; skipped: boolean } | { ok: false; reason: string }
|
|
38
|
+
export type DockerAssetsResult = { ok: true; devMode: boolean } | { ok: false; reason: string }
|
|
39
|
+
export type HatchingResult = { ok: true } | { ok: false; reason: string }
|
|
40
|
+
|
|
41
|
+
export type InitStep =
|
|
42
|
+
| 'preflight'
|
|
43
|
+
| 'oauth-login'
|
|
44
|
+
| 'scaffold'
|
|
45
|
+
| 'kakaotalk-auth'
|
|
46
|
+
| 'install'
|
|
47
|
+
| 'dockerfile'
|
|
48
|
+
| 'git'
|
|
49
|
+
| 'hatching'
|
|
50
|
+
|
|
51
|
+
export type KakaotalkAuthResult = { ok: true } | { ok: false; reason: string }
|
|
52
|
+
|
|
53
|
+
export type InitStepEvent =
|
|
54
|
+
| { step: 'preflight'; phase: 'start' }
|
|
55
|
+
| { step: 'preflight'; phase: 'done'; result: DockerAvailability }
|
|
56
|
+
| { step: 'oauth-login'; phase: 'start' }
|
|
57
|
+
| { step: 'oauth-login'; phase: 'done'; result: OAuthLoginResult }
|
|
58
|
+
| { step: 'scaffold'; phase: 'start' }
|
|
59
|
+
| { step: 'scaffold'; phase: 'done' }
|
|
60
|
+
| { step: 'kakaotalk-auth'; phase: 'start' }
|
|
61
|
+
| { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
|
|
62
|
+
| { step: 'install'; phase: 'start' }
|
|
63
|
+
| { step: 'install'; phase: 'done'; result: InstallResult }
|
|
64
|
+
| { step: 'dockerfile'; phase: 'start' }
|
|
65
|
+
| { step: 'dockerfile'; phase: 'done'; result: DockerAssetsResult }
|
|
66
|
+
| { step: 'git'; phase: 'start' }
|
|
67
|
+
| { step: 'git'; phase: 'done'; result: GitInitResult }
|
|
68
|
+
| { step: 'hatching'; phase: 'start' }
|
|
69
|
+
| { step: 'hatching'; phase: 'done'; result: HatchingResult }
|
|
70
|
+
|
|
71
|
+
export type HatchRunner = (options: { cwd: string; port: number }) => Promise<HatchingResult>
|
|
72
|
+
|
|
73
|
+
export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
|
|
74
|
+
|
|
75
|
+
// Discriminated by `kind` so the type system enforces "you can't pass an
|
|
76
|
+
// API key to an OAuth provider, and you can't pass an OAuth runner to an
|
|
77
|
+
// API-key provider". Optional model defaults to DEFAULT_MODEL_REF, which is
|
|
78
|
+
// an OpenAI api-key provider — so test fixtures that omit both fields keep
|
|
79
|
+
// working under the api-key path.
|
|
80
|
+
export type LLMAuth = { kind: 'api-key'; apiKey: string } | { kind: 'oauth'; runLogin: OAuthLoginRunner }
|
|
81
|
+
|
|
82
|
+
export type InitOptions = {
|
|
83
|
+
cwd: string
|
|
84
|
+
// Selected `provider/model` ref written into typeclaw.json. Defaults to
|
|
85
|
+
// DEFAULT_MODEL_REF when callers (or older test fixtures) omit it.
|
|
86
|
+
model?: KnownModelRef
|
|
87
|
+
// How the agent will authenticate to the LLM provider. When omitted,
|
|
88
|
+
// defaults to the api-key path with `apiKey` (legacy field, still
|
|
89
|
+
// supported for backwards compat with the old `runInit` signature).
|
|
90
|
+
llmAuth?: LLMAuth
|
|
91
|
+
apiKey?: string
|
|
92
|
+
discordBotToken?: string
|
|
93
|
+
discordAllowAll?: boolean
|
|
94
|
+
slackBotToken?: string
|
|
95
|
+
slackAppToken?: string
|
|
96
|
+
slackAllowAll?: boolean
|
|
97
|
+
telegramBotToken?: string
|
|
98
|
+
telegramAllowAll?: boolean
|
|
99
|
+
withKakaotalk?: boolean
|
|
100
|
+
kakaotalkAllowAll?: boolean
|
|
101
|
+
runKakaotalkAuth?: KakaotalkAuthRunner
|
|
102
|
+
onProgress?: (event: InitStepEvent) => void
|
|
103
|
+
runHatching?: HatchRunner
|
|
104
|
+
dockerExec?: DockerExec
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function runInit({
|
|
108
|
+
cwd,
|
|
109
|
+
apiKey,
|
|
110
|
+
llmAuth,
|
|
111
|
+
model = DEFAULT_MODEL_REF,
|
|
112
|
+
discordBotToken,
|
|
113
|
+
discordAllowAll = true,
|
|
114
|
+
slackBotToken,
|
|
115
|
+
slackAppToken,
|
|
116
|
+
slackAllowAll = true,
|
|
117
|
+
telegramBotToken,
|
|
118
|
+
telegramAllowAll = true,
|
|
119
|
+
withKakaotalk = false,
|
|
120
|
+
kakaotalkAllowAll = false,
|
|
121
|
+
runKakaotalkAuth,
|
|
122
|
+
onProgress,
|
|
123
|
+
runHatching = defaultRunHatching,
|
|
124
|
+
dockerExec,
|
|
125
|
+
}: InitOptions): Promise<void> {
|
|
126
|
+
const emit = onProgress ?? (() => {})
|
|
127
|
+
|
|
128
|
+
// Docker preflight runs BEFORE any scaffolding so a missing-binary or
|
|
129
|
+
// daemon-down failure leaves the user's directory untouched. Without this
|
|
130
|
+
// gate, init would lay the egg, write the Dockerfile, init git, and then
|
|
131
|
+
// fail at hatching with a raw "Executable not found in $PATH: docker" —
|
|
132
|
+
// leaving a half-initialized agent folder the user has to clean up by hand.
|
|
133
|
+
emit({ step: 'preflight', phase: 'start' })
|
|
134
|
+
const preflight = await checkDockerAvailable(dockerExec)
|
|
135
|
+
emit({ step: 'preflight', phase: 'done', result: preflight })
|
|
136
|
+
if (!preflight.ok) return
|
|
137
|
+
|
|
138
|
+
// Resolve the auth contract: explicit `llmAuth` wins; otherwise, fall back
|
|
139
|
+
// to the legacy `apiKey` field (api-key path). Throwing here instead of
|
|
140
|
+
// proceeding with bad data prevents writing a half-initialized agent
|
|
141
|
+
// folder for a doomed config.
|
|
142
|
+
const resolvedAuth = resolveLLMAuth(llmAuth, apiKey)
|
|
143
|
+
|
|
144
|
+
// OAuth login runs BEFORE scaffold so a failed/aborted browser flow leaves
|
|
145
|
+
// the user's directory untouched (same rationale as the docker preflight).
|
|
146
|
+
// Same trap as kakaotalk-auth: scaffold-then-fail-auth would leave
|
|
147
|
+
// typeclaw.json without working credentials and the runtime would silently
|
|
148
|
+
// refuse to boot. The login itself doesn't need the agent folder to exist
|
|
149
|
+
// — pi-ai's OAuth helper just needs a writable path for secrets.json, which
|
|
150
|
+
// we create on demand inside scaffold().
|
|
151
|
+
if (resolvedAuth.kind === 'oauth') {
|
|
152
|
+
emit({ step: 'oauth-login', phase: 'start' })
|
|
153
|
+
await mkdir(cwd, { recursive: true })
|
|
154
|
+
const result = await resolvedAuth.runLogin({ cwd, model })
|
|
155
|
+
emit({ step: 'oauth-login', phase: 'done', result })
|
|
156
|
+
if (!result.ok) {
|
|
157
|
+
throw new Error(`OAuth login failed: ${result.reason}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const wantsDiscord = discordBotToken !== undefined && discordBotToken !== ''
|
|
162
|
+
const wantsSlack = slackBotToken !== undefined && slackBotToken !== ''
|
|
163
|
+
const wantsTelegram = telegramBotToken !== undefined && telegramBotToken !== ''
|
|
164
|
+
emit({ step: 'scaffold', phase: 'start' })
|
|
165
|
+
await scaffold(cwd, {
|
|
166
|
+
model,
|
|
167
|
+
withDiscord: wantsDiscord,
|
|
168
|
+
discordAllowAll,
|
|
169
|
+
withSlack: wantsSlack,
|
|
170
|
+
slackAllowAll,
|
|
171
|
+
withTelegram: wantsTelegram,
|
|
172
|
+
telegramAllowAll,
|
|
173
|
+
withKakaotalk,
|
|
174
|
+
kakaotalkAllowAll,
|
|
175
|
+
})
|
|
176
|
+
// Only write the LLM API key on the api-key path. OAuth providers persist
|
|
177
|
+
// their credentials to secrets.json (via the OAuth login step above); writing
|
|
178
|
+
// an empty FIREWORKS_API_KEY/OPENAI_API_KEY would just confuse users.
|
|
179
|
+
await writeSecrets(cwd, {
|
|
180
|
+
model,
|
|
181
|
+
apiKey: resolvedAuth.kind === 'api-key' ? resolvedAuth.apiKey : undefined,
|
|
182
|
+
discordBotToken,
|
|
183
|
+
slackBotToken,
|
|
184
|
+
slackAppToken,
|
|
185
|
+
telegramBotToken,
|
|
186
|
+
})
|
|
187
|
+
emit({ step: 'scaffold', phase: 'done' })
|
|
188
|
+
|
|
189
|
+
if (withKakaotalk && runKakaotalkAuth !== undefined) {
|
|
190
|
+
emit({ step: 'kakaotalk-auth', phase: 'start' })
|
|
191
|
+
const result = await runKakaotalkAuth({ cwd })
|
|
192
|
+
emit({ step: 'kakaotalk-auth', phase: 'done', result })
|
|
193
|
+
if (!result.ok) {
|
|
194
|
+
// Abort the rest of the pipeline. Continuing would leave the agent
|
|
195
|
+
// folder with `channels.kakaotalk` in typeclaw.json but no credentials
|
|
196
|
+
// file, which `typeclaw start` later treats as "missing credentials,
|
|
197
|
+
// skip adapter" — confusing the user about whether KakaoTalk works.
|
|
198
|
+
// The user can re-run `typeclaw init` after fixing the auth issue;
|
|
199
|
+
// the scaffold/Dockerfile work above is idempotent.
|
|
200
|
+
throw new Error(`KakaoTalk authentication failed: ${result.reason}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
emit({ step: 'install', phase: 'start' })
|
|
205
|
+
const install = await runBunInstall(cwd)
|
|
206
|
+
emit({ step: 'install', phase: 'done', result: install })
|
|
207
|
+
|
|
208
|
+
emit({ step: 'dockerfile', phase: 'start' })
|
|
209
|
+
const docker = await writeDockerAssets(cwd)
|
|
210
|
+
emit({ step: 'dockerfile', phase: 'done', result: docker })
|
|
211
|
+
|
|
212
|
+
emit({ step: 'git', phase: 'start' })
|
|
213
|
+
const git = await initGitRepo(cwd)
|
|
214
|
+
emit({ step: 'git', phase: 'done', result: git })
|
|
215
|
+
|
|
216
|
+
emit({ step: 'hatching', phase: 'start' })
|
|
217
|
+
const hatching = await runHatching({ cwd, port: config.port })
|
|
218
|
+
emit({ step: 'hatching', phase: 'done', result: hatching })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function defaultRunHatching({ cwd, port }: { cwd: string; port: number }): Promise<HatchingResult> {
|
|
222
|
+
try {
|
|
223
|
+
const launch = await start({ cwd, preferredHostPort: port })
|
|
224
|
+
if (!launch.ok) return { ok: false, reason: launch.reason }
|
|
225
|
+
|
|
226
|
+
// start() may have allocated a different host port (the preferred one was
|
|
227
|
+
// bound). Use the actually-published port for the TUI handshake instead of
|
|
228
|
+
// the preferred port, otherwise we'd connect to the wrong service.
|
|
229
|
+
const hostPort = launch.hostPort
|
|
230
|
+
|
|
231
|
+
await waitForAgent(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
|
|
232
|
+
|
|
233
|
+
const tui = createTui({
|
|
234
|
+
url: `ws://localhost:${hostPort}`,
|
|
235
|
+
initialPrompt: HATCHING_PROMPT,
|
|
236
|
+
})
|
|
237
|
+
await tui.run()
|
|
238
|
+
return { ok: true }
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Probe the server's plain HTTP fallback (non-upgrade requests get a 200 with
|
|
245
|
+
// body "typeclaw agent") instead of opening a WebSocket. Opening a WS here
|
|
246
|
+
// would trigger createSession on the server and burn an LLM session just to
|
|
247
|
+
// learn the port is up.
|
|
248
|
+
async function waitForAgent(httpUrl: string, { timeoutMs }: { timeoutMs: number }): Promise<void> {
|
|
249
|
+
const deadline = Date.now() + timeoutMs
|
|
250
|
+
let lastError: unknown
|
|
251
|
+
while (Date.now() < deadline) {
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch(httpUrl)
|
|
254
|
+
if (res.status === 200) return
|
|
255
|
+
lastError = new Error(`unexpected status ${res.status}`)
|
|
256
|
+
} catch (error) {
|
|
257
|
+
lastError = error
|
|
258
|
+
}
|
|
259
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`timed out waiting for agent at ${httpUrl}: ${lastError instanceof Error ? lastError.message : ''}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function isDirectoryNonEmpty(dir: string): boolean {
|
|
265
|
+
try {
|
|
266
|
+
return readdirSync(dir).some((entry) => !entry.startsWith('.'))
|
|
267
|
+
} catch {
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function isInitialized(dir: string): boolean {
|
|
273
|
+
return existsSync(join(dir, CONFIG_FILE))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Walks upward from `start` looking for the agent folder (the dir containing
|
|
277
|
+
// typeclaw.json). Returns the found dir, or null if nothing is found before
|
|
278
|
+
// the walk hits a stop boundary.
|
|
279
|
+
//
|
|
280
|
+
// Stop boundaries (whichever comes first, checked at every level):
|
|
281
|
+
// 1. The current dir contains typeclaw.json — return it.
|
|
282
|
+
// 2. The current dir contains .git — return null. A .git boundary marks a
|
|
283
|
+
// project root; refusing to cross it prevents accidentally picking up an
|
|
284
|
+
// unrelated parent project, and matches how typeclaw itself initializes
|
|
285
|
+
// one .git per agent folder.
|
|
286
|
+
// 3. We've reached the filesystem root — return null.
|
|
287
|
+
//
|
|
288
|
+
// The `.git` check fires AFTER the typeclaw.json check at the same level so
|
|
289
|
+
// that walking up from a subdir of the agent (e.g. `<agent>/workspace/`) still
|
|
290
|
+
// resolves to the agent root, even though the agent root itself contains both
|
|
291
|
+
// typeclaw.json and .git.
|
|
292
|
+
export function findAgentDir(start: string): string | null {
|
|
293
|
+
let dir = resolve(start)
|
|
294
|
+
const root = resolve(dir, '/')
|
|
295
|
+
while (true) {
|
|
296
|
+
if (existsSync(join(dir, CONFIG_FILE))) return dir
|
|
297
|
+
if (existsSync(join(dir, '.git'))) return null
|
|
298
|
+
if (dir === root) return null
|
|
299
|
+
const parent = dirname(dir)
|
|
300
|
+
if (parent === dir) return null
|
|
301
|
+
dir = parent
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const HATCHED_COMMIT_SUBJECT = 'Hatched 🐣'
|
|
306
|
+
|
|
307
|
+
export async function isHatched(dir: string): Promise<boolean> {
|
|
308
|
+
if (!existsSync(join(dir, '.git'))) return false
|
|
309
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
310
|
+
if (!bun) return false
|
|
311
|
+
try {
|
|
312
|
+
const proc = bun.spawn({ cmd: ['git', 'log', '--format=%s'], cwd: dir, stdout: 'pipe', stderr: 'pipe' })
|
|
313
|
+
if ((await proc.exited) !== 0) return false
|
|
314
|
+
const subjects = (await new Response(proc.stdout).text()).split('\n')
|
|
315
|
+
return subjects.includes(HATCHED_COMMIT_SUBJECT)
|
|
316
|
+
} catch {
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type ScaffoldOptions = {
|
|
322
|
+
model?: KnownModelRef
|
|
323
|
+
withDiscord?: boolean
|
|
324
|
+
discordAllowAll?: boolean
|
|
325
|
+
withSlack?: boolean
|
|
326
|
+
slackAllowAll?: boolean
|
|
327
|
+
withTelegram?: boolean
|
|
328
|
+
telegramAllowAll?: boolean
|
|
329
|
+
withKakaotalk?: boolean
|
|
330
|
+
kakaotalkAllowAll?: boolean
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
|
|
334
|
+
await Promise.all(DIRECTORIES.map((dir) => mkdir(join(root, dir), { recursive: true })))
|
|
335
|
+
|
|
336
|
+
// git does not track empty directories, so without this file the `packages/`
|
|
337
|
+
// workspace root would silently disappear from the initial commit and confuse
|
|
338
|
+
// the agent (its workspaces glob would resolve to nothing). The other
|
|
339
|
+
// DIRECTORIES are either gitignored (workspace, sessions, mounts) or
|
|
340
|
+
// immediately populated, so packages/ is the only one that needs this.
|
|
341
|
+
await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
|
|
342
|
+
|
|
343
|
+
// Only fields without sensible defaults elsewhere are emitted. `mounts`
|
|
344
|
+
// defaults to `[]` in configSchema, and the bundled memory plugin owns its
|
|
345
|
+
// own defaults in src/bundled-plugins/memory/index.ts — re-emitting either here would
|
|
346
|
+
// be duplicate noise the user has to maintain in sync with the source of
|
|
347
|
+
// truth.
|
|
348
|
+
const config: Record<string, unknown> = {
|
|
349
|
+
$schema: './node_modules/typeclaw/typeclaw.schema.json',
|
|
350
|
+
model: options.model ?? DEFAULT_MODEL_REF,
|
|
351
|
+
}
|
|
352
|
+
const channels: Record<string, { allow: string[] }> = {}
|
|
353
|
+
if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
|
|
354
|
+
if (options.withSlack) channels['slack-bot'] = { allow: options.slackAllowAll === false ? [] : ['*'] }
|
|
355
|
+
if (options.withTelegram) channels['telegram-bot'] = { allow: options.telegramAllowAll === false ? [] : ['*'] }
|
|
356
|
+
if (options.withKakaotalk) {
|
|
357
|
+
// KakaoTalk involves a personal account, so we default to a tighter
|
|
358
|
+
// allow list (DMs only) than Slack/Discord/Telegram which scope to a
|
|
359
|
+
// workspace the user explicitly admitted the bot into. The user can
|
|
360
|
+
// broaden to `kakao:*` later by editing typeclaw.json.
|
|
361
|
+
channels.kakaotalk = { allow: options.kakaotalkAllowAll === true ? ['kakao:*'] : ['kakao:dm/*'] }
|
|
362
|
+
}
|
|
363
|
+
if (Object.keys(channels).length > 0) config.channels = channels
|
|
364
|
+
await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
365
|
+
|
|
366
|
+
const cron = {
|
|
367
|
+
$schema: './node_modules/typeclaw/cron.schema.json',
|
|
368
|
+
jobs: [],
|
|
369
|
+
}
|
|
370
|
+
await writeFile(join(root, CRON_FILE), `${JSON.stringify(cron, null, 2)}\n`, { flag: 'wx' }).catch(ignoreExists)
|
|
371
|
+
|
|
372
|
+
const pkg = buildPackageJson(root, basename(root))
|
|
373
|
+
await writeFile(join(root, PACKAGE_FILE), `${JSON.stringify(pkg, null, 2)}\n`, { flag: 'wx' }).catch(ignoreExists)
|
|
374
|
+
|
|
375
|
+
await Promise.all(MARKDOWN_FILES.map((file) => writeFile(join(root, file), '', { flag: 'wx' }).catch(ignoreExists)))
|
|
376
|
+
|
|
377
|
+
await writeFile(join(root, GITIGNORE_FILE), buildGitignore(), { flag: 'wx' }).catch(ignoreExists)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// agent-browser ships in every agent: the bundled SKILL.md (src/skills/
|
|
381
|
+
// agent-browser/SKILL.md) is a discovery stub that calls `agent-browser
|
|
382
|
+
// skills get core` at runtime, so the CLI must be installed for the skill
|
|
383
|
+
// to function. The Dockerfile pre-downloads Chromium too, so the agent
|
|
384
|
+
// can drive a browser without any first-run setup.
|
|
385
|
+
const AGENT_BROWSER_VERSION = '^0.26.0'
|
|
386
|
+
|
|
387
|
+
function buildPackageJson(root: string, name: string): Record<string, unknown> {
|
|
388
|
+
const typeclawRoot = findTypeclawRoot()
|
|
389
|
+
// FIXME: temporary dev-stage wiring. Switch to a published version range
|
|
390
|
+
// (e.g. "typeclaw": "^x.y.z") once typeclaw is released. The `file:` spec is
|
|
391
|
+
// computed relative to the agent root because `file:` resolves relative to
|
|
392
|
+
// the consuming package.
|
|
393
|
+
const fileSpec = typeclawRoot ? `file:${toFileSpec(relative(root, typeclawRoot))}` : 'file:../typeclaw'
|
|
394
|
+
return {
|
|
395
|
+
name,
|
|
396
|
+
private: true,
|
|
397
|
+
type: 'module',
|
|
398
|
+
workspaces: [`${PACKAGES_DIR}/*`],
|
|
399
|
+
dependencies: {
|
|
400
|
+
typeclaw: fileSpec,
|
|
401
|
+
'agent-browser': AGENT_BROWSER_VERSION,
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function toFileSpec(rel: string): string {
|
|
407
|
+
if (rel === '') return '.'
|
|
408
|
+
// bun/npm accept POSIX-style paths in file: specifiers; normalize separators.
|
|
409
|
+
return rel.split(/[\\/]/).join('/')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function findTypeclawRoot(): string | null {
|
|
413
|
+
try {
|
|
414
|
+
let dir = dirname(fileURLToPath(import.meta.url))
|
|
415
|
+
const root = resolve('/')
|
|
416
|
+
while (dir !== root) {
|
|
417
|
+
const pkgPath = join(dir, 'package.json')
|
|
418
|
+
if (existsSync(pkgPath)) {
|
|
419
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string }
|
|
420
|
+
if (pkg.name === 'typeclaw') return dir
|
|
421
|
+
}
|
|
422
|
+
dir = dirname(dir)
|
|
423
|
+
}
|
|
424
|
+
} catch {}
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function writeDockerAssets(root: string): Promise<DockerAssetsResult> {
|
|
429
|
+
try {
|
|
430
|
+
const pkg = await readPackageJson(root)
|
|
431
|
+
const typeclawSpec = pkg.dependencies?.typeclaw ?? ''
|
|
432
|
+
const devMode = typeclawSpec.startsWith('file:')
|
|
433
|
+
|
|
434
|
+
const typeclawConfig = await readTypeclawConfig(root)
|
|
435
|
+
await writeFile(join(root, DOCKERFILE), buildDockerfile(typeclawConfig.dockerfile), { flag: 'wx' }).catch(
|
|
436
|
+
ignoreExists,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return { ok: true, devMode }
|
|
440
|
+
} catch (error) {
|
|
441
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function readPackageJson(root: string): Promise<{ name?: string; dependencies?: Record<string, string> }> {
|
|
446
|
+
const raw = await readFile(join(root, PACKAGE_FILE), 'utf8')
|
|
447
|
+
return JSON.parse(raw) as { name?: string; dependencies?: Record<string, string> }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function readTypeclawConfig(root: string): Promise<Config> {
|
|
451
|
+
try {
|
|
452
|
+
const raw = await readFile(join(root, CONFIG_FILE), 'utf8')
|
|
453
|
+
return configSchema.parse(JSON.parse(raw))
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return configSchema.parse({})
|
|
456
|
+
throw error
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function initGitRepo(cwd: string): Promise<GitInitResult> {
|
|
461
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
462
|
+
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
463
|
+
|
|
464
|
+
if (existsSync(join(cwd, '.git'))) return { ok: true, skipped: true }
|
|
465
|
+
|
|
466
|
+
// Author the initial commit as TypeClaw itself. The agent is still unnamed
|
|
467
|
+
// (IDENTITY.md is empty and hatching hasn't run), so the agent identity will
|
|
468
|
+
// take over from the hatching commit onward. This also avoids depending on
|
|
469
|
+
// the user's global `user.name`/`user.email`.
|
|
470
|
+
const env = {
|
|
471
|
+
...process.env,
|
|
472
|
+
GIT_AUTHOR_NAME: 'TypeClaw',
|
|
473
|
+
GIT_AUTHOR_EMAIL: 'hello@typeclaw.dev',
|
|
474
|
+
GIT_COMMITTER_NAME: 'TypeClaw',
|
|
475
|
+
GIT_COMMITTER_EMAIL: 'hello@typeclaw.dev',
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
|
|
480
|
+
if ((await init.exited) !== 0) {
|
|
481
|
+
const stderr = await new Response(init.stderr).text()
|
|
482
|
+
return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const add = bun.spawn({ cmd: ['git', 'add', '.'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
|
|
486
|
+
if ((await add.exited) !== 0) {
|
|
487
|
+
const stderr = await new Response(add.stderr).text()
|
|
488
|
+
return { ok: false, reason: `git add failed: ${stderr.trim() || 'no stderr'}` }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const commit = bun.spawn({
|
|
492
|
+
cmd: ['git', 'commit', '-m', 'Initial commit 🥚'],
|
|
493
|
+
cwd,
|
|
494
|
+
env,
|
|
495
|
+
stdout: 'pipe',
|
|
496
|
+
stderr: 'pipe',
|
|
497
|
+
})
|
|
498
|
+
if ((await commit.exited) !== 0) {
|
|
499
|
+
const stderr = await new Response(commit.stderr).text()
|
|
500
|
+
return { ok: false, reason: `git commit failed: ${stderr.trim() || 'no stderr'}` }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { ok: true, skipped: false }
|
|
504
|
+
} catch (error) {
|
|
505
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Writes the LLM provider's API key (under its provider-specific env var,
|
|
510
|
+
// e.g. OPENAI_API_KEY or FIREWORKS_API_KEY) plus any channel adapter tokens.
|
|
511
|
+
// The provider env var is resolved from KNOWN_PROVIDERS via the model ref,
|
|
512
|
+
// so adding a new provider only requires touching providers.ts.
|
|
513
|
+
export async function writeSecrets(
|
|
514
|
+
root: string,
|
|
515
|
+
{
|
|
516
|
+
model = DEFAULT_MODEL_REF,
|
|
517
|
+
apiKey,
|
|
518
|
+
discordBotToken,
|
|
519
|
+
slackBotToken,
|
|
520
|
+
slackAppToken,
|
|
521
|
+
telegramBotToken,
|
|
522
|
+
}: {
|
|
523
|
+
model?: KnownModelRef
|
|
524
|
+
// Omitted on the OAuth path — credentials live in secrets.json instead. The
|
|
525
|
+
// .env file still gets written for any channel adapter tokens.
|
|
526
|
+
apiKey?: string
|
|
527
|
+
discordBotToken?: string
|
|
528
|
+
slackBotToken?: string
|
|
529
|
+
slackAppToken?: string
|
|
530
|
+
telegramBotToken?: string
|
|
531
|
+
},
|
|
532
|
+
): Promise<void> {
|
|
533
|
+
const providerId = providerForModelRef(model)
|
|
534
|
+
const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
|
|
535
|
+
const lines: string[] = []
|
|
536
|
+
if (apiKey !== undefined && apiKeyEnv !== null) {
|
|
537
|
+
lines.push(`${apiKeyEnv}=${apiKey}`)
|
|
538
|
+
}
|
|
539
|
+
if (discordBotToken !== undefined && discordBotToken !== '') {
|
|
540
|
+
lines.push(`DISCORD_BOT_TOKEN=${discordBotToken}`)
|
|
541
|
+
}
|
|
542
|
+
if (slackBotToken !== undefined && slackBotToken !== '') {
|
|
543
|
+
lines.push(`SLACK_BOT_TOKEN=${slackBotToken}`)
|
|
544
|
+
}
|
|
545
|
+
if (slackAppToken !== undefined && slackAppToken !== '') {
|
|
546
|
+
lines.push(`SLACK_APP_TOKEN=${slackAppToken}`)
|
|
547
|
+
}
|
|
548
|
+
if (telegramBotToken !== undefined && telegramBotToken !== '') {
|
|
549
|
+
lines.push(`TELEGRAM_BOT_TOKEN=${telegramBotToken}`)
|
|
550
|
+
}
|
|
551
|
+
// Always write .env even when empty so existing callers that read it
|
|
552
|
+
// post-init (channel `add`, runtime startup) don't ENOENT-crash.
|
|
553
|
+
const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
|
|
554
|
+
await writeFile(join(root, SECRETS_FILE), body)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function resolveLLMAuth(llmAuth: LLMAuth | undefined, apiKey: string | undefined): LLMAuth {
|
|
558
|
+
if (llmAuth) return llmAuth
|
|
559
|
+
if (apiKey !== undefined) return { kind: 'api-key', apiKey }
|
|
560
|
+
throw new Error('runInit requires either `llmAuth` or `apiKey`')
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function ignoreExists(error: NodeJS.ErrnoException): void {
|
|
564
|
+
if (error.code !== 'EEXIST') throw error
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ----------------------------------------------------------------------------
|
|
568
|
+
// `typeclaw channel add`
|
|
569
|
+
//
|
|
570
|
+
// `runAddChannel` is the post-init counterpart to `runInit`'s channel-related
|
|
571
|
+
// steps. It is intentionally a separate pipeline rather than a mode switch on
|
|
572
|
+
// `runInit` because the two have opposite file semantics:
|
|
573
|
+
//
|
|
574
|
+
// - `runInit` creates a fresh agent folder. Writes overwrite by design
|
|
575
|
+
// (typeclaw.json, .env), and idempotency comes from `wx`-flag guards on
|
|
576
|
+
// never-rewritten files (markdown stubs, cron.json, package.json).
|
|
577
|
+
//
|
|
578
|
+
// - `runAddChannel` mutates an already-initialized agent folder. It MUST
|
|
579
|
+
// preserve the user's existing channel config and existing .env values.
|
|
580
|
+
// The only writes are an additive merge of one new channel adapter and
|
|
581
|
+
// an append of that adapter's env vars.
|
|
582
|
+
//
|
|
583
|
+
// Sharing one function would pile mode flags on every helper and turn the
|
|
584
|
+
// "is this overwrite or merge?" question into a runtime branch the test
|
|
585
|
+
// suite would have to cover for both behaviors. The mass of independent
|
|
586
|
+
// scaffold-test cases above demonstrates how easy it is to lose a single
|
|
587
|
+
// behavior under a mode flag.
|
|
588
|
+
|
|
589
|
+
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk'
|
|
590
|
+
|
|
591
|
+
// Public adapter names match the typeclaw.json `channels.*` keys exactly.
|
|
592
|
+
// The CLI takes these as the optional positional arg, the picker shows
|
|
593
|
+
// these labels, and they're the keys we use to detect "already configured"
|
|
594
|
+
// when reading typeclaw.json.
|
|
595
|
+
export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = ['slack-bot', 'discord-bot', 'telegram-bot', 'kakaotalk']
|
|
596
|
+
|
|
597
|
+
export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets'
|
|
598
|
+
|
|
599
|
+
export type AddChannelStepEvent =
|
|
600
|
+
| { step: 'config'; phase: 'start' }
|
|
601
|
+
| { step: 'config'; phase: 'done' }
|
|
602
|
+
| { step: 'kakaotalk-auth'; phase: 'start' }
|
|
603
|
+
| { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
|
|
604
|
+
| { step: 'secrets'; phase: 'start' }
|
|
605
|
+
| { step: 'secrets'; phase: 'done' }
|
|
606
|
+
|
|
607
|
+
// Discriminated union per channel so the type system enforces "you must pass
|
|
608
|
+
// the right credentials for the channel you're adding". The CLI builds these
|
|
609
|
+
// from prompts; tests build them inline.
|
|
610
|
+
export type AddChannelOptions = {
|
|
611
|
+
cwd: string
|
|
612
|
+
allowAll?: boolean
|
|
613
|
+
onProgress?: (event: AddChannelStepEvent) => void
|
|
614
|
+
} & (
|
|
615
|
+
| { channel: 'discord-bot'; discordBotToken: string }
|
|
616
|
+
| { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
|
|
617
|
+
| { channel: 'telegram-bot'; telegramBotToken: string }
|
|
618
|
+
| { channel: 'kakaotalk'; runKakaotalkAuth: KakaotalkAuthRunner }
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
622
|
+
const emit = options.onProgress ?? (() => {})
|
|
623
|
+
|
|
624
|
+
// Order: kakaotalk-auth (if applicable) -> config -> secrets.
|
|
625
|
+
//
|
|
626
|
+
// We run KakaoTalk auth FIRST so a failed login leaves typeclaw.json and
|
|
627
|
+
// .env untouched. The runtime treats `channels.kakaotalk` without a
|
|
628
|
+
// credentials file as "missing credentials, skip adapter", which silently
|
|
629
|
+
// drops messages — the same trap `runInit` already guards against. Aborting
|
|
630
|
+
// before any file write means the user's next `typeclaw channel add
|
|
631
|
+
// kakaotalk` retry has no half-applied state to clean up.
|
|
632
|
+
if (options.channel === 'kakaotalk') {
|
|
633
|
+
emit({ step: 'kakaotalk-auth', phase: 'start' })
|
|
634
|
+
const result = await options.runKakaotalkAuth({ cwd: options.cwd })
|
|
635
|
+
emit({ step: 'kakaotalk-auth', phase: 'done', result })
|
|
636
|
+
if (!result.ok) throw new Error(`KakaoTalk authentication failed: ${result.reason}`)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
emit({ step: 'config', phase: 'start' })
|
|
640
|
+
await mergeChannelIntoConfig(options.cwd, options.channel, options.allowAll ?? defaultAllowAll(options.channel))
|
|
641
|
+
emit({ step: 'config', phase: 'done' })
|
|
642
|
+
|
|
643
|
+
emit({ step: 'secrets', phase: 'start' })
|
|
644
|
+
await appendChannelSecrets(options.cwd, channelSecretsFromOptions(options))
|
|
645
|
+
emit({ step: 'secrets', phase: 'done' })
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// `channel add` mirrors `runInit`'s allow defaults: workspace-scoped adapters
|
|
649
|
+
// (discord/slack/telegram) default to `*` because the bot only sees what the
|
|
650
|
+
// operator invited it into, while KakaoTalk uses a personal account and
|
|
651
|
+
// defaults to DMs only.
|
|
652
|
+
function defaultAllowAll(channel: ChannelKind): boolean {
|
|
653
|
+
return channel !== 'kakaotalk'
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
657
|
+
switch (options.channel) {
|
|
658
|
+
case 'discord-bot':
|
|
659
|
+
return { DISCORD_BOT_TOKEN: options.discordBotToken }
|
|
660
|
+
case 'slack-bot':
|
|
661
|
+
return { SLACK_BOT_TOKEN: options.slackBotToken, SLACK_APP_TOKEN: options.slackAppToken }
|
|
662
|
+
case 'telegram-bot':
|
|
663
|
+
return { TELEGRAM_BOT_TOKEN: options.telegramBotToken }
|
|
664
|
+
case 'kakaotalk':
|
|
665
|
+
// Credentials live in workspace/.agent-messenger/, not .env.
|
|
666
|
+
return {}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
type ChannelSecrets = Record<string, string>
|
|
671
|
+
|
|
672
|
+
// Returns the set of channel keys already present in typeclaw.json. Used by
|
|
673
|
+
// the CLI's picker to hide already-configured adapters and to reject explicit
|
|
674
|
+
// re-adds with a clear error rather than silently merging.
|
|
675
|
+
export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKind>> {
|
|
676
|
+
const path = join(cwd, CONFIG_FILE)
|
|
677
|
+
let raw: string
|
|
678
|
+
try {
|
|
679
|
+
raw = await readFile(path, 'utf8')
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Set()
|
|
682
|
+
throw error
|
|
683
|
+
}
|
|
684
|
+
const parsed = JSON.parse(raw) as { channels?: Record<string, unknown> }
|
|
685
|
+
const channels = parsed.channels ?? {}
|
|
686
|
+
const present = new Set<ChannelKind>()
|
|
687
|
+
for (const kind of CHANNEL_KINDS) {
|
|
688
|
+
if (kind in channels) present.add(kind)
|
|
689
|
+
}
|
|
690
|
+
return present
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAll: boolean): Promise<void> {
|
|
694
|
+
const path = join(cwd, CONFIG_FILE)
|
|
695
|
+
let parsed: Record<string, unknown>
|
|
696
|
+
try {
|
|
697
|
+
const raw = await readFile(path, 'utf8')
|
|
698
|
+
parsed = JSON.parse(raw) as Record<string, unknown>
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
throw error
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const existingChannels =
|
|
709
|
+
typeof parsed.channels === 'object' && parsed.channels !== null && !Array.isArray(parsed.channels)
|
|
710
|
+
? (parsed.channels as Record<string, unknown>)
|
|
711
|
+
: {}
|
|
712
|
+
|
|
713
|
+
if (channel in existingChannels) {
|
|
714
|
+
// Defense in depth — the CLI already filters configured channels out of
|
|
715
|
+
// the picker and rejects them as the positional arg. Hitting this branch
|
|
716
|
+
// means a programmatic caller passed a duplicate; better to fail loudly
|
|
717
|
+
// than silently overwrite the user's existing allow list.
|
|
718
|
+
throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
parsed.channels = {
|
|
722
|
+
...existingChannels,
|
|
723
|
+
[channel]: { allow: buildAllow(channel, allowAll) },
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
|
|
730
|
+
if (channel === 'kakaotalk') return allowAll ? ['kakao:*'] : ['kakao:dm/*']
|
|
731
|
+
return allowAll ? ['*'] : []
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Appends only keys that are not already present in .env. We never rewrite
|
|
735
|
+
// existing values: if the user has `SLACK_BOT_TOKEN=` left over from a manual
|
|
736
|
+
// edit, we surface that as a hard error rather than overwrite the user's
|
|
737
|
+
// hand-rolled value.
|
|
738
|
+
//
|
|
739
|
+
// `.env` parsing is intentionally line-based and dumb (matching dotenv's
|
|
740
|
+
// minimum surface): trim, skip blanks/comments, split on the first `=`. We do
|
|
741
|
+
// not unquote values because we only check for key presence.
|
|
742
|
+
async function appendChannelSecrets(cwd: string, secrets: ChannelSecrets): Promise<void> {
|
|
743
|
+
if (Object.keys(secrets).length === 0) return
|
|
744
|
+
|
|
745
|
+
const path = join(cwd, SECRETS_FILE)
|
|
746
|
+
let existing: string
|
|
747
|
+
try {
|
|
748
|
+
existing = await readFile(path, 'utf8')
|
|
749
|
+
} catch (error) {
|
|
750
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
751
|
+
throw new Error(
|
|
752
|
+
`${SECRETS_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
throw error
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const presentKeys = parseEnvKeys(existing)
|
|
759
|
+
for (const key of Object.keys(secrets)) {
|
|
760
|
+
if (presentKeys.has(key)) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
`${key} is already set in ${SECRETS_FILE}. Remove it before re-adding the channel, or edit the value by hand.`,
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Ensure exactly one trailing newline before our appended block so the
|
|
768
|
+
// resulting file remains POSIX-clean even if the user's editor stripped it.
|
|
769
|
+
const trailingNewline = existing.endsWith('\n') || existing === '' ? '' : '\n'
|
|
770
|
+
const appended = Object.entries(secrets)
|
|
771
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
772
|
+
.join('\n')
|
|
773
|
+
await writeFile(path, `${existing}${trailingNewline}${appended}\n`)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function parseEnvKeys(content: string): Set<string> {
|
|
777
|
+
const keys = new Set<string>()
|
|
778
|
+
for (const rawLine of content.split('\n')) {
|
|
779
|
+
const line = rawLine.trim()
|
|
780
|
+
if (line === '' || line.startsWith('#')) continue
|
|
781
|
+
const eq = line.indexOf('=')
|
|
782
|
+
if (eq <= 0) continue
|
|
783
|
+
keys.add(line.slice(0, eq).trim())
|
|
784
|
+
}
|
|
785
|
+
return keys
|
|
786
|
+
}
|