@doriandev/devcc 0.1.2 → 0.3.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 (151) hide show
  1. package/README.md +589 -389
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +119 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +182 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +23 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +451 -19
  10. package/dist/devcc/app/App.js.map +1 -1
  11. package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
  12. package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
  13. package/dist/devcc/app/controlPlaneCommands.js +204 -0
  14. package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
  15. package/dist/devcc/app/keymap.d.ts.map +1 -1
  16. package/dist/devcc/app/keymap.js +25 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +52 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +339 -0
  21. package/dist/devcc/automation.js.map +1 -0
  22. package/dist/devcc/cli.d.ts.map +1 -1
  23. package/dist/devcc/cli.js +73 -3
  24. package/dist/devcc/cli.js.map +1 -1
  25. package/dist/devcc/components/Sidebar.d.ts.map +1 -1
  26. package/dist/devcc/components/Sidebar.js +1 -0
  27. package/dist/devcc/components/Sidebar.js.map +1 -1
  28. package/dist/devcc/components/primitives.d.ts.map +1 -1
  29. package/dist/devcc/components/primitives.js +3 -0
  30. package/dist/devcc/components/primitives.js.map +1 -1
  31. package/dist/devcc/core/commands.d.ts +33 -0
  32. package/dist/devcc/core/commands.d.ts.map +1 -1
  33. package/dist/devcc/core/commands.js.map +1 -1
  34. package/dist/devcc/core/config.d.ts +71 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +189 -5
  37. package/dist/devcc/core/config.js.map +1 -1
  38. package/dist/devcc/core/state.d.ts +19 -1
  39. package/dist/devcc/core/state.d.ts.map +1 -1
  40. package/dist/devcc/core/state.js +6 -0
  41. package/dist/devcc/core/state.js.map +1 -1
  42. package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
  43. package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
  44. package/dist/devcc/environment/EnvironmentService.js +159 -0
  45. package/dist/devcc/environment/EnvironmentService.js.map +1 -0
  46. package/dist/devcc/environment/integrations.d.ts +52 -0
  47. package/dist/devcc/environment/integrations.d.ts.map +1 -0
  48. package/dist/devcc/environment/integrations.js +94 -0
  49. package/dist/devcc/environment/integrations.js.map +1 -0
  50. package/dist/devcc/environment/tools.d.ts +51 -0
  51. package/dist/devcc/environment/tools.d.ts.map +1 -0
  52. package/dist/devcc/environment/tools.js +99 -0
  53. package/dist/devcc/environment/tools.js.map +1 -0
  54. package/dist/devcc/git/GitService.d.ts +26 -0
  55. package/dist/devcc/git/GitService.d.ts.map +1 -1
  56. package/dist/devcc/git/GitService.js +71 -2
  57. package/dist/devcc/git/GitService.js.map +1 -1
  58. package/dist/devcc/git/worktrees.d.ts +88 -0
  59. package/dist/devcc/git/worktrees.d.ts.map +1 -0
  60. package/dist/devcc/git/worktrees.js +226 -0
  61. package/dist/devcc/git/worktrees.js.map +1 -0
  62. package/dist/devcc/index.d.ts +15 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +14 -2
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/init/detect.d.ts +55 -0
  67. package/dist/devcc/init/detect.d.ts.map +1 -0
  68. package/dist/devcc/init/detect.js +207 -0
  69. package/dist/devcc/init/detect.js.map +1 -0
  70. package/dist/devcc/init/prompt.d.ts +25 -0
  71. package/dist/devcc/init/prompt.d.ts.map +1 -0
  72. package/dist/devcc/init/prompt.js +156 -0
  73. package/dist/devcc/init/prompt.js.map +1 -0
  74. package/dist/devcc/platform/launch.d.ts +30 -0
  75. package/dist/devcc/platform/launch.d.ts.map +1 -0
  76. package/dist/devcc/platform/launch.js +93 -0
  77. package/dist/devcc/platform/launch.js.map +1 -0
  78. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  79. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  80. package/dist/devcc/services/ServiceManager.js +18 -4
  81. package/dist/devcc/services/ServiceManager.js.map +1 -1
  82. package/dist/devcc/services/node.d.ts +11 -0
  83. package/dist/devcc/services/node.d.ts.map +1 -1
  84. package/dist/devcc/services/node.js +20 -18
  85. package/dist/devcc/services/node.js.map +1 -1
  86. package/dist/devcc/services/ollama.d.ts +64 -5
  87. package/dist/devcc/services/ollama.d.ts.map +1 -1
  88. package/dist/devcc/services/ollama.js +118 -17
  89. package/dist/devcc/services/ollama.js.map +1 -1
  90. package/dist/devcc/services/remote.d.ts +90 -0
  91. package/dist/devcc/services/remote.d.ts.map +1 -0
  92. package/dist/devcc/services/remote.js +233 -0
  93. package/dist/devcc/services/remote.js.map +1 -0
  94. package/dist/devcc/services/types.d.ts +8 -1
  95. package/dist/devcc/services/types.d.ts.map +1 -1
  96. package/dist/devcc/services/types.js.map +1 -1
  97. package/dist/devcc/utils/exec.d.ts +6 -0
  98. package/dist/devcc/utils/exec.d.ts.map +1 -1
  99. package/dist/devcc/utils/exec.js +12 -2
  100. package/dist/devcc/utils/exec.js.map +1 -1
  101. package/dist/devcc/verify/verify.d.ts +107 -0
  102. package/dist/devcc/verify/verify.d.ts.map +1 -0
  103. package/dist/devcc/verify/verify.js +203 -0
  104. package/dist/devcc/verify/verify.js.map +1 -0
  105. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  106. package/dist/devcc/views/OverviewView.js +58 -2
  107. package/dist/devcc/views/OverviewView.js.map +1 -1
  108. package/dist/devcc/views/ServicesView.d.ts +2 -0
  109. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  110. package/dist/devcc/views/ServicesView.js +15 -0
  111. package/dist/devcc/views/ServicesView.js.map +1 -1
  112. package/dist/devcc/views/SystemView.d.ts +10 -1
  113. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  114. package/dist/devcc/views/SystemView.js +127 -19
  115. package/dist/devcc/views/SystemView.js.map +1 -1
  116. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  117. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  118. package/dist/devcc/views/WorktreesView.js +235 -0
  119. package/dist/devcc/views/WorktreesView.js.map +1 -0
  120. package/package.json +1 -1
  121. package/src/devcc/agents/agents.ts +292 -0
  122. package/src/devcc/app/App.ts +529 -22
  123. package/src/devcc/app/controlPlaneCommands.ts +227 -0
  124. package/src/devcc/app/keymap.ts +25 -3
  125. package/src/devcc/automation.ts +409 -0
  126. package/src/devcc/cli.ts +77 -3
  127. package/src/devcc/components/Sidebar.ts +1 -0
  128. package/src/devcc/components/primitives.ts +3 -0
  129. package/src/devcc/core/commands.ts +34 -0
  130. package/src/devcc/core/config.ts +275 -11
  131. package/src/devcc/core/state.ts +25 -0
  132. package/src/devcc/environment/EnvironmentService.ts +215 -0
  133. package/src/devcc/environment/integrations.ts +133 -0
  134. package/src/devcc/environment/tools.ts +151 -0
  135. package/src/devcc/git/GitService.ts +104 -2
  136. package/src/devcc/git/worktrees.ts +256 -0
  137. package/src/devcc/index.ts +66 -2
  138. package/src/devcc/init/detect.ts +268 -0
  139. package/src/devcc/init/prompt.ts +179 -0
  140. package/src/devcc/platform/launch.ts +125 -0
  141. package/src/devcc/services/ServiceManager.ts +15 -3
  142. package/src/devcc/services/node.ts +17 -14
  143. package/src/devcc/services/ollama.ts +162 -19
  144. package/src/devcc/services/remote.ts +306 -0
  145. package/src/devcc/services/types.ts +9 -1
  146. package/src/devcc/utils/exec.ts +26 -2
  147. package/src/devcc/verify/verify.ts +316 -0
  148. package/src/devcc/views/OverviewView.ts +84 -2
  149. package/src/devcc/views/ServicesView.ts +17 -0
  150. package/src/devcc/views/SystemView.ts +144 -22
  151. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -0,0 +1,409 @@
1
+ import { spawn } from "node:child_process"
2
+ import { writeFileSync } from "node:fs"
3
+ import { basename, join } from "node:path"
4
+ import { createAgentAdapters } from "./agents/agents.js"
5
+ import { loadConfig, loadConfigFile, type LoadedConfig } from "./core/config.js"
6
+ import { EnvironmentService, type EnvironmentSnapshot } from "./environment/EnvironmentService.js"
7
+ import { GitService } from "./git/GitService.js"
8
+ import { worktreeLabel } from "./git/worktrees.js"
9
+ import { detectProject, renderStarterConfig, starterConfig } from "./init/detect.js"
10
+ import { buildSetupPrompt } from "./init/prompt.js"
11
+ import { errorMessage } from "./utils/errors.js"
12
+ import { pluralise } from "./utils/format.js"
13
+ import { createLogger } from "./utils/logger.js"
14
+ import { detectPackageManager, readPackageJson, readScripts } from "./utils/packageManager.js"
15
+ import { tildify } from "./utils/paths.js"
16
+ import {
17
+ createSpawnExecutor,
18
+ isVerifyIssue,
19
+ resolveVerifySteps,
20
+ runVerification,
21
+ summariseVerify,
22
+ type StepExecutor,
23
+ type VerifyReport,
24
+ } from "./verify/verify.js"
25
+
26
+ /**
27
+ * Non-interactive commands for scripts and AI agents: `devcc verify`,
28
+ * `devcc worktree list`, `devcc env`. None of them load the TUI, so they run on
29
+ * plain Node without FFI. With `--json`, stdout carries exactly one JSON
30
+ * document and nothing else; diagnostics go to stderr.
31
+ */
32
+
33
+ export interface AutomationIo {
34
+ stdout: (text: string) => void
35
+ stderr: (text: string) => void
36
+ }
37
+
38
+ export interface AutomationOptions {
39
+ rootDir: string
40
+ json: boolean
41
+ io?: AutomationIo
42
+ /** Injected for tests; defaults to spawning each step. */
43
+ executor?: StepExecutor
44
+ signal?: AbortSignal
45
+ }
46
+
47
+ export const EXIT_OK = 0
48
+ export const EXIT_FAILED = 1
49
+ /** Misconfiguration or misuse: nothing was verified. */
50
+ export const EXIT_USAGE = 2
51
+
52
+ const processIo: AutomationIo = {
53
+ stdout: (text) => process.stdout.write(text),
54
+ stderr: (text) => process.stderr.write(text),
55
+ }
56
+
57
+ function printJson(io: AutomationIo, value: unknown): void {
58
+ io.stdout(`${JSON.stringify(value, null, 2)}\n`)
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // devcc verify
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export async function verifyCommand(options: AutomationOptions): Promise<number> {
66
+ const io = options.io ?? processIo
67
+ const { rootDir, json } = options
68
+
69
+ const packageJson = readPackageJson(rootDir)
70
+ const packageManager = detectPackageManager({ rootDir, packageJson, userAgent: undefined }).manager
71
+ const loaded = await loadConfig(rootDir)
72
+ const { steps, issues } = resolveVerifySteps(loaded.config.verify, {
73
+ commands: loaded.config.commands,
74
+ scripts: readScripts(packageJson),
75
+ packageManager,
76
+ })
77
+
78
+ // A dropped or unresolvable verify entry must fail the gate: silently running
79
+ // fewer checks than configured would let a broken project pass.
80
+ const blocking = [
81
+ ...loaded.issues.filter((issue) => issue.startsWith("Failed to load") || isVerifyIssue(issue)),
82
+ ...issues,
83
+ ]
84
+ if (steps.length === 0 && blocking.length === 0) {
85
+ blocking.push(
86
+ loaded.filePath
87
+ ? `No verification steps: add verify: ["lint", "test"] to ${tildify(loaded.filePath)}`
88
+ : 'No verification steps: create .devcc.ts with verify: ["lint", "test"]',
89
+ )
90
+ }
91
+ if (blocking.length > 0) {
92
+ if (json) printJson(io, { ok: false, error: "configuration", issues: blocking })
93
+ else for (const issue of blocking) io.stderr(`devcc verify: ${issue}\n`)
94
+ return EXIT_USAGE
95
+ }
96
+
97
+ if (!json) io.stdout(`devcc verify · ${tildify(rootDir)} · ${steps.length} step(s)\n\n`)
98
+
99
+ const executor = options.executor ?? createSpawnExecutor({ rootDir })
100
+ let printed = 0
101
+ const report = await runVerification(steps, executor, {
102
+ signal: options.signal,
103
+ onProgress: (progress) => {
104
+ if (json) return
105
+ for (; printed < progress.length; printed += 1) {
106
+ const step = progress[printed]
107
+ if (!step || step.status === "pending") break
108
+ if (step.status === "running") {
109
+ io.stdout(`→ ${step.name}: ${[step.command, ...step.args].join(" ")}\n`)
110
+ break
111
+ }
112
+ io.stdout(stepLine(step))
113
+ if (step.status === "failed") io.stdout(failureOutput(step))
114
+ }
115
+ },
116
+ })
117
+
118
+ if (json) {
119
+ printJson(io, { ...report, rootDir, summary: summariseVerify(report) })
120
+ } else {
121
+ for (const step of report.steps.slice(printed)) if (step.status === "skipped") io.stdout(stepLine(step))
122
+ io.stdout(`\n${report.ok ? "✓" : "✗"} ${summariseVerify(report)} in ${seconds(report.durationMs)}\n`)
123
+ }
124
+ return report.ok ? EXIT_OK : EXIT_FAILED
125
+ }
126
+
127
+ function stepLine(step: VerifyReport["steps"][number]): string {
128
+ const mark = step.status === "passed" ? "✓" : step.status === "skipped" ? "-" : "✗"
129
+ const detail =
130
+ step.status === "skipped"
131
+ ? "skipped"
132
+ : [
133
+ seconds(step.durationMs),
134
+ step.exitCode !== null && step.exitCode !== 0 ? `exit ${step.exitCode}` : undefined,
135
+ step.error,
136
+ ]
137
+ .filter(Boolean)
138
+ .join(" · ")
139
+ return `${mark} ${step.name} (${detail})\n`
140
+ }
141
+
142
+ /** The tail of a failed step's output, indented under it. */
143
+ function failureOutput(step: VerifyReport["steps"][number], maxLines = 60): string {
144
+ const text = [step.stdout, step.stderr]
145
+ .filter((part) => part.trim() !== "")
146
+ .join("\n")
147
+ .trimEnd()
148
+ if (text === "") return ""
149
+ const lines = text.split("\n")
150
+ const shown = lines.slice(-maxLines)
151
+ const header = lines.length > maxLines ? [`… ${lines.length - maxLines} earlier line(s) omitted`] : []
152
+ return `${[...header, ...shown].map((line) => ` ${line}`).join("\n")}\n`
153
+ }
154
+
155
+ function seconds(ms: number): string {
156
+ return `${(ms / 1000).toFixed(1)}s`
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // devcc worktree list
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export async function worktreeListCommand(options: AutomationOptions): Promise<number> {
164
+ const io = options.io ?? processIo
165
+ const git = new GitService({ rootDir: options.rootDir, logger: createLogger(options.rootDir).child("git") })
166
+ try {
167
+ const worktrees = await git.listWorktrees()
168
+ if (options.json) {
169
+ printJson(io, { rootDir: options.rootDir, worktrees })
170
+ return EXIT_OK
171
+ }
172
+ const width = Math.max(...worktrees.map((worktree) => worktreeLabel(worktree).length), 6)
173
+ for (const worktree of worktrees) {
174
+ const flags = [
175
+ worktree.isMain ? "main" : undefined,
176
+ worktree.locked ? "locked" : undefined,
177
+ worktree.prunable ? "prunable" : undefined,
178
+ ].filter(Boolean)
179
+ io.stdout(
180
+ `${worktree.isCurrent ? "*" : " "} ${worktreeLabel(worktree).padEnd(width)} ${tildify(worktree.path)}${
181
+ flags.length > 0 ? ` [${flags.join(", ")}]` : ""
182
+ }\n`,
183
+ )
184
+ }
185
+ return EXIT_OK
186
+ } catch (error) {
187
+ const message = errorMessage(error)
188
+ if (options.json) printJson(io, { error: message, rootDir: options.rootDir, worktrees: [] })
189
+ else io.stderr(`devcc worktree list: ${message}\n`)
190
+ return EXIT_FAILED
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // devcc env
196
+ // ---------------------------------------------------------------------------
197
+
198
+ export async function envCommand(
199
+ options: AutomationOptions & { environment?: EnvironmentService },
200
+ ): Promise<number> {
201
+ const io = options.io ?? processIo
202
+ const environment = options.environment ?? new EnvironmentService({ rootDir: options.rootDir })
203
+ const snapshot = await environment.refresh()
204
+ if (options.json) {
205
+ const { checking: _checking, ...rest } = snapshot
206
+ printJson(io, { rootDir: options.rootDir, ...rest })
207
+ } else {
208
+ io.stdout(formatEnvironment(snapshot))
209
+ }
210
+ return EXIT_OK
211
+ }
212
+
213
+ export function formatEnvironment(snapshot: EnvironmentSnapshot): string {
214
+ const lines: string[] = ["TOOLS"]
215
+ const nameWidth = Math.max(...snapshot.tools.map((tool) => tool.name.length), 4) + 2
216
+ for (const tool of snapshot.tools) {
217
+ const mark = !tool.installed ? "·" : tool.status === "ready" ? "✓" : tool.status === "warning" ? "⚠" : "✗"
218
+ const value = tool.installed ? (tool.version ?? "installed") : "not installed"
219
+ const note = tool.installed && tool.message ? ` ${tool.message}` : ""
220
+ lines.push(` ${mark} ${tool.name.padEnd(nameWidth)}${value}${note}`)
221
+ }
222
+
223
+ if (snapshot.mise.installed) {
224
+ lines.push("", "RUNTIMES (mise)")
225
+ if (snapshot.mise.error) lines.push(` ⚠ ${snapshot.mise.error}`)
226
+ else if (snapshot.mise.runtimes.length === 0) lines.push(" no tools pinned for this project")
227
+ for (const runtime of snapshot.mise.runtimes) {
228
+ lines.push(
229
+ ` ${runtime.tool.padEnd(nameWidth + 2)}${runtime.version}${runtime.installed ? "" : " (not installed)"}`,
230
+ )
231
+ }
232
+ }
233
+
234
+ const { direnv } = snapshot
235
+ if (direnv.installed || direnv.rcPath) {
236
+ lines.push("", "DIRENV")
237
+ if (direnv.error) lines.push(` ⚠ ${direnv.error}`)
238
+ if (direnv.rcPath) {
239
+ lines.push(` .envrc ${tildify(direnv.rcPath)}`)
240
+ if (direnv.installed) {
241
+ lines.push(` approval ${direnv.approval}`)
242
+ lines.push(` loaded ${direnv.loaded ? "yes" : "no"}`)
243
+ }
244
+ } else {
245
+ lines.push(" no .envrc for this project")
246
+ }
247
+ }
248
+ return `${lines.join("\n")}\n`
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // devcc config check
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /** Everything wrong with a loaded config, including verify names that resolve to nothing. */
256
+ export function configProblems(rootDir: string, loaded: LoadedConfig): string[] {
257
+ const packageJson = readPackageJson(rootDir)
258
+ const { issues } = resolveVerifySteps(loaded.config.verify, {
259
+ commands: loaded.config.commands,
260
+ scripts: readScripts(packageJson),
261
+ packageManager: detectPackageManager({ rootDir, packageJson }).manager,
262
+ })
263
+ return [...loaded.issues, ...issues]
264
+ }
265
+
266
+ function configSummary(loaded: LoadedConfig) {
267
+ const { config } = loaded
268
+ return {
269
+ processes: config.processes.length,
270
+ commands: config.commands.length,
271
+ verify: config.verify.length,
272
+ services: config.services.length,
273
+ remoteMachines: config.remoteMachines.length,
274
+ }
275
+ }
276
+
277
+ export async function configCheckCommand(options: AutomationOptions): Promise<number> {
278
+ const io = options.io ?? processIo
279
+ const loaded = await loadConfig(options.rootDir)
280
+ if (!loaded.filePath) {
281
+ const issue = "No devcc config found (run `devcc init` to create .devcc.ts)"
282
+ if (options.json) printJson(io, { ok: false, file: null, issues: [issue] })
283
+ else io.stderr(`devcc config check: ${issue}\n`)
284
+ return EXIT_FAILED
285
+ }
286
+
287
+ const problems = configProblems(options.rootDir, loaded)
288
+ const summary = configSummary(loaded)
289
+ if (options.json) {
290
+ printJson(io, { ok: problems.length === 0, file: loaded.filePath, issues: problems, summary })
291
+ } else if (problems.length > 0) {
292
+ io.stderr(`✗ ${basename(loaded.filePath)} has ${problems.length} issue(s):\n`)
293
+ for (const problem of problems) io.stderr(` - ${problem}\n`)
294
+ } else {
295
+ const labels: Record<keyof typeof summary, [string, string]> = {
296
+ processes: ["process", "processes"],
297
+ commands: ["command", "commands"],
298
+ verify: ["verify step", "verify steps"],
299
+ services: ["service", "services"],
300
+ remoteMachines: ["remote machine", "remote machines"],
301
+ }
302
+ const parts = (Object.entries(summary) as [keyof typeof summary, number][])
303
+ .filter(([, count]) => count > 0)
304
+ .map(([field, count]) => pluralise(count, ...labels[field]))
305
+ io.stdout(`✓ ${basename(loaded.filePath)} is valid${parts.length > 0 ? `: ${parts.join(", ")}` : ""}\n`)
306
+ }
307
+ return problems.length === 0 ? EXIT_OK : EXIT_FAILED
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // devcc init
312
+ // ---------------------------------------------------------------------------
313
+
314
+ export interface InitOptions extends AutomationOptions {
315
+ /** Replace an existing .devcc.ts with the starter. */
316
+ force?: boolean
317
+ /** Print the agent setup prompt instead of writing anything. */
318
+ printPrompt?: boolean
319
+ /** Start this agent interactively with the setup prompt. */
320
+ agent?: string
321
+ /** Injected for tests. */
322
+ isTTY?: boolean
323
+ runInteractive?: (command: string, args: string[], cwd: string) => Promise<number>
324
+ }
325
+
326
+ function runInteractive(command: string, args: string[], cwd: string): Promise<number> {
327
+ return new Promise((resolve) => {
328
+ const child = spawn(command, args, { cwd, stdio: "inherit" })
329
+ child.once("error", (error) => {
330
+ process.stderr.write(`devcc init: could not start ${command}: ${error.message}\n`)
331
+ resolve(EXIT_FAILED)
332
+ })
333
+ child.once("exit", (code) => resolve(code ?? EXIT_FAILED))
334
+ })
335
+ }
336
+
337
+ /**
338
+ * `devcc init`: write a starter .devcc.ts from what can be detected.
339
+ * `--prompt` prints the agent setup prompt; `--agent <id>` opens that agent
340
+ * interactively with it, so the user watches and approves its edits.
341
+ */
342
+ export async function initCommand(options: InitOptions): Promise<number> {
343
+ const io = options.io ?? processIo
344
+ const { rootDir } = options
345
+ const facts = detectProject(rootDir)
346
+
347
+ if (options.printPrompt) {
348
+ io.stdout(buildSetupPrompt(facts, "write"))
349
+ return EXIT_OK
350
+ }
351
+
352
+ if (options.agent) {
353
+ const loaded = await loadConfig(rootDir)
354
+ const adapters = createAgentAdapters(loaded.config.agents)
355
+ const adapter = adapters.find((candidate) => candidate.id === options.agent)
356
+ if (!adapter?.interactiveArgs) {
357
+ const available = adapters
358
+ .filter((candidate) => candidate.interactiveArgs)
359
+ .map((candidate) => candidate.id)
360
+ io.stderr(`devcc init: --agent must be one of: ${available.join(", ")}\n`)
361
+ return EXIT_USAGE
362
+ }
363
+ if (!(options.isTTY ?? process.stdout.isTTY)) {
364
+ io.stderr(
365
+ "devcc init --agent needs an interactive terminal. Use `devcc init --prompt` to get the prompt instead.\n",
366
+ )
367
+ return EXIT_USAGE
368
+ }
369
+ if (!(await adapter.detect())) {
370
+ io.stderr(`devcc init: ${adapter.name} is not installed (\`${adapter.command}\` was not found)\n`)
371
+ return EXIT_FAILED
372
+ }
373
+ io.stdout(`Starting ${adapter.name} to set up devcc for ${facts.name}…\n`)
374
+ const run = options.runInteractive ?? runInteractive
375
+ return run(adapter.command, adapter.interactiveArgs(buildSetupPrompt(facts, "write")), rootDir)
376
+ }
377
+
378
+ if (facts.configPath && !options.force) {
379
+ io.stderr(
380
+ `devcc init: ${basename(facts.configPath)} already exists.\n` +
381
+ " Improve it with an agent: devcc init --agent claude (or codex)\n" +
382
+ " Replace it with a starter: devcc init --force\n",
383
+ )
384
+ return EXIT_USAGE
385
+ }
386
+
387
+ const target = join(rootDir, ".devcc.ts")
388
+ writeFileSync(target, renderStarterConfig(facts), "utf8")
389
+ const loaded = await loadConfigFile(target)
390
+ const problems = configProblems(rootDir, loaded)
391
+ const starter = starterConfig(facts)
392
+
393
+ io.stdout(`Created ${tildify(target)}\n`)
394
+ if (facts.configPath && facts.configPath !== target) {
395
+ io.stdout(` note: .devcc.ts takes precedence over ${basename(facts.configPath)}, which is now ignored\n`)
396
+ }
397
+ io.stdout(
398
+ ` processes: ${starter.processes.map((entry) => entry.name).join(", ") || "none detected"}\n` +
399
+ ` verify: ${starter.verify.map((entry) => (typeof entry === "string" ? entry : entry.id)).join(", ") || "none detected"}\n`,
400
+ )
401
+ for (const problem of problems) io.stderr(` ⚠ ${problem}\n`)
402
+ io.stdout(
403
+ "\nNext:\n" +
404
+ " devcc init --agent claude let an agent read the project and refine it (or codex)\n" +
405
+ " devcc init --prompt print that prompt for any other agent\n" +
406
+ " devcc config check validate after editing\n",
407
+ )
408
+ return problems.length === 0 ? EXIT_OK : EXIT_FAILED
409
+ }
package/src/devcc/cli.ts CHANGED
@@ -8,8 +8,16 @@
8
8
  * bypassed it.
9
9
  */
10
10
  import { createRequire } from "node:module"
11
+ import { resolve } from "node:path"
11
12
  import { pathToFileURL } from "node:url"
12
- import { App } from "./app/App.js"
13
+ import {
14
+ configCheckCommand,
15
+ envCommand,
16
+ EXIT_USAGE,
17
+ initCommand,
18
+ verifyCommand,
19
+ worktreeListCommand,
20
+ } from "./automation.js"
13
21
  import { errorMessage } from "./utils/errors.js"
14
22
  import { findProjectRoot } from "./utils/paths.js"
15
23
 
@@ -17,6 +25,12 @@ interface CliArgs {
17
25
  cwd?: string
18
26
  help: boolean
19
27
  version: boolean
28
+ json: boolean
29
+ force: boolean
30
+ prompt: boolean
31
+ agent?: string
32
+ /** Subcommand and its arguments, e.g. `["worktree", "list"]`. */
33
+ positionals: string[]
20
34
  }
21
35
 
22
36
  /**
@@ -37,11 +51,25 @@ function readVersion(): string {
37
51
  const VERSION = readVersion()
38
52
 
39
53
  function parseArgs(argv: readonly string[]): CliArgs {
40
- const args: CliArgs = { help: false, version: false }
54
+ const args: CliArgs = {
55
+ help: false,
56
+ version: false,
57
+ json: false,
58
+ force: false,
59
+ prompt: false,
60
+ positionals: [],
61
+ }
41
62
  for (let index = 0; index < argv.length; index += 1) {
42
63
  const arg = argv[index]
43
64
  if (arg === "--help" || arg === "-h") args.help = true
44
65
  else if (arg === "--version" || arg === "-v") args.version = true
66
+ else if (arg === "--json") args.json = true
67
+ else if (arg === "--force") args.force = true
68
+ else if (arg === "--prompt") args.prompt = true
69
+ else if (arg === "--agent") {
70
+ args.agent = argv[index + 1]
71
+ index += 1
72
+ } else if (arg?.startsWith("--agent=")) args.agent = arg.slice("--agent=".length)
45
73
  else if (arg === "--cwd" || arg === "-C") {
46
74
  const value = argv[index + 1]
47
75
  if (value) {
@@ -50,6 +78,8 @@ function parseArgs(argv: readonly string[]): CliArgs {
50
78
  }
51
79
  } else if (arg?.startsWith("--cwd=")) {
52
80
  args.cwd = arg.slice("--cwd=".length)
81
+ } else if (arg !== undefined && !arg.startsWith("-")) {
82
+ args.positionals.push(arg)
53
83
  }
54
84
  }
55
85
  return args
@@ -58,13 +88,25 @@ function parseArgs(argv: readonly string[]): CliArgs {
58
88
  const USAGE = `dev command center ${VERSION}
59
89
 
60
90
  Usage:
61
- devcc [options]
91
+ devcc [options] Open the dashboard
92
+ devcc verify [--json] Run the project's verify steps; exit 0 only if all pass
93
+ devcc worktree list [--json] List git worktrees
94
+ devcc env [--json] Report tools, mise runtimes and direnv status
95
+ devcc init Write a starter .devcc.ts from what is detected
96
+ devcc init --agent <claude|codex> Let an agent inspect the project and set up .devcc.ts
97
+ devcc init --prompt Print that setup prompt for any other agent
98
+ devcc config check [--json] Validate the project's devcc config
62
99
 
63
100
  Options:
64
101
  -C, --cwd <dir> Project directory (default: nearest project root)
102
+ --json Machine-readable output (verify, worktree list, env, config check)
103
+ --force init: replace an existing .devcc.ts
65
104
  -h, --help Show this help
66
105
  -v, --version Show the version
67
106
 
107
+ Exit codes (verify):
108
+ 0 every step passed 1 a step failed 2 nothing to verify / bad config
109
+
68
110
  Environment:
69
111
  DEVCC_LOG_LEVEL debug | info | warn | error | silent (default: info)
70
112
 
@@ -84,11 +126,28 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
84
126
  return 0
85
127
  }
86
128
 
129
+ const [command, ...rest] = args.positionals
130
+ if (command !== undefined) {
131
+ const rootDir = args.cwd ? resolve(args.cwd) : findProjectRoot()
132
+ const options = { rootDir, json: args.json }
133
+ if (command === "verify" && rest.length === 0) return runVerifyCli(options)
134
+ if (command === "worktree" && rest.length === 1 && rest[0] === "list") return worktreeListCommand(options)
135
+ if (command === "env" && rest.length === 0) return envCommand(options)
136
+ if (command === "init" && rest.length === 0) {
137
+ return initCommand({ ...options, force: args.force, printPrompt: args.prompt, agent: args.agent })
138
+ }
139
+ if (command === "config" && rest.length === 1 && rest[0] === "check") return configCheckCommand(options)
140
+ process.stderr.write(`devcc: unknown command "${args.positionals.join(" ")}"\n\n${USAGE}`)
141
+ return EXIT_USAGE
142
+ }
143
+
87
144
  if (!process.stdout.isTTY) {
88
145
  process.stderr.write("devcc needs an interactive terminal (stdout is not a TTY).\n")
89
146
  return 1
90
147
  }
91
148
 
149
+ // Loaded lazily: OpenTUI needs FFI, which the automation commands above do not.
150
+ const { App } = await import("./app/App.js")
92
151
  const app = new App({ cwd: args.cwd ? args.cwd : findProjectRoot() })
93
152
 
94
153
  try {
@@ -107,6 +166,21 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
107
166
  }
108
167
  }
109
168
 
169
+ /** Ctrl+C stops the running step instead of leaving it orphaned. */
170
+ async function runVerifyCli(options: { rootDir: string; json: boolean }): Promise<number> {
171
+ const controller = new AbortController()
172
+ const onSignal = () => controller.abort()
173
+ process.once("SIGINT", onSignal)
174
+ process.once("SIGTERM", onSignal)
175
+ try {
176
+ const code = await verifyCommand({ ...options, signal: controller.signal })
177
+ return controller.signal.aborted ? 130 : code
178
+ } finally {
179
+ process.off("SIGINT", onSignal)
180
+ process.off("SIGTERM", onSignal)
181
+ }
182
+ }
183
+
110
184
  const entry = process.argv[1]
111
185
  const isDirectRun = entry !== undefined && import.meta.url === pathToFileURL(entry).href
112
186
 
@@ -16,6 +16,7 @@ export const SIDEBAR_ITEMS: readonly SidebarItem[] = [
16
16
  { id: "services", label: "Services" },
17
17
  { id: "scripts", label: "Scripts" },
18
18
  { id: "git", label: "Git" },
19
+ { id: "worktrees", label: "Worktrees" },
19
20
  { id: "logs", label: "Logs" },
20
21
  { id: "dependencies", label: "Dependencies" },
21
22
  { id: "system", label: "System" },
@@ -98,6 +98,9 @@ export class RowList {
98
98
  })
99
99
  this.box.onSizeChange = () => {
100
100
  const visible = this.visibleCount
101
+ // The first setRows can happen before layout, when the viewport is one
102
+ // row tall; re-derive the scroll position now that the real height is known.
103
+ this.ensureVisible()
101
104
  this.paint()
102
105
  if (visible !== this.lastVisibleCount) {
103
106
  this.lastVisibleCount = visible
@@ -1,5 +1,9 @@
1
+ import type { AgentManager } from "../agents/agents.js"
2
+ import type { RemoteMachineConfig } from "./config.js"
1
3
  import type { DependencyService } from "../deps/DependencyService.js"
4
+ import type { EnvironmentService } from "../environment/EnvironmentService.js"
2
5
  import type { GitService } from "../git/GitService.js"
6
+ import type { GitWorktree } from "../git/worktrees.js"
3
7
  import type { LogStore } from "../logs/LogBuffer.js"
4
8
  import type { ProcessManager } from "../processes/ProcessManager.js"
5
9
  import type { ServiceManager } from "../services/ServiceManager.js"
@@ -23,6 +27,8 @@ export interface CommandContext {
23
27
  logs: LogStore
24
28
  system: SystemMonitor
25
29
  deps: DependencyService
30
+ agents: AgentManager
31
+ environment: EnvironmentService
26
32
 
27
33
  navigate: (view: ViewId) => void
28
34
  notify: (level: NotificationLevel, message: string, detail?: string) => void
@@ -43,6 +49,34 @@ export interface CommandContext {
43
49
  switchProject: (path: string) => Promise<void>
44
50
  /** Close an open project, stopping what devcc started there. Defaults to the current one. */
45
51
  closeProject: (path?: string) => Promise<void>
52
+
53
+ /**
54
+ * The worktree commands act on: the one selected in the Worktrees view, or
55
+ * the worktree devcc is running in. Undefined outside a git repository.
56
+ */
57
+ selectedWorktree: () => GitWorktree | undefined
58
+ /** Prompt for branch, base and path, then `git worktree add`. */
59
+ createWorktree: () => Promise<void>
60
+ /** Confirmed, never forced `git worktree remove`. */
61
+ removeWorktree: (worktree: GitWorktree) => Promise<void>
62
+ /** Prompt for a task and start the agent in the worktree (default: the selected one). */
63
+ launchAgent: (agentId: string, worktree?: GitWorktree) => Promise<void>
64
+ openInEditor: (path: string) => Promise<void>
65
+ openInTerminal: (path: string) => Promise<void>
66
+ /** Run the project's verification steps; output lands in Logs. */
67
+ runVerify: () => Promise<void>
68
+ /** Remote machines declared in config. */
69
+ remoteMachines: () => RemoteMachineConfig[]
70
+ /** Open an interactive SSH session in a terminal. */
71
+ sshTo: (machineId: string) => Promise<void>
72
+ /**
73
+ * Set up `.devcc.ts`: `"starter"` writes the detected starter; an agent id
74
+ * asks that agent to inspect the project and propose one, which devcc
75
+ * validates and writes only after confirmation.
76
+ */
77
+ setUpConfig: (how: "starter" | (string & {})) => Promise<void>
78
+ /** Re-read the config, rebuilding the project session (confirms if processes run). */
79
+ reloadConfig: () => Promise<void>
46
80
  quit: () => void
47
81
  }
48
82