tmux-team 3.0.0 → 3.1.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.
- package/README.md +56 -248
- package/package.json +5 -3
- package/skills/README.md +9 -10
- package/src/cli.test.ts +163 -0
- package/src/cli.ts +29 -18
- package/src/commands/basic-commands.test.ts +252 -0
- package/src/commands/config-command.test.ts +116 -0
- package/src/commands/help.ts +19 -3
- package/src/commands/install.test.ts +205 -0
- package/src/commands/install.ts +207 -0
- package/src/commands/learn.ts +80 -0
- package/src/commands/setup.test.ts +175 -0
- package/src/commands/setup.ts +163 -0
- package/src/commands/talk.test.ts +169 -101
- package/src/commands/talk.ts +186 -98
- package/src/context.test.ts +68 -0
- package/src/identity.test.ts +70 -0
- package/src/state.test.ts +14 -0
- package/src/tmux.test.ts +50 -0
- package/src/tmux.ts +66 -2
- package/src/types.ts +10 -1
- package/src/ui.test.ts +63 -0
- package/src/version.test.ts +31 -0
- package/src/version.ts +1 -1
- package/src/commands/install-skill.ts +0 -148
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// install command - install tmux-team for AI agents
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import * as os from 'node:os';
|
|
8
|
+
import * as readline from 'node:readline';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import type { Context } from '../types.js';
|
|
11
|
+
import { ExitCodes } from '../exits.js';
|
|
12
|
+
import { colors } from '../ui.js';
|
|
13
|
+
import { cmdSetup } from './setup.js';
|
|
14
|
+
|
|
15
|
+
type AgentType = 'claude' | 'codex';
|
|
16
|
+
|
|
17
|
+
interface SkillConfig {
|
|
18
|
+
sourceFile: string;
|
|
19
|
+
userDir: string;
|
|
20
|
+
targetFile: string;
|
|
21
|
+
detected: () => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getCodexHome(): string {
|
|
25
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SKILL_CONFIGS: Record<AgentType, SkillConfig> = {
|
|
29
|
+
claude: {
|
|
30
|
+
sourceFile: 'skills/claude/team.md',
|
|
31
|
+
userDir: path.join(os.homedir(), '.claude', 'commands'),
|
|
32
|
+
targetFile: 'team.md',
|
|
33
|
+
detected: () => fs.existsSync(path.join(os.homedir(), '.claude')),
|
|
34
|
+
},
|
|
35
|
+
codex: {
|
|
36
|
+
sourceFile: 'skills/codex/SKILL.md',
|
|
37
|
+
userDir: path.join(getCodexHome(), 'skills', 'tmux-team'),
|
|
38
|
+
targetFile: 'SKILL.md',
|
|
39
|
+
detected: () => fs.existsSync(getCodexHome()),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const SUPPORTED_AGENTS = Object.keys(SKILL_CONFIGS) as AgentType[];
|
|
44
|
+
|
|
45
|
+
function findPackageRoot(): string {
|
|
46
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
47
|
+
let dir = path.dirname(currentFile);
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < 5; i++) {
|
|
50
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
51
|
+
if (fs.existsSync(pkgPath)) {
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
const parent = path.dirname(dir);
|
|
55
|
+
if (parent === dir) break;
|
|
56
|
+
dir = parent;
|
|
57
|
+
}
|
|
58
|
+
return path.resolve(path.dirname(currentFile), '..', '..');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function detectEnvironment(): AgentType[] {
|
|
62
|
+
const detected: AgentType[] = [];
|
|
63
|
+
for (const [agent, config] of Object.entries(SKILL_CONFIGS)) {
|
|
64
|
+
if (config.detected()) {
|
|
65
|
+
detected.push(agent as AgentType);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return detected;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function prompt(rl: readline.Interface, question: string): Promise<string> {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
rl.question(question, (answer) => {
|
|
74
|
+
resolve(answer.trim());
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function confirm(rl: readline.Interface, question: string): Promise<boolean> {
|
|
80
|
+
const answer = await prompt(rl, `${question} [Y/n]: `);
|
|
81
|
+
return answer.toLowerCase() !== 'n';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function installSkill(ctx: Context, agent: AgentType): boolean {
|
|
85
|
+
const config = SKILL_CONFIGS[agent];
|
|
86
|
+
const pkgRoot = findPackageRoot();
|
|
87
|
+
const sourcePath = path.join(pkgRoot, config.sourceFile);
|
|
88
|
+
|
|
89
|
+
if (!fs.existsSync(sourcePath)) {
|
|
90
|
+
ctx.ui.error(`Skill file not found: ${sourcePath}`);
|
|
91
|
+
ctx.ui.info('Make sure tmux-team is properly installed.');
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const targetPath = path.join(config.userDir, config.targetFile);
|
|
96
|
+
|
|
97
|
+
if (fs.existsSync(targetPath) && !ctx.flags.force) {
|
|
98
|
+
ctx.ui.warn(`Skill already exists: ${targetPath}`);
|
|
99
|
+
ctx.ui.info('Use --force to overwrite.');
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!fs.existsSync(config.userDir)) {
|
|
104
|
+
fs.mkdirSync(config.userDir, { recursive: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function cmdInstall(ctx: Context, agent?: string): Promise<void> {
|
|
112
|
+
const { ui, flags, exit } = ctx;
|
|
113
|
+
|
|
114
|
+
const rl = readline.createInterface({
|
|
115
|
+
input: process.stdin,
|
|
116
|
+
output: process.stdout,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
let selectedAgent: AgentType;
|
|
121
|
+
|
|
122
|
+
if (agent) {
|
|
123
|
+
// Direct agent specification
|
|
124
|
+
const agentLower = agent.toLowerCase() as AgentType;
|
|
125
|
+
if (!SUPPORTED_AGENTS.includes(agentLower)) {
|
|
126
|
+
ui.error(`Unknown agent: ${agent}`);
|
|
127
|
+
ui.info(`Supported agents: ${SUPPORTED_AGENTS.join(', ')}`);
|
|
128
|
+
exit(ExitCodes.ERROR);
|
|
129
|
+
}
|
|
130
|
+
selectedAgent = agentLower;
|
|
131
|
+
} else {
|
|
132
|
+
// Auto-detect and prompt
|
|
133
|
+
const detected = detectEnvironment();
|
|
134
|
+
console.log();
|
|
135
|
+
|
|
136
|
+
if (detected.length === 0) {
|
|
137
|
+
ui.info('No AI agent environments detected.');
|
|
138
|
+
ui.info(`Supported agents: ${SUPPORTED_AGENTS.join(', ')}`);
|
|
139
|
+
console.log();
|
|
140
|
+
const choice = await prompt(rl, 'Which agent are you using? ');
|
|
141
|
+
const choiceLower = choice.toLowerCase() as AgentType;
|
|
142
|
+
if (!SUPPORTED_AGENTS.includes(choiceLower)) {
|
|
143
|
+
ui.error(`Unknown agent: ${choice}`);
|
|
144
|
+
exit(ExitCodes.ERROR);
|
|
145
|
+
}
|
|
146
|
+
selectedAgent = choiceLower;
|
|
147
|
+
} else if (detected.length === 1) {
|
|
148
|
+
ui.success(`Detected: ${detected[0]}`);
|
|
149
|
+
selectedAgent = detected[0];
|
|
150
|
+
} else {
|
|
151
|
+
ui.info(`Detected multiple environments: ${detected.join(', ')}`);
|
|
152
|
+
const choice = await prompt(rl, 'Which agent are you installing for? ');
|
|
153
|
+
const choiceLower = choice.toLowerCase() as AgentType;
|
|
154
|
+
if (!SUPPORTED_AGENTS.includes(choiceLower)) {
|
|
155
|
+
ui.error(`Unknown agent: ${choice}`);
|
|
156
|
+
exit(ExitCodes.ERROR);
|
|
157
|
+
}
|
|
158
|
+
selectedAgent = choiceLower;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Install the skill
|
|
163
|
+
console.log();
|
|
164
|
+
const success = installSkill(ctx, selectedAgent);
|
|
165
|
+
if (!success) {
|
|
166
|
+
exit(ExitCodes.ERROR);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const config = SKILL_CONFIGS[selectedAgent];
|
|
170
|
+
const targetPath = path.join(config.userDir, config.targetFile);
|
|
171
|
+
ui.success(`Installed ${selectedAgent} skill to ${targetPath}`);
|
|
172
|
+
|
|
173
|
+
// Agent-specific instructions
|
|
174
|
+
console.log();
|
|
175
|
+
if (selectedAgent === 'claude') {
|
|
176
|
+
console.log(colors.yellow('For full plugin features, run these in Claude Code:'));
|
|
177
|
+
console.log(` ${colors.cyan('/plugin marketplace add wkh237/tmux-team')}`);
|
|
178
|
+
console.log(` ${colors.cyan('/plugin install tmux-team')}`);
|
|
179
|
+
console.log();
|
|
180
|
+
} else if (selectedAgent === 'codex') {
|
|
181
|
+
console.log(colors.yellow('Enable skills in Codex:'));
|
|
182
|
+
console.log(` ${colors.cyan('codex --enable skills')}`);
|
|
183
|
+
console.log();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Offer to run setup
|
|
187
|
+
if (process.env.TMUX) {
|
|
188
|
+
const runSetup = await confirm(rl, 'Run setup wizard now?');
|
|
189
|
+
if (runSetup) {
|
|
190
|
+
rl.close();
|
|
191
|
+
await cmdSetup(ctx);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
ui.info('Run tmux-team setup inside tmux to configure your agents.');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log();
|
|
199
|
+
console.log(colors.yellow('Next steps:'));
|
|
200
|
+
console.log(` 1. Start tmux and open panes for your AI agents`);
|
|
201
|
+
console.log(` 2. Run ${colors.cyan('tmux-team setup')} to configure agents`);
|
|
202
|
+
console.log(` 3. Use ${colors.cyan('tmux-team talk <agent> "message" --wait')}`);
|
|
203
|
+
console.log();
|
|
204
|
+
} finally {
|
|
205
|
+
rl.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// learn command - educational guide for tmux-team
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { colors } from '../ui.js';
|
|
6
|
+
|
|
7
|
+
export function cmdLearn(): void {
|
|
8
|
+
console.log(`
|
|
9
|
+
${colors.cyan('tmux-team')} - Multi-Agent Coordination Guide
|
|
10
|
+
|
|
11
|
+
${colors.yellow('WHAT IS TMUX-TEAM?')}
|
|
12
|
+
|
|
13
|
+
tmux-team enables AI agents (Claude, Codex, Gemini) running in separate
|
|
14
|
+
terminal panes to communicate with each other. Think of it as a messaging
|
|
15
|
+
system for terminal-based AI agents.
|
|
16
|
+
|
|
17
|
+
${colors.yellow('CORE CONCEPT')}
|
|
18
|
+
|
|
19
|
+
Each agent runs in its own tmux pane. When you talk to another agent:
|
|
20
|
+
1. Your message is sent to their pane via tmux send-keys
|
|
21
|
+
2. They see it as if a human typed it
|
|
22
|
+
3. You read their response by capturing their pane output
|
|
23
|
+
|
|
24
|
+
${colors.yellow('ESSENTIAL COMMANDS')}
|
|
25
|
+
|
|
26
|
+
${colors.green('tmux-team list')} List available agents
|
|
27
|
+
${colors.green('tmux-team talk')} <agent> "<msg>" Send a message
|
|
28
|
+
${colors.green('tmux-team check')} <agent> [lines] Read agent's response
|
|
29
|
+
${colors.green('tmux-team talk')} <agent> --wait Send and wait for response
|
|
30
|
+
|
|
31
|
+
${colors.yellow('RECOMMENDED: ASYNC MODE (--wait)')}
|
|
32
|
+
|
|
33
|
+
The ${colors.green('--wait')} flag is recommended for better token utilization:
|
|
34
|
+
|
|
35
|
+
${colors.dim('# Without --wait (polling mode):')}
|
|
36
|
+
tmux-team talk codex "Review this code"
|
|
37
|
+
${colors.dim('# ... wait manually ...')}
|
|
38
|
+
tmux-team check codex ${colors.dim('← extra command')}
|
|
39
|
+
|
|
40
|
+
${colors.dim('# With --wait (async mode):')}
|
|
41
|
+
tmux-team talk codex "Review this code" --wait
|
|
42
|
+
${colors.dim('↳ Blocks until response, returns it directly')}
|
|
43
|
+
|
|
44
|
+
Enable by default: ${colors.cyan('tmux-team config set mode wait')}
|
|
45
|
+
|
|
46
|
+
${colors.yellow('PRACTICAL EXAMPLES')}
|
|
47
|
+
|
|
48
|
+
${colors.dim('# Quick question (async)')}
|
|
49
|
+
tmux-team talk codex "What's the auth status?" --wait
|
|
50
|
+
|
|
51
|
+
${colors.dim('# Delegate a task with timeout')}
|
|
52
|
+
tmux-team talk gemini "Implement login form" --wait --timeout 300
|
|
53
|
+
|
|
54
|
+
${colors.dim('# Broadcast to all agents')}
|
|
55
|
+
tmux-team talk all "Sync: PR #123 was merged" --wait
|
|
56
|
+
|
|
57
|
+
${colors.yellow('CONFIGURATION')}
|
|
58
|
+
|
|
59
|
+
Config file: ${colors.cyan('./tmux-team.json')}
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
"$config": { "mode": "wait" },
|
|
63
|
+
"codex": { "pane": "%1", "remark": "Code reviewer" },
|
|
64
|
+
"gemini": { "pane": "%2", "remark": "Documentation" }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Find your pane ID: ${colors.cyan('tmux display-message -p "#{pane_id}"')}
|
|
68
|
+
|
|
69
|
+
${colors.yellow('BEST PRACTICES')}
|
|
70
|
+
|
|
71
|
+
1. ${colors.green('Use --wait for important tasks')} - ensures complete response
|
|
72
|
+
2. ${colors.green('Be explicit')} - tell agents exactly what you need
|
|
73
|
+
3. ${colors.green('Set timeout appropriately')} - complex tasks need more time
|
|
74
|
+
4. ${colors.green('Broadcast sparingly')} - only for announcements everyone needs
|
|
75
|
+
|
|
76
|
+
${colors.yellow('NEXT STEP')}
|
|
77
|
+
|
|
78
|
+
Run ${colors.cyan('tmux-team list')} to see available agents in your project.
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import type { Context, Flags, Paths, ResolvedConfig, Tmux, UI } from '../types.js';
|
|
6
|
+
import { ExitCodes } from '../exits.js';
|
|
7
|
+
|
|
8
|
+
function createMockUI(): UI {
|
|
9
|
+
return {
|
|
10
|
+
info: vi.fn(),
|
|
11
|
+
success: vi.fn(),
|
|
12
|
+
warn: vi.fn(),
|
|
13
|
+
error: vi.fn(),
|
|
14
|
+
table: vi.fn(),
|
|
15
|
+
json: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createCtx(testDir: string, tmux: Tmux, flags?: Partial<Flags>): Context {
|
|
20
|
+
const paths: Paths = {
|
|
21
|
+
globalDir: testDir,
|
|
22
|
+
globalConfig: path.join(testDir, 'config.json'),
|
|
23
|
+
localConfig: path.join(testDir, 'tmux-team.json'),
|
|
24
|
+
stateFile: path.join(testDir, 'state.json'),
|
|
25
|
+
};
|
|
26
|
+
const config: ResolvedConfig = {
|
|
27
|
+
mode: 'polling',
|
|
28
|
+
preambleMode: 'always',
|
|
29
|
+
defaults: { timeout: 180, pollInterval: 1, captureLines: 100, preambleEvery: 3 },
|
|
30
|
+
agents: {},
|
|
31
|
+
paneRegistry: {},
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
argv: [],
|
|
35
|
+
flags: { json: false, verbose: false, ...(flags ?? {}) } as Flags,
|
|
36
|
+
ui: createMockUI(),
|
|
37
|
+
config,
|
|
38
|
+
tmux,
|
|
39
|
+
paths,
|
|
40
|
+
exit: ((code: number) => {
|
|
41
|
+
const err = new Error(`exit(${code})`);
|
|
42
|
+
(err as Error & { exitCode: number }).exitCode = code;
|
|
43
|
+
throw err;
|
|
44
|
+
}) as any,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('cmdSetup', () => {
|
|
49
|
+
let testDir = '';
|
|
50
|
+
const originalTmux = process.env.TMUX;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-setup-'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
process.env.TMUX = originalTmux;
|
|
58
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('errors when not in tmux', async () => {
|
|
63
|
+
vi.resetModules();
|
|
64
|
+
delete process.env.TMUX;
|
|
65
|
+
const { cmdSetup } = await import('./setup.js');
|
|
66
|
+
const ctx = createCtx(testDir, {
|
|
67
|
+
send: vi.fn(),
|
|
68
|
+
capture: vi.fn(),
|
|
69
|
+
listPanes: vi.fn(() => []),
|
|
70
|
+
getCurrentPaneId: vi.fn(() => null),
|
|
71
|
+
});
|
|
72
|
+
await expect(cmdSetup(ctx)).rejects.toThrow(`exit(${ExitCodes.ERROR})`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('creates tmux-team.json by configuring panes', async () => {
|
|
76
|
+
vi.resetModules();
|
|
77
|
+
process.env.TMUX = '1';
|
|
78
|
+
|
|
79
|
+
const answers = [
|
|
80
|
+
'', // accept default "codex" for pane %1
|
|
81
|
+
'reviewer', // remark
|
|
82
|
+
'1bad', // invalid name for pane %2 -> skipped
|
|
83
|
+
'', // remark (unused)
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
vi.doMock('readline', () => ({
|
|
87
|
+
default: {
|
|
88
|
+
createInterface: () => ({
|
|
89
|
+
question: (_q: string, cb: (a: string) => void) => cb(answers.shift() ?? ''),
|
|
90
|
+
close: () => {},
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
createInterface: () => ({
|
|
94
|
+
question: (_q: string, cb: (a: string) => void) => cb(answers.shift() ?? ''),
|
|
95
|
+
close: () => {},
|
|
96
|
+
}),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const { cmdSetup } = await import('./setup.js');
|
|
100
|
+
const ctx = createCtx(testDir, {
|
|
101
|
+
send: vi.fn(),
|
|
102
|
+
capture: vi.fn(),
|
|
103
|
+
getCurrentPaneId: vi.fn(() => '%0'),
|
|
104
|
+
listPanes: vi.fn(() => [
|
|
105
|
+
{ id: '%0', command: 'zsh', suggestedName: null },
|
|
106
|
+
{ id: '%1', command: 'codex', suggestedName: 'codex' },
|
|
107
|
+
{ id: '%2', command: 'zsh', suggestedName: null },
|
|
108
|
+
]),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await cmdSetup(ctx);
|
|
112
|
+
const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
|
|
113
|
+
expect(saved.codex.pane).toBe('%1');
|
|
114
|
+
expect(saved.codex.remark).toBe('reviewer');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('errors when no panes found', async () => {
|
|
118
|
+
vi.resetModules();
|
|
119
|
+
process.env.TMUX = '1';
|
|
120
|
+
vi.doMock('readline', () => ({
|
|
121
|
+
default: {
|
|
122
|
+
createInterface: () => ({
|
|
123
|
+
question: (_q: string, cb: (a: string) => void) => cb(''),
|
|
124
|
+
close: () => {},
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
createInterface: () => ({
|
|
128
|
+
question: (_q: string, cb: (a: string) => void) => cb(''),
|
|
129
|
+
close: () => {},
|
|
130
|
+
}),
|
|
131
|
+
}));
|
|
132
|
+
const { cmdSetup } = await import('./setup.js');
|
|
133
|
+
const ctx = createCtx(testDir, {
|
|
134
|
+
send: vi.fn(),
|
|
135
|
+
capture: vi.fn(),
|
|
136
|
+
getCurrentPaneId: vi.fn(() => '%0'),
|
|
137
|
+
listPanes: vi.fn(() => []),
|
|
138
|
+
});
|
|
139
|
+
await expect(cmdSetup(ctx)).rejects.toThrow(`exit(${ExitCodes.ERROR})`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('exits success when user skips all panes', async () => {
|
|
143
|
+
vi.resetModules();
|
|
144
|
+
process.env.TMUX = '1';
|
|
145
|
+
|
|
146
|
+
const answers = [
|
|
147
|
+
'', // pane %1 has no suggested name -> press Enter to skip
|
|
148
|
+
];
|
|
149
|
+
vi.doMock('readline', () => ({
|
|
150
|
+
default: {
|
|
151
|
+
createInterface: () => ({
|
|
152
|
+
question: (_q: string, cb: (a: string) => void) => cb(answers.shift() ?? ''),
|
|
153
|
+
close: () => {},
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
createInterface: () => ({
|
|
157
|
+
question: (_q: string, cb: (a: string) => void) => cb(answers.shift() ?? ''),
|
|
158
|
+
close: () => {},
|
|
159
|
+
}),
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const { cmdSetup } = await import('./setup.js');
|
|
163
|
+
const ctx = createCtx(testDir, {
|
|
164
|
+
send: vi.fn(),
|
|
165
|
+
capture: vi.fn(),
|
|
166
|
+
getCurrentPaneId: vi.fn(() => '%0'),
|
|
167
|
+
listPanes: vi.fn(() => [
|
|
168
|
+
{ id: '%0', command: 'zsh', suggestedName: null },
|
|
169
|
+
{ id: '%1', command: 'zsh', suggestedName: null },
|
|
170
|
+
]),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(cmdSetup(ctx)).rejects.toThrow(`exit(${ExitCodes.SUCCESS})`);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// setup command - interactive wizard for configuring agents
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import readline from 'readline';
|
|
7
|
+
import type { Context, PaneEntry, PaneInfo, LocalConfigFile } from '../types.js';
|
|
8
|
+
import { ExitCodes } from '../exits.js';
|
|
9
|
+
import { loadLocalConfigFile, saveLocalConfigFile } from '../config.js';
|
|
10
|
+
import { colors } from '../ui.js';
|
|
11
|
+
|
|
12
|
+
async function prompt(rl: readline.Interface, question: string): Promise<string> {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
rl.question(question, (answer) => {
|
|
15
|
+
resolve(answer.trim());
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function promptWithDefault(
|
|
21
|
+
rl: readline.Interface,
|
|
22
|
+
question: string,
|
|
23
|
+
defaultValue: string
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
const answer = await prompt(rl, `${question} [${defaultValue}]: `);
|
|
26
|
+
return answer || defaultValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function confirm(rl: readline.Interface, question: string): Promise<boolean> {
|
|
30
|
+
const answer = await prompt(rl, `${question} [Y/n]: `);
|
|
31
|
+
return answer.toLowerCase() !== 'n';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function cmdSetup(ctx: Context): Promise<void> {
|
|
35
|
+
const { ui, tmux, paths, exit } = ctx;
|
|
36
|
+
|
|
37
|
+
// Check if in tmux
|
|
38
|
+
if (!process.env.TMUX) {
|
|
39
|
+
ui.error('Not running inside tmux. Please run this command from within a tmux session.');
|
|
40
|
+
exit(ExitCodes.ERROR);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rl = readline.createInterface({
|
|
44
|
+
input: process.stdin,
|
|
45
|
+
output: process.stdout,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
console.log();
|
|
50
|
+
ui.info('Detecting tmux panes...');
|
|
51
|
+
console.log();
|
|
52
|
+
|
|
53
|
+
const panes = tmux.listPanes();
|
|
54
|
+
const currentPaneId = tmux.getCurrentPaneId();
|
|
55
|
+
|
|
56
|
+
if (panes.length === 0) {
|
|
57
|
+
ui.error('No tmux panes found.');
|
|
58
|
+
exit(ExitCodes.ERROR);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Filter out current pane
|
|
62
|
+
const otherPanes = panes.filter((p) => p.id !== currentPaneId);
|
|
63
|
+
|
|
64
|
+
if (otherPanes.length === 0) {
|
|
65
|
+
ui.warn('No other panes found. Create more tmux panes with other agents first.');
|
|
66
|
+
ui.info('Hint: Use Ctrl+B % or Ctrl+B " to split panes, then start your AI agents.');
|
|
67
|
+
exit(ExitCodes.ERROR);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Show detected panes
|
|
71
|
+
console.log(colors.yellow('Found panes:'));
|
|
72
|
+
console.log(` ${colors.dim(currentPaneId || '?')} ${colors.dim('(current pane - skipped)')}`);
|
|
73
|
+
for (const pane of otherPanes) {
|
|
74
|
+
const detected = pane.suggestedName
|
|
75
|
+
? colors.green(pane.suggestedName)
|
|
76
|
+
: colors.dim('(unknown)');
|
|
77
|
+
console.log(` ${pane.id} running "${pane.command}" → detected: ${detected}`);
|
|
78
|
+
}
|
|
79
|
+
console.log();
|
|
80
|
+
|
|
81
|
+
// Load existing config
|
|
82
|
+
let localConfig: LocalConfigFile = {};
|
|
83
|
+
if (fs.existsSync(paths.localConfig)) {
|
|
84
|
+
localConfig = loadLocalConfigFile(paths);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const agents: Record<string, PaneEntry> = {};
|
|
88
|
+
let configuredCount = 0;
|
|
89
|
+
|
|
90
|
+
// Configure each pane
|
|
91
|
+
for (const pane of otherPanes) {
|
|
92
|
+
const detected = pane.suggestedName || '';
|
|
93
|
+
|
|
94
|
+
console.log(colors.cyan(`Configure pane ${pane.id}?`));
|
|
95
|
+
if (pane.suggestedName) {
|
|
96
|
+
console.log(` Detected: ${colors.green(pane.suggestedName)}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Ask for name
|
|
100
|
+
let name: string;
|
|
101
|
+
if (detected) {
|
|
102
|
+
name = await promptWithDefault(rl, ' Name', detected);
|
|
103
|
+
} else {
|
|
104
|
+
name = await prompt(rl, ' Name (or press Enter to skip): ');
|
|
105
|
+
if (!name) {
|
|
106
|
+
console.log(colors.dim(' Skipped'));
|
|
107
|
+
console.log();
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Validate name
|
|
113
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
|
|
114
|
+
ui.warn(
|
|
115
|
+
' Invalid name. Use letters, numbers, underscores, hyphens. Starting with a letter.'
|
|
116
|
+
);
|
|
117
|
+
console.log(colors.dim(' Skipped'));
|
|
118
|
+
console.log();
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Ask for optional remark
|
|
123
|
+
const remark = await prompt(rl, ' Remark (optional): ');
|
|
124
|
+
|
|
125
|
+
const entry: PaneEntry = { pane: pane.id };
|
|
126
|
+
if (remark) {
|
|
127
|
+
entry.remark = remark;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
agents[name] = entry;
|
|
131
|
+
configuredCount++;
|
|
132
|
+
console.log();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (configuredCount === 0) {
|
|
136
|
+
ui.warn('No agents configured.');
|
|
137
|
+
exit(ExitCodes.SUCCESS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Merge with existing config (preserve $config and existing agents)
|
|
141
|
+
const newConfig: LocalConfigFile = { ...localConfig };
|
|
142
|
+
for (const [name, entry] of Object.entries(agents)) {
|
|
143
|
+
newConfig[name] = entry;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Save config
|
|
147
|
+
if (!fs.existsSync(paths.localConfig)) {
|
|
148
|
+
fs.writeFileSync(paths.localConfig, '{}\n');
|
|
149
|
+
}
|
|
150
|
+
saveLocalConfigFile(paths, newConfig);
|
|
151
|
+
|
|
152
|
+
ui.success(`Created ${paths.localConfig} with ${configuredCount} agent(s)`);
|
|
153
|
+
console.log();
|
|
154
|
+
|
|
155
|
+
// Show next steps
|
|
156
|
+
const firstAgent = Object.keys(agents)[0];
|
|
157
|
+
console.log(colors.yellow('Try it:'));
|
|
158
|
+
console.log(` ${colors.cyan(`tmux-team talk ${firstAgent} "hello" --wait`)}`);
|
|
159
|
+
console.log();
|
|
160
|
+
} finally {
|
|
161
|
+
rl.close();
|
|
162
|
+
}
|
|
163
|
+
}
|