tmux-team 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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
+ }