tmux-ide 1.2.1 → 2.0.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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const cli = join(__dirname, "..", "bin", "cli.js");
10
+
11
+ function makeProject(config) {
12
+ const dir = mkdtempSync(join(tmpdir(), "tmux-ide-config-test-"));
13
+ writeFileSync(join(dir, "ide.yml"), config);
14
+ return dir;
15
+ }
16
+
17
+ function runCli(args, cwd) {
18
+ return spawnSync("node", [cli, ...args], {
19
+ cwd,
20
+ encoding: "utf-8",
21
+ });
22
+ }
23
+
24
+ describe("config command hardening", () => {
25
+ it("rejects config set when the YAML root is not an object", () => {
26
+ const dir = makeProject("null\n");
27
+
28
+ try {
29
+ const result = runCli(["config", "set", "rows.0.title", "Shell"], dir);
30
+
31
+ expect(result.status).not.toBe(0);
32
+ expect(result.stderr).toMatch(/config root must be an object/);
33
+ expect(result.stderr).not.toMatch(/TypeError/);
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ it("rejects add-pane when row panes is not an array", () => {
40
+ const dir = makeProject("rows:\n - panes: nope\n");
41
+
42
+ try {
43
+ const result = runCli(["config", "add-pane", "--row", "0", "--title", "Shell"], dir);
44
+
45
+ expect(result.status).not.toBe(0);
46
+ expect(result.stderr).toMatch(/row 0 panes must be an array/);
47
+ expect(result.stderr).not.toMatch(/TypeError/);
48
+ } finally {
49
+ rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ });
52
+
53
+ it("refuses enable-team when there are no Claude panes to assign", () => {
54
+ const dir = makeProject("name: my-app\nrows:\n - panes:\n - title: Shell\n");
55
+
56
+ try {
57
+ const result = runCli(["config", "enable-team"], dir);
58
+
59
+ expect(result.status).not.toBe(0);
60
+ expect(result.stderr).toMatch(/no Claude panes found/i);
61
+ } finally {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ it("assigns team metadata and Claude pane roles when enable-team succeeds", () => {
67
+ const dir = makeProject(
68
+ "name: my-app\nrows:\n - panes:\n - title: Lead\n command: claude\n - title: Reviewer\n command: claude\n",
69
+ );
70
+
71
+ try {
72
+ const result = runCli(["config", "enable-team", "--json"], dir);
73
+
74
+ expect(result.status).toBe(0);
75
+
76
+ const payload = JSON.parse(result.stdout);
77
+ expect(payload.team).toEqual({ name: "my-app" });
78
+ expect(payload.roles).toEqual([
79
+ { row: 0, pane: 0, title: "Lead", role: "lead" },
80
+ { row: 0, pane: 1, title: "Reviewer", role: "teammate" },
81
+ ]);
82
+ } finally {
83
+ rmSync(dir, { recursive: true, force: true });
84
+ }
85
+ });
86
+ });
@@ -0,0 +1,205 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import yaml from "js-yaml";
6
+ import { config } from "./config.ts";
7
+
8
+ let tmpDir;
9
+ let origLog;
10
+ let logged;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-config-test-"));
14
+ logged = [];
15
+ origLog = console.log;
16
+ console.log = (...a) => logged.push(a.join(" "));
17
+ });
18
+
19
+ afterEach(() => {
20
+ console.log = origLog;
21
+ rmSync(tmpDir, { recursive: true, force: true });
22
+ });
23
+
24
+ function writeIdeYml(obj) {
25
+ writeFileSync(
26
+ join(tmpDir, "ide.yml"),
27
+ yaml.dump(obj, { lineWidth: -1, noRefs: true, quotingType: '"' }),
28
+ );
29
+ }
30
+
31
+ function readIdeYml() {
32
+ return yaml.load(readFileSync(join(tmpDir, "ide.yml"), "utf-8"));
33
+ }
34
+
35
+ describe("config dump", () => {
36
+ it("outputs config as JSON", async () => {
37
+ const cfg = { name: "test", rows: [{ panes: [{ title: "Shell" }] }] };
38
+ writeIdeYml(cfg);
39
+ await config(tmpDir, { json: true, action: "dump", args: [] });
40
+ const output = JSON.parse(logged[0]);
41
+ expect(output.name).toBe("test");
42
+ expect(output.rows[0].panes[0].title).toBe("Shell");
43
+ });
44
+ });
45
+
46
+ describe("config set", () => {
47
+ it("updates a top-level value", async () => {
48
+ writeIdeYml({ name: "old", rows: [{ panes: [{ title: "Shell" }] }] });
49
+ await config(tmpDir, { json: true, action: "set", args: ["name", "new-name"] });
50
+ const output = JSON.parse(logged[0]);
51
+ expect(output.ok).toBe(true);
52
+ expect(output.value).toBe("new-name");
53
+ const saved = readIdeYml();
54
+ expect(saved.name).toBe("new-name");
55
+ });
56
+
57
+ it("updates a nested value", async () => {
58
+ writeIdeYml({ name: "test", rows: [{ panes: [{ title: "Shell" }] }] });
59
+ await config(tmpDir, { json: true, action: "set", args: ["rows.0.panes.0.title", "Editor"] });
60
+ const saved = readIdeYml();
61
+ expect(saved.rows[0].panes[0].title).toBe("Editor");
62
+ });
63
+
64
+ it("coerces boolean strings", async () => {
65
+ writeIdeYml({ name: "test", rows: [{ panes: [{ title: "Shell" }] }] });
66
+ await config(tmpDir, { json: true, action: "set", args: ["rows.0.panes.0.focus", "true"] });
67
+ const saved = readIdeYml();
68
+ expect(saved.rows[0].panes[0].focus).toBe(true);
69
+ });
70
+
71
+ it("coerces numeric strings", async () => {
72
+ writeIdeYml({ name: "test", rows: [{ panes: [{ title: "Shell" }] }] });
73
+ await config(tmpDir, { json: true, action: "set", args: ["rows.0.panes.0.width", "120"] });
74
+ const saved = readIdeYml();
75
+ expect(saved.rows[0].panes[0].width).toBe(120);
76
+ });
77
+ });
78
+
79
+ describe("config add-pane", () => {
80
+ it("adds pane to existing row", async () => {
81
+ writeIdeYml({ name: "test", rows: [{ panes: [{ title: "Shell" }] }] });
82
+ await config(tmpDir, {
83
+ json: true,
84
+ action: "add-pane",
85
+ args: ["--row", "0", "--title", "Tests", "--command", "pnpm test"],
86
+ });
87
+ const output = JSON.parse(logged[0]);
88
+ expect(output.ok).toBe(true);
89
+ expect(output.pane.title).toBe("Tests");
90
+ expect(output.pane.command).toBe("pnpm test");
91
+ const saved = readIdeYml();
92
+ expect(saved.rows[0].panes.length).toBe(2);
93
+ expect(saved.rows[0].panes[1].title).toBe("Tests");
94
+ });
95
+
96
+ it("adds pane with size", async () => {
97
+ writeIdeYml({ name: "test", rows: [{ panes: [{ title: "Shell" }] }] });
98
+ await config(tmpDir, {
99
+ json: true,
100
+ action: "add-pane",
101
+ args: ["--row", "0", "--title", "Wide", "--size", "60%"],
102
+ });
103
+ const saved = readIdeYml();
104
+ expect(saved.rows[0].panes[1].size).toBe("60%");
105
+ });
106
+ });
107
+
108
+ describe("config remove-pane", () => {
109
+ it("removes pane by index", async () => {
110
+ writeIdeYml({
111
+ name: "test",
112
+ rows: [{ panes: [{ title: "A" }, { title: "B" }, { title: "C" }] }],
113
+ });
114
+ await config(tmpDir, {
115
+ json: true,
116
+ action: "remove-pane",
117
+ args: ["--row", "0", "--pane", "1"],
118
+ });
119
+ const output = JSON.parse(logged[0]);
120
+ expect(output.ok).toBe(true);
121
+ expect(output.removed.title).toBe("B");
122
+ const saved = readIdeYml();
123
+ expect(saved.rows[0].panes.length).toBe(2);
124
+ expect(saved.rows[0].panes[0].title).toBe("A");
125
+ expect(saved.rows[0].panes[1].title).toBe("C");
126
+ });
127
+ });
128
+
129
+ describe("config add-row", () => {
130
+ it("creates row with default Shell pane", async () => {
131
+ writeIdeYml({ name: "test", rows: [{ panes: [{ title: "Shell" }] }] });
132
+ await config(tmpDir, { json: true, action: "add-row", args: [] });
133
+ const output = JSON.parse(logged[0]);
134
+ expect(output.ok).toBe(true);
135
+ expect(output.row).toBe(1);
136
+ const saved = readIdeYml();
137
+ expect(saved.rows.length).toBe(2);
138
+ expect(saved.rows[1].panes).toEqual([{ title: "Shell" }]);
139
+ });
140
+
141
+ it("creates row with size", async () => {
142
+ writeIdeYml({ name: "test", rows: [{ panes: [{ title: "Shell" }] }] });
143
+ await config(tmpDir, { json: true, action: "add-row", args: ["--size", "30%"] });
144
+ const saved = readIdeYml();
145
+ expect(saved.rows[1].size).toBe("30%");
146
+ });
147
+
148
+ it("initializes rows array when it doesn't exist", async () => {
149
+ writeIdeYml({ name: "test" });
150
+ await config(tmpDir, { json: true, action: "add-row", args: [] });
151
+ const saved = readIdeYml();
152
+ expect(saved.rows.length).toBe(1);
153
+ });
154
+ });
155
+
156
+ describe("config enable-team", () => {
157
+ it("assigns roles to Claude panes", async () => {
158
+ writeIdeYml({
159
+ name: "my-app",
160
+ rows: [
161
+ {
162
+ panes: [
163
+ { title: "Lead", command: "claude" },
164
+ { title: "Worker", command: "claude" },
165
+ { title: "Shell" },
166
+ ],
167
+ },
168
+ ],
169
+ });
170
+ await config(tmpDir, { json: true, action: "enable-team", args: [] });
171
+ const output = JSON.parse(logged[0]);
172
+ expect(output.ok).toBe(true);
173
+ expect(output.team.name).toBe("my-app");
174
+ const saved = readIdeYml();
175
+ expect(saved.rows[0].panes[0].role).toBe("lead");
176
+ expect(saved.rows[0].panes[1].role).toBe("teammate");
177
+ expect(saved.rows[0].panes[2].role).toBe(undefined);
178
+ });
179
+ });
180
+
181
+ describe("config disable-team", () => {
182
+ it("removes team and role/task fields", async () => {
183
+ writeIdeYml({
184
+ name: "my-app",
185
+ team: { name: "my-app" },
186
+ rows: [
187
+ {
188
+ panes: [
189
+ { title: "Lead", command: "claude", role: "lead" },
190
+ { title: "Worker", command: "claude", role: "teammate", task: "Build UI" },
191
+ ],
192
+ },
193
+ ],
194
+ });
195
+ await config(tmpDir, { json: true, action: "disable-team", args: [] });
196
+ const output = JSON.parse(logged[0]);
197
+ expect(output.ok).toBe(true);
198
+ expect(output.disabled).toBe(true);
199
+ const saved = readIdeYml();
200
+ expect(saved.team).toBe(undefined);
201
+ expect(saved.rows[0].panes[0].role).toBe(undefined);
202
+ expect(saved.rows[0].panes[1].role).toBe(undefined);
203
+ expect(saved.rows[0].panes[1].task).toBe(undefined);
204
+ });
205
+ });
package/src/config.ts ADDED
@@ -0,0 +1,313 @@
1
+ import { resolve } from "node:path";
2
+ import { readConfig, writeConfig } from "./lib/yaml-io.ts";
3
+ import { setByPath } from "./lib/dot-path.ts";
4
+ import { outputError } from "./lib/output.ts";
5
+ import { IdeConfigSchema } from "./schemas/ide-config.ts";
6
+ import type { IdeConfig, Pane, Row } from "./types.ts";
7
+
8
+ /**
9
+ * Read config safely (read-only, no write). Returns config or undefined on error.
10
+ */
11
+ function readConfigSafe(dir: string): IdeConfig | undefined {
12
+ let cfg: IdeConfig;
13
+ try {
14
+ ({ config: cfg } = readConfig(dir));
15
+ } catch (e) {
16
+ outputError(`Cannot read ide.yml: ${(e as Error).message}`, "READ_ERROR");
17
+ return;
18
+ }
19
+ return cfg;
20
+ }
21
+
22
+ /**
23
+ * Read config, validate it's an object, run mutator, then write back.
24
+ * Returns the mutator's return value, or undefined on error.
25
+ */
26
+ function withConfig<T>(dir: string, mutator: (cfg: IdeConfig) => T): T | undefined {
27
+ const cfg = readConfigSafe(dir);
28
+ if (cfg === undefined) return;
29
+
30
+ if (!isConfigObject(cfg)) {
31
+ outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG");
32
+ return;
33
+ }
34
+
35
+ const result = mutator(cfg);
36
+
37
+ const validation = IdeConfigSchema.safeParse(cfg);
38
+ if (!validation.success) {
39
+ const issues = validation.error.issues
40
+ .map((i) => ` ${i.path.join(".")}: ${i.message}`)
41
+ .join("\n");
42
+ outputError(`Invalid config after mutation:\n${issues}`, "INVALID_CONFIG");
43
+ return;
44
+ }
45
+
46
+ writeConfig(dir, cfg);
47
+ return result;
48
+ }
49
+
50
+ export async function config(
51
+ targetDir: string | null,
52
+ { json, action, args }: { json?: boolean; action?: string; args?: string[] } = {},
53
+ ): Promise<void> {
54
+ const dir = resolve(targetDir ?? ".");
55
+
56
+ switch (action) {
57
+ case "dump":
58
+ return dumpConfig(dir, { json });
59
+ case "set":
60
+ return setConfig(dir, args ?? [], { json });
61
+ case "add-pane":
62
+ return addPane(dir, args ?? [], { json });
63
+ case "remove-pane":
64
+ return removePane(dir, args ?? [], { json });
65
+ case "add-row":
66
+ return addRow(dir, args ?? [], { json });
67
+ case "enable-team":
68
+ return enableTeam(dir, args ?? [], { json });
69
+ case "disable-team":
70
+ return disableTeam(dir, { json });
71
+ default:
72
+ return dumpConfig(dir, { json });
73
+ }
74
+ }
75
+
76
+ function dumpConfig(dir: string, { json }: { json?: boolean }): void {
77
+ const cfg = readConfigSafe(dir);
78
+ if (cfg === undefined) return;
79
+
80
+ if (json) {
81
+ console.log(JSON.stringify(cfg, null, 2));
82
+ } else {
83
+ console.log(JSON.stringify(cfg, null, 2));
84
+ }
85
+ }
86
+
87
+ function setConfig(dir: string, args: string[], { json }: { json?: boolean }): void {
88
+ const [dotpath, ...rest] = args;
89
+ if (!dotpath || rest.length === 0) {
90
+ outputError("Usage: tmux-ide config set <dotpath> <value>", "USAGE");
91
+ return;
92
+ }
93
+
94
+ let value: string | boolean | number = rest.join(" ");
95
+ if (value === "true") value = true;
96
+ else if (value === "false") value = false;
97
+ else if (/^\d+$/.test(value)) value = parseInt(value);
98
+
99
+ withConfig(dir, (cfg) => {
100
+ setByPath(cfg as unknown as Record<string, unknown>, dotpath, value);
101
+ });
102
+
103
+ if (json) {
104
+ console.log(JSON.stringify({ ok: true, path: dotpath, value }, null, 2));
105
+ } else {
106
+ console.log(`Set ${dotpath} = ${JSON.stringify(value)}`);
107
+ }
108
+ }
109
+
110
+ function addPane(dir: string, args: string[], { json }: { json?: boolean }): void {
111
+ const { row, title, command, size } = parseNamedArgs(args);
112
+ if (row === undefined) {
113
+ outputError(
114
+ "Usage: tmux-ide config add-pane --row <N> --title <T> [--command <C>] [--size <S>]",
115
+ "USAGE",
116
+ );
117
+ return;
118
+ }
119
+
120
+ const rowIdx = parseIndex(row);
121
+ if (rowIdx == null) {
122
+ outputError(`Invalid row index "${row}"`, "USAGE");
123
+ return;
124
+ }
125
+
126
+ const pane: Partial<Pane> = {};
127
+ if (title) pane.title = title;
128
+ if (command) pane.command = command;
129
+ if (size) pane.size = size;
130
+
131
+ withConfig(dir, (cfg) => {
132
+ if (!Array.isArray(cfg.rows)) {
133
+ outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
134
+ }
135
+
136
+ if (!cfg.rows[rowIdx]) {
137
+ outputError(`Row ${rowIdx} does not exist`, "INVALID_ROW");
138
+ }
139
+
140
+ if (!Array.isArray(cfg.rows[rowIdx]!.panes)) {
141
+ outputError(`Invalid ide.yml: row ${rowIdx} panes must be an array`, "INVALID_CONFIG");
142
+ }
143
+
144
+ cfg.rows[rowIdx]!.panes.push(pane as Pane);
145
+ });
146
+
147
+ if (json) {
148
+ console.log(JSON.stringify({ ok: true, row: rowIdx, pane }, null, 2));
149
+ } else {
150
+ console.log(`Added pane "${title ?? "untitled"}" to row ${rowIdx}`);
151
+ }
152
+ }
153
+
154
+ function removePane(dir: string, args: string[], { json }: { json?: boolean }): void {
155
+ const { row, pane } = parseNamedArgs(args);
156
+ if (row === undefined || pane === undefined) {
157
+ outputError("Usage: tmux-ide config remove-pane --row <N> --pane <M>", "USAGE");
158
+ return;
159
+ }
160
+
161
+ const rowIdx = parseIndex(row);
162
+ const paneIdx = parseIndex(pane);
163
+ if (rowIdx == null || paneIdx == null) {
164
+ outputError("Usage: tmux-ide config remove-pane --row <N> --pane <M>", "USAGE");
165
+ return;
166
+ }
167
+
168
+ let removed: Pane | undefined;
169
+ withConfig(dir, (cfg) => {
170
+ if (!Array.isArray(cfg.rows)) {
171
+ outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
172
+ }
173
+
174
+ if (!Array.isArray(cfg.rows[rowIdx]?.panes)) {
175
+ outputError(`Invalid ide.yml: row ${rowIdx} panes must be an array`, "INVALID_CONFIG");
176
+ }
177
+
178
+ if (!cfg.rows[rowIdx]!.panes[paneIdx]) {
179
+ outputError(`Pane ${paneIdx} in row ${rowIdx} does not exist`, "INVALID_PANE");
180
+ }
181
+
182
+ removed = cfg.rows[rowIdx]!.panes.splice(paneIdx, 1)[0];
183
+ });
184
+
185
+ if (json) {
186
+ console.log(JSON.stringify({ ok: true, row: rowIdx, pane: paneIdx, removed }, null, 2));
187
+ } else {
188
+ console.log(`Removed pane ${paneIdx} ("${removed?.title ?? "untitled"}") from row ${rowIdx}`);
189
+ }
190
+ }
191
+
192
+ function addRow(dir: string, args: string[], { json }: { json?: boolean }): void {
193
+ const { size } = parseNamedArgs(args);
194
+
195
+ let rowIdx: number | undefined;
196
+ withConfig(dir, (cfg) => {
197
+ if (cfg.rows !== undefined && !Array.isArray(cfg.rows)) {
198
+ outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
199
+ }
200
+
201
+ const row: Row = { panes: [{ title: "Shell" }] };
202
+ if (size) row.size = size;
203
+ cfg.rows = cfg.rows ?? [];
204
+ cfg.rows.push(row);
205
+ rowIdx = cfg.rows.length - 1;
206
+ });
207
+
208
+ if (json) {
209
+ console.log(JSON.stringify({ ok: true, row: rowIdx, size: size ?? null }, null, 2));
210
+ } else {
211
+ console.log(`Added row ${rowIdx}${size ? ` (${size})` : ""}`);
212
+ }
213
+ }
214
+
215
+ function enableTeam(dir: string, args: string[], { json }: { json?: boolean }): void {
216
+ const { name } = parseNamedArgs(args);
217
+
218
+ let teamName: string | undefined;
219
+ let result: { team: IdeConfig["team"]; roles: ReturnType<typeof summarizeRoles> } | undefined;
220
+ withConfig(dir, (cfg) => {
221
+ if (cfg.rows !== undefined && !Array.isArray(cfg.rows)) {
222
+ outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
223
+ }
224
+
225
+ teamName = name ?? cfg.name ?? "my-team";
226
+ cfg.team = { name: teamName };
227
+
228
+ let leadAssigned = false;
229
+ for (const row of cfg.rows ?? []) {
230
+ for (const pane of row.panes ?? []) {
231
+ if (pane.command === "claude" || pane.role === "lead" || pane.role === "teammate") {
232
+ if (!leadAssigned) {
233
+ pane.role = "lead";
234
+ leadAssigned = true;
235
+ } else {
236
+ pane.role = "teammate";
237
+ }
238
+ }
239
+ }
240
+ }
241
+ if (!leadAssigned) {
242
+ delete cfg.team;
243
+ outputError("Cannot enable agent team: no Claude panes found", "INVALID_CONFIG");
244
+ }
245
+
246
+ result = { team: cfg.team, roles: summarizeRoles(cfg) };
247
+ });
248
+
249
+ if (json) {
250
+ console.log(JSON.stringify({ ok: true, ...result }, null, 2));
251
+ } else {
252
+ console.log(`Enabled agent team "${teamName}"`);
253
+ }
254
+ }
255
+
256
+ function disableTeam(dir: string, { json }: { json?: boolean }): void {
257
+ withConfig(dir, (cfg) => {
258
+ if (cfg.rows !== undefined && !Array.isArray(cfg.rows)) {
259
+ outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
260
+ }
261
+
262
+ delete cfg.team;
263
+ for (const row of cfg.rows ?? []) {
264
+ if (!Array.isArray(row?.panes)) continue;
265
+ for (const pane of row.panes) {
266
+ delete pane.role;
267
+ delete pane.task;
268
+ }
269
+ }
270
+ });
271
+
272
+ if (json) {
273
+ console.log(JSON.stringify({ ok: true, disabled: true }, null, 2));
274
+ } else {
275
+ console.log("Disabled agent team");
276
+ }
277
+ }
278
+
279
+ function summarizeRoles(
280
+ cfg: IdeConfig,
281
+ ): { row: number; pane: number; title: string | null; role: string }[] {
282
+ const roles: { row: number; pane: number; title: string | null; role: string }[] = [];
283
+ for (let i = 0; i < (cfg.rows ?? []).length; i++) {
284
+ for (let j = 0; j < (cfg.rows[i]!.panes ?? []).length; j++) {
285
+ const p = cfg.rows[i]!.panes[j]!;
286
+ if (p.role) {
287
+ roles.push({ row: i, pane: j, title: p.title ?? null, role: p.role });
288
+ }
289
+ }
290
+ }
291
+ return roles;
292
+ }
293
+
294
+ function parseNamedArgs(args: string[]): Record<string, string> {
295
+ const result: Record<string, string> = {};
296
+ for (let i = 0; i < args.length; i++) {
297
+ if (args[i]!.startsWith("--") && i + 1 < args.length) {
298
+ const key = args[i]!.slice(2);
299
+ result[key] = args[i + 1]!;
300
+ i++;
301
+ }
302
+ }
303
+ return result;
304
+ }
305
+
306
+ function isConfigObject(value: unknown): value is Record<string, unknown> {
307
+ return value != null && typeof value === "object" && !Array.isArray(value);
308
+ }
309
+
310
+ function parseIndex(value: string): number | null {
311
+ if (!/^\d+$/.test(String(value))) return null;
312
+ return Number.parseInt(value, 10);
313
+ }