@doriandev/devcc 0.2.0 → 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 (63) hide show
  1. package/README.md +39 -9
  2. package/bin/devcc.mjs +2 -2
  3. package/dist/devcc/agents/agents.d.ts +7 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -1
  5. package/dist/devcc/agents/agents.js +3 -0
  6. package/dist/devcc/agents/agents.js.map +1 -1
  7. package/dist/devcc/app/App.d.ts +8 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +125 -2
  10. package/dist/devcc/app/App.js.map +1 -1
  11. package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -1
  12. package/dist/devcc/app/controlPlaneCommands.js +25 -0
  13. package/dist/devcc/app/controlPlaneCommands.js.map +1 -1
  14. package/dist/devcc/app/keymap.d.ts.map +1 -1
  15. package/dist/devcc/app/keymap.js +1 -0
  16. package/dist/devcc/app/keymap.js.map +1 -1
  17. package/dist/devcc/automation.d.ts +21 -0
  18. package/dist/devcc/automation.d.ts.map +1 -1
  19. package/dist/devcc/automation.js +138 -1
  20. package/dist/devcc/automation.js.map +1 -1
  21. package/dist/devcc/cli.d.ts.map +1 -1
  22. package/dist/devcc/cli.js +30 -3
  23. package/dist/devcc/cli.js.map +1 -1
  24. package/dist/devcc/core/commands.d.ts +8 -0
  25. package/dist/devcc/core/commands.d.ts.map +1 -1
  26. package/dist/devcc/core/commands.js.map +1 -1
  27. package/dist/devcc/core/config.d.ts +2 -0
  28. package/dist/devcc/core/config.d.ts.map +1 -1
  29. package/dist/devcc/core/config.js +4 -0
  30. package/dist/devcc/core/config.js.map +1 -1
  31. package/dist/devcc/index.d.ts +4 -2
  32. package/dist/devcc/index.d.ts.map +1 -1
  33. package/dist/devcc/index.js +4 -2
  34. package/dist/devcc/index.js.map +1 -1
  35. package/dist/devcc/init/detect.d.ts +55 -0
  36. package/dist/devcc/init/detect.d.ts.map +1 -0
  37. package/dist/devcc/init/detect.js +207 -0
  38. package/dist/devcc/init/detect.js.map +1 -0
  39. package/dist/devcc/init/prompt.d.ts +25 -0
  40. package/dist/devcc/init/prompt.d.ts.map +1 -0
  41. package/dist/devcc/init/prompt.js +156 -0
  42. package/dist/devcc/init/prompt.js.map +1 -0
  43. package/dist/devcc/services/node.d.ts +11 -0
  44. package/dist/devcc/services/node.d.ts.map +1 -1
  45. package/dist/devcc/services/node.js +20 -18
  46. package/dist/devcc/services/node.js.map +1 -1
  47. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  48. package/dist/devcc/views/OverviewView.js +3 -1
  49. package/dist/devcc/views/OverviewView.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/devcc/agents/agents.ts +10 -0
  52. package/src/devcc/app/App.ts +153 -2
  53. package/src/devcc/app/controlPlaneCommands.ts +30 -0
  54. package/src/devcc/app/keymap.ts +1 -0
  55. package/src/devcc/automation.ts +168 -1
  56. package/src/devcc/cli.ts +35 -3
  57. package/src/devcc/core/commands.ts +8 -0
  58. package/src/devcc/core/config.ts +4 -0
  59. package/src/devcc/index.ts +10 -1
  60. package/src/devcc/init/detect.ts +268 -0
  61. package/src/devcc/init/prompt.ts +179 -0
  62. package/src/devcc/services/node.ts +17 -14
  63. package/src/devcc/views/OverviewView.ts +8 -1
@@ -1,7 +1,10 @@
1
1
  import { BoxRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core"
2
- import { existsSync } from "node:fs"
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
3
3
  import { basename, join, resolve } from "node:path"
4
4
  import { AgentManager, createAgentAdapters } from "../agents/agents.js"
5
+ import { configProblems } from "../automation.js"
6
+ import { detectProject, renderStarterConfig } from "../init/detect.js"
7
+ import { buildSetupPrompt, extractConfigFromOutput } from "../init/prompt.js"
5
8
  import { ConfirmDialog, PromptDialog } from "../components/ConfirmDialog.js"
6
9
  import { Footer, type KeyHint } from "../components/Footer.js"
7
10
  import { Header } from "../components/Header.js"
@@ -11,7 +14,7 @@ import { Sidebar } from "../components/Sidebar.js"
11
14
  import { CommandPalette } from "../components/CommandPalette.js"
12
15
  import { ProjectPicker } from "../components/ProjectPicker.js"
13
16
  import { CommandRegistry, type CommandContext } from "../core/commands.js"
14
- import { loadConfig, type CommandConfigEntry, type ResolvedConfig } from "../core/config.js"
17
+ import { loadConfig, loadConfigFile, type CommandConfigEntry, type ResolvedConfig } from "../core/config.js"
15
18
  import { EnvironmentService, type EnvironmentSnapshot } from "../environment/EnvironmentService.js"
16
19
  import { Subscriptions } from "../core/events.js"
17
20
  import {
@@ -132,6 +135,8 @@ interface ProjectSession {
132
135
  /** Config problems that make verification untrustworthy; running is refused while any exist. */
133
136
  verifyIssues: string[]
134
137
  verifyAbort: AbortController | null
138
+ /** The agent session proposing a .devcc.ts, if one is running. */
139
+ setupAgentSessionId: string | null
135
140
  scripts: { name: string; command: string }[]
136
141
  /** Script runs belong to the project they ran in, not to the store. */
137
142
  scriptRuns: Record<string, ScriptRun>
@@ -361,6 +366,7 @@ export class App {
361
366
  verify: { configured: resolvedVerify.steps.length, running: false, steps: [] },
362
367
  verifyIssues: [...loaded.issues.filter(isVerifyIssue), ...resolvedVerify.issues],
363
368
  verifyAbort: null,
369
+ setupAgentSessionId: null,
364
370
  scripts,
365
371
  scriptRuns: {},
366
372
  wiring: new Subscriptions(),
@@ -388,6 +394,9 @@ export class App {
388
394
  for (const issue of this.session.project.configIssues) {
389
395
  this.pushNotification("warn", `Config: ${issue}`)
390
396
  }
397
+ if (!this.session.project.configPath) {
398
+ this.pushNotification("info", "No .devcc.ts yet: press / and run “Project: set up .devcc.ts”")
399
+ }
391
400
  }
392
401
 
393
402
  // ---------------------------------------------------------------------
@@ -519,6 +528,7 @@ export class App {
519
528
  processes.on("process:exit", (process) => {
520
529
  this.finishScriptRun(session, process.id, process.exitCode ?? 0)
521
530
  syncProcesses()
531
+ if (process.id === session.setupAgentSessionId) void this.finishConfigSetup(session, process)
522
532
  })
523
533
  processes.on("process:error", (error) => notify("error", error.message, error.detail))
524
534
  processes.on("process:retry", (managed, delayMs) => {
@@ -1148,6 +1158,8 @@ export class App {
1148
1158
  runVerify: () => this.runVerify(),
1149
1159
  remoteMachines: () => this.config.remoteMachines,
1150
1160
  sshTo: (machineId) => this.sshTo(machineId),
1161
+ setUpConfig: (how) => this.setUpConfig(how),
1162
+ reloadConfig: () => this.reloadConfig(),
1151
1163
  quit: () => void this.quit(),
1152
1164
  }
1153
1165
 
@@ -1690,6 +1702,145 @@ export class App {
1690
1702
  }
1691
1703
  }
1692
1704
 
1705
+ // ---------------------------------------------------------------------
1706
+ // Project setup (.devcc.ts)
1707
+ // ---------------------------------------------------------------------
1708
+
1709
+ private async setUpConfig(how: string): Promise<void> {
1710
+ const session = this.session
1711
+ const facts = detectProject(session.rootDir)
1712
+
1713
+ if (how === "starter") {
1714
+ if (facts.configPath) {
1715
+ const replace = await this.askConfirm(
1716
+ "Replace the existing config with a starter?",
1717
+ `${tildify(facts.configPath)} will be overwritten with what devcc detects. Consider setting it up with an agent instead, which keeps what is still right.`,
1718
+ )
1719
+ if (!replace) return
1720
+ }
1721
+ const target = join(session.rootDir, ".devcc.ts")
1722
+ writeFileSync(target, renderStarterConfig(facts), "utf8")
1723
+ this.pushNotification("success", `Created ${tildify(target)}`)
1724
+ await this.reloadConfig()
1725
+ return
1726
+ }
1727
+
1728
+ const adapter = this.agents.get(how)
1729
+ if (!adapter) {
1730
+ this.pushNotification("warn", `Unknown agent "${how}"`)
1731
+ return
1732
+ }
1733
+ if (session.setupAgentSessionId && session.processes.isRunning(session.setupAgentSessionId)) {
1734
+ this.pushNotification("warn", "An agent is already setting up .devcc.ts")
1735
+ return
1736
+ }
1737
+
1738
+ try {
1739
+ const agentSession = await this.agents.launch(how, {
1740
+ cwd: session.rootDir,
1741
+ prompt: buildSetupPrompt(facts, "print"),
1742
+ })
1743
+ session.setupAgentSessionId = agentSession.id
1744
+ updateUi(this.store, { logSource: agentSession.id })
1745
+ this.pushNotification(
1746
+ "info",
1747
+ `${adapter.name} is reading the project; devcc will offer its .devcc.ts when it finishes`,
1748
+ )
1749
+ } catch (error) {
1750
+ const devccError = toDevccError(error, "process")
1751
+ this.pushNotification("error", devccError.message, devccError.detail)
1752
+ }
1753
+ }
1754
+
1755
+ /**
1756
+ * The setup agent exited: take the config it printed, validate it from a
1757
+ * scratch file under .devcc/, and write it only if the user agrees.
1758
+ */
1759
+ private async finishConfigSetup(session: ProjectSession, agent: ManagedProcess): Promise<void> {
1760
+ session.setupAgentSessionId = null
1761
+ const name = session.project.name
1762
+ if (agent.exitCode !== 0) {
1763
+ this.pushNotification(
1764
+ "error",
1765
+ `[${name}] Config setup agent exited with code ${agent.exitCode ?? "?"}; see its logs`,
1766
+ )
1767
+ return
1768
+ }
1769
+
1770
+ const output = (session.logs.get(agent.id)?.toArray() ?? [])
1771
+ .filter((entry) => entry.stream !== "system")
1772
+ .map((entry) => entry.text)
1773
+ .join("\n")
1774
+ const proposal = extractConfigFromOutput(output)
1775
+ if (!proposal) {
1776
+ this.pushNotification("warn", `[${name}] The agent did not print a .devcc.ts; see its logs`)
1777
+ return
1778
+ }
1779
+
1780
+ const scratchDir = join(session.rootDir, ".devcc")
1781
+ const proposedPath = join(scratchDir, "proposed.devcc.ts")
1782
+ mkdirSync(scratchDir, { recursive: true })
1783
+ writeFileSync(proposedPath, proposal, "utf8")
1784
+ const loaded = await loadConfigFile(proposedPath)
1785
+ const problems = configProblems(session.rootDir, loaded)
1786
+ const { config } = loaded
1787
+
1788
+ const current = session.project.configPath
1789
+ const target = current && !current.endsWith(".json") ? current : join(session.rootDir, ".devcc.ts")
1790
+ const summary = [
1791
+ `${config.processes.length} process(es), ${config.commands.length} command(s), verify: ${
1792
+ config.verify.map((entry) => (typeof entry === "string" ? entry : entry.id)).join(", ") || "none"
1793
+ }.`,
1794
+ problems.length > 0 ? `${problems.length} issue(s): ${problems[0] ?? ""}` : "It validates cleanly.",
1795
+ `Review it first in ${tildify(proposedPath)}.`,
1796
+ ].join(" ")
1797
+
1798
+ const write = await this.askConfirm(
1799
+ `Write ${current ? "the updated" : "the proposed"} ${tildify(target)}?`,
1800
+ summary,
1801
+ )
1802
+ if (!write) {
1803
+ this.pushNotification("info", `Kept the proposal at ${tildify(proposedPath)}`)
1804
+ return
1805
+ }
1806
+ writeFileSync(target, proposal, "utf8")
1807
+ this.pushNotification("success", `Wrote ${tildify(target)}`)
1808
+ if (session === this.session) await this.reloadConfig()
1809
+ else this.pushNotification("info", `Switch to ${name} and reload its config to use it`)
1810
+ }
1811
+
1812
+ /** Rebuild the active project's session so a changed config takes effect. */
1813
+ private async reloadConfig(): Promise<void> {
1814
+ const previous = this.session
1815
+ const running = previous.processes.list().filter((entry) => isActive(entry.status))
1816
+ if (running.length > 0) {
1817
+ const confirmed = await this.askConfirm(
1818
+ `Reload the config and stop ${running.length} running process(es)?`,
1819
+ `${running.map((entry) => entry.name).join(", ")} will be stopped; start them again afterwards.`,
1820
+ )
1821
+ if (!confirmed) return
1822
+ }
1823
+
1824
+ this.stopSessionBackground(previous)
1825
+ previous.verifyAbort?.abort()
1826
+ await previous.processes.stopAll()
1827
+ previous.wiring.dispose()
1828
+
1829
+ try {
1830
+ const next = await this.createSession(previous.rootDir)
1831
+ this.sessions.set(next.rootDir, next)
1832
+ this.wireSession(next)
1833
+ this.activateSession(next)
1834
+ this.reportConfigIssues()
1835
+ this.pushNotification(
1836
+ "success",
1837
+ `Reloaded ${next.project.configPath ? basename(next.project.configPath) : "config"}`,
1838
+ )
1839
+ } catch (error) {
1840
+ this.pushNotification("error", `Could not reload the config: ${errorMessage(error)}`)
1841
+ }
1842
+ }
1843
+
1693
1844
  // ---------------------------------------------------------------------
1694
1845
  // Dependencies
1695
1846
  // ---------------------------------------------------------------------
@@ -168,6 +168,36 @@ export function createControlPlaneCommands(options: ControlPlaneCommandOptions):
168
168
  },
169
169
  ]
170
170
 
171
+ // --- Project setup -------------------------------------------------------
172
+ const hasConfig = (context: CommandContext) => context.store.getState().project.configPath !== null
173
+ commands.push(
174
+ {
175
+ id: "config.starter",
176
+ title: "Project: set up .devcc.ts from detected scripts",
177
+ category: "Project",
178
+ keywords: ["init", "config", "setup", "starter", "generate"],
179
+ when: (context) => !hasConfig(context),
180
+ execute: (context) => context.setUpConfig("starter"),
181
+ },
182
+ ...options.agents
183
+ .filter((agent) => agent.separatesPrompt)
184
+ .map((agent): CommandDefinition => ({
185
+ id: `config.agent.${agent.id}`,
186
+ title: `Project: set up .devcc.ts with ${agent.name}`,
187
+ category: "Project",
188
+ keywords: ["init", "config", "setup", "improve", "agent", agent.id],
189
+ execute: (context) => context.setUpConfig(agent.id),
190
+ })),
191
+ {
192
+ id: "config.reload",
193
+ title: "Project: reload .devcc.ts",
194
+ category: "Project",
195
+ keywords: ["config", "refresh", "reread"],
196
+ when: hasConfig,
197
+ execute: (context) => context.reloadConfig(),
198
+ },
199
+ )
200
+
171
201
  // --- Remote machines -----------------------------------------------------
172
202
  for (const machine of options.machines) {
173
203
  commands.push({
@@ -64,6 +64,7 @@ export const HELP_SECTIONS: HelpSection[] = [
64
64
  title: "Getting started",
65
65
  entries: [
66
66
  { keys: ".devcc.ts", description: "declare processes devcc can control (see Config below)" },
67
+ { keys: "/ set up", description: "“Project: set up .devcc.ts” writes a starter or asks an agent" },
67
68
  { keys: "ctrl+p", description: "open another project; the current one keeps running" },
68
69
  { keys: "/", description: "the palette lists everything the app can do" },
69
70
  { keys: ".devcc/devcc.log", description: "internal diagnostics, if something looks wrong" },
@@ -1,8 +1,15 @@
1
- import { loadConfig } from "./core/config.js"
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"
2
6
  import { EnvironmentService, type EnvironmentSnapshot } from "./environment/EnvironmentService.js"
3
7
  import { GitService } from "./git/GitService.js"
4
8
  import { worktreeLabel } from "./git/worktrees.js"
9
+ import { detectProject, renderStarterConfig, starterConfig } from "./init/detect.js"
10
+ import { buildSetupPrompt } from "./init/prompt.js"
5
11
  import { errorMessage } from "./utils/errors.js"
12
+ import { pluralise } from "./utils/format.js"
6
13
  import { createLogger } from "./utils/logger.js"
7
14
  import { detectPackageManager, readPackageJson, readScripts } from "./utils/packageManager.js"
8
15
  import { tildify } from "./utils/paths.js"
@@ -240,3 +247,163 @@ export function formatEnvironment(snapshot: EnvironmentSnapshot): string {
240
247
  }
241
248
  return `${lines.join("\n")}\n`
242
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
@@ -10,7 +10,14 @@
10
10
  import { createRequire } from "node:module"
11
11
  import { resolve } from "node:path"
12
12
  import { pathToFileURL } from "node:url"
13
- import { envCommand, EXIT_USAGE, verifyCommand, worktreeListCommand } from "./automation.js"
13
+ import {
14
+ configCheckCommand,
15
+ envCommand,
16
+ EXIT_USAGE,
17
+ initCommand,
18
+ verifyCommand,
19
+ worktreeListCommand,
20
+ } from "./automation.js"
14
21
  import { errorMessage } from "./utils/errors.js"
15
22
  import { findProjectRoot } from "./utils/paths.js"
16
23
 
@@ -19,6 +26,9 @@ interface CliArgs {
19
26
  help: boolean
20
27
  version: boolean
21
28
  json: boolean
29
+ force: boolean
30
+ prompt: boolean
31
+ agent?: string
22
32
  /** Subcommand and its arguments, e.g. `["worktree", "list"]`. */
23
33
  positionals: string[]
24
34
  }
@@ -41,12 +51,25 @@ function readVersion(): string {
41
51
  const VERSION = readVersion()
42
52
 
43
53
  function parseArgs(argv: readonly string[]): CliArgs {
44
- const args: CliArgs = { help: false, version: false, json: false, positionals: [] }
54
+ const args: CliArgs = {
55
+ help: false,
56
+ version: false,
57
+ json: false,
58
+ force: false,
59
+ prompt: false,
60
+ positionals: [],
61
+ }
45
62
  for (let index = 0; index < argv.length; index += 1) {
46
63
  const arg = argv[index]
47
64
  if (arg === "--help" || arg === "-h") args.help = true
48
65
  else if (arg === "--version" || arg === "-v") args.version = true
49
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)
50
73
  else if (arg === "--cwd" || arg === "-C") {
51
74
  const value = argv[index + 1]
52
75
  if (value) {
@@ -69,10 +92,15 @@ Usage:
69
92
  devcc verify [--json] Run the project's verify steps; exit 0 only if all pass
70
93
  devcc worktree list [--json] List git worktrees
71
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
72
99
 
73
100
  Options:
74
101
  -C, --cwd <dir> Project directory (default: nearest project root)
75
- --json Machine-readable output (verify, worktree list, env)
102
+ --json Machine-readable output (verify, worktree list, env, config check)
103
+ --force init: replace an existing .devcc.ts
76
104
  -h, --help Show this help
77
105
  -v, --version Show the version
78
106
 
@@ -105,6 +133,10 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
105
133
  if (command === "verify" && rest.length === 0) return runVerifyCli(options)
106
134
  if (command === "worktree" && rest.length === 1 && rest[0] === "list") return worktreeListCommand(options)
107
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)
108
140
  process.stderr.write(`devcc: unknown command "${args.positionals.join(" ")}"\n\n${USAGE}`)
109
141
  return EXIT_USAGE
110
142
  }
@@ -69,6 +69,14 @@ export interface CommandContext {
69
69
  remoteMachines: () => RemoteMachineConfig[]
70
70
  /** Open an interactive SSH session in a terminal. */
71
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>
72
80
  quit: () => void
73
81
  }
74
82
 
@@ -411,7 +411,11 @@ export interface LoadedConfig extends ValidationResult {
411
411
  export async function loadConfig(rootDir: string): Promise<LoadedConfig> {
412
412
  const filePath = findConfigFile(rootDir)
413
413
  if (!filePath) return { filePath: null, config: { ...EMPTY_CONFIG }, issues: [] }
414
+ return loadConfigFile(filePath)
415
+ }
414
416
 
417
+ /** Load and validate one specific config file (e.g. a proposed config before it is written). */
418
+ export async function loadConfigFile(filePath: string): Promise<LoadedConfig> {
415
419
  try {
416
420
  const raw = await importConfigModule(filePath)
417
421
  const result = validateConfig(raw)
@@ -17,7 +17,15 @@ export {
17
17
  type AgentLaunchOptions,
18
18
  type AgentSession,
19
19
  } from "./agents/agents.js"
20
- export { envCommand, verifyCommand, worktreeListCommand } from "./automation.js"
20
+ export {
21
+ configCheckCommand,
22
+ envCommand,
23
+ initCommand,
24
+ verifyCommand,
25
+ worktreeListCommand,
26
+ } from "./automation.js"
27
+ export { detectProject, renderStarterConfig, starterConfig, type ProjectFacts } from "./init/detect.js"
28
+ export { buildSetupPrompt, extractConfigFromOutput, type SetupPromptMode } from "./init/prompt.js"
21
29
  export { createBuiltinCommands, createConfigCommands, createScriptCommands } from "./app/builtinCommands.js"
22
30
  export { HELP_SECTIONS, VIEW_SHORTCUTS } from "./app/keymap.js"
23
31
  export { theme, glyphs, processStatusStyle, serviceStateStyle } from "./app/theme.js"
@@ -28,6 +36,7 @@ export {
28
36
  loadConfig,
29
37
  validateConfig,
30
38
  findConfigFile,
39
+ loadConfigFile,
31
40
  CONFIG_FILENAMES,
32
41
  DEFAULT_REFRESH,
33
42
  type CommandConfigEntry,