ccmanager 0.1.8 → 0.1.9

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 CHANGED
@@ -11,6 +11,7 @@ https://github.com/user-attachments/assets/a6d80e73-dc06-4ef8-849d-e3857f6c7024
11
11
  - Visual status indicators for session states (busy, waiting, idle)
12
12
  - Create, merge, and delete worktrees from within the app
13
13
  - Configurable keyboard shortcuts
14
+ - Command configuration with automatic fallback support
14
15
  - Status change hooks for automation and notifications
15
16
 
16
17
  ## Why CCManager over Claude Squad?
@@ -117,6 +118,31 @@ Note: Shortcuts from `shortcuts.json` will be automatically migrated to `config.
117
118
  - Ctrl+D
118
119
  - Ctrl+[ (equivalent to Escape)
119
120
 
121
+
122
+ ## Command Configuration
123
+
124
+ ![Screenshot From 2025-06-18 16-43-27](https://github.com/user-attachments/assets/47d62483-ce81-4340-8687-8afcae93d5db)
125
+
126
+
127
+ CCManager supports configuring the command and arguments used to run Claude Code sessions, with automatic fallback options for reliability.
128
+
129
+ ### Features
130
+
131
+ - Configure the main command (default: `claude`)
132
+ - Set primary arguments (e.g., `--resume`)
133
+ - Define fallback arguments if the primary configuration fails
134
+ - Automatic retry with no arguments as final fallback
135
+
136
+ ### Quick Start
137
+
138
+ 1. Navigate to **Configuration** → **Configure Command**
139
+ 2. Set your desired arguments (e.g., `--resume` for resuming sessions)
140
+ 3. Optionally set fallback arguments
141
+ 4. Save changes
142
+
143
+ For detailed configuration options and examples, see [docs/command-config.md](docs/command-config.md).
144
+
145
+
120
146
  ## Status Change Hooks
121
147
 
122
148
  CCManager can execute custom commands when Claude Code session status changes. This enables powerful automation workflows like desktop notifications, logging, or integration with other tools.
@@ -46,7 +46,7 @@ const App = () => {
46
46
  sessionManager.destroy();
47
47
  };
48
48
  }, [sessionManager]);
49
- const handleSelectWorktree = (worktree) => {
49
+ const handleSelectWorktree = async (worktree) => {
50
50
  // Check if this is the new worktree option
51
51
  if (worktree.path === '') {
52
52
  setView('new-worktree');
@@ -76,7 +76,13 @@ const App = () => {
76
76
  // Get or create session for this worktree
77
77
  let session = sessionManager.getSession(worktree.path);
78
78
  if (!session) {
79
- session = sessionManager.createSession(worktree.path);
79
+ try {
80
+ session = await sessionManager.createSession(worktree.path);
81
+ }
82
+ catch (error) {
83
+ setError(`Failed to create session: ${error}`);
84
+ return;
85
+ }
80
86
  }
81
87
  setActiveSession(session);
82
88
  setView('session');
@@ -4,6 +4,7 @@ import SelectInput from 'ink-select-input';
4
4
  import ConfigureShortcuts from './ConfigureShortcuts.js';
5
5
  import ConfigureHooks from './ConfigureHooks.js';
6
6
  import ConfigureWorktree from './ConfigureWorktree.js';
7
+ import ConfigureCommand from './ConfigureCommand.js';
7
8
  const Configuration = ({ onComplete }) => {
8
9
  const [view, setView] = useState('menu');
9
10
  const menuItems = [
@@ -19,6 +20,10 @@ const Configuration = ({ onComplete }) => {
19
20
  label: '📁 Configure Worktree Settings',
20
21
  value: 'worktree',
21
22
  },
23
+ {
24
+ label: '🚀 Configure Command',
25
+ value: 'command',
26
+ },
22
27
  {
23
28
  label: '← Back to Main Menu',
24
29
  value: 'back',
@@ -37,6 +42,9 @@ const Configuration = ({ onComplete }) => {
37
42
  else if (item.value === 'worktree') {
38
43
  setView('worktree');
39
44
  }
45
+ else if (item.value === 'command') {
46
+ setView('command');
47
+ }
40
48
  };
41
49
  const handleSubMenuComplete = () => {
42
50
  setView('menu');
@@ -50,6 +58,9 @@ const Configuration = ({ onComplete }) => {
50
58
  if (view === 'worktree') {
51
59
  return React.createElement(ConfigureWorktree, { onComplete: handleSubMenuComplete });
52
60
  }
61
+ if (view === 'command') {
62
+ return React.createElement(ConfigureCommand, { onComplete: handleSubMenuComplete });
63
+ }
53
64
  return (React.createElement(Box, { flexDirection: "column" },
54
65
  React.createElement(Box, { marginBottom: 1 },
55
66
  React.createElement(Text, { bold: true, color: "green" }, "Configuration")),
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ interface ConfigureCommandProps {
3
+ onComplete: () => void;
4
+ }
5
+ declare const ConfigureCommand: React.FC<ConfigureCommandProps>;
6
+ export default ConfigureCommand;
@@ -0,0 +1,182 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { configurationManager } from '../services/configurationManager.js';
5
+ import { shortcutManager } from '../services/shortcutManager.js';
6
+ const ConfigureCommand = ({ onComplete }) => {
7
+ // Load current configuration once
8
+ const currentConfig = configurationManager.getCommandConfig();
9
+ const [originalConfig] = useState(currentConfig);
10
+ const [config, setConfig] = useState(currentConfig);
11
+ const [editMode, setEditMode] = useState('menu');
12
+ const [selectedIndex, setSelectedIndex] = useState(0);
13
+ const [inputValue, setInputValue] = useState('');
14
+ const [hasChanges, setHasChanges] = useState(false);
15
+ const menuItems = [
16
+ {
17
+ label: 'Command',
18
+ value: config.command,
19
+ key: 'command',
20
+ isButton: false,
21
+ disabled: false,
22
+ },
23
+ {
24
+ label: 'Arguments',
25
+ value: config.args?.join(' ') || '(none)',
26
+ key: 'args',
27
+ isButton: false,
28
+ disabled: false,
29
+ },
30
+ {
31
+ label: 'Fallback Arguments',
32
+ value: config.fallbackArgs?.join(' ') || '(none)',
33
+ key: 'fallbackArgs',
34
+ isButton: false,
35
+ disabled: false,
36
+ },
37
+ {
38
+ label: hasChanges ? '💾 Save Changes' : '💾 Save Changes (no changes)',
39
+ value: '',
40
+ key: 'save',
41
+ isButton: true,
42
+ disabled: !hasChanges,
43
+ },
44
+ {
45
+ label: '❌ Exit Without Saving',
46
+ value: '',
47
+ key: 'exit',
48
+ isButton: true,
49
+ disabled: false,
50
+ },
51
+ ];
52
+ const handleMenuNavigation = (key) => {
53
+ if (key.upArrow) {
54
+ setSelectedIndex(prev => (prev > 0 ? prev - 1 : menuItems.length - 1));
55
+ }
56
+ else if (key.downArrow) {
57
+ setSelectedIndex(prev => (prev < menuItems.length - 1 ? prev + 1 : 0));
58
+ }
59
+ };
60
+ const getInitialInputValue = (key) => {
61
+ switch (key) {
62
+ case 'command':
63
+ return config.command;
64
+ case 'args':
65
+ return config.args?.join(' ') || '';
66
+ case 'fallbackArgs':
67
+ return config.fallbackArgs?.join(' ') || '';
68
+ default:
69
+ return '';
70
+ }
71
+ };
72
+ const handleMenuItemSelect = () => {
73
+ const selectedItem = menuItems[selectedIndex];
74
+ if (!selectedItem || selectedItem.disabled)
75
+ return;
76
+ switch (selectedItem.key) {
77
+ case 'save':
78
+ configurationManager.setCommandConfig(config);
79
+ onComplete();
80
+ break;
81
+ case 'exit':
82
+ onComplete();
83
+ break;
84
+ default:
85
+ if (!selectedItem.isButton) {
86
+ setEditMode(selectedItem.key);
87
+ setInputValue(getInitialInputValue(selectedItem.key));
88
+ }
89
+ }
90
+ };
91
+ useInput((input, key) => {
92
+ // Handle cancel shortcut in any mode
93
+ if (shortcutManager.matchesShortcut('cancel', input, key)) {
94
+ if (editMode === 'menu') {
95
+ onComplete(); // Exit without saving
96
+ }
97
+ else {
98
+ setEditMode('menu');
99
+ setInputValue('');
100
+ }
101
+ return;
102
+ }
103
+ // Handle menu mode inputs
104
+ if (editMode === 'menu') {
105
+ handleMenuNavigation(key);
106
+ if (key.return) {
107
+ handleMenuItemSelect();
108
+ }
109
+ }
110
+ });
111
+ const handleInputSubmit = (value) => {
112
+ let updatedConfig = { ...config };
113
+ if (editMode === 'command') {
114
+ updatedConfig.command = value || 'claude';
115
+ }
116
+ else if (editMode === 'args') {
117
+ // Parse arguments, handling empty string as no arguments
118
+ const args = value.trim() ? value.trim().split(/\s+/) : undefined;
119
+ updatedConfig.args = args;
120
+ }
121
+ else if (editMode === 'fallbackArgs') {
122
+ // Parse fallback arguments, handling empty string as no arguments
123
+ const fallbackArgs = value.trim() ? value.trim().split(/\s+/) : undefined;
124
+ updatedConfig.fallbackArgs = fallbackArgs;
125
+ }
126
+ // Update state only (don't save to file yet)
127
+ setConfig(updatedConfig);
128
+ // Check if there are changes
129
+ const hasChanges = JSON.stringify(updatedConfig) !== JSON.stringify(originalConfig);
130
+ setHasChanges(hasChanges);
131
+ // Return to menu
132
+ setEditMode('menu');
133
+ setInputValue('');
134
+ };
135
+ if (editMode !== 'menu') {
136
+ const titles = {
137
+ command: 'Enter command (e.g., claude):',
138
+ args: 'Enter command arguments (space-separated):',
139
+ fallbackArgs: 'Enter fallback arguments (space-separated):',
140
+ };
141
+ return (React.createElement(Box, { flexDirection: "column" },
142
+ React.createElement(Box, { marginBottom: 1 },
143
+ React.createElement(Text, { bold: true, color: "green" }, "Configure Command")),
144
+ React.createElement(Box, { marginBottom: 1 },
145
+ React.createElement(Text, null, titles[editMode])),
146
+ React.createElement(Box, null,
147
+ React.createElement(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: editMode === 'args' || editMode === 'fallbackArgs'
148
+ ? 'e.g., --resume or leave empty'
149
+ : '' })),
150
+ React.createElement(Box, { marginTop: 1 },
151
+ React.createElement(Text, { dimColor: true },
152
+ "Press Enter to save, ",
153
+ shortcutManager.getShortcutDisplay('cancel'),
154
+ ' ',
155
+ "to cancel"))));
156
+ }
157
+ return (React.createElement(Box, { flexDirection: "column" },
158
+ React.createElement(Box, { marginBottom: 1 },
159
+ React.createElement(Text, { bold: true, color: "green" }, "Configure Command")),
160
+ React.createElement(Box, { marginBottom: 1 },
161
+ React.createElement(Text, { dimColor: true }, "Configure the command and arguments for running code sessions")),
162
+ hasChanges && (React.createElement(Box, { marginBottom: 1 },
163
+ React.createElement(Text, { color: "yellow" }, "\u26A0\uFE0F You have unsaved changes"))),
164
+ React.createElement(Box, { flexDirection: "column" }, menuItems.map((item, index) => {
165
+ const isSelected = selectedIndex === index;
166
+ const isDisabled = item.disabled || false;
167
+ const color = isDisabled ? 'gray' : isSelected ? 'cyan' : undefined;
168
+ return (React.createElement(Box, { key: item.key, marginTop: item.isButton && index > 0 ? 1 : 0 },
169
+ React.createElement(Text, { color: color },
170
+ isSelected ? '> ' : ' ',
171
+ item.isButton ? (React.createElement(Text, { bold: isSelected && !isDisabled, dimColor: isDisabled }, item.label)) : (`${item.label}: ${item.value}`))));
172
+ })),
173
+ React.createElement(Box, { marginTop: 1 },
174
+ React.createElement(Text, { dimColor: true },
175
+ "Press \u2191\u2193 to navigate, Enter to edit,",
176
+ ' ',
177
+ shortcutManager.getShortcutDisplay('cancel'),
178
+ " to go back")),
179
+ React.createElement(Box, { marginTop: 1 },
180
+ React.createElement(Text, { dimColor: true }, "Note: If command fails with main args, fallback args will be tried"))));
181
+ };
182
+ export default ConfigureCommand;
@@ -1,4 +1,4 @@
1
- import { ConfigurationData, StatusHookConfig, ShortcutConfig, WorktreeConfig } from '../types/index.js';
1
+ import { ConfigurationData, StatusHookConfig, ShortcutConfig, WorktreeConfig, CommandConfig } from '../types/index.js';
2
2
  export declare class ConfigurationManager {
3
3
  private configPath;
4
4
  private legacyShortcutsPath;
@@ -15,5 +15,7 @@ export declare class ConfigurationManager {
15
15
  setConfiguration(config: ConfigurationData): void;
16
16
  getWorktreeConfig(): WorktreeConfig;
17
17
  setWorktreeConfig(worktreeConfig: WorktreeConfig): void;
18
+ getCommandConfig(): CommandConfig;
19
+ setCommandConfig(commandConfig: CommandConfig): void;
18
20
  }
19
21
  export declare const configurationManager: ConfigurationManager;
@@ -68,6 +68,11 @@ export class ConfigurationManager {
68
68
  autoDirectory: false,
69
69
  };
70
70
  }
71
+ if (!this.config.command) {
72
+ this.config.command = {
73
+ command: 'claude',
74
+ };
75
+ }
71
76
  }
72
77
  migrateLegacyShortcuts() {
73
78
  if (existsSync(this.legacyShortcutsPath)) {
@@ -125,5 +130,14 @@ export class ConfigurationManager {
125
130
  this.config.worktree = worktreeConfig;
126
131
  this.saveConfig();
127
132
  }
133
+ getCommandConfig() {
134
+ return (this.config.command || {
135
+ command: 'claude',
136
+ });
137
+ }
138
+ setCommandConfig(commandConfig) {
139
+ this.config.command = commandConfig;
140
+ this.saveConfig();
141
+ }
128
142
  }
129
143
  export const configurationManager = new ConfigurationManager();
@@ -6,11 +6,14 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
6
6
  sessions: Map<string, Session>;
7
7
  private waitingWithBottomBorder;
8
8
  private busyTimers;
9
- private stripAnsi;
9
+ private spawn;
10
10
  detectTerminalState(terminal: InstanceType<typeof Terminal>): SessionState;
11
11
  constructor();
12
- createSession(worktreePath: string): Session;
12
+ createSession(worktreePath: string): Promise<Session>;
13
+ private setupDataHandler;
14
+ private setupExitHandler;
13
15
  private setupBackgroundHandler;
16
+ private cleanupSession;
14
17
  getSession(worktreePath: string): Session | undefined;
15
18
  setSessionActive(worktreePath: string, active: boolean): void;
16
19
  destroySession(worktreePath: string): void;
@@ -6,19 +6,15 @@ import { configurationManager } from './configurationManager.js';
6
6
  import { WorktreeService } from './worktreeService.js';
7
7
  const { Terminal } = pkg;
8
8
  export class SessionManager extends EventEmitter {
9
- stripAnsi(str) {
10
- // Remove all ANSI escape sequences including cursor movement, color codes, etc.
11
- return str
12
- .replace(/\x1b\[[0-9;]*m/g, '') // Color codes (including 24-bit)
13
- .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
14
- .replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences
15
- .replace(/\x1b[PX^_].*?\x1b\\/g, '') // DCS/PM/APC/SOS sequences
16
- .replace(/\x1b\[\?[0-9;]*[hl]/g, '') // Private mode sequences
17
- .replace(/\x1b[>=]/g, '') // Other escape sequences
18
- .replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '') // Control characters except newline (\x0A)
19
- .replace(/\r/g, '') // Carriage returns
20
- .replace(/^[0-9;]+m/gm, '') // Orphaned color codes at line start
21
- .replace(/[0-9]+;[0-9]+;[0-9;]+m/g, ''); // Orphaned 24-bit color codes
9
+ async spawn(command, args, worktreePath) {
10
+ const spawnOptions = {
11
+ name: 'xterm-color',
12
+ cols: process.stdout.columns || 80,
13
+ rows: process.stdout.rows || 24,
14
+ cwd: worktreePath,
15
+ env: process.env,
16
+ };
17
+ return spawn(command, args, spawnOptions);
22
18
  }
23
19
  detectTerminalState(terminal) {
24
20
  // Get the last 30 lines from the terminal buffer
@@ -72,7 +68,7 @@ export class SessionManager extends EventEmitter {
72
68
  });
73
69
  this.sessions = new Map();
74
70
  }
75
- createSession(worktreePath) {
71
+ async createSession(worktreePath) {
76
72
  // Check if session already exists
77
73
  const existing = this.sessions.get(worktreePath);
78
74
  if (existing) {
@@ -81,17 +77,12 @@ export class SessionManager extends EventEmitter {
81
77
  const id = `session-${Date.now()}-${Math.random()
82
78
  .toString(36)
83
79
  .substr(2, 9)}`;
84
- // Parse Claude command arguments from environment variable
85
- const claudeArgs = process.env['CCMANAGER_CLAUDE_ARGS']
86
- ? process.env['CCMANAGER_CLAUDE_ARGS'].split(' ')
87
- : [];
88
- const ptyProcess = spawn('claude', claudeArgs, {
89
- name: 'xterm-color',
90
- cols: process.stdout.columns || 80,
91
- rows: process.stdout.rows || 24,
92
- cwd: worktreePath,
93
- env: process.env,
94
- });
80
+ // Get command configuration
81
+ const commandConfig = configurationManager.getCommandConfig();
82
+ const command = commandConfig.command || 'claude';
83
+ const args = commandConfig.args || [];
84
+ // Spawn the process with fallback support
85
+ const ptyProcess = await this.spawn(command, args, worktreePath);
95
86
  // Create virtual terminal for state detection
96
87
  const terminal = new Terminal({
97
88
  cols: process.stdout.columns || 80,
@@ -108,6 +99,8 @@ export class SessionManager extends EventEmitter {
108
99
  lastActivity: new Date(),
109
100
  isActive: false,
110
101
  terminal,
102
+ isPrimaryCommand: true,
103
+ commandConfig,
111
104
  };
112
105
  // Set up persistent background data handler for state detection
113
106
  this.setupBackgroundHandler(session);
@@ -115,7 +108,7 @@ export class SessionManager extends EventEmitter {
115
108
  this.emit('sessionCreated', session);
116
109
  return session;
117
110
  }
118
- setupBackgroundHandler(session) {
111
+ setupDataHandler(session) {
119
112
  // This handler always runs for all data
120
113
  session.process.onData((data) => {
121
114
  // Write data to virtual terminal
@@ -138,6 +131,37 @@ export class SessionManager extends EventEmitter {
138
131
  this.emit('sessionData', session, data);
139
132
  }
140
133
  });
134
+ }
135
+ setupExitHandler(session) {
136
+ session.process.onExit(async (e) => {
137
+ // Check if we should attempt fallback
138
+ if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
139
+ try {
140
+ // Spawn fallback process
141
+ const fallbackProcess = await this.spawn(session.commandConfig?.command || 'claude', session.commandConfig?.fallbackArgs || [], session.worktreePath);
142
+ // Replace the process
143
+ session.process = fallbackProcess;
144
+ session.isPrimaryCommand = false;
145
+ // Setup handlers for the new process (data and exit only)
146
+ this.setupDataHandler(session);
147
+ this.setupExitHandler(session);
148
+ // Emit event to notify process replacement
149
+ this.emit('sessionProcessReplaced', session);
150
+ }
151
+ catch (_error) {
152
+ // Fallback failed, proceed with cleanup
153
+ this.cleanupSession(session);
154
+ }
155
+ }
156
+ else {
157
+ // No fallback needed or possible, cleanup
158
+ this.cleanupSession(session);
159
+ }
160
+ });
161
+ }
162
+ setupBackgroundHandler(session) {
163
+ // Setup data handler
164
+ this.setupDataHandler(session);
141
165
  // Set up interval-based state detection
142
166
  session.stateCheckInterval = setInterval(() => {
143
167
  const oldState = session.state;
@@ -148,17 +172,19 @@ export class SessionManager extends EventEmitter {
148
172
  this.emit('sessionStateChanged', session);
149
173
  }
150
174
  }, 100); // Check every 100ms
151
- session.process.onExit(() => {
152
- // Clear the state check interval
153
- if (session.stateCheckInterval) {
154
- clearInterval(session.stateCheckInterval);
155
- }
156
- // Update state to idle before destroying
157
- session.state = 'idle';
158
- this.emit('sessionStateChanged', session);
159
- this.destroySession(session.worktreePath);
160
- this.emit('sessionExit', session);
161
- });
175
+ // Setup exit handler
176
+ this.setupExitHandler(session);
177
+ }
178
+ cleanupSession(session) {
179
+ // Clear the state check interval
180
+ if (session.stateCheckInterval) {
181
+ clearInterval(session.stateCheckInterval);
182
+ }
183
+ // Update state to idle before destroying
184
+ session.state = 'idle';
185
+ this.emit('sessionStateChanged', session);
186
+ this.destroySession(session.worktreePath);
187
+ this.emit('sessionExit', session);
162
188
  }
163
189
  getSession(worktreePath) {
164
190
  return this.sessions.get(worktreePath);
@@ -1,252 +1,260 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { SessionManager } from './sessionManager.js';
3
+ import { configurationManager } from './configurationManager.js';
4
+ import { spawn } from 'node-pty';
5
+ import { EventEmitter } from 'events';
6
+ // Mock node-pty
7
+ vi.mock('node-pty');
8
+ // Mock configuration manager
9
+ vi.mock('./configurationManager.js', () => ({
10
+ configurationManager: {
11
+ getCommandConfig: vi.fn(),
12
+ getStatusHooks: vi.fn(() => ({})),
13
+ },
14
+ }));
15
+ // Mock Terminal
16
+ vi.mock('@xterm/headless', () => ({
17
+ default: {
18
+ Terminal: vi.fn().mockImplementation(() => ({
19
+ buffer: {
20
+ active: {
21
+ length: 0,
22
+ getLine: vi.fn(),
23
+ },
24
+ },
25
+ write: vi.fn(),
26
+ })),
27
+ },
28
+ }));
29
+ // Create a mock IPty class
30
+ class MockPty extends EventEmitter {
31
+ constructor() {
32
+ super(...arguments);
33
+ Object.defineProperty(this, "kill", {
34
+ enumerable: true,
35
+ configurable: true,
36
+ writable: true,
37
+ value: vi.fn()
38
+ });
39
+ Object.defineProperty(this, "resize", {
40
+ enumerable: true,
41
+ configurable: true,
42
+ writable: true,
43
+ value: vi.fn()
44
+ });
45
+ Object.defineProperty(this, "write", {
46
+ enumerable: true,
47
+ configurable: true,
48
+ writable: true,
49
+ value: vi.fn()
50
+ });
51
+ Object.defineProperty(this, "onData", {
52
+ enumerable: true,
53
+ configurable: true,
54
+ writable: true,
55
+ value: vi.fn((callback) => {
56
+ this.on('data', callback);
57
+ })
58
+ });
59
+ Object.defineProperty(this, "onExit", {
60
+ enumerable: true,
61
+ configurable: true,
62
+ writable: true,
63
+ value: vi.fn((callback) => {
64
+ this.on('exit', callback);
65
+ })
66
+ });
67
+ }
68
+ }
3
69
  describe('SessionManager', () => {
4
70
  let sessionManager;
71
+ let mockPty;
5
72
  beforeEach(() => {
6
- sessionManager = new SessionManager();
7
73
  vi.clearAllMocks();
8
- });
9
- // TODO: Update tests for new xterm-based state detection
10
- it('should create session manager', () => {
11
- expect(sessionManager).toBeDefined();
12
- expect(sessionManager.sessions).toBeDefined();
13
- });
14
- });
15
- /*
16
- describe('SessionManager', () => {
17
- let sessionManager: SessionManager;
18
- const mockSessionId = 'test-session-123';
19
-
20
- beforeEach(() => {
21
74
  sessionManager = new SessionManager();
22
- vi.clearAllMocks();
75
+ mockPty = new MockPty();
23
76
  });
24
-
25
- describe.skip('detectSessionState', () => {
26
- it('should detect waiting_input state when "Do you want" prompt is present', () => {
27
- const cleanData = ' Do you want to continue?';
28
- const currentState: SessionState = 'idle';
29
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
30
-
31
- const newState = sessionManager.detectSessionState(
32
- cleanData,
33
- currentState,
34
- mockSessionId,
35
- );
36
-
37
- expect(newState).toBe('waiting_input');
77
+ afterEach(() => {
78
+ sessionManager.destroy();
79
+ });
80
+ describe('createSession with command configuration', () => {
81
+ it('should create session with default command when no args configured', async () => {
82
+ // Setup mock configuration
83
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
84
+ command: 'claude',
85
+ });
86
+ // Setup spawn mock
87
+ vi.mocked(spawn).mockReturnValue(mockPty);
88
+ // Create session
89
+ await sessionManager.createSession('/test/worktree');
90
+ // Verify spawn was called with correct arguments
91
+ expect(spawn).toHaveBeenCalledWith('claude', [], {
92
+ name: 'xterm-color',
93
+ cols: expect.any(Number),
94
+ rows: expect.any(Number),
95
+ cwd: '/test/worktree',
96
+ env: process.env,
97
+ });
98
+ // Session creation verified by spawn being called
38
99
  });
39
-
40
- it('should detect waiting_input state when "Would you like" prompt is present', () => {
41
- const cleanData = '│ Would you like to proceed?';
42
- const currentState: SessionState = 'idle';
43
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
44
-
45
- const newState = sessionManager.detectSessionState(
46
- cleanData,
47
- currentState,
48
- mockSessionId,
49
- );
50
-
51
- expect(newState).toBe('waiting_input');
100
+ it('should create session with configured arguments', async () => {
101
+ // Setup mock configuration with args
102
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
103
+ command: 'claude',
104
+ args: ['--resume', '--model', 'opus'],
105
+ });
106
+ // Setup spawn mock
107
+ vi.mocked(spawn).mockReturnValue(mockPty);
108
+ // Create session
109
+ await sessionManager.createSession('/test/worktree');
110
+ // Verify spawn was called with configured arguments
111
+ expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--model', 'opus'], expect.objectContaining({
112
+ cwd: '/test/worktree',
113
+ }));
114
+ // Session creation verified by spawn being called
52
115
  });
53
-
54
- it('should set waitingWithBottomBorder when waiting prompt and bottom border are both present', () => {
55
- const cleanData = '│ Do you want to continue?\n└───────────────────────┘';
56
- const currentState: SessionState = 'idle';
57
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
58
-
59
- const newState = sessionManager.detectSessionState(
60
- cleanData,
61
- currentState,
62
- mockSessionId,
63
- );
64
-
65
- expect(newState).toBe('waiting_input');
66
- // The internal map should have been set to true
116
+ it('should use fallback args when main command exits with code 1', async () => {
117
+ // Setup mock configuration with fallback
118
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
119
+ command: 'claude',
120
+ args: ['--invalid-flag'],
121
+ fallbackArgs: ['--resume'],
122
+ });
123
+ // First spawn attempt - will exit with code 1
124
+ const firstMockPty = new MockPty();
125
+ // Second spawn attempt - succeeds
126
+ const secondMockPty = new MockPty();
127
+ vi.mocked(spawn)
128
+ .mockReturnValueOnce(firstMockPty)
129
+ .mockReturnValueOnce(secondMockPty);
130
+ // Create session
131
+ const session = await sessionManager.createSession('/test/worktree');
132
+ // Verify initial spawn
133
+ expect(spawn).toHaveBeenCalledTimes(1);
134
+ expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
135
+ // Simulate exit with code 1 on first attempt
136
+ firstMockPty.emit('exit', { exitCode: 1 });
137
+ // Wait for fallback to occur
138
+ await new Promise(resolve => setTimeout(resolve, 50));
139
+ // Verify fallback spawn was called
140
+ expect(spawn).toHaveBeenCalledTimes(2);
141
+ expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
142
+ // Verify session process was replaced
143
+ expect(session.process).toBe(secondMockPty);
144
+ expect(session.isPrimaryCommand).toBe(false);
67
145
  });
68
-
69
- it('should maintain waiting_input state when bottom border appears after waiting prompt', () => {
70
- const cleanData = '└───────────────────────┘';
71
- const currentState: SessionState = 'waiting_input';
72
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
73
-
74
- // First call to set up the waiting state without bottom border
75
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
76
- sessionManager.detectSessionState(
77
- '│ Do you want to continue?',
78
- 'idle',
79
- mockSessionId,
80
- );
81
-
82
- // Now test the bottom border appearing
83
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
84
- const newState = sessionManager.detectSessionState(
85
- cleanData,
86
- currentState,
87
- mockSessionId,
88
- );
89
-
90
- expect(newState).toBe('waiting_input');
146
+ it('should throw error when spawn fails and no fallback configured', async () => {
147
+ // Setup mock configuration without fallback
148
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
149
+ command: 'claude',
150
+ args: ['--invalid-flag'],
151
+ });
152
+ // Mock spawn to throw error
153
+ vi.mocked(spawn).mockImplementation(() => {
154
+ throw new Error('spawn failed');
155
+ });
156
+ // Expect createSession to throw
157
+ await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('spawn failed');
91
158
  });
92
-
93
- it('should detect busy state when "esc to interrupt" is present', () => {
94
- const cleanData = 'Processing... Press ESC to interrupt';
95
- const currentState: SessionState = 'idle';
96
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
97
-
98
- const newState = sessionManager.detectSessionState(
99
- cleanData,
100
- currentState,
101
- mockSessionId,
102
- );
103
-
104
- expect(newState).toBe('busy');
159
+ it('should handle custom command configuration', async () => {
160
+ // Setup mock configuration with custom command
161
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
162
+ command: 'my-custom-claude',
163
+ args: ['--config', '/path/to/config'],
164
+ });
165
+ // Setup spawn mock
166
+ vi.mocked(spawn).mockReturnValue(mockPty);
167
+ // Create session
168
+ await sessionManager.createSession('/test/worktree');
169
+ // Verify spawn was called with custom command
170
+ expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
171
+ cwd: '/test/worktree',
172
+ }));
173
+ // Session creation verified by spawn being called
105
174
  });
106
-
107
- it('should maintain busy state when transitioning from busy without "esc to interrupt"', () => {
108
- const cleanData = 'Some regular output text';
109
- const currentState: SessionState = 'busy';
110
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
111
-
112
- const newState = sessionManager.detectSessionState(
113
- cleanData,
114
- currentState,
115
- mockSessionId,
116
- );
117
-
118
- // With the new logic, it should remain busy and start a timer
119
- expect(newState).toBe('busy');
175
+ it('should not use fallback if main command succeeds', async () => {
176
+ // Setup mock configuration with fallback
177
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
178
+ command: 'claude',
179
+ args: ['--resume'],
180
+ fallbackArgs: ['--other-flag'],
181
+ });
182
+ // Setup spawn mock - process doesn't exit early
183
+ vi.mocked(spawn).mockReturnValue(mockPty);
184
+ // Create session
185
+ await sessionManager.createSession('/test/worktree');
186
+ // Wait a bit to ensure no early exit
187
+ await new Promise(resolve => setTimeout(resolve, 600));
188
+ // Verify only one spawn attempt
189
+ expect(spawn).toHaveBeenCalledTimes(1);
190
+ expect(spawn).toHaveBeenCalledWith('claude', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
191
+ // Session creation verified by spawn being called
120
192
  });
121
-
122
- it('should handle case-insensitive "esc to interrupt" detection', () => {
123
- const cleanData = 'Running task... PRESS ESC TO INTERRUPT';
124
- const currentState: SessionState = 'idle';
125
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
126
-
127
- const newState = sessionManager.detectSessionState(
128
- cleanData,
129
- currentState,
130
- mockSessionId,
131
- );
132
-
133
- expect(newState).toBe('busy');
193
+ it('should return existing session if already created', async () => {
194
+ // Setup mock configuration
195
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
196
+ command: 'claude',
197
+ });
198
+ // Setup spawn mock
199
+ vi.mocked(spawn).mockReturnValue(mockPty);
200
+ // Create session twice
201
+ const session1 = await sessionManager.createSession('/test/worktree');
202
+ const session2 = await sessionManager.createSession('/test/worktree');
203
+ // Should return the same session
204
+ expect(session1).toBe(session2);
205
+ // Spawn should only be called once
206
+ expect(spawn).toHaveBeenCalledTimes(1);
134
207
  });
135
-
136
- it('should clear waitingWithBottomBorder flag when transitioning to busy', () => {
137
- const cleanData = 'Processing... Press ESC to interrupt';
138
- const currentState: SessionState = 'waiting_input';
139
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
140
-
141
- // First set up waiting state with bottom border
142
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(true);
143
- sessionManager.detectSessionState(
144
- '│ Do you want to continue?\n└───────────────────────┘',
145
- 'idle',
146
- mockSessionId,
147
- );
148
-
149
- // Now transition to busy
150
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
151
- const newState = sessionManager.detectSessionState(
152
- cleanData,
153
- currentState,
154
- mockSessionId,
155
- );
156
-
157
- expect(newState).toBe('busy');
208
+ it('should throw error when spawn fails with fallback args', async () => {
209
+ // Setup mock configuration with fallback
210
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
211
+ command: 'nonexistent-command',
212
+ args: ['--flag1'],
213
+ fallbackArgs: ['--flag2'],
214
+ });
215
+ // Mock spawn to always throw error
216
+ vi.mocked(spawn).mockImplementation(() => {
217
+ throw new Error('Command not found');
218
+ });
219
+ // Expect createSession to throw the original error
220
+ await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('Command not found');
158
221
  });
159
-
160
- it('should transition from busy to idle after 500ms timer when no "esc to interrupt"', async () => {
161
- // Create a mock session for the timer test
162
- const mockWorktreePath = '/test/worktree';
163
- const mockSession = {
164
- id: mockSessionId,
165
- worktreePath: mockWorktreePath,
166
- state: 'busy' as SessionState,
167
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
168
- process: {} as any,
169
- output: [],
170
- outputHistory: [],
171
- lastActivity: new Date(),
172
- isActive: false,
173
- };
174
-
175
- // Add the session to the manager
176
- sessionManager.sessions.set(mockWorktreePath, mockSession);
177
-
178
- // Mock the EventEmitter emit method
179
- const emitSpy = vi.spyOn(sessionManager, 'emit');
180
-
181
- // First call with no esc to interrupt should maintain busy state
182
- const cleanData = 'Some regular output text';
183
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
184
-
185
- const newState = sessionManager.detectSessionState(
186
- cleanData,
187
- 'busy',
188
- mockWorktreePath,
189
- );
190
-
191
- expect(newState).toBe('busy');
192
-
193
- // Wait for timer to fire (500ms + buffer)
194
- await new Promise(resolve => setTimeout(resolve, 600));
195
-
196
- // Check that the session state was changed to idle
197
- expect(mockSession.state).toBe('idle');
198
- expect(emitSpy).toHaveBeenCalledWith('sessionStateChanged', mockSession);
222
+ });
223
+ describe('session lifecycle', () => {
224
+ it('should destroy session and clean up resources', async () => {
225
+ // Setup
226
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
227
+ command: 'claude',
228
+ });
229
+ vi.mocked(spawn).mockReturnValue(mockPty);
230
+ // Create and destroy session
231
+ await sessionManager.createSession('/test/worktree');
232
+ sessionManager.destroySession('/test/worktree');
233
+ // Verify cleanup
234
+ expect(mockPty.kill).toHaveBeenCalled();
235
+ expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
199
236
  });
200
-
201
- it('should cancel timer when "esc to interrupt" appears again', async () => {
202
- // Create a mock session for the timer test
203
- const mockWorktreePath = '/test/worktree';
204
- const mockSession = {
205
- id: mockSessionId,
206
- worktreePath: mockWorktreePath,
207
- state: 'busy' as SessionState,
208
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
- process: {} as any,
210
- output: [],
211
- outputHistory: [],
212
- lastActivity: new Date(),
213
- isActive: false,
214
- };
215
-
216
- // Add the session to the manager
217
- sessionManager.sessions.set(mockWorktreePath, mockSession);
218
-
219
- // First call with no esc to interrupt should maintain busy state and start timer
220
- const cleanData1 = 'Some regular output text';
221
- vi.mocked(includesPromptBoxBottomBorder).mockReturnValue(false);
222
-
223
- const newState1 = sessionManager.detectSessionState(
224
- cleanData1,
225
- 'busy',
226
- mockWorktreePath,
227
- );
228
-
229
- expect(newState1).toBe('busy');
230
-
231
- // Wait 200ms (less than timer duration)
232
- await new Promise(resolve => setTimeout(resolve, 200));
233
-
234
- // Second call with esc to interrupt should cancel timer and keep busy
235
- const cleanData2 = 'Running... Press ESC to interrupt';
236
- const newState2 = sessionManager.detectSessionState(
237
- cleanData2,
238
- 'busy',
239
- mockWorktreePath,
240
- );
241
-
242
- expect(newState2).toBe('busy');
243
-
244
- // Wait another 400ms (total 600ms, more than timer duration)
245
- await new Promise(resolve => setTimeout(resolve, 400));
246
-
247
- // State should still be busy because timer was cancelled
248
- expect(mockSession.state).toBe('busy');
237
+ it('should handle session exit event', async () => {
238
+ // Setup
239
+ vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
240
+ command: 'claude',
241
+ });
242
+ vi.mocked(spawn).mockReturnValue(mockPty);
243
+ // Track session exit event
244
+ let exitedSession = null;
245
+ sessionManager.on('sessionExit', (session) => {
246
+ exitedSession = session;
247
+ });
248
+ // Create session
249
+ const createdSession = await sessionManager.createSession('/test/worktree');
250
+ // Simulate process exit after successful creation
251
+ setTimeout(() => {
252
+ mockPty.emit('exit', { exitCode: 0 });
253
+ }, 600); // After early exit timeout
254
+ // Wait for exit event
255
+ await new Promise(resolve => setTimeout(resolve, 700));
256
+ expect(exitedSession).toBe(createdSession);
257
+ expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
249
258
  });
250
259
  });
251
260
  });
252
- */
@@ -19,10 +19,12 @@ export interface Session {
19
19
  isActive: boolean;
20
20
  terminal: Terminal;
21
21
  stateCheckInterval?: NodeJS.Timeout;
22
+ isPrimaryCommand?: boolean;
23
+ commandConfig?: CommandConfig;
22
24
  }
23
25
  export interface SessionManager {
24
26
  sessions: Map<string, Session>;
25
- createSession(worktreePath: string): Session;
27
+ createSession(worktreePath: string): Promise<Session>;
26
28
  getSession(worktreePath: string): Session | undefined;
27
29
  destroySession(worktreePath: string): void;
28
30
  getAllSessions(): Session[];
@@ -51,8 +53,14 @@ export interface WorktreeConfig {
51
53
  autoDirectory: boolean;
52
54
  autoDirectoryPattern?: string;
53
55
  }
56
+ export interface CommandConfig {
57
+ command: string;
58
+ args?: string[];
59
+ fallbackArgs?: string[];
60
+ }
54
61
  export interface ConfigurationData {
55
62
  shortcuts?: ShortcutConfig;
56
63
  statusHooks?: StatusHookConfig;
57
64
  worktree?: WorktreeConfig;
65
+ command?: CommandConfig;
58
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
package/dist/app.d.ts DELETED
@@ -1,6 +0,0 @@
1
- import React from 'react';
2
- interface AppProps {
3
- onReturnToMenu?: () => void;
4
- }
5
- declare const App: React.FC<AppProps>;
6
- export default App;
package/dist/app.js DELETED
@@ -1,57 +0,0 @@
1
- import React, { useEffect, useState } from 'react';
2
- import { Box, Text, useInput, useApp, useStdout } from 'ink';
3
- import { spawn } from 'node-pty';
4
- const App = ({ onReturnToMenu }) => {
5
- const { exit } = useApp();
6
- const { stdout } = useStdout();
7
- const [pty, setPty] = useState(null);
8
- const [showMenu, setShowMenu] = useState(false);
9
- useEffect(() => {
10
- const ptyProcess = spawn('claude', [], {
11
- name: 'xterm-color',
12
- cols: process.stdout.columns || 80,
13
- rows: process.stdout.rows || 24,
14
- cwd: process.cwd(),
15
- env: process.env,
16
- });
17
- ptyProcess.onData((data) => {
18
- if (stdout) {
19
- stdout.write(data);
20
- }
21
- });
22
- ptyProcess.onExit(() => {
23
- exit();
24
- });
25
- setPty(ptyProcess);
26
- if (stdout) {
27
- stdout.on('resize', () => {
28
- ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
29
- });
30
- }
31
- return () => {
32
- ptyProcess.kill();
33
- };
34
- }, [exit, stdout]);
35
- useInput((char, key) => {
36
- if (!pty)
37
- return;
38
- if (key.ctrl && char === 'e') {
39
- if (onReturnToMenu) {
40
- onReturnToMenu();
41
- }
42
- else {
43
- setShowMenu(true);
44
- }
45
- return;
46
- }
47
- // if (char) {
48
- // pty.write(char);
49
- // }
50
- });
51
- if (showMenu) {
52
- return (React.createElement(Box, { flexDirection: "column" },
53
- React.createElement(Text, { color: "green" }, "Press Ctrl+E to return to menu")));
54
- }
55
- return null;
56
- };
57
- export default App;