@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
@@ -0,0 +1,197 @@
1
+ import type { AgentAdapter } from "../agents/agents.js"
2
+ import type { CommandContext, CommandDefinition } from "../core/commands.js"
3
+ import type { RemoteMachineConfig } from "../core/config.js"
4
+ import { isActive } from "../processes/ManagedProcess.js"
5
+ import { errorMessage } from "../utils/errors.js"
6
+
7
+ export interface ControlPlaneCommandOptions {
8
+ agents: readonly AgentAdapter[]
9
+ machines: readonly RemoteMachineConfig[]
10
+ }
11
+
12
+ /** The directory commands act on: the selected worktree, else the project root. */
13
+ function targetPath(context: CommandContext): string {
14
+ return context.selectedWorktree()?.path ?? context.store.getState().project.rootDir
15
+ }
16
+
17
+ /**
18
+ * Palette commands for worktrees, agents, verification, environment and remote
19
+ * machines. They resolve their target at run time (`selectedWorktree`), so
20
+ * "Launch Claude" from the Worktrees view acts on the highlighted worktree.
21
+ */
22
+ export function createControlPlaneCommands(options: ControlPlaneCommandOptions): CommandDefinition[] {
23
+ const inRepository = (context: CommandContext) => context.store.getState().git.isRepository
24
+
25
+ const commands: CommandDefinition[] = [
26
+ // --- Worktrees ---------------------------------------------------------
27
+ {
28
+ id: "worktree.create",
29
+ title: "Worktree: create…",
30
+ category: "Worktree",
31
+ shortcut: "n",
32
+ keywords: ["new", "branch", "add", "git"],
33
+ when: inRepository,
34
+ execute: (context) => context.createWorktree(),
35
+ },
36
+ {
37
+ id: "worktree.remove",
38
+ title: "Worktree: remove the selected worktree",
39
+ category: "Worktree",
40
+ shortcut: "d",
41
+ keywords: ["delete", "git"],
42
+ when: (context) => {
43
+ const worktree = context.selectedWorktree()
44
+ return worktree !== undefined && !worktree.isMain && !worktree.isCurrent
45
+ },
46
+ execute: async (context) => {
47
+ const worktree = context.selectedWorktree()
48
+ if (worktree) await context.removeWorktree(worktree)
49
+ },
50
+ },
51
+ {
52
+ id: "worktree.openProject",
53
+ title: "Worktree: open in devcc",
54
+ category: "Worktree",
55
+ shortcut: "o",
56
+ keywords: ["switch", "project", "workspace"],
57
+ when: (context) => {
58
+ const worktree = context.selectedWorktree()
59
+ return worktree !== undefined && !worktree.isCurrent && !worktree.bare
60
+ },
61
+ execute: async (context) => {
62
+ const worktree = context.selectedWorktree()
63
+ if (worktree) await context.switchProject(worktree.path)
64
+ },
65
+ },
66
+ {
67
+ id: "worktree.openTerminal",
68
+ title: "Worktree: open terminal",
69
+ category: "Worktree",
70
+ shortcut: "t",
71
+ keywords: ["shell", "console"],
72
+ execute: (context) => context.openInTerminal(targetPath(context)),
73
+ },
74
+ {
75
+ id: "worktree.openEditor",
76
+ title: "Worktree: open in editor",
77
+ category: "Worktree",
78
+ shortcut: "e",
79
+ keywords: ["ide", "code", "zed", "cursor", "webstorm"],
80
+ execute: (context) => context.openInEditor(targetPath(context)),
81
+ },
82
+ {
83
+ id: "worktree.list",
84
+ title: "Worktree: show worktrees and agents",
85
+ category: "Worktree",
86
+ keywords: ["list", "agents"],
87
+ when: inRepository,
88
+ execute: async (context) => {
89
+ context.navigate("worktrees")
90
+ await context.git.refresh()
91
+ },
92
+ },
93
+
94
+ // --- AI agents ---------------------------------------------------------
95
+ ...options.agents.map((agent): CommandDefinition => ({
96
+ id: `agent.launch.${agent.id}`,
97
+ title: `AI: launch ${agent.name}`,
98
+ category: "AI",
99
+ shortcut: agent.id === "claude" ? "c" : agent.id === "codex" ? "x" : undefined,
100
+ keywords: ["agent", "ai", agent.id, agent.command],
101
+ execute: (context) => context.launchAgent(agent.id),
102
+ })),
103
+ {
104
+ id: "agent.stopAll",
105
+ title: "AI: stop all running agents",
106
+ category: "AI",
107
+ keywords: ["agent", "kill", "cancel"],
108
+ when: (context) => context.store.getState().agents.some((session) => isActive(session.status)),
109
+ confirm: { title: "Stop every running agent?", message: "Their work so far stays in the worktrees." },
110
+ execute: async (context) => {
111
+ const running = context.store.getState().agents.filter((session) => isActive(session.status))
112
+ await Promise.all(running.map((session) => context.agents.stop(session.id)))
113
+ context.notify("info", `Stopped ${running.length} agent(s)`)
114
+ },
115
+ },
116
+
117
+ // --- Verification ------------------------------------------------------
118
+ {
119
+ id: "verify.run",
120
+ title: "Project: verify (run every verification step)",
121
+ category: "Project",
122
+ shortcut: "V",
123
+ keywords: ["check", "ci", "test", "lint", "gate", "ship"],
124
+ execute: (context) => context.runVerify(),
125
+ },
126
+
127
+ // --- Environment -------------------------------------------------------
128
+ {
129
+ id: "environment.refresh",
130
+ title: "Environment: refresh tools, runtimes and direnv",
131
+ category: "Environment",
132
+ keywords: ["mise", "direnv", "versions", "doctor", "health"],
133
+ execute: async (context) => {
134
+ context.navigate("system")
135
+ const snapshot = await context.environment.refresh()
136
+ const problems = snapshot.tools.filter((tool) => tool.status === "error" || tool.status === "warning")
137
+ context.notify(
138
+ problems.length > 0 ? "warn" : "success",
139
+ problems.length > 0
140
+ ? `Environment: ${problems.map((tool) => tool.name).join(", ")} need attention`
141
+ : "Environment checked",
142
+ )
143
+ },
144
+ },
145
+ {
146
+ id: "environment.direnvAllow",
147
+ title: "Environment: direnv allow this project's .envrc",
148
+ category: "Environment",
149
+ keywords: ["direnv", "envrc", "approve", "trust"],
150
+ when: (context) => {
151
+ const { direnv } = context.store.getState().environment
152
+ return direnv.installed && direnv.rcPath !== undefined && direnv.approval !== "allowed"
153
+ },
154
+ execute: async (context) => {
155
+ const { rcPath } = context.store.getState().environment.direnv
156
+ const confirmed = await context.confirm(
157
+ "Allow this .envrc?",
158
+ `direnv will run ${rcPath ?? ".envrc"} whenever a shell enters the project. Only allow files you have read.`,
159
+ )
160
+ if (!confirmed) return
161
+ try {
162
+ await context.environment.allowDirenv()
163
+ context.notify("success", "direnv: .envrc allowed")
164
+ } catch (error) {
165
+ context.notify("error", errorMessage(error))
166
+ }
167
+ },
168
+ },
169
+ ]
170
+
171
+ // --- Remote machines -----------------------------------------------------
172
+ for (const machine of options.machines) {
173
+ commands.push({
174
+ id: `remote.ssh.${machine.id}`,
175
+ title: `Remote: SSH to ${machine.name}`,
176
+ category: "Remote",
177
+ keywords: ["ssh", "remote", "connect", machine.host],
178
+ execute: (context) => context.sshTo(machine.id),
179
+ })
180
+ }
181
+ commands.push({
182
+ id: "remote.refresh",
183
+ title: "Remote: refresh machine and Tailscale status",
184
+ category: "Remote",
185
+ keywords: ["ssh", "tailscale", "ollama", "reachability"],
186
+ when: (context) =>
187
+ context.remoteMachines().length > 0 ||
188
+ context.store.getState().services.some((service) => service.category === "remote"),
189
+ execute: async (context) => {
190
+ context.navigate("services")
191
+ await context.services.refresh()
192
+ context.notify("info", "Remote status refreshed")
193
+ },
194
+ })
195
+
196
+ return commands
197
+ }
@@ -12,8 +12,10 @@ export const VIEW_SHORTCUTS: Readonly<Record<string, ViewId>> = {
12
12
  "7": "dependencies",
13
13
  "8": "system",
14
14
  "9": "settings",
15
+ "0": "worktrees",
15
16
  p: "processes",
16
17
  g: "git",
18
+ w: "worktrees",
17
19
  l: "logs",
18
20
  d: "dependencies",
19
21
  }
@@ -51,9 +53,10 @@ export const HELP_SECTIONS: HelpSection[] = [
51
53
  { keys: "Services", description: "things detected on this machine: Docker, databases, Ollama" },
52
54
  { keys: "Scripts", description: "package.json scripts, run through your package manager" },
53
55
  { keys: "Git", description: "branch, changes, commits; stage, commit, pull, push" },
56
+ { keys: "Worktrees", description: "git worktrees, and the AI agents (Claude, Codex) working in them" },
54
57
  { keys: "Logs", description: "output of everything devcc started, per source or merged" },
55
58
  { keys: "Dependencies", description: "outdated packages, checked only when you ask" },
56
- { keys: "System", description: "host, toolchain versions, CPU, memory, disk" },
59
+ { keys: "System", description: "environment health (tools, mise, direnv), CPU, memory, disk, host" },
57
60
  { keys: "Settings", description: "detection results, config issues, diagnostics history" },
58
61
  ],
59
62
  },
@@ -74,8 +77,8 @@ export const HELP_SECTIONS: HelpSection[] = [
74
77
  { keys: "← / h", description: "previous section" },
75
78
  { keys: "→", description: "next section" },
76
79
  { keys: "tab / S-tab", description: "cycle sections" },
77
- { keys: "1…9", description: "jump to a section" },
78
- { keys: "p / g / l / d", description: "processes / git / logs / dependencies" },
80
+ { keys: "1…9, 0", description: "jump to a section (0 is Worktrees)" },
81
+ { keys: "p / g / w / l / d", description: "processes / git / worktrees / logs / dependencies" },
79
82
  { keys: "enter", description: "open or activate" },
80
83
  { keys: "esc", description: "close overlay / back" },
81
84
  ],
@@ -109,6 +112,18 @@ export const HELP_SECTIONS: HelpSection[] = [
109
112
  { keys: "r", description: "refresh status" },
110
113
  ],
111
114
  },
115
+ {
116
+ title: "Worktrees & agents",
117
+ entries: [
118
+ { keys: "n", description: "new worktree (branch, base, directory)" },
119
+ { keys: "d", description: "remove the worktree (confirmed, never forced)" },
120
+ { keys: "o / enter", description: "open the worktree in devcc; on an agent, its logs" },
121
+ { keys: "t / e", description: "open a terminal / the editor in the worktree" },
122
+ { keys: "c / x", description: "launch Claude Code / Codex with a task in the worktree" },
123
+ { keys: "s", description: "stop the selected agent" },
124
+ { keys: "r", description: "refresh the worktree list" },
125
+ ],
126
+ },
112
127
  {
113
128
  title: "Logs",
114
129
  entries: [
@@ -148,6 +163,7 @@ export const HELP_SECTIONS: HelpSection[] = [
148
163
  { keys: "ctrl+p / ctrl+o", description: "open / cycle projects" },
149
164
  { keys: "?", description: "this help" },
150
165
  { keys: "R", description: "refresh everything" },
166
+ { keys: "V", description: "verify the project (every verify step)" },
151
167
  { keys: "q / ctrl+c", description: "quit" },
152
168
  ],
153
169
  },
@@ -162,6 +178,11 @@ export const HELP_SECTIONS: HelpSection[] = [
162
178
  { keys: "services[]", description: "extra ports to watch: id, name, port, healthPath" },
163
179
  { keys: "projects[]", description: "extra paths offered by the project switcher" },
164
180
  { keys: "refresh", description: "{ system, services, git, clock } intervals in ms" },
181
+ { keys: "verify[]", description: "command ids, script names or { id, command }: devcc verify" },
182
+ { keys: "agents", description: "{ claude: { command, args }, codex: {...} } overrides" },
183
+ { keys: "editor / terminal", description: "{ command } or { application } for open actions" },
184
+ { keys: "remoteMachines[]", description: "id, name, host (an SSH alias works), services" },
185
+ { keys: "services[] ollama", description: '{ id, type: "ollama", baseUrl }: remote models' },
165
186
  ],
166
187
  },
167
188
  ]
@@ -0,0 +1,242 @@
1
+ import { loadConfig } from "./core/config.js"
2
+ import { EnvironmentService, type EnvironmentSnapshot } from "./environment/EnvironmentService.js"
3
+ import { GitService } from "./git/GitService.js"
4
+ import { worktreeLabel } from "./git/worktrees.js"
5
+ import { errorMessage } from "./utils/errors.js"
6
+ import { createLogger } from "./utils/logger.js"
7
+ import { detectPackageManager, readPackageJson, readScripts } from "./utils/packageManager.js"
8
+ import { tildify } from "./utils/paths.js"
9
+ import {
10
+ createSpawnExecutor,
11
+ isVerifyIssue,
12
+ resolveVerifySteps,
13
+ runVerification,
14
+ summariseVerify,
15
+ type StepExecutor,
16
+ type VerifyReport,
17
+ } from "./verify/verify.js"
18
+
19
+ /**
20
+ * Non-interactive commands for scripts and AI agents: `devcc verify`,
21
+ * `devcc worktree list`, `devcc env`. None of them load the TUI, so they run on
22
+ * plain Node without FFI. With `--json`, stdout carries exactly one JSON
23
+ * document and nothing else; diagnostics go to stderr.
24
+ */
25
+
26
+ export interface AutomationIo {
27
+ stdout: (text: string) => void
28
+ stderr: (text: string) => void
29
+ }
30
+
31
+ export interface AutomationOptions {
32
+ rootDir: string
33
+ json: boolean
34
+ io?: AutomationIo
35
+ /** Injected for tests; defaults to spawning each step. */
36
+ executor?: StepExecutor
37
+ signal?: AbortSignal
38
+ }
39
+
40
+ export const EXIT_OK = 0
41
+ export const EXIT_FAILED = 1
42
+ /** Misconfiguration or misuse: nothing was verified. */
43
+ export const EXIT_USAGE = 2
44
+
45
+ const processIo: AutomationIo = {
46
+ stdout: (text) => process.stdout.write(text),
47
+ stderr: (text) => process.stderr.write(text),
48
+ }
49
+
50
+ function printJson(io: AutomationIo, value: unknown): void {
51
+ io.stdout(`${JSON.stringify(value, null, 2)}\n`)
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // devcc verify
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export async function verifyCommand(options: AutomationOptions): Promise<number> {
59
+ const io = options.io ?? processIo
60
+ const { rootDir, json } = options
61
+
62
+ const packageJson = readPackageJson(rootDir)
63
+ const packageManager = detectPackageManager({ rootDir, packageJson, userAgent: undefined }).manager
64
+ const loaded = await loadConfig(rootDir)
65
+ const { steps, issues } = resolveVerifySteps(loaded.config.verify, {
66
+ commands: loaded.config.commands,
67
+ scripts: readScripts(packageJson),
68
+ packageManager,
69
+ })
70
+
71
+ // A dropped or unresolvable verify entry must fail the gate: silently running
72
+ // fewer checks than configured would let a broken project pass.
73
+ const blocking = [
74
+ ...loaded.issues.filter((issue) => issue.startsWith("Failed to load") || isVerifyIssue(issue)),
75
+ ...issues,
76
+ ]
77
+ if (steps.length === 0 && blocking.length === 0) {
78
+ blocking.push(
79
+ loaded.filePath
80
+ ? `No verification steps: add verify: ["lint", "test"] to ${tildify(loaded.filePath)}`
81
+ : 'No verification steps: create .devcc.ts with verify: ["lint", "test"]',
82
+ )
83
+ }
84
+ if (blocking.length > 0) {
85
+ if (json) printJson(io, { ok: false, error: "configuration", issues: blocking })
86
+ else for (const issue of blocking) io.stderr(`devcc verify: ${issue}\n`)
87
+ return EXIT_USAGE
88
+ }
89
+
90
+ if (!json) io.stdout(`devcc verify · ${tildify(rootDir)} · ${steps.length} step(s)\n\n`)
91
+
92
+ const executor = options.executor ?? createSpawnExecutor({ rootDir })
93
+ let printed = 0
94
+ const report = await runVerification(steps, executor, {
95
+ signal: options.signal,
96
+ onProgress: (progress) => {
97
+ if (json) return
98
+ for (; printed < progress.length; printed += 1) {
99
+ const step = progress[printed]
100
+ if (!step || step.status === "pending") break
101
+ if (step.status === "running") {
102
+ io.stdout(`→ ${step.name}: ${[step.command, ...step.args].join(" ")}\n`)
103
+ break
104
+ }
105
+ io.stdout(stepLine(step))
106
+ if (step.status === "failed") io.stdout(failureOutput(step))
107
+ }
108
+ },
109
+ })
110
+
111
+ if (json) {
112
+ printJson(io, { ...report, rootDir, summary: summariseVerify(report) })
113
+ } else {
114
+ for (const step of report.steps.slice(printed)) if (step.status === "skipped") io.stdout(stepLine(step))
115
+ io.stdout(`\n${report.ok ? "✓" : "✗"} ${summariseVerify(report)} in ${seconds(report.durationMs)}\n`)
116
+ }
117
+ return report.ok ? EXIT_OK : EXIT_FAILED
118
+ }
119
+
120
+ function stepLine(step: VerifyReport["steps"][number]): string {
121
+ const mark = step.status === "passed" ? "✓" : step.status === "skipped" ? "-" : "✗"
122
+ const detail =
123
+ step.status === "skipped"
124
+ ? "skipped"
125
+ : [
126
+ seconds(step.durationMs),
127
+ step.exitCode !== null && step.exitCode !== 0 ? `exit ${step.exitCode}` : undefined,
128
+ step.error,
129
+ ]
130
+ .filter(Boolean)
131
+ .join(" · ")
132
+ return `${mark} ${step.name} (${detail})\n`
133
+ }
134
+
135
+ /** The tail of a failed step's output, indented under it. */
136
+ function failureOutput(step: VerifyReport["steps"][number], maxLines = 60): string {
137
+ const text = [step.stdout, step.stderr]
138
+ .filter((part) => part.trim() !== "")
139
+ .join("\n")
140
+ .trimEnd()
141
+ if (text === "") return ""
142
+ const lines = text.split("\n")
143
+ const shown = lines.slice(-maxLines)
144
+ const header = lines.length > maxLines ? [`… ${lines.length - maxLines} earlier line(s) omitted`] : []
145
+ return `${[...header, ...shown].map((line) => ` ${line}`).join("\n")}\n`
146
+ }
147
+
148
+ function seconds(ms: number): string {
149
+ return `${(ms / 1000).toFixed(1)}s`
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // devcc worktree list
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export async function worktreeListCommand(options: AutomationOptions): Promise<number> {
157
+ const io = options.io ?? processIo
158
+ const git = new GitService({ rootDir: options.rootDir, logger: createLogger(options.rootDir).child("git") })
159
+ try {
160
+ const worktrees = await git.listWorktrees()
161
+ if (options.json) {
162
+ printJson(io, { rootDir: options.rootDir, worktrees })
163
+ return EXIT_OK
164
+ }
165
+ const width = Math.max(...worktrees.map((worktree) => worktreeLabel(worktree).length), 6)
166
+ for (const worktree of worktrees) {
167
+ const flags = [
168
+ worktree.isMain ? "main" : undefined,
169
+ worktree.locked ? "locked" : undefined,
170
+ worktree.prunable ? "prunable" : undefined,
171
+ ].filter(Boolean)
172
+ io.stdout(
173
+ `${worktree.isCurrent ? "*" : " "} ${worktreeLabel(worktree).padEnd(width)} ${tildify(worktree.path)}${
174
+ flags.length > 0 ? ` [${flags.join(", ")}]` : ""
175
+ }\n`,
176
+ )
177
+ }
178
+ return EXIT_OK
179
+ } catch (error) {
180
+ const message = errorMessage(error)
181
+ if (options.json) printJson(io, { error: message, rootDir: options.rootDir, worktrees: [] })
182
+ else io.stderr(`devcc worktree list: ${message}\n`)
183
+ return EXIT_FAILED
184
+ }
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // devcc env
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export async function envCommand(
192
+ options: AutomationOptions & { environment?: EnvironmentService },
193
+ ): Promise<number> {
194
+ const io = options.io ?? processIo
195
+ const environment = options.environment ?? new EnvironmentService({ rootDir: options.rootDir })
196
+ const snapshot = await environment.refresh()
197
+ if (options.json) {
198
+ const { checking: _checking, ...rest } = snapshot
199
+ printJson(io, { rootDir: options.rootDir, ...rest })
200
+ } else {
201
+ io.stdout(formatEnvironment(snapshot))
202
+ }
203
+ return EXIT_OK
204
+ }
205
+
206
+ export function formatEnvironment(snapshot: EnvironmentSnapshot): string {
207
+ const lines: string[] = ["TOOLS"]
208
+ const nameWidth = Math.max(...snapshot.tools.map((tool) => tool.name.length), 4) + 2
209
+ for (const tool of snapshot.tools) {
210
+ const mark = !tool.installed ? "·" : tool.status === "ready" ? "✓" : tool.status === "warning" ? "⚠" : "✗"
211
+ const value = tool.installed ? (tool.version ?? "installed") : "not installed"
212
+ const note = tool.installed && tool.message ? ` ${tool.message}` : ""
213
+ lines.push(` ${mark} ${tool.name.padEnd(nameWidth)}${value}${note}`)
214
+ }
215
+
216
+ if (snapshot.mise.installed) {
217
+ lines.push("", "RUNTIMES (mise)")
218
+ if (snapshot.mise.error) lines.push(` ⚠ ${snapshot.mise.error}`)
219
+ else if (snapshot.mise.runtimes.length === 0) lines.push(" no tools pinned for this project")
220
+ for (const runtime of snapshot.mise.runtimes) {
221
+ lines.push(
222
+ ` ${runtime.tool.padEnd(nameWidth + 2)}${runtime.version}${runtime.installed ? "" : " (not installed)"}`,
223
+ )
224
+ }
225
+ }
226
+
227
+ const { direnv } = snapshot
228
+ if (direnv.installed || direnv.rcPath) {
229
+ lines.push("", "DIRENV")
230
+ if (direnv.error) lines.push(` ⚠ ${direnv.error}`)
231
+ if (direnv.rcPath) {
232
+ lines.push(` .envrc ${tildify(direnv.rcPath)}`)
233
+ if (direnv.installed) {
234
+ lines.push(` approval ${direnv.approval}`)
235
+ lines.push(` loaded ${direnv.loaded ? "yes" : "no"}`)
236
+ }
237
+ } else {
238
+ lines.push(" no .envrc for this project")
239
+ }
240
+ }
241
+ return `${lines.join("\n")}\n`
242
+ }
package/src/devcc/cli.ts CHANGED
@@ -8,8 +8,9 @@
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 { envCommand, EXIT_USAGE, verifyCommand, worktreeListCommand } from "./automation.js"
13
14
  import { errorMessage } from "./utils/errors.js"
14
15
  import { findProjectRoot } from "./utils/paths.js"
15
16
 
@@ -17,6 +18,9 @@ interface CliArgs {
17
18
  cwd?: string
18
19
  help: boolean
19
20
  version: boolean
21
+ json: boolean
22
+ /** Subcommand and its arguments, e.g. `["worktree", "list"]`. */
23
+ positionals: string[]
20
24
  }
21
25
 
22
26
  /**
@@ -37,11 +41,12 @@ function readVersion(): string {
37
41
  const VERSION = readVersion()
38
42
 
39
43
  function parseArgs(argv: readonly string[]): CliArgs {
40
- const args: CliArgs = { help: false, version: false }
44
+ const args: CliArgs = { help: false, version: false, json: false, positionals: [] }
41
45
  for (let index = 0; index < argv.length; index += 1) {
42
46
  const arg = argv[index]
43
47
  if (arg === "--help" || arg === "-h") args.help = true
44
48
  else if (arg === "--version" || arg === "-v") args.version = true
49
+ else if (arg === "--json") args.json = true
45
50
  else if (arg === "--cwd" || arg === "-C") {
46
51
  const value = argv[index + 1]
47
52
  if (value) {
@@ -50,6 +55,8 @@ function parseArgs(argv: readonly string[]): CliArgs {
50
55
  }
51
56
  } else if (arg?.startsWith("--cwd=")) {
52
57
  args.cwd = arg.slice("--cwd=".length)
58
+ } else if (arg !== undefined && !arg.startsWith("-")) {
59
+ args.positionals.push(arg)
53
60
  }
54
61
  }
55
62
  return args
@@ -58,13 +65,20 @@ function parseArgs(argv: readonly string[]): CliArgs {
58
65
  const USAGE = `dev command center ${VERSION}
59
66
 
60
67
  Usage:
61
- devcc [options]
68
+ devcc [options] Open the dashboard
69
+ devcc verify [--json] Run the project's verify steps; exit 0 only if all pass
70
+ devcc worktree list [--json] List git worktrees
71
+ devcc env [--json] Report tools, mise runtimes and direnv status
62
72
 
63
73
  Options:
64
74
  -C, --cwd <dir> Project directory (default: nearest project root)
75
+ --json Machine-readable output (verify, worktree list, env)
65
76
  -h, --help Show this help
66
77
  -v, --version Show the version
67
78
 
79
+ Exit codes (verify):
80
+ 0 every step passed 1 a step failed 2 nothing to verify / bad config
81
+
68
82
  Environment:
69
83
  DEVCC_LOG_LEVEL debug | info | warn | error | silent (default: info)
70
84
 
@@ -84,11 +98,24 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
84
98
  return 0
85
99
  }
86
100
 
101
+ const [command, ...rest] = args.positionals
102
+ if (command !== undefined) {
103
+ const rootDir = args.cwd ? resolve(args.cwd) : findProjectRoot()
104
+ const options = { rootDir, json: args.json }
105
+ if (command === "verify" && rest.length === 0) return runVerifyCli(options)
106
+ if (command === "worktree" && rest.length === 1 && rest[0] === "list") return worktreeListCommand(options)
107
+ if (command === "env" && rest.length === 0) return envCommand(options)
108
+ process.stderr.write(`devcc: unknown command "${args.positionals.join(" ")}"\n\n${USAGE}`)
109
+ return EXIT_USAGE
110
+ }
111
+
87
112
  if (!process.stdout.isTTY) {
88
113
  process.stderr.write("devcc needs an interactive terminal (stdout is not a TTY).\n")
89
114
  return 1
90
115
  }
91
116
 
117
+ // Loaded lazily: OpenTUI needs FFI, which the automation commands above do not.
118
+ const { App } = await import("./app/App.js")
92
119
  const app = new App({ cwd: args.cwd ? args.cwd : findProjectRoot() })
93
120
 
94
121
  try {
@@ -107,6 +134,21 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
107
134
  }
108
135
  }
109
136
 
137
+ /** Ctrl+C stops the running step instead of leaving it orphaned. */
138
+ async function runVerifyCli(options: { rootDir: string; json: boolean }): Promise<number> {
139
+ const controller = new AbortController()
140
+ const onSignal = () => controller.abort()
141
+ process.once("SIGINT", onSignal)
142
+ process.once("SIGTERM", onSignal)
143
+ try {
144
+ const code = await verifyCommand({ ...options, signal: controller.signal })
145
+ return controller.signal.aborted ? 130 : code
146
+ } finally {
147
+ process.off("SIGINT", onSignal)
148
+ process.off("SIGTERM", onSignal)
149
+ }
150
+ }
151
+
110
152
  const entry = process.argv[1]
111
153
  const isDirectRun = entry !== undefined && import.meta.url === pathToFileURL(entry).href
112
154
 
@@ -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