tmux-team 1.0.0 → 2.0.0-alpha.1
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.
- package/README.md +197 -24
- package/bin/tmux-team +31 -430
- package/package.json +26 -6
- package/src/cli.ts +212 -0
- package/src/commands/add.ts +38 -0
- package/src/commands/check.ts +34 -0
- package/src/commands/completion.ts +118 -0
- package/src/commands/help.ts +51 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/remove.ts +25 -0
- package/src/commands/talk.test.ts +652 -0
- package/src/commands/talk.ts +261 -0
- package/src/commands/update.ts +47 -0
- package/src/config.test.ts +246 -0
- package/src/config.ts +159 -0
- package/src/context.ts +38 -0
- package/src/exits.ts +14 -0
- package/src/pm/commands.test.ts +158 -0
- package/src/pm/commands.ts +654 -0
- package/src/pm/manager.test.ts +377 -0
- package/src/pm/manager.ts +140 -0
- package/src/pm/storage/adapter.ts +55 -0
- package/src/pm/storage/fs.test.ts +384 -0
- package/src/pm/storage/fs.ts +255 -0
- package/src/pm/storage/github.ts +751 -0
- package/src/pm/types.ts +79 -0
- package/src/state.test.ts +311 -0
- package/src/state.ts +83 -0
- package/src/tmux.test.ts +205 -0
- package/src/tmux.ts +27 -0
- package/src/types.ts +86 -0
- package/src/ui.ts +67 -0
- package/src/version.ts +21 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// talk command - send message to agent(s)
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import type { Context } from '../types.js';
|
|
6
|
+
import type { WaitResult } from '../types.js';
|
|
7
|
+
import { ExitCodes } from '../exits.js';
|
|
8
|
+
import { colors } from '../ui.js';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import { cleanupState, clearActiveRequest, setActiveRequest } from '../state.js';
|
|
11
|
+
|
|
12
|
+
function sleepMs(ms: number): Promise<void> {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeRequestId(): string {
|
|
17
|
+
return `req_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeNonce(): string {
|
|
21
|
+
return crypto.randomBytes(2).toString('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderWaitLine(agent: string, elapsedSeconds: number): string {
|
|
25
|
+
const s = Math.max(0, Math.floor(elapsedSeconds));
|
|
26
|
+
return `⏳ Waiting for ${agent}... (${s}s)`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the final message with optional preamble.
|
|
31
|
+
* Format: [SYSTEM: <preamble>]\n\n<message>
|
|
32
|
+
*/
|
|
33
|
+
function buildMessage(message: string, agentName: string, ctx: Context): string {
|
|
34
|
+
const { config, flags } = ctx;
|
|
35
|
+
|
|
36
|
+
// Skip preamble if disabled or --no-preamble flag
|
|
37
|
+
if (config.preambleMode === 'disabled' || flags.noPreamble) {
|
|
38
|
+
return message;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Get agent-specific preamble
|
|
42
|
+
const agentConfig = config.agents[agentName];
|
|
43
|
+
const preamble = agentConfig?.preamble;
|
|
44
|
+
|
|
45
|
+
if (!preamble) {
|
|
46
|
+
return message;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return `[SYSTEM: ${preamble}]\n\n${message}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function cmdTalk(ctx: Context, target: string, message: string): Promise<void> {
|
|
53
|
+
const { ui, config, tmux, flags, exit } = ctx;
|
|
54
|
+
const waitEnabled = Boolean(flags.wait) || config.mode === 'wait';
|
|
55
|
+
|
|
56
|
+
if (waitEnabled && target === 'all') {
|
|
57
|
+
ui.error("Wait mode is not supported with 'all' yet. Send to one agent at a time.");
|
|
58
|
+
exit(ExitCodes.ERROR);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (target === 'all') {
|
|
62
|
+
const agents = Object.entries(config.paneRegistry);
|
|
63
|
+
if (agents.length === 0) {
|
|
64
|
+
ui.error("No agents configured. Use 'tmux-team add' first.");
|
|
65
|
+
exit(ExitCodes.CONFIG_MISSING);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (flags.delay && flags.delay > 0) {
|
|
69
|
+
await sleepMs(flags.delay * 1000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const results: { agent: string; pane: string; status: string }[] = [];
|
|
73
|
+
|
|
74
|
+
for (const [name, data] of agents) {
|
|
75
|
+
try {
|
|
76
|
+
// Build message with preamble, then apply Gemini filter
|
|
77
|
+
let msg = buildMessage(message, name, ctx);
|
|
78
|
+
if (name === 'gemini') msg = msg.replace(/!/g, '');
|
|
79
|
+
tmux.send(data.pane, msg);
|
|
80
|
+
results.push({ agent: name, pane: data.pane, status: 'sent' });
|
|
81
|
+
if (!flags.json) {
|
|
82
|
+
console.log(`${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
results.push({ agent: name, pane: data.pane, status: 'failed' });
|
|
86
|
+
if (!flags.json) {
|
|
87
|
+
ui.warn(`Failed to send to ${name}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (flags.json) {
|
|
93
|
+
ui.json({ target: 'all', results });
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Single agent
|
|
99
|
+
if (!config.paneRegistry[target]) {
|
|
100
|
+
const available = Object.keys(config.paneRegistry).join(', ');
|
|
101
|
+
ui.error(`Agent '${target}' not found. Available: ${available || 'none'}`);
|
|
102
|
+
exit(ExitCodes.PANE_NOT_FOUND);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const pane = config.paneRegistry[target].pane;
|
|
106
|
+
|
|
107
|
+
if (flags.delay && flags.delay > 0) {
|
|
108
|
+
await sleepMs(flags.delay * 1000);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!waitEnabled) {
|
|
112
|
+
try {
|
|
113
|
+
// Build message with preamble, then apply Gemini filter
|
|
114
|
+
let msg = buildMessage(message, target, ctx);
|
|
115
|
+
if (target === 'gemini') msg = msg.replace(/!/g, '');
|
|
116
|
+
tmux.send(pane, msg);
|
|
117
|
+
|
|
118
|
+
if (flags.json) {
|
|
119
|
+
ui.json({ target, pane, status: 'sent' });
|
|
120
|
+
} else {
|
|
121
|
+
console.log(`${colors.green('→')} Sent to ${colors.cyan(target)} (${pane})`);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
} catch {
|
|
125
|
+
ui.error(`Failed to send to pane ${pane}. Is tmux running?`);
|
|
126
|
+
exit(ExitCodes.ERROR);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Wait mode
|
|
131
|
+
const timeoutSeconds = flags.timeout ?? config.defaults.timeout;
|
|
132
|
+
const pollIntervalSeconds = Math.max(0.1, config.defaults.pollInterval);
|
|
133
|
+
const captureLines = config.defaults.captureLines;
|
|
134
|
+
|
|
135
|
+
const requestId = makeRequestId();
|
|
136
|
+
const nonce = makeNonce();
|
|
137
|
+
const marker = `{tmux-team-end:${nonce}}`;
|
|
138
|
+
|
|
139
|
+
// Build message with preamble, then append nonce instruction
|
|
140
|
+
const messageWithPreamble = buildMessage(message, target, ctx);
|
|
141
|
+
const fullMessage = `${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${marker}]`;
|
|
142
|
+
|
|
143
|
+
// Best-effort cleanup and soft-lock warning
|
|
144
|
+
const state = cleanupState(ctx.paths, 60 * 60); // 1 hour TTL
|
|
145
|
+
const existing = state.requests[target];
|
|
146
|
+
if (existing && !flags.json && !flags.force) {
|
|
147
|
+
ui.warn(
|
|
148
|
+
`Another recent wait request exists for '${target}' (id: ${existing.id}). Results may interleave.`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let baseline = '';
|
|
153
|
+
try {
|
|
154
|
+
baseline = tmux.capture(pane, captureLines);
|
|
155
|
+
} catch {
|
|
156
|
+
ui.error(`Failed to capture pane ${pane}. Is tmux running?`);
|
|
157
|
+
exit(ExitCodes.ERROR);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setActiveRequest(ctx.paths, target, { id: requestId, nonce, pane, startedAtMs: Date.now() });
|
|
161
|
+
|
|
162
|
+
const startedAt = Date.now();
|
|
163
|
+
let lastNonTtyLogAt = 0;
|
|
164
|
+
const isTTY = process.stdout.isTTY && !flags.json;
|
|
165
|
+
|
|
166
|
+
const onSigint = (): void => {
|
|
167
|
+
clearActiveRequest(ctx.paths, target, requestId);
|
|
168
|
+
if (!flags.json) process.stdout.write('\n');
|
|
169
|
+
ui.error('Interrupted.');
|
|
170
|
+
exit(ExitCodes.ERROR);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
process.once('SIGINT', onSigint);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const msg = target === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
|
|
177
|
+
tmux.send(pane, msg);
|
|
178
|
+
|
|
179
|
+
while (true) {
|
|
180
|
+
const elapsedSeconds = (Date.now() - startedAt) / 1000;
|
|
181
|
+
if (elapsedSeconds >= timeoutSeconds) {
|
|
182
|
+
clearActiveRequest(ctx.paths, target, requestId);
|
|
183
|
+
if (flags.json) {
|
|
184
|
+
// Single JSON output with error field (don't call ui.error separately)
|
|
185
|
+
ui.json({
|
|
186
|
+
target,
|
|
187
|
+
pane,
|
|
188
|
+
status: 'timeout',
|
|
189
|
+
error: `Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s`,
|
|
190
|
+
requestId,
|
|
191
|
+
nonce,
|
|
192
|
+
marker,
|
|
193
|
+
});
|
|
194
|
+
exit(ExitCodes.TIMEOUT);
|
|
195
|
+
}
|
|
196
|
+
if (isTTY) {
|
|
197
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
198
|
+
}
|
|
199
|
+
ui.error(`Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s.`);
|
|
200
|
+
exit(ExitCodes.TIMEOUT);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!flags.json) {
|
|
204
|
+
if (isTTY) {
|
|
205
|
+
process.stdout.write('\r' + renderWaitLine(target, elapsedSeconds));
|
|
206
|
+
} else {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
if (now - lastNonTtyLogAt >= 5000) {
|
|
209
|
+
lastNonTtyLogAt = now;
|
|
210
|
+
console.error(
|
|
211
|
+
`[tmux-team] Waiting for ${target} (${Math.floor(elapsedSeconds)}s elapsed)`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await sleepMs(pollIntervalSeconds * 1000);
|
|
218
|
+
|
|
219
|
+
let output = '';
|
|
220
|
+
try {
|
|
221
|
+
output = tmux.capture(pane, captureLines);
|
|
222
|
+
} catch {
|
|
223
|
+
clearActiveRequest(ctx.paths, target, requestId);
|
|
224
|
+
ui.error(`Failed to capture pane ${pane}. Is tmux running?`);
|
|
225
|
+
exit(ExitCodes.ERROR);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const markerIndex = output.indexOf(marker);
|
|
229
|
+
if (markerIndex === -1) continue;
|
|
230
|
+
|
|
231
|
+
let startIndex = 0;
|
|
232
|
+
const baselineIndex = baseline ? output.lastIndexOf(baseline) : -1;
|
|
233
|
+
if (baselineIndex !== -1) {
|
|
234
|
+
startIndex = baselineIndex + baseline.length;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const response = output.slice(startIndex, markerIndex).trim();
|
|
238
|
+
|
|
239
|
+
if (!flags.json && isTTY) {
|
|
240
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
241
|
+
} else if (!flags.json) {
|
|
242
|
+
// Ensure the next output starts on a new line
|
|
243
|
+
process.stdout.write('\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
clearActiveRequest(ctx.paths, target, requestId);
|
|
247
|
+
|
|
248
|
+
const result: WaitResult = { requestId, nonce, marker, response };
|
|
249
|
+
if (flags.json) {
|
|
250
|
+
ui.json({ target, pane, status: 'completed', ...result });
|
|
251
|
+
} else {
|
|
252
|
+
console.log(colors.cyan(`─── Response from ${target} (${pane}) ───`));
|
|
253
|
+
console.log(response);
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
process.removeListener('SIGINT', onSigint);
|
|
259
|
+
clearActiveRequest(ctx.paths, target, requestId);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// update command - modify agent config
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import type { Context } from '../types.js';
|
|
6
|
+
import { ExitCodes } from '../exits.js';
|
|
7
|
+
import { saveLocalConfig } from '../config.js';
|
|
8
|
+
|
|
9
|
+
export function cmdUpdate(
|
|
10
|
+
ctx: Context,
|
|
11
|
+
name: string,
|
|
12
|
+
options: { pane?: string; remark?: string }
|
|
13
|
+
): void {
|
|
14
|
+
const { ui, config, paths, flags, exit } = ctx;
|
|
15
|
+
|
|
16
|
+
if (!config.paneRegistry[name]) {
|
|
17
|
+
ui.error(`Agent '${name}' not found. Use 'tmux-team add' to create.`);
|
|
18
|
+
exit(ExitCodes.PANE_NOT_FOUND);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!options.pane && !options.remark) {
|
|
22
|
+
ui.error('No updates specified. Use --pane or --remark.');
|
|
23
|
+
exit(ExitCodes.ERROR);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const updates: string[] = [];
|
|
27
|
+
|
|
28
|
+
if (options.pane) {
|
|
29
|
+
config.paneRegistry[name].pane = options.pane;
|
|
30
|
+
updates.push(`pane → ${options.pane}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.remark) {
|
|
34
|
+
config.paneRegistry[name].remark = options.remark;
|
|
35
|
+
updates.push(`remark updated`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
saveLocalConfig(paths, config.paneRegistry);
|
|
39
|
+
|
|
40
|
+
if (flags.json) {
|
|
41
|
+
ui.json({ updated: name, ...options });
|
|
42
|
+
} else {
|
|
43
|
+
for (const update of updates) {
|
|
44
|
+
ui.success(`Updated '${name}': ${update}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Config Tests - XDG path resolution and config hierarchy
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { resolveGlobalDir, resolvePaths, loadConfig, ConfigParseError } from './config.js';
|
|
10
|
+
|
|
11
|
+
// Mock fs and os modules
|
|
12
|
+
vi.mock('fs');
|
|
13
|
+
vi.mock('os');
|
|
14
|
+
|
|
15
|
+
describe('resolveGlobalDir', () => {
|
|
16
|
+
const mockHome = '/home/testuser';
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.mocked(os.homedir).mockReturnValue(mockHome);
|
|
20
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
21
|
+
// Clear environment variables
|
|
22
|
+
delete process.env.TMUX_TEAM_HOME;
|
|
23
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('uses TMUX_TEAM_HOME when set (highest priority)', () => {
|
|
31
|
+
process.env.TMUX_TEAM_HOME = '/custom/tmux-team';
|
|
32
|
+
expect(resolveGlobalDir()).toBe('/custom/tmux-team');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('uses XDG_CONFIG_HOME when set', () => {
|
|
36
|
+
process.env.XDG_CONFIG_HOME = '/custom/config';
|
|
37
|
+
expect(resolveGlobalDir()).toBe('/custom/config/tmux-team');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('uses ~/.config/tmux-team when directory exists', () => {
|
|
41
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
42
|
+
return p === path.join(mockHome, '.config', 'tmux-team');
|
|
43
|
+
});
|
|
44
|
+
expect(resolveGlobalDir()).toBe(path.join(mockHome, '.config', 'tmux-team'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses legacy ~/.tmux-team when it exists and XDG does not', () => {
|
|
48
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
49
|
+
return p === path.join(mockHome, '.tmux-team');
|
|
50
|
+
});
|
|
51
|
+
expect(resolveGlobalDir()).toBe(path.join(mockHome, '.tmux-team'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('defaults to XDG style (~/.config/tmux-team) for new installs', () => {
|
|
55
|
+
// Neither path exists
|
|
56
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
57
|
+
expect(resolveGlobalDir()).toBe(path.join(mockHome, '.config', 'tmux-team'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('prefers path with config.json when both XDG and legacy exist', () => {
|
|
61
|
+
const xdgPath = path.join(mockHome, '.config', 'tmux-team');
|
|
62
|
+
const legacyPath = path.join(mockHome, '.tmux-team');
|
|
63
|
+
const legacyConfig = path.join(legacyPath, 'config.json');
|
|
64
|
+
|
|
65
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
66
|
+
// Both dirs exist, but only legacy has config.json
|
|
67
|
+
if (p === xdgPath || p === legacyPath) return true;
|
|
68
|
+
if (p === legacyConfig) return true;
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(resolveGlobalDir()).toBe(legacyPath);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('prefers XDG when both exist and both have config.json', () => {
|
|
76
|
+
const xdgPath = path.join(mockHome, '.config', 'tmux-team');
|
|
77
|
+
const legacyPath = path.join(mockHome, '.tmux-team');
|
|
78
|
+
const xdgConfig = path.join(xdgPath, 'config.json');
|
|
79
|
+
const legacyConfig = path.join(legacyPath, 'config.json');
|
|
80
|
+
|
|
81
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
82
|
+
// Both dirs exist with config.json
|
|
83
|
+
if (p === xdgPath || p === legacyPath) return true;
|
|
84
|
+
if (p === xdgConfig || p === legacyConfig) return true;
|
|
85
|
+
return false;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(resolveGlobalDir()).toBe(xdgPath);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('prefers XDG when only XDG has config.json', () => {
|
|
92
|
+
const xdgPath = path.join(mockHome, '.config', 'tmux-team');
|
|
93
|
+
const legacyPath = path.join(mockHome, '.tmux-team');
|
|
94
|
+
const xdgConfig = path.join(xdgPath, 'config.json');
|
|
95
|
+
|
|
96
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
97
|
+
if (p === xdgPath || p === legacyPath) return true;
|
|
98
|
+
if (p === xdgConfig) return true;
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(resolveGlobalDir()).toBe(xdgPath);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('resolvePaths', () => {
|
|
107
|
+
const mockHome = '/home/testuser';
|
|
108
|
+
const mockCwd = '/projects/myapp';
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
vi.mocked(os.homedir).mockReturnValue(mockHome);
|
|
112
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
113
|
+
delete process.env.TMUX_TEAM_HOME;
|
|
114
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
vi.clearAllMocks();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns correct path structure', () => {
|
|
122
|
+
const paths = resolvePaths(mockCwd);
|
|
123
|
+
|
|
124
|
+
expect(paths.globalDir).toBe(path.join(mockHome, '.config', 'tmux-team'));
|
|
125
|
+
expect(paths.globalConfig).toBe(path.join(mockHome, '.config', 'tmux-team', 'config.json'));
|
|
126
|
+
expect(paths.localConfig).toBe(path.join(mockCwd, 'tmux-team.json'));
|
|
127
|
+
expect(paths.stateFile).toBe(path.join(mockHome, '.config', 'tmux-team', 'state.json'));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('uses TMUX_TEAM_HOME for global paths', () => {
|
|
131
|
+
process.env.TMUX_TEAM_HOME = '/custom/path';
|
|
132
|
+
const paths = resolvePaths(mockCwd);
|
|
133
|
+
|
|
134
|
+
expect(paths.globalDir).toBe('/custom/path');
|
|
135
|
+
expect(paths.globalConfig).toBe('/custom/path/config.json');
|
|
136
|
+
expect(paths.stateFile).toBe('/custom/path/state.json');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('loadConfig', () => {
|
|
141
|
+
const mockPaths = {
|
|
142
|
+
globalDir: '/home/test/.config/tmux-team',
|
|
143
|
+
globalConfig: '/home/test/.config/tmux-team/config.json',
|
|
144
|
+
localConfig: '/projects/myapp/tmux-team.json',
|
|
145
|
+
stateFile: '/home/test/.config/tmux-team/state.json',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
vi.clearAllMocks();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns default config when no config files exist', () => {
|
|
157
|
+
const config = loadConfig(mockPaths);
|
|
158
|
+
|
|
159
|
+
expect(config.mode).toBe('polling');
|
|
160
|
+
expect(config.preambleMode).toBe('always');
|
|
161
|
+
expect(config.defaults.timeout).toBe(60);
|
|
162
|
+
expect(config.defaults.pollInterval).toBe(1);
|
|
163
|
+
expect(config.defaults.captureLines).toBe(100);
|
|
164
|
+
expect(config.agents).toEqual({});
|
|
165
|
+
expect(config.paneRegistry).toEqual({});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('loads and merges global config', () => {
|
|
169
|
+
const globalConfig = {
|
|
170
|
+
mode: 'wait',
|
|
171
|
+
preambleMode: 'disabled',
|
|
172
|
+
defaults: { timeout: 120 },
|
|
173
|
+
agents: { claude: { preamble: 'Be helpful' } },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
|
|
177
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(globalConfig));
|
|
178
|
+
|
|
179
|
+
const config = loadConfig(mockPaths);
|
|
180
|
+
|
|
181
|
+
expect(config.mode).toBe('wait');
|
|
182
|
+
expect(config.preambleMode).toBe('disabled');
|
|
183
|
+
expect(config.defaults.timeout).toBe(120);
|
|
184
|
+
expect(config.defaults.pollInterval).toBe(1); // Default preserved
|
|
185
|
+
expect(config.agents.claude?.preamble).toBe('Be helpful');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('loads local pane registry from tmux-team.json', () => {
|
|
189
|
+
const localConfig = {
|
|
190
|
+
claude: { pane: '1.0', remark: 'Main assistant' },
|
|
191
|
+
codex: { pane: '1.1' },
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
195
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
196
|
+
|
|
197
|
+
const config = loadConfig(mockPaths);
|
|
198
|
+
|
|
199
|
+
expect(config.paneRegistry.claude?.pane).toBe('1.0');
|
|
200
|
+
expect(config.paneRegistry.claude?.remark).toBe('Main assistant');
|
|
201
|
+
expect(config.paneRegistry.codex?.pane).toBe('1.1');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('merges both global and local config', () => {
|
|
205
|
+
const globalConfig = {
|
|
206
|
+
agents: { claude: { preamble: 'Be brief' } },
|
|
207
|
+
};
|
|
208
|
+
const localConfig = {
|
|
209
|
+
claude: { pane: '1.0' },
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
213
|
+
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
|
214
|
+
if (p === mockPaths.globalConfig) return JSON.stringify(globalConfig);
|
|
215
|
+
if (p === mockPaths.localConfig) return JSON.stringify(localConfig);
|
|
216
|
+
return '';
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const config = loadConfig(mockPaths);
|
|
220
|
+
|
|
221
|
+
expect(config.agents.claude?.preamble).toBe('Be brief');
|
|
222
|
+
expect(config.paneRegistry.claude?.pane).toBe('1.0');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('throws ConfigParseError on invalid JSON in config file', () => {
|
|
226
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
|
|
227
|
+
vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }');
|
|
228
|
+
|
|
229
|
+
expect(() => loadConfig(mockPaths)).toThrow(ConfigParseError);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('ConfigParseError includes file path and cause', () => {
|
|
233
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
|
|
234
|
+
vi.mocked(fs.readFileSync).mockReturnValue('not json');
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
loadConfig(mockPaths);
|
|
238
|
+
expect.fail('Should have thrown');
|
|
239
|
+
} catch (err) {
|
|
240
|
+
expect(err).toBeInstanceOf(ConfigParseError);
|
|
241
|
+
const parseError = err as ConfigParseError;
|
|
242
|
+
expect(parseError.filePath).toBe(mockPaths.globalConfig);
|
|
243
|
+
expect(parseError.cause).toBeDefined();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|