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,114 @@
1
+ import { createRequire } from 'node:module'
2
+ import { join } from 'node:path'
3
+
4
+ import { KakaoCredentialManager } from 'agent-messenger/kakaotalk'
5
+
6
+ export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
7
+
8
+ export type KakaotalkLoginCallbacks = {
9
+ onPasscode: (passcode: string) => void
10
+ }
11
+
12
+ export type KakaotalkLoginInput = {
13
+ email: string
14
+ password: string
15
+ agentDir: string
16
+ callbacks: KakaotalkLoginCallbacks
17
+ loginFlow?: LoginFlowFn
18
+ }
19
+
20
+ export type LoginFlowOptions = {
21
+ email: string
22
+ password: string
23
+ deviceType?: 'pc' | 'tablet'
24
+ force?: boolean
25
+ savedDeviceUuid?: string
26
+ onPasscodeDisplay?: (code: string) => void
27
+ debugLog?: (message: string) => void
28
+ }
29
+
30
+ export type LoginFlowCredentials = {
31
+ access_token: string
32
+ refresh_token: string
33
+ user_id: string
34
+ device_uuid: string
35
+ device_type: 'pc' | 'tablet'
36
+ }
37
+
38
+ export type LoginFlowResult = {
39
+ authenticated: boolean
40
+ next_action?: string
41
+ message?: string
42
+ warning?: string
43
+ error?: string
44
+ credentials?: LoginFlowCredentials
45
+ }
46
+
47
+ export type LoginFlowFn = (options: LoginFlowOptions) => Promise<LoginFlowResult>
48
+
49
+ export function kakaotalkConfigDir(agentDir: string): string {
50
+ return join(agentDir, 'workspace', '.agent-messenger')
51
+ }
52
+
53
+ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise<KakaotalkBootstrapStatus> {
54
+ const configDir = kakaotalkConfigDir(input.agentDir)
55
+ try {
56
+ const loginFlow = input.loginFlow ?? (await resolveLoginFlow())
57
+ const credManager = new KakaoCredentialManager(configDir)
58
+ const pending = await credManager.loadPendingLogin()
59
+ const existing = await credManager.getAccount()
60
+ const savedDeviceUuid =
61
+ pending?.device_uuid ?? (existing?.auth_method === 'login' ? existing.device_uuid : undefined)
62
+
63
+ const result = await loginFlow({
64
+ email: input.email,
65
+ password: input.password,
66
+ deviceType: 'tablet',
67
+ force: false,
68
+ ...(savedDeviceUuid !== undefined ? { savedDeviceUuid } : {}),
69
+ onPasscodeDisplay: input.callbacks.onPasscode,
70
+ })
71
+
72
+ if (!result.authenticated || !result.credentials) {
73
+ const reason = result.message ?? result.error ?? 'agent-kakaotalk did not authenticate (check email/password)'
74
+ return { ok: false, reason }
75
+ }
76
+
77
+ const now = new Date().toISOString()
78
+ const accountId = result.credentials.user_id || 'default'
79
+ await credManager.setAccount({
80
+ account_id: accountId,
81
+ oauth_token: result.credentials.access_token,
82
+ user_id: result.credentials.user_id,
83
+ refresh_token: result.credentials.refresh_token,
84
+ device_uuid: result.credentials.device_uuid,
85
+ device_type: result.credentials.device_type,
86
+ auth_method: 'login',
87
+ created_at: now,
88
+ updated_at: now,
89
+ })
90
+ await credManager.setCurrentAccount(accountId)
91
+ await credManager.clearPendingLogin()
92
+
93
+ return { ok: true }
94
+ } catch (err) {
95
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
96
+ }
97
+ }
98
+
99
+ // agent-messenger does not export `loginFlow` from its public `exports` map
100
+ // (only the runtime client + credential manager), so we resolve the package's
101
+ // installed location and import the implementation file directly. This
102
+ // bypasses the exports gate but stays within the same installed copy of the
103
+ // package — no version drift risk. If a future agent-messenger release adds
104
+ // `loginFlow` to its public exports, swap this for a normal import and delete
105
+ // the resolveLoginFlow helper.
106
+ async function resolveLoginFlow(): Promise<LoginFlowFn> {
107
+ const require = createRequire(import.meta.url)
108
+ const pkgJson = require.resolve('agent-messenger/package.json')
109
+ const pkgDir = pkgJson.replace(/\/package\.json$/, '')
110
+ const mod = (await import(`${pkgDir}/dist/src/platforms/kakaotalk/auth/kakao-login.js`)) as {
111
+ loginFlow: LoginFlowFn
112
+ }
113
+ return mod.loginFlow
114
+ }
@@ -0,0 +1,130 @@
1
+ import { KNOWN_PROVIDERS, type KnownModelRef, type KnownProviderId, listKnownModelRefs } from '@/config/providers'
2
+
3
+ const MODELS_DEV_URL = 'https://models.dev/api.json'
4
+ const REQUEST_TIMEOUT_MS = 10_000
5
+
6
+ // models.dev keys providers by a string id that does NOT always match our
7
+ // KnownProviderId. Specifically, they ship Fireworks under `fireworks-ai`.
8
+ // This map is the single place that bridges the two namespaces; every other
9
+ // helper in this file works in OUR namespace.
10
+ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
11
+ openai: 'openai',
12
+ // openai-codex models live under the `openai` namespace on models.dev too
13
+ // (Codex is a backend, not a separate provider in their taxonomy). Curated
14
+ // entries are surfaced regardless of upstream membership.
15
+ 'openai-codex': 'openai',
16
+ fireworks: 'fireworks-ai',
17
+ }
18
+
19
+ export type ModelOption = {
20
+ ref: KnownModelRef
21
+ providerId: KnownProviderId
22
+ providerName: string
23
+ modelId: string
24
+ modelName: string
25
+ reasoning: boolean
26
+ contextWindow: number | null
27
+ curated: boolean
28
+ }
29
+
30
+ type ModelsDevModel = {
31
+ id?: string
32
+ name?: string
33
+ reasoning?: boolean
34
+ tool_call?: boolean
35
+ status?: string
36
+ release_date?: string
37
+ modalities?: { input?: string[]; output?: string[] }
38
+ limit?: { context?: number }
39
+ }
40
+
41
+ type ModelsDevProvider = {
42
+ id?: string
43
+ name?: string
44
+ models?: Record<string, ModelsDevModel>
45
+ }
46
+
47
+ export type FetchModelsResult = {
48
+ options: ModelOption[]
49
+ source: 'models.dev' | 'curated'
50
+ warning?: string
51
+ }
52
+
53
+ // Pulls the live model catalog from models.dev, intersects it with our
54
+ // curated KNOWN_PROVIDERS allowlist, and returns one ModelOption per
55
+ // (provider, model) pair the user is allowed to pick at init time.
56
+ //
57
+ // Falls back to the curated list alone if the network is unreachable, the
58
+ // response is malformed, or any unexpected error fires — the wizard MUST
59
+ // stay functional offline because `typeclaw init` is the very first thing a
60
+ // user does on a fresh machine, often before networking is sorted.
61
+ export async function fetchModelOptions(
62
+ options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
63
+ ): Promise<FetchModelsResult> {
64
+ const fetchImpl = options.fetchImpl ?? fetch
65
+ const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS
66
+ try {
67
+ const res = await fetchImpl(MODELS_DEV_URL, { signal: AbortSignal.timeout(timeoutMs) })
68
+ if (!res.ok) {
69
+ return { options: curatedOptions(), source: 'curated', warning: `models.dev returned ${res.status}` }
70
+ }
71
+ const data = (await res.json()) as Record<string, ModelsDevProvider>
72
+ return { options: mergeWithCurated(data), source: 'models.dev' }
73
+ } catch (error) {
74
+ const reason = error instanceof Error ? error.message : String(error)
75
+ return { options: curatedOptions(), source: 'curated', warning: reason }
76
+ }
77
+ }
78
+
79
+ // The curated-only path: every model in KNOWN_PROVIDERS, sorted with the
80
+ // default model first so the picker can use index-0 as `initialValue`.
81
+ export function curatedOptions(): ModelOption[] {
82
+ const refs = listKnownModelRefs()
83
+ return refs.map((ref) => buildOption(ref, { curated: true }))
84
+ }
85
+
86
+ // `data` is the parsed models.dev JSON. We walk only the providers we care
87
+ // about (openai, fireworks-ai) and only emit options for models that are
88
+ // also in our curated allowlist — anything outside the allowlist would fail
89
+ // schema validation when written to typeclaw.json. Curated entries that
90
+ // models.dev doesn't list (e.g. kimi-k2p6-turbo) are still surfaced so the
91
+ // user can pick them.
92
+ function mergeWithCurated(data: Record<string, ModelsDevProvider>): ModelOption[] {
93
+ const out: ModelOption[] = []
94
+ for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
95
+ const known = KNOWN_PROVIDERS[providerId]
96
+ const upstream = data[PROVIDER_TO_MODELS_DEV[providerId]]
97
+ const upstreamModels = upstream?.models ?? {}
98
+ for (const modelId of Object.keys(known.models)) {
99
+ const upstreamModel = upstreamModels[modelId]
100
+ const ref = `${providerId}/${modelId}` as KnownModelRef
101
+ out.push(buildOption(ref, { curated: true, upstream: upstreamModel }))
102
+ }
103
+ }
104
+ return out
105
+ }
106
+
107
+ type BuildOptionOpts = {
108
+ curated: boolean
109
+ upstream?: ModelsDevModel
110
+ }
111
+
112
+ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
113
+ const slash = ref.indexOf('/')
114
+ const providerId = ref.slice(0, slash) as KnownProviderId
115
+ const modelId = ref.slice(slash + 1)
116
+ const provider = KNOWN_PROVIDERS[providerId]
117
+ const curatedModel = (
118
+ provider.models as Record<string, { name: string; contextWindow?: number; reasoning?: boolean }>
119
+ )[modelId]
120
+ return {
121
+ ref,
122
+ providerId,
123
+ providerName: provider.name,
124
+ modelId,
125
+ modelName: opts.upstream?.name ?? curatedModel?.name ?? modelId,
126
+ reasoning: opts.upstream?.reasoning ?? curatedModel?.reasoning ?? false,
127
+ contextWindow: opts.upstream?.limit?.context ?? curatedModel?.contextWindow ?? null,
128
+ curated: opts.curated,
129
+ }
130
+ }
@@ -0,0 +1,74 @@
1
+ import { join } from 'node:path'
2
+
3
+ import {
4
+ KNOWN_PROVIDERS,
5
+ providerForModelRef,
6
+ supportsOAuth,
7
+ type KnownModelRef,
8
+ type KnownProviderId,
9
+ } from '@/config/providers'
10
+ import { createSecretsStoreForAgent } from '@/secrets'
11
+
12
+ export type OAuthLoginResult = { ok: true } | { ok: false; reason: string }
13
+
14
+ export type OAuthLoginRunner = (options: { cwd: string; model: KnownModelRef }) => Promise<OAuthLoginResult>
15
+
16
+ // Wrap pi-ai's OAuth callbacks so the CLI doesn't have to know about the
17
+ // upstream callback shape. The CLI only sees three lifecycle events:
18
+ // (1) onAuth(url) — print the URL the user must visit
19
+ // (2) onProgress(message) — show waiting/finalizing status
20
+ // (3) onPrompt(prompt) — ask the user for a manual code if the browser flow
21
+ // can't reach the local callback server. Most users won't see this; it
22
+ // fires when they paste the post-redirect URL by hand.
23
+ export type OAuthCallbacks = {
24
+ onAuth: (url: string, instructions?: string) => void
25
+ onProgress?: (message: string) => void
26
+ onPrompt: (message: string, placeholder?: string) => Promise<string | null>
27
+ }
28
+
29
+ // Default runner: real OAuth flow against pi-ai. Tests inject a stub to skip
30
+ // network entirely. The runner's only job is to log in, write to the secrets
31
+ // file, and report ok/error — it does NOT update typeclaw.json (the model
32
+ // ref is already chosen by the caller and written by `scaffold`).
33
+ export function makeOAuthLoginRunner(callbacks: OAuthCallbacks): OAuthLoginRunner {
34
+ return async ({ cwd, model }) => {
35
+ const providerId = providerForModelRef(model)
36
+ const provider = KNOWN_PROVIDERS[providerId]
37
+ if (!supportsOAuth(provider) || !provider.oauthProviderId) {
38
+ return { ok: false, reason: `Provider ${provider.name} does not support OAuth` }
39
+ }
40
+
41
+ try {
42
+ const secrets = createSecretsStoreForAgent(join(cwd, 'secrets.json'))
43
+ await secrets.login(provider.oauthProviderId, {
44
+ onAuth: (info) => callbacks.onAuth(info.url, info.instructions),
45
+ onProgress: callbacks.onProgress,
46
+ onPrompt: async (prompt) => {
47
+ const value = await callbacks.onPrompt(prompt.message, prompt.placeholder)
48
+ if (value === null) {
49
+ throw new Error('Login cancelled by user')
50
+ }
51
+ return value
52
+ },
53
+ })
54
+ return { ok: true }
55
+ } catch (error) {
56
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
57
+ }
58
+ }
59
+ }
60
+
61
+ // Test seam: lets unit tests assert "OAuth login was invoked with these
62
+ // params" without spinning up a real secrets store / browser callback server.
63
+ export type FakeOAuthLoginRunnerOptions = {
64
+ result?: OAuthLoginResult
65
+ onCalled?: (options: { cwd: string; model: KnownModelRef; providerId: KnownProviderId }) => void
66
+ }
67
+
68
+ export function makeFakeOAuthLoginRunner(options: FakeOAuthLoginRunnerOptions = {}): OAuthLoginRunner {
69
+ return async ({ cwd, model }) => {
70
+ const providerId = providerForModelRef(model)
71
+ options.onCalled?.({ cwd, model, providerId })
72
+ return options.result ?? { ok: true }
73
+ }
74
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
6
+
7
+ export const PACKAGE_FILE = 'package.json'
8
+ export const WORKSPACES_GLOB = `${PACKAGES_DIR}/*`
9
+
10
+ export type PackageJsonRefreshResult = {
11
+ changed: boolean
12
+ files: string[]
13
+ }
14
+
15
+ // Migrates an existing agent folder into a bun monorepo layout. Idempotent —
16
+ // running twice is a no-op. Skips silently when:
17
+ // - package.json is missing (folder not initialized yet)
18
+ // - package.json is unparseable (we never touch corrupt user files)
19
+ // - workspaces is already set (user opted in or customized)
20
+ //
21
+ // Always ensures `packages/<GITKEEP_FILE>` exists so the directory survives the
22
+ // initial git commit, regardless of whether package.json was touched.
23
+ //
24
+ // Returns the list of paths the caller should consider for git auto-commit.
25
+ // The caller (typeclaw start) commits these via the same `commitSystemFile`
26
+ // pattern as .gitignore, keeping the monorepo migration on git's record.
27
+ export async function refreshPackageJson(cwd: string): Promise<PackageJsonRefreshResult> {
28
+ const changed: string[] = []
29
+ const pkgPath = join(cwd, PACKAGE_FILE)
30
+
31
+ if (existsSync(pkgPath)) {
32
+ const updated = await ensureWorkspacesField(pkgPath)
33
+ if (updated) changed.push(PACKAGE_FILE)
34
+ }
35
+
36
+ const gitkeepRel = join(PACKAGES_DIR, GITKEEP_FILE)
37
+ const gitkeepPath = join(cwd, gitkeepRel)
38
+ if (!existsSync(gitkeepPath)) {
39
+ await mkdir(join(cwd, PACKAGES_DIR), { recursive: true })
40
+ await writeFile(gitkeepPath, '')
41
+ changed.push(gitkeepRel)
42
+ }
43
+
44
+ return { changed: changed.length > 0, files: changed }
45
+ }
46
+
47
+ async function ensureWorkspacesField(pkgPath: string): Promise<boolean> {
48
+ let raw: string
49
+ try {
50
+ raw = await readFile(pkgPath, 'utf8')
51
+ } catch {
52
+ return false
53
+ }
54
+
55
+ let pkg: Record<string, unknown>
56
+ try {
57
+ const parsed = JSON.parse(raw) as unknown
58
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false
59
+ pkg = parsed as Record<string, unknown>
60
+ } catch {
61
+ return false
62
+ }
63
+
64
+ if ('workspaces' in pkg) return false
65
+
66
+ // Insertion order matters: place `workspaces` right after `type` (or after
67
+ // top-of-object metadata if `type` is absent) so the diff reads cleanly
68
+ // alongside the buildPackageJson template's field order. Without this, a
69
+ // freshly-migrated package.json has `workspaces` at the bottom — visually
70
+ // jarring and harder to spot on review.
71
+ const next = insertAfterKey(pkg, 'type', 'workspaces', [WORKSPACES_GLOB])
72
+ await writeFile(pkgPath, `${JSON.stringify(next, null, 2)}\n`)
73
+ return true
74
+ }
75
+
76
+ function insertAfterKey(
77
+ obj: Record<string, unknown>,
78
+ anchor: string,
79
+ key: string,
80
+ value: unknown,
81
+ ): Record<string, unknown> {
82
+ const out: Record<string, unknown> = {}
83
+ const keys = Object.keys(obj)
84
+ const anchorIdx = keys.indexOf(anchor)
85
+ if (anchorIdx === -1) {
86
+ return { [key]: value, ...obj }
87
+ }
88
+ for (let i = 0; i < keys.length; i++) {
89
+ const k = keys[i] as string
90
+ out[k] = obj[k]
91
+ if (i === anchorIdx) out[key] = value
92
+ }
93
+ return out
94
+ }
@@ -0,0 +1,2 @@
1
+ export const PACKAGES_DIR = 'packages'
2
+ export const GITKEEP_FILE = '.gitkeep'
@@ -0,0 +1,20 @@
1
+ export type InstallResult = { ok: true } | { ok: false; reason: string }
2
+
3
+ export async function runBunInstall(cwd: string): Promise<InstallResult> {
4
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
5
+ if (!bun) return { ok: false, reason: 'bun runtime not available' }
6
+ try {
7
+ const proc = bun.spawn({
8
+ cmd: ['bun', 'install'],
9
+ cwd,
10
+ stdout: 'pipe',
11
+ stderr: 'pipe',
12
+ })
13
+ const code = await proc.exited
14
+ if (code === 0) return { ok: true }
15
+ const stderr = await new Response(proc.stderr).text()
16
+ return { ok: false, reason: `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
17
+ } catch (error) {
18
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
19
+ }
20
+ }