tmux-ide 1.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.
@@ -0,0 +1,26 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve, basename } from "node:path";
3
+ import yaml from "js-yaml";
4
+
5
+ export function readConfig(dir) {
6
+ const configPath = resolve(dir, "ide.yml");
7
+ const raw = readFileSync(configPath, "utf-8");
8
+ const config = yaml.load(raw);
9
+ return { config, configPath };
10
+ }
11
+
12
+ export function writeConfig(dir, config) {
13
+ const configPath = resolve(dir, "ide.yml");
14
+ const out = yaml.dump(config, { lineWidth: -1, noRefs: true, quotingType: '"' });
15
+ writeFileSync(configPath, out);
16
+ return configPath;
17
+ }
18
+
19
+ export function getSessionName(dir) {
20
+ try {
21
+ const { config } = readConfig(dir);
22
+ return config.name ?? basename(dir);
23
+ } catch {
24
+ return basename(dir);
25
+ }
26
+ }
package/src/ls.js ADDED
@@ -0,0 +1,42 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ export async function ls({ json } = {}) {
4
+ let raw;
5
+ try {
6
+ raw = execSync(
7
+ 'tmux list-sessions -F "#{session_name}|#{session_created}|#{session_attached}"',
8
+ { encoding: "utf-8" }
9
+ ).trim();
10
+ } catch {
11
+ if (json) {
12
+ console.log(JSON.stringify({ sessions: [] }));
13
+ } else {
14
+ console.log("No tmux sessions running.");
15
+ }
16
+ return;
17
+ }
18
+
19
+ const sessions = raw.split("\n").map((line) => {
20
+ const [name, created, attached] = line.split("|");
21
+ return {
22
+ name,
23
+ created: new Date(parseInt(created) * 1000).toISOString(),
24
+ attached: attached !== "0",
25
+ };
26
+ });
27
+
28
+ if (json) {
29
+ console.log(JSON.stringify({ sessions }, null, 2));
30
+ return;
31
+ }
32
+
33
+ // Table output
34
+ console.log("SESSION".padEnd(24) + "CREATED".padEnd(22) + "ATTACHED");
35
+ console.log("─".repeat(54));
36
+ for (const s of sessions) {
37
+ const date = new Date(s.created).toLocaleString();
38
+ console.log(
39
+ s.name.padEnd(24) + date.padEnd(22) + (s.attached ? "yes" : "no")
40
+ );
41
+ }
42
+ }
package/src/restart.js ADDED
@@ -0,0 +1,23 @@
1
+ import { resolve } from "node:path";
2
+ import { execSync } from "node:child_process";
3
+ import { getSessionName } from "./lib/yaml-io.js";
4
+ import { launch } from "./launch.js";
5
+
6
+ export async function restart(targetDir) {
7
+ const dir = resolve(targetDir ?? ".");
8
+ const session = getSessionName(dir);
9
+
10
+ let wasRunning = false;
11
+ try {
12
+ execSync(`tmux kill-session -t "${session}"`, { stdio: "ignore" });
13
+ wasRunning = true;
14
+ } catch {
15
+ // Session wasn't running — that's fine
16
+ }
17
+
18
+ if (wasRunning) {
19
+ console.log(`Stopped session "${session}"`);
20
+ }
21
+
22
+ await launch(dir);
23
+ }
package/src/status.js ADDED
@@ -0,0 +1,53 @@
1
+ import { resolve } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { execSync } from "node:child_process";
4
+ import { getSessionName } from "./lib/yaml-io.js";
5
+
6
+ export async function status(targetDir, { json } = {}) {
7
+ const dir = resolve(targetDir ?? ".");
8
+ const session = getSessionName(dir);
9
+ const configExists = existsSync(resolve(dir, "ide.yml"));
10
+
11
+ let running = false;
12
+ let panes = [];
13
+
14
+ try {
15
+ execSync(`tmux has-session -t "${session}"`, { stdio: "ignore" });
16
+ running = true;
17
+
18
+ const raw = execSync(
19
+ `tmux list-panes -t "${session}" -F "#{pane_index}|#{pane_title}|#{pane_width}|#{pane_height}|#{pane_active}"`,
20
+ { encoding: "utf-8" }
21
+ ).trim();
22
+
23
+ panes = raw.split("\n").map((line) => {
24
+ const [index, title, width, height, active] = line.split("|");
25
+ return {
26
+ index: parseInt(index),
27
+ title,
28
+ width: parseInt(width),
29
+ height: parseInt(height),
30
+ active: active === "1",
31
+ };
32
+ });
33
+ } catch {}
34
+
35
+ const data = { session, running, configExists, panes };
36
+
37
+ if (json) {
38
+ console.log(JSON.stringify(data, null, 2));
39
+ return;
40
+ }
41
+
42
+ console.log(`Session: ${session}`);
43
+ console.log(`Running: ${running ? "yes" : "no"}`);
44
+ console.log(`Config: ${configExists ? "ide.yml found" : "no ide.yml"}`);
45
+
46
+ if (panes.length > 0) {
47
+ console.log(`\nPanes:`);
48
+ for (const p of panes) {
49
+ const active = p.active ? " (active)" : "";
50
+ console.log(` ${p.index}: ${p.title} [${p.width}x${p.height}]${active}`);
51
+ }
52
+ }
53
+ }
package/src/stop.js ADDED
@@ -0,0 +1,23 @@
1
+ import { resolve } from "node:path";
2
+ import { execSync } from "node:child_process";
3
+ import { getSessionName } from "./lib/yaml-io.js";
4
+
5
+ export async function stop(targetDir, { json } = {}) {
6
+ const dir = resolve(targetDir ?? ".");
7
+ const session = getSessionName(dir);
8
+
9
+ try {
10
+ execSync(`tmux kill-session -t "${session}"`, { stdio: "inherit" });
11
+ if (json) {
12
+ console.log(JSON.stringify({ stopped: session }));
13
+ } else {
14
+ console.log(`Stopped session "${session}"`);
15
+ }
16
+ } catch {
17
+ if (json) {
18
+ console.error(JSON.stringify({ error: `No active session "${session}" found`, code: "NOT_RUNNING" }));
19
+ } else {
20
+ console.error(`No active session "${session}" found`);
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,170 @@
1
+ import { resolve } from "node:path";
2
+ import { readConfig } from "./lib/yaml-io.js";
3
+ import { outputError } from "./lib/output.js";
4
+
5
+ export function validateConfig(config) {
6
+ const errors = [];
7
+
8
+ if (config == null || typeof config !== "object" || Array.isArray(config)) {
9
+ errors.push("config must be an object");
10
+ return errors;
11
+ }
12
+
13
+ if (config.name !== undefined && typeof config.name !== "string") {
14
+ errors.push("'name' must be a string");
15
+ }
16
+
17
+ if (config.before !== undefined && typeof config.before !== "string") {
18
+ errors.push("'before' must be a string");
19
+ }
20
+
21
+ if (!Array.isArray(config.rows)) {
22
+ errors.push("'rows' must be an array");
23
+ } else if (config.rows.length === 0) {
24
+ errors.push("'rows' must not be empty");
25
+ } else {
26
+ for (let i = 0; i < config.rows.length; i++) {
27
+ const row = config.rows[i];
28
+ if (!row.panes || !Array.isArray(row.panes)) {
29
+ errors.push(`rows[${i}].panes must be an array`);
30
+ continue;
31
+ }
32
+ if (row.panes.length === 0) {
33
+ errors.push(`rows[${i}].panes must not be empty`);
34
+ }
35
+ if (row.size !== undefined) {
36
+ validateSize(row.size, `rows[${i}].size`, errors);
37
+ }
38
+ for (let j = 0; j < row.panes.length; j++) {
39
+ const pane = row.panes[j];
40
+ if (pane.title !== undefined && typeof pane.title !== "string") {
41
+ errors.push(`rows[${i}].panes[${j}].title must be a string`);
42
+ }
43
+ if (pane.command !== undefined && typeof pane.command !== "string") {
44
+ errors.push(`rows[${i}].panes[${j}].command must be a string`);
45
+ }
46
+ if (pane.dir !== undefined && typeof pane.dir !== "string") {
47
+ errors.push(`rows[${i}].panes[${j}].dir must be a string`);
48
+ }
49
+ if (pane.focus !== undefined && typeof pane.focus !== "boolean") {
50
+ errors.push(`rows[${i}].panes[${j}].focus must be a boolean`);
51
+ }
52
+ if (pane.env !== undefined) {
53
+ if (pane.env == null || typeof pane.env !== "object" || Array.isArray(pane.env)) {
54
+ errors.push(`rows[${i}].panes[${j}].env must be an object`);
55
+ } else {
56
+ for (const [key, val] of Object.entries(pane.env)) {
57
+ if (typeof val !== "string" && typeof val !== "number") {
58
+ errors.push(`rows[${i}].panes[${j}].env.${key} must be a string or number`);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ if (pane.size !== undefined) {
64
+ validateSize(pane.size, `rows[${i}].panes[${j}].size`, errors);
65
+ }
66
+ if (pane.role !== undefined) {
67
+ if (pane.role !== "lead" && pane.role !== "teammate") {
68
+ errors.push(`rows[${i}].panes[${j}].role must be "lead" or "teammate"`);
69
+ }
70
+ }
71
+ if (pane.task !== undefined && typeof pane.task !== "string") {
72
+ errors.push(`rows[${i}].panes[${j}].task must be a string`);
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // Validate team config
79
+ if (config.team !== undefined) {
80
+ if (config.team == null || typeof config.team !== "object" || Array.isArray(config.team)) {
81
+ errors.push("'team' must be an object");
82
+ } else {
83
+ if (config.team.name === undefined) {
84
+ errors.push("'team.name' is required when team is specified");
85
+ } else if (typeof config.team.name !== "string") {
86
+ errors.push("'team.name' must be a string");
87
+ }
88
+ if (config.team.model !== undefined && typeof config.team.model !== "string") {
89
+ errors.push("'team.model' must be a string");
90
+ }
91
+ if (config.team.permissions !== undefined && !Array.isArray(config.team.permissions)) {
92
+ errors.push("'team.permissions' must be an array");
93
+ }
94
+ }
95
+ }
96
+
97
+ // Cross-validate team roles
98
+ if (config.team && Array.isArray(config.rows)) {
99
+ let leadCount = 0;
100
+ for (const row of config.rows) {
101
+ if (!Array.isArray(row.panes)) continue;
102
+ for (const pane of row.panes) {
103
+ if (pane.role === "lead") leadCount++;
104
+ }
105
+ }
106
+ if (leadCount === 0) {
107
+ errors.push("team config requires exactly one pane with role: lead");
108
+ } else if (leadCount > 1) {
109
+ errors.push("team config allows only one pane with role: lead");
110
+ }
111
+ }
112
+
113
+ if (config.theme !== undefined) {
114
+ if (config.theme == null || typeof config.theme !== "object" || Array.isArray(config.theme)) {
115
+ errors.push("'theme' must be an object");
116
+ } else {
117
+ for (const key of ["accent", "border", "bg", "fg"]) {
118
+ if (config.theme[key] !== undefined && typeof config.theme[key] !== "string") {
119
+ errors.push(`theme.${key} must be a string`);
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ return errors;
126
+ }
127
+
128
+ function validateSize(value, path, errors) {
129
+ const s = String(value);
130
+ if (!/^\d+%$/.test(s)) {
131
+ errors.push(`${path} "${value}" must be a percentage (e.g. "50%")`);
132
+ return;
133
+ }
134
+ const num = parseInt(s, 10);
135
+ if (num === 0) {
136
+ errors.push(`${path} must not be 0%`);
137
+ } else if (num > 100) {
138
+ errors.push(`${path} must not exceed 100%`);
139
+ }
140
+ }
141
+
142
+ export async function validate(targetDir, { json } = {}) {
143
+ const dir = resolve(targetDir ?? ".");
144
+ let config;
145
+
146
+ try {
147
+ ({ config } = readConfig(dir));
148
+ } catch (e) {
149
+ outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR", { json });
150
+ return;
151
+ }
152
+
153
+ const errors = validateConfig(config);
154
+ const valid = errors.length === 0;
155
+
156
+ if (json) {
157
+ console.log(JSON.stringify({ valid, errors }, null, 2));
158
+ return;
159
+ }
160
+
161
+ if (valid) {
162
+ console.log("✓ ide.yml is valid");
163
+ } else {
164
+ console.log("✗ ide.yml has errors:");
165
+ for (const e of errors) {
166
+ console.log(` - ${e}`);
167
+ }
168
+ process.exitCode = 1;
169
+ }
170
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validateConfig } from "./validate.js";
4
+
5
+ describe("validateConfig", () => {
6
+ it("accepts a valid minimal config", () => {
7
+ const errors = validateConfig({ rows: [{ panes: [{}] }] });
8
+ assert.deepStrictEqual(errors, []);
9
+ });
10
+
11
+ it("accepts a valid full config", () => {
12
+ const errors = validateConfig({
13
+ name: "my-project",
14
+ before: "pnpm install",
15
+ rows: [
16
+ {
17
+ size: "70%",
18
+ panes: [
19
+ { title: "Editor", command: "vim", dir: "src", size: "60%", focus: true, env: { PORT: 3000, HOST: "localhost" } },
20
+ { title: "Shell" },
21
+ ],
22
+ },
23
+ {
24
+ panes: [{ title: "Dev", command: "pnpm dev" }],
25
+ },
26
+ ],
27
+ theme: { accent: "colour75", border: "colour238", bg: "colour235", fg: "colour248" },
28
+ });
29
+ assert.deepStrictEqual(errors, []);
30
+ });
31
+
32
+ it("rejects null config", () => {
33
+ const errors = validateConfig(null);
34
+ assert.deepStrictEqual(errors, ["config must be an object"]);
35
+ });
36
+
37
+ it("rejects string config", () => {
38
+ const errors = validateConfig("hello");
39
+ assert.deepStrictEqual(errors, ["config must be an object"]);
40
+ });
41
+
42
+ it("rejects array config", () => {
43
+ const errors = validateConfig([]);
44
+ assert.deepStrictEqual(errors, ["config must be an object"]);
45
+ });
46
+
47
+ it("requires rows to be an array", () => {
48
+ const errors = validateConfig({ rows: "nope" });
49
+ assert.ok(errors.includes("'rows' must be an array"));
50
+ });
51
+
52
+ it("requires rows to be non-empty", () => {
53
+ const errors = validateConfig({ rows: [] });
54
+ assert.ok(errors.includes("'rows' must not be empty"));
55
+ });
56
+
57
+ it("requires rows to have a panes array", () => {
58
+ const errors = validateConfig({ rows: [{}] });
59
+ assert.ok(errors.includes("rows[0].panes must be an array"));
60
+ });
61
+
62
+ it("requires panes to be non-empty", () => {
63
+ const errors = validateConfig({ rows: [{ panes: [] }] });
64
+ assert.ok(errors.includes("rows[0].panes must not be empty"));
65
+ });
66
+
67
+ it("rejects non-string name", () => {
68
+ const errors = validateConfig({ name: 123, rows: [{ panes: [{}] }] });
69
+ assert.ok(errors.includes("'name' must be a string"));
70
+ });
71
+
72
+ it("rejects non-string before", () => {
73
+ const errors = validateConfig({ before: true, rows: [{ panes: [{}] }] });
74
+ assert.ok(errors.includes("'before' must be a string"));
75
+ });
76
+
77
+ it("rejects non-string pane.title", () => {
78
+ const errors = validateConfig({ rows: [{ panes: [{ title: 42 }] }] });
79
+ assert.ok(errors.includes("rows[0].panes[0].title must be a string"));
80
+ });
81
+
82
+ it("rejects non-string pane.command", () => {
83
+ const errors = validateConfig({ rows: [{ panes: [{ command: false }] }] });
84
+ assert.ok(errors.includes("rows[0].panes[0].command must be a string"));
85
+ });
86
+
87
+ it("rejects non-string pane.dir", () => {
88
+ const errors = validateConfig({ rows: [{ panes: [{ dir: [] }] }] });
89
+ assert.ok(errors.includes("rows[0].panes[0].dir must be a string"));
90
+ });
91
+
92
+ it("rejects non-boolean pane.focus", () => {
93
+ const errors = validateConfig({ rows: [{ panes: [{ focus: "yes" }] }] });
94
+ assert.ok(errors.includes("rows[0].panes[0].focus must be a boolean"));
95
+ });
96
+
97
+ it("rejects non-object pane.env", () => {
98
+ const errors = validateConfig({ rows: [{ panes: [{ env: "PORT=3000" }] }] });
99
+ assert.ok(errors.includes("rows[0].panes[0].env must be an object"));
100
+ });
101
+
102
+ it("rejects invalid env values", () => {
103
+ const errors = validateConfig({ rows: [{ panes: [{ env: { PORT: true } }] }] });
104
+ assert.ok(errors.includes("rows[0].panes[0].env.PORT must be a string or number"));
105
+ });
106
+
107
+ it("rejects size without % suffix", () => {
108
+ const errors = validateConfig({ rows: [{ size: "70", panes: [{}] }] });
109
+ assert.ok(errors.some((e) => e.includes('must be a percentage')));
110
+ });
111
+
112
+ it("rejects 0% size", () => {
113
+ const errors = validateConfig({ rows: [{ size: "0%", panes: [{}] }] });
114
+ assert.ok(errors.some((e) => e.includes("must not be 0%")));
115
+ });
116
+
117
+ it("rejects >100% size", () => {
118
+ const errors = validateConfig({ rows: [{ panes: [{ size: "150%" }] }] });
119
+ assert.ok(errors.some((e) => e.includes("must not exceed 100%")));
120
+ });
121
+
122
+ it("validates theme fields as strings", () => {
123
+ const errors = validateConfig({ rows: [{ panes: [{}] }], theme: { accent: 123 } });
124
+ assert.ok(errors.includes("theme.accent must be a string"));
125
+ });
126
+
127
+ it("rejects non-object theme", () => {
128
+ const errors = validateConfig({ rows: [{ panes: [{}] }], theme: "blue" });
129
+ assert.ok(errors.includes("'theme' must be an object"));
130
+ });
131
+
132
+ it("collects multiple errors at once", () => {
133
+ const errors = validateConfig({
134
+ name: 123,
135
+ before: [],
136
+ rows: [{ panes: [{ title: 42, focus: "yes", size: "0%" }] }],
137
+ theme: "nope",
138
+ });
139
+ assert.ok(errors.length >= 4);
140
+ });
141
+ });
@@ -0,0 +1,31 @@
1
+ name: my-monorepo
2
+
3
+ team:
4
+ name: my-monorepo
5
+
6
+ rows:
7
+ - size: 70%
8
+ panes:
9
+ - title: Lead — Architect
10
+ command: claude
11
+ role: lead
12
+ focus: true
13
+ - title: Frontend Agent
14
+ command: claude
15
+ role: teammate
16
+ task: "Work on the frontend application"
17
+ dir: apps/web
18
+ - title: Backend Agent
19
+ command: claude
20
+ role: teammate
21
+ task: "Work on the backend services"
22
+ dir: apps/api
23
+
24
+ - panes:
25
+ - title: Frontend
26
+ command: pnpm dev
27
+ dir: apps/web
28
+ - title: Backend
29
+ command: pnpm dev
30
+ dir: apps/api
31
+ - title: Shell
@@ -0,0 +1,27 @@
1
+ name: my-app
2
+
3
+ team:
4
+ name: my-app
5
+
6
+ before: pnpm install
7
+
8
+ rows:
9
+ - size: 70%
10
+ panes:
11
+ - title: Lead
12
+ command: claude
13
+ role: lead
14
+ focus: true
15
+ - title: Frontend
16
+ command: claude
17
+ role: teammate
18
+ task: "Work on React components and pages"
19
+ - title: Backend
20
+ command: claude
21
+ role: teammate
22
+ task: "Work on API routes and server logic"
23
+
24
+ - panes:
25
+ - title: Next.js
26
+ command: pnpm dev
27
+ - title: Shell
@@ -0,0 +1,23 @@
1
+ name: my-project
2
+
3
+ team:
4
+ name: my-project
5
+
6
+ rows:
7
+ - size: 70%
8
+ panes:
9
+ - title: Lead
10
+ command: claude
11
+ role: lead
12
+ focus: true
13
+ - title: Teammate 1
14
+ command: claude
15
+ role: teammate
16
+ - title: Teammate 2
17
+ command: claude
18
+ role: teammate
19
+
20
+ - panes:
21
+ - title: Dev Server
22
+ # command: npm run dev
23
+ - title: Shell
@@ -0,0 +1,19 @@
1
+ name: my-app
2
+
3
+ rows:
4
+ - size: 70%
5
+ panes:
6
+ - title: Claude 1 — feature
7
+ command: claude
8
+ - title: Claude 2 — review
9
+ command: claude
10
+ - title: Claude 3 — explore
11
+ command: claude
12
+
13
+ - panes:
14
+ - title: Next.js
15
+ command: pnpm dev
16
+ - title: Convex
17
+ command: npx convex dev
18
+ - title: Shell
19
+ focus: true
@@ -0,0 +1,20 @@
1
+ name: my-project
2
+
3
+ # before: npm install # optional pre-launch hook
4
+
5
+ rows:
6
+ - size: 70%
7
+ panes:
8
+ - title: Claude 1
9
+ command: claude
10
+ - title: Claude 2
11
+ command: claude
12
+
13
+ - panes:
14
+ - title: Dev Server
15
+ # command: npm run dev
16
+ # dir: apps/web # per-pane working directory
17
+ # env: # per-pane environment variables
18
+ # PORT: 3000
19
+ - title: Shell
20
+ # focus: true # give this pane initial focus
@@ -0,0 +1,15 @@
1
+ name: my-app
2
+
3
+ rows:
4
+ - size: 70%
5
+ panes:
6
+ - title: Claude 1 — feature
7
+ command: claude
8
+ - title: Claude 2 — review
9
+ command: claude
10
+
11
+ - panes:
12
+ - title: Go
13
+ command: go run .
14
+ - title: Shell
15
+ focus: true
@@ -0,0 +1,17 @@
1
+ name: my-app
2
+
3
+ rows:
4
+ - size: 70%
5
+ panes:
6
+ - title: Claude 1 — feature
7
+ command: claude
8
+ - title: Claude 2 — review
9
+ command: claude
10
+ - title: Claude 3 — explore
11
+ command: claude
12
+
13
+ - panes:
14
+ - title: Next.js
15
+ command: pnpm dev
16
+ - title: Shell
17
+ focus: true
@@ -0,0 +1,15 @@
1
+ name: my-app
2
+
3
+ rows:
4
+ - size: 70%
5
+ panes:
6
+ - title: Claude 1 — feature
7
+ command: claude
8
+ - title: Claude 2 — review
9
+ command: claude
10
+
11
+ - panes:
12
+ - title: Server
13
+ # command: uvicorn main:app --reload
14
+ - title: Shell
15
+ focus: true
@@ -0,0 +1,15 @@
1
+ name: my-app
2
+
3
+ rows:
4
+ - size: 70%
5
+ panes:
6
+ - title: Claude 1 — feature
7
+ command: claude
8
+ - title: Claude 2 — review
9
+ command: claude
10
+
11
+ - panes:
12
+ - title: Vite
13
+ command: pnpm dev
14
+ - title: Shell
15
+ focus: true