@doriandev/devcc 0.1.2 → 0.2.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 (136) hide show
  1. package/README.md +556 -386
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +112 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +179 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +15 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +326 -17
  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 +179 -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 +24 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +31 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +202 -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 +46 -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 +25 -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 +69 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +185 -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 +13 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +11 -1
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/platform/launch.d.ts +30 -0
  67. package/dist/devcc/platform/launch.d.ts.map +1 -0
  68. package/dist/devcc/platform/launch.js +93 -0
  69. package/dist/devcc/platform/launch.js.map +1 -0
  70. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  71. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  72. package/dist/devcc/services/ServiceManager.js +18 -4
  73. package/dist/devcc/services/ServiceManager.js.map +1 -1
  74. package/dist/devcc/services/ollama.d.ts +64 -5
  75. package/dist/devcc/services/ollama.d.ts.map +1 -1
  76. package/dist/devcc/services/ollama.js +118 -17
  77. package/dist/devcc/services/ollama.js.map +1 -1
  78. package/dist/devcc/services/remote.d.ts +90 -0
  79. package/dist/devcc/services/remote.d.ts.map +1 -0
  80. package/dist/devcc/services/remote.js +233 -0
  81. package/dist/devcc/services/remote.js.map +1 -0
  82. package/dist/devcc/services/types.d.ts +8 -1
  83. package/dist/devcc/services/types.d.ts.map +1 -1
  84. package/dist/devcc/services/types.js.map +1 -1
  85. package/dist/devcc/utils/exec.d.ts +6 -0
  86. package/dist/devcc/utils/exec.d.ts.map +1 -1
  87. package/dist/devcc/utils/exec.js +12 -2
  88. package/dist/devcc/utils/exec.js.map +1 -1
  89. package/dist/devcc/verify/verify.d.ts +107 -0
  90. package/dist/devcc/verify/verify.d.ts.map +1 -0
  91. package/dist/devcc/verify/verify.js +203 -0
  92. package/dist/devcc/verify/verify.js.map +1 -0
  93. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  94. package/dist/devcc/views/OverviewView.js +55 -1
  95. package/dist/devcc/views/OverviewView.js.map +1 -1
  96. package/dist/devcc/views/ServicesView.d.ts +2 -0
  97. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  98. package/dist/devcc/views/ServicesView.js +15 -0
  99. package/dist/devcc/views/ServicesView.js.map +1 -1
  100. package/dist/devcc/views/SystemView.d.ts +10 -1
  101. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  102. package/dist/devcc/views/SystemView.js +127 -19
  103. package/dist/devcc/views/SystemView.js.map +1 -1
  104. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  105. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  106. package/dist/devcc/views/WorktreesView.js +235 -0
  107. package/dist/devcc/views/WorktreesView.js.map +1 -0
  108. package/package.json +1 -1
  109. package/src/devcc/agents/agents.ts +282 -0
  110. package/src/devcc/app/App.ts +376 -20
  111. package/src/devcc/app/controlPlaneCommands.ts +197 -0
  112. package/src/devcc/app/keymap.ts +24 -3
  113. package/src/devcc/automation.ts +242 -0
  114. package/src/devcc/cli.ts +45 -3
  115. package/src/devcc/components/Sidebar.ts +1 -0
  116. package/src/devcc/components/primitives.ts +3 -0
  117. package/src/devcc/core/commands.ts +26 -0
  118. package/src/devcc/core/config.ts +271 -11
  119. package/src/devcc/core/state.ts +25 -0
  120. package/src/devcc/environment/EnvironmentService.ts +215 -0
  121. package/src/devcc/environment/integrations.ts +133 -0
  122. package/src/devcc/environment/tools.ts +151 -0
  123. package/src/devcc/git/GitService.ts +104 -2
  124. package/src/devcc/git/worktrees.ts +256 -0
  125. package/src/devcc/index.ts +57 -2
  126. package/src/devcc/platform/launch.ts +125 -0
  127. package/src/devcc/services/ServiceManager.ts +15 -3
  128. package/src/devcc/services/ollama.ts +162 -19
  129. package/src/devcc/services/remote.ts +306 -0
  130. package/src/devcc/services/types.ts +9 -1
  131. package/src/devcc/utils/exec.ts +26 -2
  132. package/src/devcc/verify/verify.ts +316 -0
  133. package/src/devcc/views/OverviewView.ts +76 -1
  134. package/src/devcc/views/ServicesView.ts +17 -0
  135. package/src/devcc/views/SystemView.ts +144 -22
  136. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -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,26 @@ 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>
46
72
  quit: () => void
47
73
  }
48
74
 
@@ -1,9 +1,12 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { join } from "node:path"
3
+ import type { AgentConfig } from "../agents/agents.js"
3
4
  import type { ProcessDefinition, ProcessKind, ReadinessCheck } from "../processes/ManagedProcess.js"
5
+ import type { ServiceCategory } from "../services/types.js"
4
6
  import { errorMessage } from "../utils/errors.js"
5
7
  import type { LogLevel } from "../utils/logger.js"
6
8
  import { isLogLevel } from "../utils/logger.js"
9
+ import type { VerifyEntry } from "../verify/verify.js"
7
10
 
8
11
  /** A command declared in project config: runs an executable and streams its logs. */
9
12
  export interface CommandConfigEntry {
@@ -18,14 +21,70 @@ export interface CommandConfigEntry {
18
21
  confirm?: { title: string; message: string } | boolean
19
22
  }
20
23
 
21
- /** An extra port/HTTP endpoint to watch in the Services view. */
24
+ /**
25
+ * An extra endpoint to watch in the Services view.
26
+ *
27
+ * The default `type: "port"` is a TCP (or HTTP, with `healthPath`) probe.
28
+ * `type: "ollama"` watches an Ollama server at `baseUrl` - localhost, a LAN
29
+ * hostname or a Tailscale name - including its models.
30
+ */
22
31
  export interface ServiceConfigEntry {
23
32
  id: string
24
33
  name: string
34
+ /** Required for port services; derived from `baseUrl` for Ollama. */
25
35
  port: number
26
36
  host?: string
27
37
  healthPath?: string
28
- category?: "container" | "database" | "runtime" | "ai" | "web"
38
+ category?: ServiceCategory
39
+ type?: "port" | "ollama"
40
+ /** Ollama only, e.g. `http://gpu-server:11434`. */
41
+ baseUrl?: string
42
+ }
43
+
44
+ /** How to open a directory in an editor. `command` wins over `application`. */
45
+ export interface EditorConfig {
46
+ /** CLI launcher on PATH: `code`, `zed`, `cursor`, `webstorm`. */
47
+ command?: string
48
+ /** Extra arguments placed before the path. */
49
+ args?: string[]
50
+ /** macOS application name for `open -a`, e.g. `WebStorm`. */
51
+ application?: string
52
+ }
53
+
54
+ /**
55
+ * How to open a terminal. By default devcc uses Terminal on macOS.
56
+ * `application` names another macOS app; `command` + `args` launches anything
57
+ * else, with `{path}` in an argument replaced by the directory.
58
+ */
59
+ export interface TerminalConfig {
60
+ application?: string
61
+ command?: string
62
+ /** Arguments to open a directory (default `["{path}"]`). */
63
+ args?: string[]
64
+ /** Arguments that make `command` run a program, e.g. `["start", "--"]` for WezTerm; used for SSH. */
65
+ execArgs?: string[]
66
+ }
67
+
68
+ export interface WorktreesConfig {
69
+ /** Where new worktrees go, relative to the main worktree (default `..`). */
70
+ directory?: string
71
+ }
72
+
73
+ /**
74
+ * A development machine reached over SSH. `host` may be an alias from
75
+ * `~/.ssh/config`; devcc resolves it with `ssh -G` and never stores credentials.
76
+ */
77
+ export interface RemoteMachineConfig {
78
+ id: string
79
+ name: string
80
+ host: string
81
+ transport: "ssh"
82
+ /** Overrides the port from SSH config. */
83
+ port?: number
84
+ /** Overrides the user from SSH config. */
85
+ user?: string
86
+ /** Ids of `services` entries that run on this machine. */
87
+ services?: string[]
29
88
  }
30
89
 
31
90
  export interface RefreshIntervals {
@@ -52,12 +111,29 @@ export interface DevccConfig {
52
111
  /** Cap for each log source's ring buffer. */
53
112
  maxLogLines?: number
54
113
  logLevel?: LogLevel
114
+ /** Editor for "open in editor" (default: `$VISUAL`/`$EDITOR` is not used; see README). */
115
+ editor?: EditorConfig
116
+ terminal?: TerminalConfig
117
+ /** Override or add AI agents, keyed by id (`claude`, `codex`, or your own). */
118
+ agents?: Record<string, AgentConfig>
119
+ worktrees?: WorktreesConfig
120
+ /** Steps for `devcc verify`: command ids, package.json scripts, or inline steps. */
121
+ verify?: VerifyEntry[]
122
+ remoteMachines?: RemoteMachineConfig[]
55
123
  }
56
124
 
57
- export type ResolvedConfig = Required<
58
- Pick<DevccConfig, "processes" | "commands" | "services" | "projects" | "projectSearchDirs">
59
- > &
60
- Omit<DevccConfig, "processes" | "commands" | "services" | "projects" | "projectSearchDirs"> & {
125
+ type ListFields =
126
+ | "processes"
127
+ | "commands"
128
+ | "services"
129
+ | "projects"
130
+ | "projectSearchDirs"
131
+ | "verify"
132
+ | "remoteMachines"
133
+ | "agents"
134
+
135
+ export type ResolvedConfig = Required<Pick<DevccConfig, ListFields>> &
136
+ Omit<DevccConfig, ListFields> & {
61
137
  refresh: Required<RefreshIntervals>
62
138
  }
63
139
 
@@ -74,6 +150,9 @@ export const EMPTY_CONFIG: ResolvedConfig = {
74
150
  services: [],
75
151
  projects: [],
76
152
  projectSearchDirs: [],
153
+ verify: [],
154
+ remoteMachines: [],
155
+ agents: {},
77
156
  refresh: DEFAULT_REFRESH,
78
157
  }
79
158
 
@@ -231,15 +310,41 @@ export function validateConfig(raw: unknown): ValidationResult {
231
310
  continue
232
311
  }
233
312
  const id = asString(record.id)
234
- const port = asPositiveInt(record.port)
235
- if (!id || port === undefined) {
236
- issues.push(`services[${index}] needs "id" and a numeric "port"`)
313
+ const type = asString(record.type) ?? "port"
314
+ if (type !== "port" && type !== "ollama") {
315
+ issues.push(`services[${index}] has unknown type "${type}" (expected "port" or "ollama")`)
237
316
  continue
238
317
  }
239
- if (seenServiceIds.has(id)) {
318
+ if (id && seenServiceIds.has(id)) {
240
319
  issues.push(`services[${index}] duplicates id "${id}"`)
241
320
  continue
242
321
  }
322
+
323
+ if (type === "ollama") {
324
+ const baseUrl = asString(record.baseUrl)
325
+ const endpoint = baseUrl ? parseHttpUrl(baseUrl) : null
326
+ if (!id || !endpoint) {
327
+ issues.push(`services[${index}] (ollama) needs "id" and an http(s) "baseUrl"`)
328
+ continue
329
+ }
330
+ seenServiceIds.add(id)
331
+ services.push({
332
+ id,
333
+ name: asString(record.name) ?? `Ollama (${endpoint.host})`,
334
+ type: "ollama",
335
+ baseUrl: endpoint.baseUrl,
336
+ host: endpoint.host,
337
+ port: endpoint.port,
338
+ category: "ai",
339
+ })
340
+ continue
341
+ }
342
+
343
+ const port = asPositiveInt(record.port)
344
+ if (!id || port === undefined) {
345
+ issues.push(`services[${index}] needs "id" and a numeric "port"`)
346
+ continue
347
+ }
243
348
  seenServiceIds.add(id)
244
349
  services.push({
245
350
  id,
@@ -251,6 +356,15 @@ export function validateConfig(raw: unknown): ValidationResult {
251
356
  })
252
357
  }
253
358
 
359
+ const remoteMachines = asRemoteMachines(input.remoteMachines, seenServiceIds, issues)
360
+ const verify = asVerify(input.verify, issues)
361
+ const agents = asAgents(input.agents, issues)
362
+ const editor = asEditor(input.editor, issues)
363
+ const terminal = asTerminal(input.terminal, issues)
364
+ const worktreesRecord = asRecord(input.worktrees)
365
+ if (input.worktrees !== undefined && !worktreesRecord) issues.push(`"worktrees" must be an object`)
366
+ const worktrees = worktreesRecord ? { directory: asString(worktreesRecord.directory) } : undefined
367
+
254
368
  const refreshInput = asRecord(input.refresh) ?? {}
255
369
  const refresh: Required<RefreshIntervals> = {
256
370
  system: asPositiveInt(refreshInput.system) ?? DEFAULT_REFRESH.system,
@@ -273,6 +387,12 @@ export function validateConfig(raw: unknown): ValidationResult {
273
387
  refresh,
274
388
  maxLogLines: asPositiveInt(input.maxLogLines),
275
389
  logLevel: logLevel && isLogLevel(logLevel) ? logLevel : undefined,
390
+ editor,
391
+ terminal,
392
+ agents,
393
+ worktrees,
394
+ verify,
395
+ remoteMachines,
276
396
  },
277
397
  issues,
278
398
  }
@@ -397,9 +517,149 @@ function asReadiness(value: unknown, processId: string, issues: string[]): Readi
397
517
  }
398
518
 
399
519
  function asServiceCategory(value: unknown): ServiceConfigEntry["category"] {
400
- const categories = ["container", "database", "runtime", "ai", "web"] as const
520
+ const categories = ["container", "database", "runtime", "ai", "web", "remote"] as const
401
521
  const name = asString(value)
402
522
  return name && (categories as readonly string[]).includes(name)
403
523
  ? (name as ServiceConfigEntry["category"])
404
524
  : "web"
405
525
  }
526
+
527
+ function parseHttpUrl(value: string): { baseUrl: string; host: string; port: number } | null {
528
+ try {
529
+ const url = new URL(value)
530
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null
531
+ const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80
532
+ return { baseUrl: `${url.protocol}//${url.host}`, host: url.hostname, port }
533
+ } catch {
534
+ return null
535
+ }
536
+ }
537
+
538
+ /**
539
+ * A value handed to `ssh` as an argument must never be readable as an option
540
+ * (`-oProxyCommand=...`), so hosts and users are held to a conservative charset.
541
+ */
542
+ const SSH_TOKEN = /^[A-Za-z0-9_][A-Za-z0-9._%+:[\]-]*$/u
543
+
544
+ function asRemoteMachines(value: unknown, serviceIds: Set<string>, issues: string[]): RemoteMachineConfig[] {
545
+ const machines: RemoteMachineConfig[] = []
546
+ const seen = new Set<string>()
547
+ for (const [index, entry] of asArray(value, "remoteMachines", issues).entries()) {
548
+ const record = asRecord(entry)
549
+ const id = asString(record?.id)
550
+ const host = asString(record?.host)
551
+ if (!record || !id || !host) {
552
+ issues.push(`remoteMachines[${index}] needs "id" and "host"`)
553
+ continue
554
+ }
555
+ if (seen.has(id)) {
556
+ issues.push(`remoteMachines[${index}] duplicates id "${id}"`)
557
+ continue
558
+ }
559
+ const transport = asString(record.transport) ?? "ssh"
560
+ if (transport !== "ssh") {
561
+ issues.push(`remoteMachines "${id}": transport "${transport}" is not supported (only "ssh")`)
562
+ continue
563
+ }
564
+ if (!SSH_TOKEN.test(host)) {
565
+ issues.push(`remoteMachines "${id}": host "${host}" is not a valid hostname or SSH alias`)
566
+ continue
567
+ }
568
+ const user = asString(record.user)
569
+ if (user !== undefined && !SSH_TOKEN.test(user)) {
570
+ issues.push(`remoteMachines "${id}": user "${user}" is not valid`)
571
+ continue
572
+ }
573
+ const services = asStringArray(record.services)
574
+ for (const serviceId of services ?? []) {
575
+ if (!serviceIds.has(serviceId)) {
576
+ issues.push(`remoteMachines "${id}": service "${serviceId}" is not a declared service`)
577
+ }
578
+ }
579
+ seen.add(id)
580
+ machines.push({
581
+ id,
582
+ name: asString(record.name) ?? id,
583
+ host,
584
+ transport: "ssh",
585
+ port: asPositiveInt(record.port),
586
+ user,
587
+ services,
588
+ })
589
+ }
590
+ return machines
591
+ }
592
+
593
+ function asVerify(value: unknown, issues: string[]): VerifyEntry[] {
594
+ const entries: VerifyEntry[] = []
595
+ for (const [index, entry] of asArray(value, "verify", issues).entries()) {
596
+ const name = asString(entry)
597
+ if (name) {
598
+ entries.push(name)
599
+ continue
600
+ }
601
+ const record = asRecord(entry)
602
+ const id = asString(record?.id)
603
+ const command = asString(record?.command)
604
+ if (!record || !id || !command) {
605
+ issues.push(`verify[${index}] must be a command id, a script name, or { id, command }`)
606
+ continue
607
+ }
608
+ entries.push({
609
+ id,
610
+ name: asString(record.name),
611
+ command,
612
+ args: asStringArray(record.args),
613
+ cwd: asString(record.cwd),
614
+ })
615
+ }
616
+ return entries
617
+ }
618
+
619
+ function asAgents(value: unknown, issues: string[]): Record<string, AgentConfig> {
620
+ if (value === undefined) return {}
621
+ const record = asRecord(value)
622
+ if (!record) {
623
+ issues.push(`"agents" must be an object keyed by agent id`)
624
+ return {}
625
+ }
626
+ const agents: Record<string, AgentConfig> = {}
627
+ for (const [id, entry] of Object.entries(record)) {
628
+ const agent = asRecord(entry)
629
+ if (!agent) {
630
+ issues.push(`agents.${id} must be an object`)
631
+ continue
632
+ }
633
+ agents[id] = {
634
+ name: asString(agent.name),
635
+ command: asString(agent.command),
636
+ args: asStringArray(agent.args),
637
+ enabled: agent.enabled === false ? false : undefined,
638
+ }
639
+ }
640
+ return agents
641
+ }
642
+
643
+ function asEditor(value: unknown, issues: string[]): EditorConfig | undefined {
644
+ if (value === undefined) return undefined
645
+ const record = asRecord(value)
646
+ const command = asString(record?.command)
647
+ const application = asString(record?.application)
648
+ if (!record || (!command && !application)) {
649
+ issues.push(`"editor" needs a "command" (e.g. "zed") or an "application" (e.g. "WebStorm")`)
650
+ return undefined
651
+ }
652
+ return { command, application, args: asStringArray(record.args) }
653
+ }
654
+
655
+ function asTerminal(value: unknown, issues: string[]): TerminalConfig | undefined {
656
+ if (value === undefined) return undefined
657
+ const record = asRecord(value)
658
+ const command = asString(record?.command)
659
+ const application = asString(record?.application)
660
+ if (!record || (!command && !application)) {
661
+ issues.push(`"terminal" needs a "command" or an "application"`)
662
+ return undefined
663
+ }
664
+ return { command, application, args: asStringArray(record.args), execArgs: asStringArray(record.execArgs) }
665
+ }
@@ -1,3 +1,6 @@
1
+ import type { AgentSession } from "../agents/agents.js"
2
+ import type { EnvironmentSnapshot } from "../environment/EnvironmentService.js"
3
+ import { EMPTY_ENVIRONMENT } from "../environment/EnvironmentService.js"
1
4
  import type { GitSnapshot } from "../git/GitService.js"
2
5
  import { EMPTY_GIT_SNAPSHOT } from "../git/GitService.js"
3
6
  import type { LogSource } from "../logs/LogBuffer.js"
@@ -8,6 +11,7 @@ import type { ProjectEntry } from "../projects/ProjectRegistry.js"
8
11
  import type { ServiceSnapshot } from "../services/types.js"
9
12
  import type { HostInfo, SystemMetrics, ToolVersions } from "../system/SystemMonitor.js"
10
13
  import type { PackageManagerDetection } from "../utils/packageManager.js"
14
+ import type { VerifyStepResult } from "../verify/verify.js"
11
15
 
12
16
  export const VIEW_IDS = [
13
17
  "overview",
@@ -15,6 +19,7 @@ export const VIEW_IDS = [
15
19
  "services",
16
20
  "scripts",
17
21
  "git",
22
+ "worktrees",
18
23
  "logs",
19
24
  "dependencies",
20
25
  "system",
@@ -60,6 +65,19 @@ export interface ScriptsState {
60
65
  runs: Record<string, ScriptRun>
61
66
  }
62
67
 
68
+ export interface VerifyState {
69
+ /** Steps resolved from config; empty when verification is not configured. */
70
+ configured: number
71
+ running: boolean
72
+ /** Progress of the current or most recent run. */
73
+ steps: VerifyStepResult[]
74
+ startedAt?: Date
75
+ finishedAt?: Date
76
+ ok?: boolean
77
+ }
78
+
79
+ export const EMPTY_VERIFY: VerifyState = { configured: 0, running: false, steps: [] }
80
+
63
81
  export interface UiState {
64
82
  view: ViewId
65
83
  overlay: OverlayKind
@@ -83,6 +101,10 @@ export interface AppState {
83
101
  scripts: ScriptsState
84
102
  git: GitSnapshot
85
103
  dependencies: DependencySnapshot
104
+ /** AI agent sessions started in this project, newest first. */
105
+ agents: AgentSession[]
106
+ environment: EnvironmentSnapshot
107
+ verify: VerifyState
86
108
  system: SystemMetrics | null
87
109
  logSources: LogSource[]
88
110
  notifications: Notification[]
@@ -119,6 +141,9 @@ export function createInitialState(project: ProjectState, width: number, height:
119
141
  scripts: { available: [], runs: {} },
120
142
  git: EMPTY_GIT_SNAPSHOT,
121
143
  dependencies: EMPTY_DEPENDENCY_SNAPSHOT,
144
+ agents: [],
145
+ environment: EMPTY_ENVIRONMENT,
146
+ verify: EMPTY_VERIFY,
122
147
  system: null,
123
148
  logSources: [],
124
149
  notifications: [],
@@ -0,0 +1,215 @@
1
+ import { existsSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { TypedEmitter } from "../core/events.js"
4
+ import { DevccError } from "../utils/errors.js"
5
+ import { exec } from "../utils/exec.js"
6
+ import type { ScopedLogger } from "../utils/logger.js"
7
+ import {
8
+ isDirenvLoaded,
9
+ MISE_ARGS,
10
+ miseErrorMessage,
11
+ parseDirenvStatus,
12
+ parseMiseLs,
13
+ type DirenvStatus,
14
+ type MiseStatus,
15
+ } from "./integrations.js"
16
+ import {
17
+ DEFAULT_TOOLS,
18
+ detectTool,
19
+ findExecutable,
20
+ firstLine,
21
+ type Exec,
22
+ type ToolDefinition,
23
+ type ToolStatus,
24
+ type Which,
25
+ } from "./tools.js"
26
+
27
+ export interface EnvironmentSnapshot {
28
+ tools: ToolStatus[]
29
+ mise: MiseStatus
30
+ direnv: DirenvStatus
31
+ /** Null until the first detection finishes. */
32
+ checkedAt: Date | null
33
+ checking: boolean
34
+ }
35
+
36
+ export const EMPTY_ENVIRONMENT: EnvironmentSnapshot = {
37
+ tools: [],
38
+ mise: { installed: false, runtimes: [] },
39
+ direnv: { installed: false, approval: "unknown", loaded: null },
40
+ checkedAt: null,
41
+ checking: false,
42
+ }
43
+
44
+ export type EnvironmentServiceEvents = {
45
+ "environment:changed": [EnvironmentSnapshot]
46
+ }
47
+
48
+ export interface EnvironmentServiceOptions {
49
+ rootDir: string
50
+ logger?: ScopedLogger
51
+ tools?: readonly ToolDefinition[]
52
+ /** Injected for tests. */
53
+ run?: Exec
54
+ which?: Which
55
+ env?: NodeJS.ProcessEnv
56
+ exists?: (path: string) => boolean
57
+ timeoutMs?: number
58
+ }
59
+
60
+ /**
61
+ * Machine and project environment health.
62
+ *
63
+ * Detection runs asynchronously, every external call has a timeout, and the
64
+ * result is cached until someone asks for a refresh: tool versions change
65
+ * rarely, so there is nothing to poll.
66
+ */
67
+ export class EnvironmentService extends TypedEmitter<EnvironmentServiceEvents> {
68
+ private snapshot: EnvironmentSnapshot = EMPTY_ENVIRONMENT
69
+ private pending: Promise<EnvironmentSnapshot> | null = null
70
+ private readonly run: Exec
71
+ private readonly which: Which
72
+ private readonly env: NodeJS.ProcessEnv
73
+
74
+ constructor(private readonly options: EnvironmentServiceOptions) {
75
+ super()
76
+ this.env = options.env ?? process.env
77
+ this.run = options.run ?? exec
78
+ this.which = options.which ?? ((command) => findExecutable(command, this.env.PATH ?? ""))
79
+ }
80
+
81
+ getSnapshot(): EnvironmentSnapshot {
82
+ return this.snapshot
83
+ }
84
+
85
+ /** Detect everything. Concurrent calls share one run. */
86
+ refresh(): Promise<EnvironmentSnapshot> {
87
+ if (this.pending) return this.pending
88
+ this.publish({ ...this.snapshot, checking: true })
89
+ this.pending = this.detect().finally(() => {
90
+ this.pending = null
91
+ })
92
+ return this.pending
93
+ }
94
+
95
+ /** The cached snapshot, detecting first if nothing has been checked yet. */
96
+ async ensure(): Promise<EnvironmentSnapshot> {
97
+ return this.snapshot.checkedAt ? this.snapshot : this.refresh()
98
+ }
99
+
100
+ /**
101
+ * `direnv allow` for this project's `.envrc`. Only ever called from an
102
+ * explicit, confirmed user action - devcc never approves an `.envrc` itself.
103
+ */
104
+ async allowDirenv(): Promise<void> {
105
+ const { rcPath, installed } = this.snapshot.direnv
106
+ if (!installed || !rcPath) {
107
+ throw new DevccError({ scope: "system", message: "No .envrc for direnv to allow" })
108
+ }
109
+ const result = await this.run("direnv", ["allow", rcPath], {
110
+ cwd: this.options.rootDir,
111
+ timeoutMs: 10_000,
112
+ logger: this.options.logger,
113
+ })
114
+ if (!result.ok) {
115
+ throw new DevccError({
116
+ scope: "system",
117
+ message: "direnv allow failed",
118
+ detail: result.stderr.trim() || undefined,
119
+ })
120
+ }
121
+ await this.refresh()
122
+ }
123
+
124
+ private async detect(): Promise<EnvironmentSnapshot> {
125
+ const startedAt = Date.now()
126
+ const definitions = this.options.tools ?? DEFAULT_TOOLS
127
+ const tools = await Promise.all(
128
+ definitions.map((definition) =>
129
+ detectTool(definition, {
130
+ run: (command, args, options) =>
131
+ this.run(command, args, { ...options, logger: this.options.logger }),
132
+ which: this.which,
133
+ timeoutMs: this.options.timeoutMs,
134
+ }),
135
+ ),
136
+ )
137
+
138
+ const installed = (id: string) => tools.find((tool) => tool.id === id)?.installed === true
139
+ const [mise, direnv] = await Promise.all([
140
+ this.detectMise(installed("mise")),
141
+ this.detectDirenv(installed("direnv")),
142
+ ])
143
+
144
+ this.options.logger?.debug("environment detected", {
145
+ durationMs: Date.now() - startedAt,
146
+ installed: tools.filter((tool) => tool.installed).map((tool) => tool.id),
147
+ })
148
+ return this.publish({ tools, mise, direnv, checkedAt: new Date(), checking: false })
149
+ }
150
+
151
+ private async detectMise(installed: boolean): Promise<MiseStatus> {
152
+ if (!installed) return { installed: false, runtimes: [] }
153
+ const result = await this.run("mise", MISE_ARGS, {
154
+ cwd: this.options.rootDir,
155
+ timeoutMs: this.options.timeoutMs ?? 5_000,
156
+ // stdin is closed by exec, so mise cannot stop to prompt (e.g. to trust a config).
157
+ logger: this.options.logger,
158
+ })
159
+ if (result.timedOut) return { installed: true, runtimes: [], error: "mise did not answer in time" }
160
+ if (!result.ok) return { installed: true, runtimes: [], error: miseErrorMessage(result.stderr) }
161
+ return { installed: true, runtimes: parseMiseLs(result.stdout) }
162
+ }
163
+
164
+ private async detectDirenv(installed: boolean): Promise<DirenvStatus> {
165
+ const exists = this.options.exists ?? existsSync
166
+ const localRc = [".envrc", ".env"]
167
+ .map((name) => join(this.options.rootDir, name))
168
+ .find((path) => exists(path))
169
+
170
+ if (!installed) {
171
+ return {
172
+ installed: false,
173
+ rcPath: localRc,
174
+ approval: "unknown",
175
+ loaded: localRc ? false : null,
176
+ error: localRc?.endsWith(".envrc")
177
+ ? "direnv is not installed; .envrc is not being loaded"
178
+ : undefined,
179
+ }
180
+ }
181
+
182
+ const options = {
183
+ cwd: this.options.rootDir,
184
+ timeoutMs: this.options.timeoutMs ?? 5_000,
185
+ logger: this.options.logger,
186
+ }
187
+ let result = await this.run("direnv", ["status", "--json"], options)
188
+ if (!result.ok && !result.timedOut) result = await this.run("direnv", ["status"], options)
189
+ if (!result.ok) {
190
+ return {
191
+ installed: true,
192
+ rcPath: localRc,
193
+ approval: "unknown",
194
+ loaded: null,
195
+ error: result.timedOut
196
+ ? "direnv did not answer in time"
197
+ : (firstLine(result.stderr) ?? "direnv status failed"),
198
+ }
199
+ }
200
+
201
+ const parsed = parseDirenvStatus(result.stdout)
202
+ return {
203
+ installed: true,
204
+ rcPath: parsed.rcPath,
205
+ approval: parsed.rcPath ? parsed.approval : "unknown",
206
+ loaded: parsed.rcPath ? isDirenvLoaded(parsed.rcPath, this.env) : null,
207
+ }
208
+ }
209
+
210
+ private publish(snapshot: EnvironmentSnapshot): EnvironmentSnapshot {
211
+ this.snapshot = snapshot
212
+ this.emit("environment:changed", snapshot)
213
+ return snapshot
214
+ }
215
+ }