@dharta/cli 0.6.77

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 (259) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/dist/agent.d.ts +17 -0
  4. package/dist/agent.js +134 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/api-error.d.ts +3 -0
  7. package/dist/api-error.js +80 -0
  8. package/dist/api-error.js.map +1 -0
  9. package/dist/browser.d.ts +1 -0
  10. package/dist/browser.js +19 -0
  11. package/dist/browser.js.map +1 -0
  12. package/dist/cli.d.ts +5 -0
  13. package/dist/cli.js +1978 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/commands/agents.d.ts +131 -0
  16. package/dist/commands/agents.js +439 -0
  17. package/dist/commands/agents.js.map +1 -0
  18. package/dist/commands/analytics.d.ts +25 -0
  19. package/dist/commands/analytics.js +166 -0
  20. package/dist/commands/analytics.js.map +1 -0
  21. package/dist/commands/api.d.ts +19 -0
  22. package/dist/commands/api.js +278 -0
  23. package/dist/commands/api.js.map +1 -0
  24. package/dist/commands/appearance.d.ts +19 -0
  25. package/dist/commands/appearance.js +63 -0
  26. package/dist/commands/appearance.js.map +1 -0
  27. package/dist/commands/artifact.d.ts +33 -0
  28. package/dist/commands/artifact.js +235 -0
  29. package/dist/commands/artifact.js.map +1 -0
  30. package/dist/commands/audit-log.d.ts +32 -0
  31. package/dist/commands/audit-log.js +102 -0
  32. package/dist/commands/audit-log.js.map +1 -0
  33. package/dist/commands/auth.d.ts +47 -0
  34. package/dist/commands/auth.js +117 -0
  35. package/dist/commands/auth.js.map +1 -0
  36. package/dist/commands/caps.d.ts +82 -0
  37. package/dist/commands/caps.js +213 -0
  38. package/dist/commands/caps.js.map +1 -0
  39. package/dist/commands/create.d.ts +22 -0
  40. package/dist/commands/create.js +76 -0
  41. package/dist/commands/create.js.map +1 -0
  42. package/dist/commands/deploy.d.ts +45 -0
  43. package/dist/commands/deploy.js +892 -0
  44. package/dist/commands/deploy.js.map +1 -0
  45. package/dist/commands/dev.d.ts +33 -0
  46. package/dist/commands/dev.js +655 -0
  47. package/dist/commands/dev.js.map +1 -0
  48. package/dist/commands/directories.d.ts +60 -0
  49. package/dist/commands/directories.js +83 -0
  50. package/dist/commands/directories.js.map +1 -0
  51. package/dist/commands/doctor.d.ts +13 -0
  52. package/dist/commands/doctor.js +123 -0
  53. package/dist/commands/doctor.js.map +1 -0
  54. package/dist/commands/embed-keys.d.ts +61 -0
  55. package/dist/commands/embed-keys.js +165 -0
  56. package/dist/commands/embed-keys.js.map +1 -0
  57. package/dist/commands/env.d.ts +59 -0
  58. package/dist/commands/env.js +300 -0
  59. package/dist/commands/env.js.map +1 -0
  60. package/dist/commands/files.d.ts +14 -0
  61. package/dist/commands/files.js +84 -0
  62. package/dist/commands/files.js.map +1 -0
  63. package/dist/commands/git-credential.d.ts +1 -0
  64. package/dist/commands/git-credential.js +70 -0
  65. package/dist/commands/git-credential.js.map +1 -0
  66. package/dist/commands/kartas.d.ts +101 -0
  67. package/dist/commands/kartas.js +289 -0
  68. package/dist/commands/kartas.js.map +1 -0
  69. package/dist/commands/keys.d.ts +33 -0
  70. package/dist/commands/keys.js +141 -0
  71. package/dist/commands/keys.js.map +1 -0
  72. package/dist/commands/login.d.ts +22 -0
  73. package/dist/commands/login.js +78 -0
  74. package/dist/commands/login.js.map +1 -0
  75. package/dist/commands/logs.d.ts +14 -0
  76. package/dist/commands/logs.js +89 -0
  77. package/dist/commands/logs.js.map +1 -0
  78. package/dist/commands/members.d.ts +32 -0
  79. package/dist/commands/members.js +77 -0
  80. package/dist/commands/members.js.map +1 -0
  81. package/dist/commands/model-keys.d.ts +22 -0
  82. package/dist/commands/model-keys.js +222 -0
  83. package/dist/commands/model-keys.js.map +1 -0
  84. package/dist/commands/open.d.ts +29 -0
  85. package/dist/commands/open.js +119 -0
  86. package/dist/commands/open.js.map +1 -0
  87. package/dist/commands/parity.d.ts +15 -0
  88. package/dist/commands/parity.js +73 -0
  89. package/dist/commands/parity.js.map +1 -0
  90. package/dist/commands/rollback.d.ts +21 -0
  91. package/dist/commands/rollback.js +26 -0
  92. package/dist/commands/rollback.js.map +1 -0
  93. package/dist/commands/schedules.d.ts +52 -0
  94. package/dist/commands/schedules.js +231 -0
  95. package/dist/commands/schedules.js.map +1 -0
  96. package/dist/commands/scopes.d.ts +3 -0
  97. package/dist/commands/scopes.js +21 -0
  98. package/dist/commands/scopes.js.map +1 -0
  99. package/dist/commands/sessions.d.ts +53 -0
  100. package/dist/commands/sessions.js +142 -0
  101. package/dist/commands/sessions.js.map +1 -0
  102. package/dist/commands/setup.d.ts +28 -0
  103. package/dist/commands/setup.js +949 -0
  104. package/dist/commands/setup.js.map +1 -0
  105. package/dist/commands/status.d.ts +19 -0
  106. package/dist/commands/status.js +63 -0
  107. package/dist/commands/status.js.map +1 -0
  108. package/dist/commands/update.d.ts +14 -0
  109. package/dist/commands/update.js +120 -0
  110. package/dist/commands/update.js.map +1 -0
  111. package/dist/commands/usage.d.ts +3 -0
  112. package/dist/commands/usage.js +37 -0
  113. package/dist/commands/usage.js.map +1 -0
  114. package/dist/commands/webhooks.d.ts +47 -0
  115. package/dist/commands/webhooks.js +156 -0
  116. package/dist/commands/webhooks.js.map +1 -0
  117. package/dist/commands/whoami.d.ts +7 -0
  118. package/dist/commands/whoami.js +17 -0
  119. package/dist/commands/whoami.js.map +1 -0
  120. package/dist/completions.d.ts +13 -0
  121. package/dist/completions.js +120 -0
  122. package/dist/completions.js.map +1 -0
  123. package/dist/config.d.ts +20 -0
  124. package/dist/config.js +98 -0
  125. package/dist/config.js.map +1 -0
  126. package/dist/context.d.ts +14 -0
  127. package/dist/context.js +38 -0
  128. package/dist/context.js.map +1 -0
  129. package/dist/device-flow.d.ts +9 -0
  130. package/dist/device-flow.js +127 -0
  131. package/dist/device-flow.js.map +1 -0
  132. package/dist/harness-preflight.d.ts +24 -0
  133. package/dist/harness-preflight.js +252 -0
  134. package/dist/harness-preflight.js.map +1 -0
  135. package/dist/harnesses.d.ts +11 -0
  136. package/dist/harnesses.js +112 -0
  137. package/dist/harnesses.js.map +1 -0
  138. package/dist/index.d.ts +3 -0
  139. package/dist/index.js +3 -0
  140. package/dist/index.js.map +1 -0
  141. package/dist/login-offer.d.ts +9 -0
  142. package/dist/login-offer.js +36 -0
  143. package/dist/login-offer.js.map +1 -0
  144. package/dist/manifest.d.ts +15 -0
  145. package/dist/manifest.js +142 -0
  146. package/dist/manifest.js.map +1 -0
  147. package/dist/money.d.ts +1 -0
  148. package/dist/money.js +16 -0
  149. package/dist/money.js.map +1 -0
  150. package/dist/output.d.ts +2 -0
  151. package/dist/output.js +20 -0
  152. package/dist/output.js.map +1 -0
  153. package/dist/runtime.d.ts +19 -0
  154. package/dist/runtime.js +95 -0
  155. package/dist/runtime.js.map +1 -0
  156. package/dist/scaffold.d.ts +16 -0
  157. package/dist/scaffold.js +74 -0
  158. package/dist/scaffold.js.map +1 -0
  159. package/dist/setup-scan.d.ts +21 -0
  160. package/dist/setup-scan.js +127 -0
  161. package/dist/setup-scan.js.map +1 -0
  162. package/dist/templates.d.ts +14 -0
  163. package/dist/templates.js +65 -0
  164. package/dist/templates.js.map +1 -0
  165. package/dist/validators.d.ts +10 -0
  166. package/dist/validators.js +116 -0
  167. package/dist/validators.js.map +1 -0
  168. package/dist/version-check.d.ts +25 -0
  169. package/dist/version-check.js +68 -0
  170. package/dist/version-check.js.map +1 -0
  171. package/node_modules/@dharta/sdk/LICENSE +21 -0
  172. package/node_modules/@dharta/sdk/README.md +87 -0
  173. package/node_modules/@dharta/sdk/dist/errors.d.ts +20 -0
  174. package/node_modules/@dharta/sdk/dist/errors.d.ts.map +1 -0
  175. package/node_modules/@dharta/sdk/dist/errors.js +51 -0
  176. package/node_modules/@dharta/sdk/dist/errors.js.map +1 -0
  177. package/node_modules/@dharta/sdk/dist/http.d.ts +26 -0
  178. package/node_modules/@dharta/sdk/dist/http.d.ts.map +1 -0
  179. package/node_modules/@dharta/sdk/dist/http.js +136 -0
  180. package/node_modules/@dharta/sdk/dist/http.js.map +1 -0
  181. package/node_modules/@dharta/sdk/dist/index.d.ts +15 -0
  182. package/node_modules/@dharta/sdk/dist/index.d.ts.map +1 -0
  183. package/node_modules/@dharta/sdk/dist/index.js +21 -0
  184. package/node_modules/@dharta/sdk/dist/index.js.map +1 -0
  185. package/node_modules/@dharta/sdk/dist/resources.d.ts +48 -0
  186. package/node_modules/@dharta/sdk/dist/resources.d.ts.map +1 -0
  187. package/node_modules/@dharta/sdk/dist/resources.js +150 -0
  188. package/node_modules/@dharta/sdk/dist/resources.js.map +1 -0
  189. package/node_modules/@dharta/sdk/dist/types.d.ts +94 -0
  190. package/node_modules/@dharta/sdk/dist/types.d.ts.map +1 -0
  191. package/node_modules/@dharta/sdk/dist/types.js +59 -0
  192. package/node_modules/@dharta/sdk/dist/types.js.map +1 -0
  193. package/node_modules/@dharta/sdk/package.json +32 -0
  194. package/node_modules/@dharta/sdk/src/errors.ts +58 -0
  195. package/node_modules/@dharta/sdk/src/http.ts +166 -0
  196. package/node_modules/@dharta/sdk/src/index.ts +36 -0
  197. package/node_modules/@dharta/sdk/src/resources.ts +197 -0
  198. package/node_modules/@dharta/sdk/src/types.ts +179 -0
  199. package/package.json +38 -0
  200. package/src/agent.ts +147 -0
  201. package/src/api-error.ts +88 -0
  202. package/src/browser.ts +20 -0
  203. package/src/cli.ts +2339 -0
  204. package/src/commands/agents.ts +635 -0
  205. package/src/commands/analytics.ts +181 -0
  206. package/src/commands/api.ts +297 -0
  207. package/src/commands/appearance.ts +79 -0
  208. package/src/commands/artifact.ts +331 -0
  209. package/src/commands/audit-log.ts +125 -0
  210. package/src/commands/auth.ts +186 -0
  211. package/src/commands/caps.ts +302 -0
  212. package/src/commands/create.ts +114 -0
  213. package/src/commands/deploy.ts +1122 -0
  214. package/src/commands/dev.ts +792 -0
  215. package/src/commands/directories.ts +136 -0
  216. package/src/commands/doctor.ts +138 -0
  217. package/src/commands/embed-keys.ts +237 -0
  218. package/src/commands/env.ts +369 -0
  219. package/src/commands/files.ts +124 -0
  220. package/src/commands/git-credential.ts +78 -0
  221. package/src/commands/kartas.ts +439 -0
  222. package/src/commands/keys.ts +206 -0
  223. package/src/commands/login.ts +115 -0
  224. package/src/commands/logs.ts +111 -0
  225. package/src/commands/members.ts +109 -0
  226. package/src/commands/model-keys.ts +278 -0
  227. package/src/commands/open.ts +173 -0
  228. package/src/commands/parity.ts +96 -0
  229. package/src/commands/rollback.ts +45 -0
  230. package/src/commands/schedules.ts +290 -0
  231. package/src/commands/scopes.ts +22 -0
  232. package/src/commands/sessions.ts +225 -0
  233. package/src/commands/setup.ts +1176 -0
  234. package/src/commands/status.ts +94 -0
  235. package/src/commands/update.ts +157 -0
  236. package/src/commands/usage.ts +41 -0
  237. package/src/commands/webhooks.ts +200 -0
  238. package/src/commands/whoami.ts +24 -0
  239. package/src/completions.ts +168 -0
  240. package/src/config.ts +143 -0
  241. package/src/context.ts +59 -0
  242. package/src/device-flow.ts +174 -0
  243. package/src/harness-preflight.ts +293 -0
  244. package/src/harnesses.ts +117 -0
  245. package/src/index.ts +3 -0
  246. package/src/login-offer.ts +49 -0
  247. package/src/manifest.ts +153 -0
  248. package/src/money.ts +15 -0
  249. package/src/output.ts +22 -0
  250. package/src/runtime.ts +127 -0
  251. package/src/scaffold.ts +102 -0
  252. package/src/setup-scan.ts +154 -0
  253. package/src/templates.ts +85 -0
  254. package/src/validators.ts +124 -0
  255. package/src/version-check.ts +75 -0
  256. package/templates/blank/CLAUDE.md +21 -0
  257. package/templates/blank/dharta.toml +11 -0
  258. package/templates/faq-agent/CLAUDE.md +51 -0
  259. package/templates/faq-agent/dharta.toml +11 -0
@@ -0,0 +1,792 @@
1
+ // `dharta dev` (RFC 0013 CLI-4): the consumer preview. Spawns the pinned
2
+ // dharta-runtime serving this folder behind the same consumer session API
3
+ // production serves, then drives it as a plain HTTP/SSE client — the
4
+ // REPL has no privileged channel, so what works here works for the
5
+ // widget (D4). Running the native harness CLI in the folder is the authoring mode;
6
+ // this is the preview mode.
7
+
8
+ import { promises as fs } from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as net from "node:net";
11
+ import * as readline from "node:readline";
12
+ import { randomUUID } from "node:crypto";
13
+ import TOML from "@iarna/toml";
14
+ import { resolveRuntime, spawnRuntime } from "../runtime.js";
15
+ import { buildContext } from "../context.js";
16
+ import { fetchRemoteEnv, prepareReleaseDir } from "./parity.js";
17
+ import { parseDotenvFile } from "./env.js";
18
+ import { describeFolder } from "../setup-scan.js";
19
+ import { hasAgentMarker } from "../harnesses.js";
20
+ import { normalizeNonBlank } from "../validators.js";
21
+
22
+ export interface DevOptions {
23
+ dir?: string;
24
+ message?: string; // -m: one turn, then exit
25
+ user?: string; // --user: simulated end-user id (session metadata)
26
+ port?: number; // stable local port (default: an ephemeral free one)
27
+ verbose?: boolean; // stream runtime output + parity details
28
+ // Parity axes (RFC 0013 CLI-10); both need a logged-in context.
29
+ release?: string; // --release vN: run the exact built tree prod serves
30
+ envRemote?: boolean; // --env remote: bind prod env vars instead of .env
31
+ includeSecrets?: boolean; // with --env remote: fetch secret values (env:secrets:read)
32
+ project?: string; // -p override for the parity flags' project inference
33
+ // Test seams. `attach` skips the spawn and drives an existing server.
34
+ attach?: string;
35
+ fetchImpl?: typeof fetch;
36
+ input?: NodeJS.ReadableStream;
37
+ write?: (text: string) => void; // raw writes (text deltas)
38
+ log?: (line: string) => void;
39
+ healthTimeoutMs?: number;
40
+ turnIdleMs?: number; // abort a turn with no output for this long (default 60s)
41
+ }
42
+
43
+ interface SseEvent {
44
+ event: string;
45
+ data: Record<string, unknown>;
46
+ }
47
+
48
+ // `dharta dev [path]` in a multi-agent repo (RFC 0024 DEP-8): with no path
49
+ // given, serve cwd if it is itself an agent; else the sole agent folder
50
+ // directly below it; else ask which one. An explicit path always wins (the
51
+ // caller skips this). Shallow by design - dev previews one local agent.
52
+ export async function resolveAgentDir(dir: string, log: (l: string) => void): Promise<string> {
53
+ if (hasAgentMarker(dir)) return dir;
54
+
55
+ let subs: string[] = [];
56
+ try {
57
+ subs = (await fs.readdir(dir, { withFileTypes: true }))
58
+ .filter(
59
+ (e) =>
60
+ e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && hasAgentMarker(path.join(dir, e.name)),
61
+ )
62
+ .map((e) => e.name)
63
+ .sort();
64
+ } catch {
65
+ return dir; // unreadable: serve as-is, miniDoctor reports the real problem
66
+ }
67
+
68
+ if (subs.length === 1) {
69
+ log(`Running the only agent here: ./${subs[0]}`);
70
+ return path.join(dir, subs[0]);
71
+ }
72
+ if (subs.length > 1) {
73
+ throw new Error(`Multiple agents here (${subs.join(", ")}). Pick one, e.g. dharta dev ./${subs[0]}`);
74
+ }
75
+ return dir; // no agent here or one level down - serve cwd (miniDoctor warns)
76
+ }
77
+
78
+ export async function dev(opts: DevOptions = {}): Promise<void> {
79
+ const log = opts.log ?? ((line: string) => console.log(line));
80
+ const write = opts.write ?? ((text: string) => process.stdout.write(text));
81
+ const fetchImpl = (opts.fetchImpl ?? globalThis.fetch).bind(globalThis);
82
+
83
+ if (opts.port !== undefined && (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535)) {
84
+ throw new Error("--port must be an integer between 1 and 65535.");
85
+ }
86
+ const normalizedDir = opts.dir === undefined ? undefined : normalizeNonBlank(opts.dir, "--dir");
87
+ const normalizedUser = opts.user === undefined ? undefined : normalizeNonBlank(opts.user, "--user");
88
+ const normalizedMessage = opts.message === undefined ? undefined : normalizeNonBlank(opts.message, "--message");
89
+ if (opts.includeSecrets && !opts.envRemote) throw new Error("--include-secrets requires --env remote.");
90
+
91
+ let dir = path.resolve(normalizedDir ?? process.cwd());
92
+ if (normalizedDir !== undefined) {
93
+ await validateExplicitDevDir(dir);
94
+ }
95
+ // No explicit path given: pick the agent to preview (cwd, sole subdir, or
96
+ // ask) - RFC 0024 DEP-8. An explicit --dir / [path] is served as-is.
97
+ if (normalizedDir === undefined) dir = await resolveAgentDir(dir, log);
98
+ let childEnv: NodeJS.ProcessEnv | undefined;
99
+ if (opts.release || opts.envRemote) {
100
+ const ctx = await buildContext({ fetch: opts.fetchImpl });
101
+ const inferOpts = { dir: normalizedDir, project: opts.project, fetchImpl: opts.fetchImpl };
102
+ if (opts.release) {
103
+ const release = await prepareReleaseDir(ctx, opts.release, inferOpts);
104
+ log(
105
+ `Running release v${release.version} (${release.commitSha.slice(0, 8)}) - the exact tree prod serves. ` +
106
+ "Edits to it are NOT deploys; hot reload applies to this copy only.",
107
+ );
108
+ dir = release.dir;
109
+ }
110
+ if (opts.envRemote) {
111
+ const remote = await fetchRemoteEnv(ctx, { ...inferOpts, includeSecrets: opts.includeSecrets });
112
+ const names = Object.keys(remote.values);
113
+ childEnv = { ...process.env, ...remote.values };
114
+ log(`Bound ${names.length} prod env var${names.length === 1 ? "" : "s"}: ${names.join(", ") || "(none)"}`);
115
+ if (remote.withheld.length > 0) {
116
+ log(
117
+ `${remote.withheld.length} secret value(s) withheld (${remote.withheld.join(", ")}) - ` +
118
+ "calls through them will fail; --include-secrets with env:secrets:read fetches them (audited).",
119
+ );
120
+ }
121
+ }
122
+ }
123
+
124
+ const agentName = (await describeFolder(dir, ".")).name ?? path.basename(dir);
125
+ log(`Dharta dev mode: ${agentName}`);
126
+ log("");
127
+
128
+ for (const warning of await miniDoctor(dir)) log(`[doctor] ${warning}`);
129
+
130
+ let baseUrl = opts.attach;
131
+ let child: ReturnType<typeof spawnRuntime> | null = null;
132
+ let spawnError: Error | null = null;
133
+ let runtimeOutput: RuntimeOutputBuffer | null = null;
134
+ let killTurn: (() => void) | null = null; // set per-turn: aborts a wedged/crashed turn
135
+ if (!baseUrl) {
136
+ const runtime = await resolveRuntime();
137
+ const port = opts.port ?? (await freePort());
138
+ baseUrl = `http://127.0.0.1:${port}`;
139
+ log(`✓ Starting agent runtime (${runtime.description})…`);
140
+ child = spawnRuntime(runtime, ["dev-serve", dir, "--host", "127.0.0.1", "--port", String(port)], {
141
+ stdio: "pipe",
142
+ env: childEnv,
143
+ });
144
+ // A spawn error (binary missing, not executable) never fires `exit` —
145
+ // capture it so waitForHealthy fails fast with the remedy, not a
146
+ // stack trace after a 120s timeout.
147
+ child.on("error", (err) => {
148
+ spawnError = new Error(
149
+ `Could not launch the agent runtime (${err.message}).\n` +
150
+ "Run `dharta doctor` to diagnose; DHARTA_RUNTIME=/path/to/checkout is the escape hatch.",
151
+ );
152
+ });
153
+ runtimeOutput = forwardRuntimeOutput(child, { stream: !!opts.verbose, onFatal: () => killTurn?.() });
154
+ }
155
+
156
+ // The runtime must die with us, including on plain `kill` — an
157
+ // orphaned uvicorn keeps the port bound.
158
+ const onSignal = (signal: NodeJS.Signals) => {
159
+ if (child) child.kill("SIGTERM");
160
+ process.exit(signal === "SIGINT" ? 130 : 143);
161
+ };
162
+ process.once("SIGINT", onSignal);
163
+ process.once("SIGTERM", onSignal);
164
+
165
+ let rl: readline.Interface | null = null;
166
+ try {
167
+ try {
168
+ await waitForHealthy(baseUrl, fetchImpl, child, opts.healthTimeoutMs ?? 120_000, () => spawnError);
169
+ } catch (err) {
170
+ // The remedial message says "see [runtime] output" - make that
171
+ // true even when the runtime stream was quieted by default.
172
+ runtimeOutput?.dump();
173
+ throw err;
174
+ }
175
+
176
+ log("✓ Agent ready");
177
+ log(` Local agent API: ${baseUrl}/v1`);
178
+ if (opts.verbose) {
179
+ log(" Point @dharta/widget or your frontend at it.");
180
+ log(" Preview parity is code + config, not infrastructure: no microVM, no per-user");
181
+ log(" workspaces, and turns run on YOUR harness credentials (prod injects its own).");
182
+ }
183
+ // The agent turn spawns `claude` on YOUR credentials; with none it exits 1.
184
+ // Hard-stop here rather than drop into a chat that can only fail (spawn
185
+ // path only - an attached runtime carries its own credentials).
186
+ if (child && !process.env.ANTHROPIC_API_KEY && !process.env.CLAUDE_CODE_OAUTH_TOKEN) {
187
+ log("");
188
+ log("! Error: No model credential found. Two options -");
189
+ log(" export ANTHROPIC_API_KEY=<your Anthropic API key>");
190
+ log(" OR");
191
+ log(" export CLAUDE_CODE_OAUTH_TOKEN=<Claude Code token from `claude setup-token`>");
192
+ process.exitCode = 1;
193
+ return;
194
+ }
195
+
196
+ const session = await createSession(baseUrl, fetchImpl, normalizedUser);
197
+ if (normalizedUser) log(`Simulating end user '${normalizedUser}'.`);
198
+
199
+ rl = readline.createInterface({
200
+ input: opts.input ?? process.stdin,
201
+ output: undefined,
202
+ terminal: false,
203
+ });
204
+ // SINGLE consumer for all input: turn text and approval answers pull
205
+ // from the same iterator. A second listener (rl.once("line")) would
206
+ // race the for-await loop — interactively the approval answer leaks
207
+ // back in as a user turn; with piped input it deadlocks outright.
208
+ const lines = rl[Symbol.asyncIterator]();
209
+ const ask = async (question: string): Promise<string | null> => {
210
+ write(question);
211
+ const { value, done } = await lines.next();
212
+ return done ? null : value; // null: input exhausted, nobody can answer
213
+ };
214
+
215
+ const idleMs = opts.turnIdleMs ?? 60_000;
216
+ // Run one turn but never hang: abort if the runtime crashes (onFatal) or
217
+ // goes silent for idleMs, then surface the buffered runtime output. The
218
+ // idle timer pauses while we wait on a human (approval prompt).
219
+ const runTurnWatched = async (turnText: string, prefix: string): Promise<void> => {
220
+ const controller = new AbortController();
221
+ let idle: ReturnType<typeof setTimeout> | undefined;
222
+ let produced = false; // did the agent write any content this turn?
223
+ const arm = () => {
224
+ clearTimeout(idle);
225
+ idle = setTimeout(() => controller.abort(), idleMs);
226
+ };
227
+ const tracked = (t: string) => {
228
+ if (t && !produced) {
229
+ produced = true;
230
+ if (prefix) write(prefix); // print "<agent>: " only once real output arrives
231
+ }
232
+ arm();
233
+ write(t);
234
+ };
235
+ const pausedAsk = async (q: string): Promise<string | null> => {
236
+ clearTimeout(idle); // don't time out while waiting on the human
237
+ const answer = await ask(q);
238
+ arm();
239
+ return answer;
240
+ };
241
+ killTurn = () => controller.abort();
242
+ arm();
243
+ const aborted = new Promise<never>((_, reject) => {
244
+ controller.signal.addEventListener("abort", () => reject(new Error("turn aborted")), { once: true });
245
+ });
246
+ const turn = runTurn(baseUrl as string, fetchImpl, session, turnText, { write: tracked, log, ask: pausedAsk }, controller.signal);
247
+ try {
248
+ // Race the turn against the abort. A real fetch unblocks its body read
249
+ // on signal, but racing also covers a stream that wedges open without
250
+ // ever erroring (the crashed-subprocess shape).
251
+ await Promise.race([turn, aborted]);
252
+ if (!produced) {
253
+ // The turn ended without crashing or stalling, but said nothing - the
254
+ // runtime swallowed the cause (often the missing credential above).
255
+ runtimeOutput?.dump();
256
+ log("[no response] the agent produced no output - see the note above, or rerun with `dharta dev --verbose`.");
257
+ }
258
+ } catch (err) {
259
+ if (controller.signal.aborted) {
260
+ turn.catch(() => {}); // detach the abandoned read
261
+ runtimeOutput?.dump();
262
+ throw new Error(
263
+ "No response - the agent runtime errored or went silent (see [runtime] output above). " +
264
+ "A missing model credential (ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN) is the usual cause.",
265
+ );
266
+ }
267
+ throw err;
268
+ } finally {
269
+ clearTimeout(idle);
270
+ killTurn = null;
271
+ }
272
+ };
273
+
274
+ if (normalizedMessage !== undefined) {
275
+ await runTurnWatched(normalizedMessage, "");
276
+ return;
277
+ }
278
+
279
+ log(" Chat with your agent. Type 'exit' to quit; edits to the harness instruction file hot-reload.");
280
+ for (;;) {
281
+ write("\nUser: ");
282
+ const { value, done } = await lines.next();
283
+ if (done) break;
284
+ const text = value.trim();
285
+ if (text === "") continue;
286
+ if (text === "exit" || text === "quit") break;
287
+ try {
288
+ await runTurnWatched(text, `\n${agentName}: `);
289
+ } catch (err) {
290
+ // One failed turn must not kill the session (or the runtime).
291
+ log(`[error] ${err instanceof Error ? err.message : String(err)}`);
292
+ }
293
+ }
294
+ } finally {
295
+ rl?.close();
296
+ process.removeListener("SIGINT", onSignal);
297
+ process.removeListener("SIGTERM", onSignal);
298
+ if (child) child.kill("SIGTERM");
299
+ }
300
+ }
301
+
302
+ async function validateExplicitDevDir(dir: string): Promise<void> {
303
+ try {
304
+ const stat = await fs.stat(dir);
305
+ if (!stat.isDirectory()) {
306
+ throw new Error(`Cannot run dev in ${dir}: expected a directory.`);
307
+ }
308
+ } catch (error) {
309
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
310
+ throw new Error(`Cannot run dev in ${dir}: directory does not exist.`);
311
+ }
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ // --- mini-doctor -----------------------------------------------------------
317
+
318
+ // The dev-start warnings half of declare-or-bind (DEV_PROD_PARITY §7):
319
+ // the same conditions fail the push-time build, so nothing said here is
320
+ // a surprise later. Full [env] tooling (env list/set/diff) is CLI-8.
321
+ export async function miniDoctor(dir: string): Promise<string[]> {
322
+ const warnings: string[] = [];
323
+
324
+ const karta = await readOptionalLocalFile(path.join(dir, "dharta.toml"), "dharta.toml");
325
+ if (karta.warning) warnings.push(karta.warning);
326
+ const kartaToml = karta.content;
327
+ if (kartaToml === null) {
328
+ warnings.push(
329
+ "dharta.toml is missing - the push-time build will reject this folder. `dharta setup` scaffolds one.",
330
+ );
331
+ } else {
332
+ try {
333
+ const parsed = TOML.parse(kartaToml) as Record<string, unknown>;
334
+ const env = parsed.env as Record<string, unknown> | undefined;
335
+ if (env) {
336
+ const localEnv = await readOptionalLocalFile(path.join(dir, ".env"), ".env");
337
+ if (localEnv.warning) warnings.push(localEnv.warning);
338
+ const dotenv = parseDotenvFile(localEnv.content);
339
+ for (const [name, spec] of Object.entries(env)) {
340
+ const required =
341
+ typeof spec === "object" && spec !== null &&
342
+ (spec as { required?: unknown }).required === true;
343
+ if (required && !(name in process.env) && !(name in dotenv)) {
344
+ warnings.push(
345
+ `Required env var ${name} is unbound - add it to .env (the push-time build fails on this too).`,
346
+ );
347
+ }
348
+ }
349
+ }
350
+ } catch {
351
+ warnings.push("dharta.toml could not be parsed - fix it before pushing.");
352
+ }
353
+ }
354
+
355
+ const localMcp = await readOptionalLocalFile(path.join(dir, ".mcp.json"), ".mcp.json");
356
+ if (localMcp.warning) warnings.push(localMcp.warning);
357
+ const mcp = localMcp.content;
358
+ if (mcp !== null && /localhost|127\.0\.0\.1/.test(mcp)) {
359
+ warnings.push(
360
+ ".mcp.json points at localhost - that endpoint won't exist in production. " +
361
+ "Reference ${VAR} and bind it per environment (declare-or-bind).",
362
+ );
363
+ }
364
+
365
+ return warnings;
366
+ }
367
+
368
+
369
+ async function readOptionalLocalFile(file: string, label: string): Promise<{ content: string | null; warning?: string }> {
370
+ try {
371
+ return { content: await fs.readFile(file, "utf8") };
372
+ } catch (error) {
373
+ const code = (error as NodeJS.ErrnoException).code;
374
+ if (code === "ENOENT") return { content: null };
375
+ if (code === "EISDIR") {
376
+ return { content: null, warning: `Cannot read ${label} at ${file}: expected a file, got a directory.` };
377
+ }
378
+ return { content: null, warning: `Cannot read ${label} at ${file}: ${(error as Error).message}` };
379
+ }
380
+ }
381
+
382
+ // --- runtime lifecycle -----------------------------------------------------
383
+
384
+ // TOCTOU between this probe and the child binding is accepted: the
385
+ // loser exits immediately and waitForHealthy reports it within ~500ms.
386
+ function freePort(): Promise<number> {
387
+ return new Promise((resolve, reject) => {
388
+ const srv = net.createServer();
389
+ srv.listen(0, "127.0.0.1", () => {
390
+ const address = srv.address();
391
+ srv.close(() =>
392
+ typeof address === "object" && address
393
+ ? resolve(address.port)
394
+ : reject(new Error("could not allocate a local port")),
395
+ );
396
+ });
397
+ srv.on("error", reject);
398
+ });
399
+ }
400
+
401
+ interface RuntimeOutputBuffer {
402
+ dump(): void;
403
+ }
404
+
405
+ // Quiet by default: the runtime's banner/uvicorn lines are noise in
406
+ // the REPL, but they are exactly what explains a failed start - so
407
+ // always buffer, stream only with --verbose, and dump the buffer when
408
+ // startup fails.
409
+ function forwardRuntimeOutput(
410
+ child: ReturnType<typeof spawnRuntime>,
411
+ { stream: streamLive, onFatal }: { stream: boolean; onFatal?: () => void },
412
+ ): RuntimeOutputBuffer {
413
+ const buffered: string[] = [];
414
+ const emit = (line: string) => process.stderr.write(`[runtime] ${line}\n`);
415
+ for (const stream of [child.stdout, child.stderr]) {
416
+ let pending = "";
417
+ const handle = (line: string) => {
418
+ if (!line.trim()) return;
419
+ // An unhandled Python traceback means the in-flight turn crashed; the SSE
420
+ // stream can wedge open, so signal the turn to abort rather than hang on
421
+ // bytes that will never come.
422
+ if (onFatal && /Traceback \(most recent call last\)/.test(line)) onFatal();
423
+ if (streamLive) emit(line);
424
+ else {
425
+ buffered.push(line);
426
+ if (buffered.length > 200) buffered.shift();
427
+ }
428
+ };
429
+ stream?.on("data", (chunk: Buffer) => {
430
+ pending += chunk.toString("utf8");
431
+ const lines = pending.split("\n");
432
+ pending = lines.pop() ?? "";
433
+ lines.forEach(handle);
434
+ });
435
+ stream?.on("end", () => handle(pending));
436
+ }
437
+ return {
438
+ dump() {
439
+ buffered.forEach(emit);
440
+ buffered.length = 0;
441
+ },
442
+ };
443
+ }
444
+
445
+ async function waitForHealthy(
446
+ baseUrl: string,
447
+ fetchImpl: typeof fetch,
448
+ child: ReturnType<typeof spawnRuntime> | null,
449
+ timeoutMs: number,
450
+ spawnError: () => Error | null = () => null,
451
+ ): Promise<void> {
452
+ const deadline = Date.now() + timeoutMs;
453
+ let lastDetail = "";
454
+ let exited = false;
455
+ child?.on("exit", () => {
456
+ exited = true;
457
+ });
458
+
459
+ while (Date.now() < deadline) {
460
+ const failed = spawnError();
461
+ if (failed) throw failed;
462
+ if (exited) {
463
+ throw new Error(
464
+ "The agent runtime exited before it became ready (see [runtime] output above). " +
465
+ "Run `dharta doctor` to diagnose; DHARTA_RUNTIME=/path/to/checkout is the escape hatch.",
466
+ );
467
+ }
468
+ try {
469
+ const res = await fetchImpl(`${baseUrl}/healthz`);
470
+ if (res.ok) return;
471
+ if (res.status === 503) {
472
+ const body = (await res.json().catch(() => null)) as { detail?: string } | null;
473
+ lastDetail = body?.detail ?? "";
474
+ }
475
+ } catch {
476
+ // not listening yet
477
+ }
478
+ await new Promise((r) => setTimeout(r, 500));
479
+ }
480
+ throw new Error(
481
+ `The agent runtime did not become ready within ${Math.round(timeoutMs / 1000)}s` +
482
+ (lastDetail ? ` (${lastDetail})` : "") +
483
+ ". Run `dharta doctor` to diagnose.",
484
+ );
485
+ }
486
+
487
+ // --- protocol client -------------------------------------------------------
488
+
489
+ async function createSession(
490
+ baseUrl: string,
491
+ fetchImpl: typeof fetch,
492
+ endUserId?: string,
493
+ ): Promise<string> {
494
+ const res = await fetchAgentApi(baseUrl, fetchImpl, "/v1/sessions", "create a session", {
495
+ method: "POST",
496
+ headers: { "Content-Type": "application/json", "Idempotency-Key": randomUUID() },
497
+ body: JSON.stringify({ metadata: endUserId ? { end_user_id: endUserId } : {} }),
498
+ });
499
+ if (!res.ok) {
500
+ throw new Error(
501
+ `Could not create a session (HTTP ${res.status}). See the [runtime] output above for the cause.`,
502
+ );
503
+ }
504
+ const body = await readAgentJson<{ id: string }>(res, "create a session");
505
+ if (typeof body.id !== "string" || body.id.length === 0) {
506
+ throw new Error("Could not create a session: the agent API response did not include a session id.");
507
+ }
508
+ return body.id;
509
+ }
510
+
511
+ export interface TurnIo {
512
+ write: (text: string) => void;
513
+ log: (line: string) => void;
514
+ // Resolves to the user's answer, or null when the input stream is exhausted
515
+ // (no TTY, or piped input ran out) — i.e. no human is there to answer.
516
+ ask: (question: string) => Promise<string | null>;
517
+ }
518
+
519
+ interface PendingInput {
520
+ request_id: string;
521
+ message?: string;
522
+ tool?: string;
523
+ target?: string;
524
+ }
525
+
526
+ export async function runTurn(
527
+ baseUrl: string,
528
+ fetchImpl: typeof fetch,
529
+ sessionId: string,
530
+ text: string,
531
+ io: TurnIo,
532
+ signal?: AbortSignal,
533
+ ): Promise<void> {
534
+ const res = await fetchAgentApi(
535
+ baseUrl,
536
+ fetchImpl,
537
+ `/v1/sessions/${sessionId}/messages`,
538
+ "send the message",
539
+ {
540
+ method: "POST",
541
+ headers: {
542
+ "Content-Type": "application/json",
543
+ Accept: "text/event-stream",
544
+ "Idempotency-Key": randomUUID(),
545
+ },
546
+ body: JSON.stringify({ text, stream: true }),
547
+ signal,
548
+ },
549
+ );
550
+ if (!res.ok || !res.body) {
551
+ throw new Error(
552
+ `Turn failed (HTTP ${res.status}). Retry the message; if it persists, check ` +
553
+ "the [runtime] output (dharta dev) or `dharta logs [slug]` (deployed).",
554
+ );
555
+ }
556
+
557
+ const pending = await renderEventStream(parseSse(res.body), io);
558
+ await resolvePendingInputs(baseUrl, fetchImpl, sessionId, pending, io);
559
+ }
560
+
561
+ // Approval loop: the harness pauses on tool permissions; the decision
562
+ // goes back over the same API the widget uses. The PUT's response is
563
+ // itself a batch of events (it may pause again).
564
+ async function resolvePendingInputs(
565
+ baseUrl: string,
566
+ fetchImpl: typeof fetch,
567
+ sessionId: string,
568
+ first: PendingInput | null,
569
+ io: TurnIo,
570
+ ): Promise<void> {
571
+ let pending = first;
572
+ let abortHintShown = false;
573
+ while (pending) {
574
+ const prompt = pending.message ?? `The agent wants to use ${pending.tool ?? "a tool"}`;
575
+ const answer = await io.ask(`\n${prompt} - approve? [y=once / s=session / n=deny] `);
576
+ if (answer === null && !abortHintShown) {
577
+ // No TTY / piped input ran out: nobody can answer, so the tool is denied
578
+ // and the turn stalls. Name the bounded-authority fix once instead of
579
+ // leaving the failure unexplained (issue #857).
580
+ io.log(
581
+ `[approval] "${pending.tool ?? "a tool"}" is not on the .claude/settings.json allowlist and this ` +
582
+ `run is non-interactive, so it was denied. Allow the tools your agent needs in .claude/settings.json ` +
583
+ `(bounded authority), run \`dharta dev\` interactively to approve as you go, or set ` +
584
+ `runtime.permission_mode = "autonomous" in dharta.jsonc.`,
585
+ );
586
+ abortHintShown = true;
587
+ }
588
+ const decision =
589
+ answer === null
590
+ ? "deny"
591
+ : answer.trim().toLowerCase() === "y"
592
+ ? "approve_once"
593
+ : answer.trim().toLowerCase() === "s"
594
+ ? "approve_session"
595
+ : "deny";
596
+
597
+ const res = await fetchAgentApi(
598
+ baseUrl,
599
+ fetchImpl,
600
+ `/v1/sessions/${sessionId}/inputs/${pending.request_id}`,
601
+ "deliver the decision",
602
+ {
603
+ method: "PUT",
604
+ headers: { "Content-Type": "application/json", "Idempotency-Key": randomUUID() },
605
+ body: JSON.stringify({ decision }),
606
+ },
607
+ );
608
+ if (!res.ok) {
609
+ throw new Error(
610
+ `Could not deliver the decision (HTTP ${res.status}). Re-send the message to retry.`,
611
+ );
612
+ }
613
+ const body = await readAgentJson<{ events?: Array<Record<string, unknown>> }>(res, "deliver the decision");
614
+
615
+ pending = null;
616
+ const renderer = makeRenderer(io);
617
+ for (const raw of body.events ?? []) {
618
+ const next = renderer(raw as { type?: string; data?: Record<string, unknown> });
619
+ if (next) pending = next;
620
+ }
621
+ renderer.finish();
622
+ }
623
+ }
624
+
625
+ async function fetchAgentApi(
626
+ baseUrl: string,
627
+ fetchImpl: typeof fetch,
628
+ path: string,
629
+ operation: string,
630
+ init?: RequestInit,
631
+ ): Promise<Response> {
632
+ try {
633
+ return await fetchImpl(`${baseUrl}${path}`, init);
634
+ } catch (error) {
635
+ if (init?.signal?.aborted) throw error;
636
+ throw new Error(
637
+ `Could not ${operation}: could not reach the agent API at ${baseUrl} (${formatCause(error)}). ` +
638
+ "For local preview, rerun with `dharta dev --verbose` or run `dharta doctor`; " +
639
+ "for deployed agents, check `dharta logs [slug]`.",
640
+ );
641
+ }
642
+ }
643
+
644
+ async function readAgentJson<T>(res: Response, operation: string): Promise<T> {
645
+ try {
646
+ return (await res.json()) as T;
647
+ } catch (error) {
648
+ throw new Error(
649
+ `Could not ${operation}: the agent API returned invalid JSON (${formatCause(error)}). ` +
650
+ "Retry the command; if it persists, check the agent runtime output or `dharta logs [slug]`.",
651
+ );
652
+ }
653
+ }
654
+
655
+ function formatCause(error: unknown): string {
656
+ return error instanceof Error && error.message ? error.message : String(error);
657
+ }
658
+
659
+ async function renderEventStream(
660
+ events: AsyncGenerator<SseEvent>,
661
+ io: TurnIo,
662
+ ): Promise<PendingInput | null> {
663
+ const renderer = makeRenderer(io);
664
+ let pending: PendingInput | null = null;
665
+ for await (const { data } of events) {
666
+ const next = renderer(data as { type?: string; data?: Record<string, unknown> });
667
+ if (next) pending = next;
668
+ }
669
+ renderer.finish();
670
+ return pending;
671
+ }
672
+
673
+ // Mirrors the Python CLIRenderer's contract: text events carry the
674
+ // CUMULATIVE text per part id; print only the delta.
675
+ function makeRenderer(io: TurnIo) {
676
+ const textByPart = new Map<string, string>();
677
+ let lineOpen = false;
678
+
679
+ const render = (event: {
680
+ type?: string;
681
+ data?: Record<string, unknown>;
682
+ }): PendingInput | null => {
683
+ const data = event.data ?? {};
684
+ switch (event.type) {
685
+ case "text": {
686
+ const part = data.part as { id?: string; text?: string } | undefined;
687
+ if (!part || typeof part.text !== "string") return null;
688
+ const id = part.id ?? "__default__";
689
+ const previous = textByPart.get(id) ?? "";
690
+ const delta = part.text.startsWith(previous)
691
+ ? part.text.slice(previous.length)
692
+ : part.text;
693
+ if (delta) {
694
+ io.write(delta);
695
+ lineOpen = true;
696
+ }
697
+ textByPart.set(id, part.text);
698
+ return null;
699
+ }
700
+ case "tool_use": {
701
+ const part = data.part as { tool?: string } | undefined;
702
+ if (lineOpen) {
703
+ io.write("\n");
704
+ lineOpen = false;
705
+ }
706
+ io.log(`[tool] ${part?.tool ?? "tool"}`);
707
+ return null;
708
+ }
709
+ case "input_required":
710
+ if (lineOpen) {
711
+ io.write("\n");
712
+ lineOpen = false;
713
+ }
714
+ return data as unknown as PendingInput;
715
+ case "error": {
716
+ if (lineOpen) {
717
+ io.write("\n");
718
+ lineOpen = false;
719
+ }
720
+ io.log(`[error] ${JSON.stringify(data)}`);
721
+ return null;
722
+ }
723
+ default:
724
+ return null;
725
+ }
726
+ };
727
+ render.finish = () => {
728
+ if (lineOpen) io.write("\n");
729
+ };
730
+ return render;
731
+ }
732
+
733
+ // Minimal SSE parser: "event:"/"data:" lines, frames separated by a
734
+ // blank line (LF or CRLF). Enough for the runtime's encode_sse output.
735
+ export async function* parseSse(
736
+ body: ReadableStream<Uint8Array> | NodeJS.ReadableStream,
737
+ ): AsyncGenerator<SseEvent> {
738
+ let buffer = "";
739
+ const decoder = new TextDecoder();
740
+
741
+ const chunks =
742
+ typeof (body as ReadableStream<Uint8Array>).getReader === "function"
743
+ ? webStreamChunks(body as ReadableStream<Uint8Array>)
744
+ : (body as NodeJS.ReadableStream);
745
+
746
+ for await (const chunk of chunks) {
747
+ buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk as Uint8Array, { stream: true });
748
+ // Normalize CRLF so a \r\n-emitting server doesn't buffer forever.
749
+ buffer = buffer.replace(/\r\n/g, "\n");
750
+ let sep: number;
751
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
752
+ const frame = buffer.slice(0, sep);
753
+ buffer = buffer.slice(sep + 2);
754
+ const parsed = parseFrame(frame);
755
+ if (parsed) yield parsed;
756
+ }
757
+ }
758
+ const tail = parseFrame(buffer);
759
+ if (tail) yield tail;
760
+ }
761
+
762
+ async function* webStreamChunks(
763
+ stream: ReadableStream<Uint8Array>,
764
+ ): AsyncGenerator<Uint8Array> {
765
+ const reader = stream.getReader();
766
+ try {
767
+ for (;;) {
768
+ const { done, value } = await reader.read();
769
+ if (done) return;
770
+ if (value) yield value;
771
+ }
772
+ } finally {
773
+ reader.releaseLock();
774
+ }
775
+ }
776
+
777
+ function parseFrame(frame: string): SseEvent | null {
778
+ let event = "";
779
+ const dataLines: string[] = [];
780
+ for (const line of frame.split("\n")) {
781
+ if (line.startsWith("event:")) event = line.slice(6).trim();
782
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
783
+ }
784
+ if (!event && dataLines.length === 0) return null;
785
+ let data: Record<string, unknown> = {};
786
+ try {
787
+ data = JSON.parse(dataLines.join("\n"));
788
+ } catch {
789
+ // non-JSON data: leave empty
790
+ }
791
+ return { event, data };
792
+ }