ccmanager 1.2.0 → 1.3.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 CHANGED
@@ -19,6 +19,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
19
19
  - Command presets with automatic fallback support
20
20
  - Configurable state detection strategies for different CLI tools
21
21
  - Status change hooks for automation and notifications
22
+ - Devcontainer integration
22
23
 
23
24
  ## Why CCManager over Claude Squad?
24
25
 
@@ -177,6 +178,35 @@ CCManager can automatically generate worktree directory paths based on branch na
177
178
 
178
179
  For detailed configuration and examples, see [docs/worktree-auto-directory.md](docs/worktree-auto-directory.md).
179
180
 
181
+ ## Devcontainer Integration
182
+
183
+ CCManager supports running AI assistant sessions inside devcontainers while keeping the manager itself on the host machine. This enables sandboxed development environments with restricted network access while maintaining host-level notifications and automation.
184
+
185
+ ### Features
186
+
187
+ - **Host-based management**: CCManager runs on your host machine, managing sessions inside containers
188
+ - **Seamless integration**: All existing features (presets, status hooks, etc.) work with devcontainers
189
+ - **Security-focused**: Compatible with Anthropic's recommended devcontainer configurations
190
+ - **Persistent state**: Configuration and history persist across container recreations
191
+
192
+ ### Usage
193
+
194
+ ```bash
195
+ # Start CCManager with devcontainer support
196
+ npx ccmanager --devc-up-command "<your devcontainer up command>" \
197
+ --devc-exec-command "<your devcontainer exec command>"
198
+ ```
199
+
200
+ The devcontainer integration requires both commands:
201
+ - `--devc-up-command`: Any command to start the devcontainer
202
+ - `--devc-exec-command`: Any command to execute inside the container
203
+
204
+ ### Benefits
205
+
206
+ - **Safe experimentation**: Run commands like `claude --dangerously-skip-permissions` without risk
207
+
208
+ For detailed setup and configuration, see [docs/devcontainer.md](docs/devcontainer.md).
209
+
180
210
  ## Git Worktree Configuration
181
211
 
182
212
  CCManager can display enhanced git status information for each worktree when Git's worktree configuration extension is enabled.
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,9 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export declare const parsedArgs: import("meow").Result<{
3
+ devcUpCommand: {
4
+ type: "string";
5
+ };
6
+ devcExecCommand: {
7
+ type: "string";
8
+ };
9
+ }>;
package/dist/cli.js CHANGED
@@ -4,19 +4,35 @@ import { render } from 'ink';
4
4
  import meow from 'meow';
5
5
  import App from './components/App.js';
6
6
  import { worktreeConfigManager } from './services/worktreeConfigManager.js';
7
- meow(`
7
+ const cli = meow(`
8
8
  Usage
9
9
  $ ccmanager
10
10
 
11
11
  Options
12
- --help Show help
13
- --version Show version
12
+ --help Show help
13
+ --version Show version
14
+ --devc-up-command Command to start devcontainer
15
+ --devc-exec-command Command to execute in devcontainer
14
16
 
15
17
  Examples
16
18
  $ ccmanager
19
+ $ ccmanager --devc-up-command "devcontainer up --workspace-folder ." --devc-exec-command "devcontainer exec --workspace-folder ."
17
20
  `, {
18
21
  importMeta: import.meta,
22
+ flags: {
23
+ devcUpCommand: {
24
+ type: 'string',
25
+ },
26
+ devcExecCommand: {
27
+ type: 'string',
28
+ },
29
+ },
19
30
  });
31
+ // Validate devcontainer arguments using XOR
32
+ if (!!cli.flags.devcUpCommand !== !!cli.flags.devcExecCommand) {
33
+ console.error('Error: Both --devc-up-command and --devc-exec-command must be provided together');
34
+ process.exit(1);
35
+ }
20
36
  // Check if we're in a TTY environment
21
37
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
22
38
  console.error('Error: ccmanager must be run in an interactive terminal (TTY)');
@@ -24,4 +40,15 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
24
40
  }
25
41
  // Initialize worktree config manager
26
42
  worktreeConfigManager.initialize();
27
- render(React.createElement(App, null));
43
+ // Prepare devcontainer config
44
+ const devcontainerConfig = cli.flags.devcUpCommand && cli.flags.devcExecCommand
45
+ ? {
46
+ upCommand: cli.flags.devcUpCommand,
47
+ execCommand: cli.flags.devcExecCommand,
48
+ }
49
+ : undefined;
50
+ // Pass config to App
51
+ const appProps = devcontainerConfig ? { devcontainerConfig } : {};
52
+ render(React.createElement(App, { ...appProps }));
53
+ // Export for testing
54
+ export const parsedArgs = cli;
@@ -1,3 +1,7 @@
1
1
  import React from 'react';
2
- declare const App: React.FC;
2
+ import { DevcontainerConfig } from '../types/index.js';
3
+ interface AppProps {
4
+ devcontainerConfig?: DevcontainerConfig;
5
+ }
6
+ declare const App: React.FC<AppProps>;
3
7
  export default App;
@@ -11,7 +11,7 @@ import { SessionManager } from '../services/sessionManager.js';
11
11
  import { WorktreeService } from '../services/worktreeService.js';
12
12
  import { shortcutManager } from '../services/shortcutManager.js';
13
13
  import { configurationManager } from '../services/configurationManager.js';
14
- const App = () => {
14
+ const App = ({ devcontainerConfig }) => {
15
15
  const { exit } = useApp();
16
16
  const [view, setView] = useState('menu');
17
17
  const [sessionManager] = useState(() => new SessionManager());
@@ -87,7 +87,12 @@ const App = () => {
87
87
  }
88
88
  try {
89
89
  // Use preset-based session creation with default preset
90
- session = await sessionManager.createSessionWithPreset(worktree.path);
90
+ if (devcontainerConfig) {
91
+ session = await sessionManager.createSessionWithDevcontainer(worktree.path, devcontainerConfig);
92
+ }
93
+ else {
94
+ session = await sessionManager.createSessionWithPreset(worktree.path);
95
+ }
91
96
  }
92
97
  catch (error) {
93
98
  setError(`Failed to create session: ${error}`);
@@ -102,7 +107,13 @@ const App = () => {
102
107
  return;
103
108
  try {
104
109
  // Create session with selected preset
105
- const session = await sessionManager.createSessionWithPreset(selectedWorktree.path, presetId);
110
+ let session;
111
+ if (devcontainerConfig) {
112
+ session = await sessionManager.createSessionWithDevcontainer(selectedWorktree.path, devcontainerConfig, presetId);
113
+ }
114
+ else {
115
+ session = await sessionManager.createSessionWithPreset(selectedWorktree.path, presetId);
116
+ }
106
117
  setActiveSession(session);
107
118
  setView('session');
108
119
  setSelectedWorktree(null);
@@ -120,7 +131,7 @@ const App = () => {
120
131
  };
121
132
  const handleReturnToMenu = () => {
122
133
  setActiveSession(null);
123
- setError(null);
134
+ // Don't clear error here - let user dismiss it manually
124
135
  // Add a small delay to ensure Session cleanup completes
125
136
  setTimeout(() => {
126
137
  setView('menu');
@@ -210,7 +221,7 @@ const App = () => {
210
221
  handleReturnToMenu();
211
222
  };
212
223
  if (view === 'menu') {
213
- return (React.createElement(Menu, { key: menuKey, sessionManager: sessionManager, onSelectWorktree: handleSelectWorktree }));
224
+ return (React.createElement(Menu, { key: menuKey, sessionManager: sessionManager, onSelectWorktree: handleSelectWorktree, error: error, onDismissError: () => setError(null) }));
214
225
  }
215
226
  if (view === 'session' && activeSession) {
216
227
  return (React.createElement(Box, { flexDirection: "column" },
@@ -4,6 +4,8 @@ import { SessionManager } from '../services/sessionManager.js';
4
4
  interface MenuProps {
5
5
  sessionManager: SessionManager;
6
6
  onSelectWorktree: (worktree: Worktree) => void;
7
+ error?: string | null;
8
+ onDismissError?: () => void;
7
9
  }
8
10
  declare const Menu: React.FC<MenuProps>;
9
11
  export default Menu;
@@ -5,7 +5,7 @@ import { WorktreeService } from '../services/worktreeService.js';
5
5
  import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIcons.js';
6
6
  import { useGitStatus } from '../hooks/useGitStatus.js';
7
7
  import { prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
8
- const Menu = ({ sessionManager, onSelectWorktree }) => {
8
+ const Menu = ({ sessionManager, onSelectWorktree, error, onDismissError, }) => {
9
9
  const [baseWorktrees, setBaseWorktrees] = useState([]);
10
10
  const [defaultBranch, setDefaultBranch] = useState(null);
11
11
  const worktrees = useGitStatus(baseWorktrees, defaultBranch);
@@ -82,6 +82,11 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
82
82
  }, [worktrees, sessions, defaultBranch]);
83
83
  // Handle hotkeys
84
84
  useInput((input, _key) => {
85
+ // Dismiss error on any key press when error is shown
86
+ if (error && onDismissError) {
87
+ onDismissError();
88
+ return;
89
+ }
85
90
  const keyPressed = input.toLowerCase();
86
91
  // Handle number keys 0-9 for worktree selection (first 10 only)
87
92
  if (/^[0-9]$/.test(keyPressed)) {
@@ -198,7 +203,13 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
198
203
  React.createElement(Text, { bold: true, color: "green" }, "CCManager - Claude Code Worktree Manager")),
199
204
  React.createElement(Box, { marginBottom: 1 },
200
205
  React.createElement(Text, { dimColor: true }, "Select a worktree to start or resume a Claude Code session:")),
201
- React.createElement(SelectInput, { items: items, onSelect: handleSelect, isFocused: true }),
206
+ React.createElement(SelectInput, { items: items, onSelect: handleSelect, isFocused: !error }),
207
+ error && (React.createElement(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red" },
208
+ React.createElement(Box, { flexDirection: "column" },
209
+ React.createElement(Text, { color: "red", bold: true },
210
+ "Error: ",
211
+ error),
212
+ React.createElement(Text, { color: "gray", dimColor: true }, "Press any key to dismiss")))),
202
213
  React.createElement(Box, { marginTop: 1, flexDirection: "column" },
203
214
  React.createElement(Text, { dimColor: true },
204
215
  "Status: ",
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { SessionManager } from '../services/sessionManager.js';
3
+ import { spawn } from 'node-pty';
4
+ import { exec } from 'child_process';
5
+ // Mock modules
6
+ vi.mock('node-pty');
7
+ vi.mock('child_process');
8
+ vi.mock('util', () => ({
9
+ promisify: vi.fn((fn) => {
10
+ return (cmd, options) => {
11
+ return new Promise((resolve, reject) => {
12
+ const callback = (err, stdout, stderr) => {
13
+ if (err) {
14
+ reject(err);
15
+ }
16
+ else {
17
+ resolve({ stdout, stderr });
18
+ }
19
+ };
20
+ // Call the original function with the promisified callback
21
+ fn(cmd, options, callback);
22
+ });
23
+ };
24
+ }),
25
+ }));
26
+ vi.mock('../services/configurationManager.js', () => ({
27
+ configurationManager: {
28
+ getDefaultPreset: vi.fn(() => ({
29
+ id: 'claude',
30
+ name: 'Claude',
31
+ command: 'claude',
32
+ args: [],
33
+ })),
34
+ getPresetById: vi.fn(),
35
+ },
36
+ }));
37
+ vi.mock('../services/worktreeService.js', () => ({
38
+ WorktreeService: vi.fn(),
39
+ }));
40
+ const mockSpawn = vi.mocked(spawn);
41
+ const mockExec = vi.mocked(exec);
42
+ describe('Devcontainer Integration', () => {
43
+ let sessionManager;
44
+ let mockPty;
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ sessionManager = new SessionManager();
48
+ // Mock PTY process
49
+ mockPty = {
50
+ onData: vi.fn(),
51
+ onExit: vi.fn(),
52
+ write: vi.fn(),
53
+ resize: vi.fn(),
54
+ kill: vi.fn(),
55
+ pid: 12345,
56
+ cols: 80,
57
+ rows: 24,
58
+ process: 'claude',
59
+ };
60
+ mockSpawn.mockReturnValue(mockPty);
61
+ });
62
+ it('should execute devcontainer up command before creating session', async () => {
63
+ const devcontainerConfig = {
64
+ upCommand: 'devcontainer up --workspace-folder .',
65
+ execCommand: 'devcontainer exec --workspace-folder .',
66
+ };
67
+ mockExec.mockImplementation((cmd, options, callback) => {
68
+ if (typeof options === 'function') {
69
+ callback = options;
70
+ options = undefined;
71
+ }
72
+ if (callback && typeof callback === 'function') {
73
+ callback(null, 'Container started', '');
74
+ }
75
+ return {};
76
+ });
77
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
78
+ expect(mockExec).toHaveBeenCalledWith(devcontainerConfig.upCommand, { cwd: '/test/worktree' }, expect.any(Function));
79
+ });
80
+ it('should handle devcontainer command execution with presets', async () => {
81
+ const devcontainerConfig = {
82
+ upCommand: 'devcontainer up --workspace-folder .',
83
+ execCommand: 'devcontainer exec --workspace-folder .',
84
+ };
85
+ mockExec.mockImplementation((cmd, options, callback) => {
86
+ if (typeof options === 'function') {
87
+ callback = options;
88
+ options = undefined;
89
+ }
90
+ if (callback && typeof callback === 'function') {
91
+ callback(null, 'Container started', '');
92
+ }
93
+ return {};
94
+ });
95
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
96
+ // Should spawn with devcontainer exec command
97
+ expect(mockSpawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({
98
+ cwd: '/test/worktree',
99
+ }));
100
+ });
101
+ });
@@ -1,4 +1,4 @@
1
- import { Session, SessionManager as ISessionManager, SessionState } from '../types/index.js';
1
+ import { Session, SessionManager as ISessionManager, SessionState, DevcontainerConfig } from '../types/index.js';
2
2
  import { EventEmitter } from 'events';
3
3
  export declare class SessionManager extends EventEmitter implements ISessionManager {
4
4
  sessions: Map<string, Session>;
@@ -7,7 +7,9 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
7
7
  private spawn;
8
8
  detectTerminalState(session: Session): SessionState;
9
9
  constructor();
10
- createSession(worktreePath: string): Promise<Session>;
10
+ private createSessionId;
11
+ private createTerminal;
12
+ private createSessionInternal;
11
13
  createSessionWithPreset(worktreePath: string, presetId?: string): Promise<Session>;
12
14
  private setupDataHandler;
13
15
  private setupExitHandler;
@@ -18,5 +20,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
18
20
  destroySession(worktreePath: string): void;
19
21
  getAllSessions(): Session[];
20
22
  private executeStatusHook;
23
+ createSessionWithDevcontainer(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Promise<Session>;
21
24
  destroy(): void;
22
25
  }
@@ -2,10 +2,12 @@ import { spawn } from 'node-pty';
2
2
  import { EventEmitter } from 'events';
3
3
  import pkg from '@xterm/headless';
4
4
  import { exec } from 'child_process';
5
+ import { promisify } from 'util';
5
6
  import { configurationManager } from './configurationManager.js';
6
7
  import { WorktreeService } from './worktreeService.js';
7
8
  import { createStateDetector } from './stateDetector.js';
8
9
  const { Terminal } = pkg;
10
+ const execAsync = promisify(exec);
9
11
  export class SessionManager extends EventEmitter {
10
12
  async spawn(command, args, worktreePath) {
11
13
  const spawnOptions = {
@@ -45,27 +47,19 @@ export class SessionManager extends EventEmitter {
45
47
  });
46
48
  this.sessions = new Map();
47
49
  }
48
- async createSession(worktreePath) {
49
- // Check if session already exists
50
- const existing = this.sessions.get(worktreePath);
51
- if (existing) {
52
- return existing;
53
- }
54
- const id = `session-${Date.now()}-${Math.random()
55
- .toString(36)
56
- .substr(2, 9)}`;
57
- // Get command configuration
58
- const commandConfig = configurationManager.getCommandConfig();
59
- const command = commandConfig.command || 'claude';
60
- const args = commandConfig.args || [];
61
- // Spawn the process with fallback support
62
- const ptyProcess = await this.spawn(command, args, worktreePath);
63
- // Create virtual terminal for state detection
64
- const terminal = new Terminal({
50
+ createSessionId() {
51
+ return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
52
+ }
53
+ createTerminal() {
54
+ return new Terminal({
65
55
  cols: process.stdout.columns || 80,
66
56
  rows: process.stdout.rows || 24,
67
57
  allowProposedApi: true,
68
58
  });
59
+ }
60
+ async createSessionInternal(worktreePath, ptyProcess, commandConfig, options = {}) {
61
+ const id = this.createSessionId();
62
+ const terminal = this.createTerminal();
69
63
  const session = {
70
64
  id,
71
65
  worktreePath,
@@ -76,9 +70,10 @@ export class SessionManager extends EventEmitter {
76
70
  lastActivity: new Date(),
77
71
  isActive: false,
78
72
  terminal,
79
- isPrimaryCommand: true,
73
+ isPrimaryCommand: options.isPrimaryCommand ?? true,
80
74
  commandConfig,
81
- detectionStrategy: 'claude', // Default to claude for legacy method
75
+ detectionStrategy: options.detectionStrategy ?? 'claude',
76
+ devcontainerConfig: options.devcontainerConfig,
82
77
  };
83
78
  // Set up persistent background data handler for state detection
84
79
  this.setupBackgroundHandler(session);
@@ -92,9 +87,6 @@ export class SessionManager extends EventEmitter {
92
87
  if (existing) {
93
88
  return existing;
94
89
  }
95
- const id = `session-${Date.now()}-${Math.random()
96
- .toString(36)
97
- .substr(2, 9)}`;
98
90
  // Get preset configuration
99
91
  let preset = presetId ? configurationManager.getPresetById(presetId) : null;
100
92
  if (!preset) {
@@ -130,31 +122,10 @@ export class SessionManager extends EventEmitter {
130
122
  throw error;
131
123
  }
132
124
  }
133
- // Create virtual terminal for state detection
134
- const terminal = new Terminal({
135
- cols: process.stdout.columns || 80,
136
- rows: process.stdout.rows || 24,
137
- allowProposedApi: true,
138
- });
139
- const session = {
140
- id,
141
- worktreePath,
142
- process: ptyProcess,
143
- state: 'busy', // Session starts as busy when created
144
- output: [],
145
- outputHistory: [],
146
- lastActivity: new Date(),
147
- isActive: false,
148
- terminal,
125
+ return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
149
126
  isPrimaryCommand,
150
- commandConfig,
151
- detectionStrategy: preset.detectionStrategy || 'claude',
152
- };
153
- // Set up persistent background data handler for state detection
154
- this.setupBackgroundHandler(session);
155
- this.sessions.set(worktreePath, session);
156
- this.emit('sessionCreated', session);
157
- return session;
127
+ detectionStrategy: preset.detectionStrategy,
128
+ });
158
129
  }
159
130
  setupDataHandler(session) {
160
131
  // This handler always runs for all data
@@ -304,6 +275,48 @@ export class SessionManager extends EventEmitter {
304
275
  });
305
276
  }
306
277
  }
278
+ async createSessionWithDevcontainer(worktreePath, devcontainerConfig, presetId) {
279
+ // Check if session already exists
280
+ const existing = this.sessions.get(worktreePath);
281
+ if (existing) {
282
+ return existing;
283
+ }
284
+ // Execute devcontainer up command first
285
+ try {
286
+ await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
287
+ }
288
+ catch (error) {
289
+ throw new Error(`Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`);
290
+ }
291
+ // Get preset configuration
292
+ let preset = presetId ? configurationManager.getPresetById(presetId) : null;
293
+ if (!preset) {
294
+ preset = configurationManager.getDefaultPreset();
295
+ }
296
+ // Parse the exec command to extract arguments
297
+ const execParts = devcontainerConfig.execCommand.split(/\s+/);
298
+ const devcontainerCmd = execParts[0] || 'devcontainer'; // Should be 'devcontainer'
299
+ const execArgs = execParts.slice(1); // Rest of the exec command args
300
+ // Build the full command: devcontainer exec [args] -- [preset command] [preset args]
301
+ const fullArgs = [
302
+ ...execArgs,
303
+ '--',
304
+ preset.command,
305
+ ...(preset.args || []),
306
+ ];
307
+ // Spawn the process within devcontainer
308
+ const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
309
+ const commandConfig = {
310
+ command: preset.command,
311
+ args: preset.args,
312
+ fallbackArgs: preset.fallbackArgs,
313
+ };
314
+ return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
315
+ isPrimaryCommand: true,
316
+ detectionStrategy: preset.detectionStrategy,
317
+ devcontainerConfig,
318
+ });
319
+ }
307
320
  destroy() {
308
321
  // Clean up all sessions
309
322
  for (const worktreePath of this.sessions.keys()) {
@@ -1,10 +1,13 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { SessionManager } from './sessionManager.js';
3
- import { configurationManager } from './configurationManager.js';
4
2
  import { spawn } from 'node-pty';
5
3
  import { EventEmitter } from 'events';
4
+ import { exec } from 'child_process';
6
5
  // Mock node-pty
7
6
  vi.mock('node-pty');
7
+ // Mock child_process
8
+ vi.mock('child_process', () => ({
9
+ exec: vi.fn(),
10
+ }));
8
11
  // Mock configuration manager
9
12
  vi.mock('./configurationManager.js', () => ({
10
13
  configurationManager: {
@@ -28,6 +31,10 @@ vi.mock('@xterm/headless', () => ({
28
31
  })),
29
32
  },
30
33
  }));
34
+ // Mock worktreeService
35
+ vi.mock('./worktreeService.js', () => ({
36
+ WorktreeService: vi.fn(),
37
+ }));
31
38
  // Create a mock IPty class
32
39
  class MockPty extends EventEmitter {
33
40
  constructor() {
@@ -71,53 +78,146 @@ class MockPty extends EventEmitter {
71
78
  describe('SessionManager', () => {
72
79
  let sessionManager;
73
80
  let mockPty;
74
- beforeEach(() => {
81
+ let SessionManager;
82
+ let configurationManager;
83
+ beforeEach(async () => {
75
84
  vi.clearAllMocks();
85
+ // Dynamically import after mocks are set up
86
+ const sessionManagerModule = await import('./sessionManager.js');
87
+ const configManagerModule = await import('./configurationManager.js');
88
+ SessionManager = sessionManagerModule.SessionManager;
89
+ configurationManager = configManagerModule.configurationManager;
76
90
  sessionManager = new SessionManager();
77
91
  mockPty = new MockPty();
78
92
  });
79
93
  afterEach(() => {
80
94
  sessionManager.destroy();
81
95
  });
82
- describe('createSession with command configuration', () => {
83
- it('should create session with default command when no args configured', async () => {
84
- // Setup mock configuration
85
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
96
+ describe('createSessionWithPreset', () => {
97
+ it('should use default preset when no preset ID specified', async () => {
98
+ // Setup mock preset
99
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
100
+ id: '1',
101
+ name: 'Main',
86
102
  command: 'claude',
103
+ args: ['--preset-arg'],
87
104
  });
88
105
  // Setup spawn mock
89
106
  vi.mocked(spawn).mockReturnValue(mockPty);
90
- // Create session
91
- await sessionManager.createSession('/test/worktree');
92
- // Verify spawn was called with correct arguments
93
- expect(spawn).toHaveBeenCalledWith('claude', [], {
107
+ // Create session with preset
108
+ await sessionManager.createSessionWithPreset('/test/worktree');
109
+ // Verify spawn was called with preset config
110
+ expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], {
94
111
  name: 'xterm-color',
95
112
  cols: expect.any(Number),
96
113
  rows: expect.any(Number),
97
114
  cwd: '/test/worktree',
98
115
  env: process.env,
99
116
  });
100
- // Session creation verified by spawn being called
101
117
  });
102
- it('should create session with configured arguments', async () => {
103
- // Setup mock configuration with args
104
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
118
+ it('should use specific preset when ID provided', async () => {
119
+ // Setup mock preset
120
+ vi.mocked(configurationManager.getPresetById).mockReturnValue({
121
+ id: '2',
122
+ name: 'Development',
105
123
  command: 'claude',
106
- args: ['--resume', '--model', 'opus'],
124
+ args: ['--resume', '--dev'],
125
+ fallbackArgs: ['--no-mcp'],
107
126
  });
108
127
  // Setup spawn mock
109
128
  vi.mocked(spawn).mockReturnValue(mockPty);
110
- // Create session
111
- await sessionManager.createSession('/test/worktree');
112
- // Verify spawn was called with configured arguments
113
- expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--model', 'opus'], expect.objectContaining({
129
+ // Create session with specific preset
130
+ await sessionManager.createSessionWithPreset('/test/worktree', '2');
131
+ // Verify getPresetById was called with correct ID
132
+ expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
133
+ // Verify spawn was called with preset config
134
+ expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--dev'], {
135
+ name: 'xterm-color',
136
+ cols: expect.any(Number),
137
+ rows: expect.any(Number),
114
138
  cwd: '/test/worktree',
115
- }));
116
- // Session creation verified by spawn being called
139
+ env: process.env,
140
+ });
141
+ });
142
+ it('should fall back to default preset if specified preset not found', async () => {
143
+ // Setup mocks
144
+ vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
145
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
146
+ id: '1',
147
+ name: 'Main',
148
+ command: 'claude',
149
+ });
150
+ // Setup spawn mock
151
+ vi.mocked(spawn).mockReturnValue(mockPty);
152
+ // Create session with non-existent preset
153
+ await sessionManager.createSessionWithPreset('/test/worktree', 'invalid');
154
+ // Verify fallback to default preset
155
+ expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
156
+ expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
157
+ });
158
+ it('should try fallback args with preset if main command fails', async () => {
159
+ // Setup mock preset with fallback
160
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
161
+ id: '1',
162
+ name: 'Main',
163
+ command: 'claude',
164
+ args: ['--bad-flag'],
165
+ fallbackArgs: ['--good-flag'],
166
+ });
167
+ // Mock spawn to fail first, succeed second
168
+ let callCount = 0;
169
+ vi.mocked(spawn).mockImplementation(() => {
170
+ callCount++;
171
+ if (callCount === 1) {
172
+ throw new Error('Command failed');
173
+ }
174
+ return mockPty;
175
+ });
176
+ // Create session
177
+ await sessionManager.createSessionWithPreset('/test/worktree');
178
+ // Verify both attempts were made
179
+ expect(spawn).toHaveBeenCalledTimes(2);
180
+ expect(spawn).toHaveBeenNthCalledWith(1, 'claude', ['--bad-flag'], expect.any(Object));
181
+ expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--good-flag'], expect.any(Object));
182
+ });
183
+ it('should return existing session if already created', async () => {
184
+ // Setup mock preset
185
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
186
+ id: '1',
187
+ name: 'Main',
188
+ command: 'claude',
189
+ });
190
+ // Setup spawn mock
191
+ vi.mocked(spawn).mockReturnValue(mockPty);
192
+ // Create session twice
193
+ const session1 = await sessionManager.createSessionWithPreset('/test/worktree');
194
+ const session2 = await sessionManager.createSessionWithPreset('/test/worktree');
195
+ // Should return the same session
196
+ expect(session1).toBe(session2);
197
+ // Spawn should only be called once
198
+ expect(spawn).toHaveBeenCalledTimes(1);
199
+ });
200
+ it('should throw error when spawn fails with fallback args', async () => {
201
+ // Setup mock preset with fallback
202
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
203
+ id: '1',
204
+ name: 'Main',
205
+ command: 'nonexistent-command',
206
+ args: ['--flag1'],
207
+ fallbackArgs: ['--flag2'],
208
+ });
209
+ // Mock spawn to always throw error
210
+ vi.mocked(spawn).mockImplementation(() => {
211
+ throw new Error('Command not found');
212
+ });
213
+ // Expect createSessionWithPreset to throw the original error
214
+ await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('Command not found');
117
215
  });
118
216
  it('should use fallback args when main command exits with code 1', async () => {
119
- // Setup mock configuration with fallback
120
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
217
+ // Setup mock preset with fallback
218
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
219
+ id: '1',
220
+ name: 'Main',
121
221
  command: 'claude',
122
222
  args: ['--invalid-flag'],
123
223
  fallbackArgs: ['--resume'],
@@ -130,7 +230,7 @@ describe('SessionManager', () => {
130
230
  .mockReturnValueOnce(firstMockPty)
131
231
  .mockReturnValueOnce(secondMockPty);
132
232
  // Create session
133
- const session = await sessionManager.createSession('/test/worktree');
233
+ const session = await sessionManager.createSessionWithPreset('/test/worktree');
134
234
  // Verify initial spawn
135
235
  expect(spawn).toHaveBeenCalledTimes(1);
136
236
  expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
@@ -145,38 +245,11 @@ describe('SessionManager', () => {
145
245
  expect(session.process).toBe(secondMockPty);
146
246
  expect(session.isPrimaryCommand).toBe(false);
147
247
  });
148
- it('should throw error when spawn fails and no fallback configured', async () => {
149
- // Setup mock configuration without fallback
150
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
151
- command: 'claude',
152
- args: ['--invalid-flag'],
153
- });
154
- // Mock spawn to throw error
155
- vi.mocked(spawn).mockImplementation(() => {
156
- throw new Error('spawn failed');
157
- });
158
- // Expect createSession to throw
159
- await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('spawn failed');
160
- });
161
- it('should handle custom command configuration', async () => {
162
- // Setup mock configuration with custom command
163
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
164
- command: 'my-custom-claude',
165
- args: ['--config', '/path/to/config'],
166
- });
167
- // Setup spawn mock
168
- vi.mocked(spawn).mockReturnValue(mockPty);
169
- // Create session
170
- await sessionManager.createSession('/test/worktree');
171
- // Verify spawn was called with custom command
172
- expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
173
- cwd: '/test/worktree',
174
- }));
175
- // Session creation verified by spawn being called
176
- });
177
248
  it('should not use fallback if main command succeeds', async () => {
178
- // Setup mock configuration with fallback
179
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
249
+ // Setup mock preset with fallback
250
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
251
+ id: '1',
252
+ name: 'Main',
180
253
  command: 'claude',
181
254
  args: ['--resume'],
182
255
  fallbackArgs: ['--other-flag'],
@@ -184,53 +257,57 @@ describe('SessionManager', () => {
184
257
  // Setup spawn mock - process doesn't exit early
185
258
  vi.mocked(spawn).mockReturnValue(mockPty);
186
259
  // Create session
187
- await sessionManager.createSession('/test/worktree');
260
+ await sessionManager.createSessionWithPreset('/test/worktree');
188
261
  // Wait a bit to ensure no early exit
189
262
  await new Promise(resolve => setTimeout(resolve, 600));
190
263
  // Verify only one spawn attempt
191
264
  expect(spawn).toHaveBeenCalledTimes(1);
192
265
  expect(spawn).toHaveBeenCalledWith('claude', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
193
- // Session creation verified by spawn being called
194
266
  });
195
- it('should return existing session if already created', async () => {
196
- // Setup mock configuration
197
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
198
- command: 'claude',
267
+ it('should handle custom command configuration', async () => {
268
+ // Setup mock preset with custom command
269
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
270
+ id: '1',
271
+ name: 'Main',
272
+ command: 'my-custom-claude',
273
+ args: ['--config', '/path/to/config'],
199
274
  });
200
275
  // Setup spawn mock
201
276
  vi.mocked(spawn).mockReturnValue(mockPty);
202
- // Create session twice
203
- const session1 = await sessionManager.createSession('/test/worktree');
204
- const session2 = await sessionManager.createSession('/test/worktree');
205
- // Should return the same session
206
- expect(session1).toBe(session2);
207
- // Spawn should only be called once
208
- expect(spawn).toHaveBeenCalledTimes(1);
277
+ // Create session
278
+ await sessionManager.createSessionWithPreset('/test/worktree');
279
+ // Verify spawn was called with custom command
280
+ expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
281
+ cwd: '/test/worktree',
282
+ }));
209
283
  });
210
- it('should throw error when spawn fails with fallback args', async () => {
211
- // Setup mock configuration with fallback
212
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
213
- command: 'nonexistent-command',
214
- args: ['--flag1'],
215
- fallbackArgs: ['--flag2'],
284
+ it('should throw error when spawn fails and no fallback configured', async () => {
285
+ // Setup mock preset without fallback
286
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
287
+ id: '1',
288
+ name: 'Main',
289
+ command: 'claude',
290
+ args: ['--invalid-flag'],
216
291
  });
217
- // Mock spawn to always throw error
292
+ // Mock spawn to throw error
218
293
  vi.mocked(spawn).mockImplementation(() => {
219
- throw new Error('Command not found');
294
+ throw new Error('spawn failed');
220
295
  });
221
- // Expect createSession to throw the original error
222
- await expect(sessionManager.createSession('/test/worktree')).rejects.toThrow('Command not found');
296
+ // Expect createSessionWithPreset to throw
297
+ await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('spawn failed');
223
298
  });
224
299
  });
225
300
  describe('session lifecycle', () => {
226
301
  it('should destroy session and clean up resources', async () => {
227
302
  // Setup
228
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
303
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
304
+ id: '1',
305
+ name: 'Main',
229
306
  command: 'claude',
230
307
  });
231
308
  vi.mocked(spawn).mockReturnValue(mockPty);
232
309
  // Create and destroy session
233
- await sessionManager.createSession('/test/worktree');
310
+ await sessionManager.createSessionWithPreset('/test/worktree');
234
311
  sessionManager.destroySession('/test/worktree');
235
312
  // Verify cleanup
236
313
  expect(mockPty.kill).toHaveBeenCalled();
@@ -238,7 +315,9 @@ describe('SessionManager', () => {
238
315
  });
239
316
  it('should handle session exit event', async () => {
240
317
  // Setup
241
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
318
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
319
+ id: '1',
320
+ name: 'Main',
242
321
  command: 'claude',
243
322
  });
244
323
  vi.mocked(spawn).mockReturnValue(mockPty);
@@ -248,7 +327,7 @@ describe('SessionManager', () => {
248
327
  exitedSession = session;
249
328
  });
250
329
  // Create session
251
- const createdSession = await sessionManager.createSession('/test/worktree');
330
+ const createdSession = await sessionManager.createSessionWithPreset('/test/worktree');
252
331
  // Simulate process exit after successful creation
253
332
  setTimeout(() => {
254
333
  mockPty.emit('exit', { exitCode: 0 });
@@ -259,27 +338,46 @@ describe('SessionManager', () => {
259
338
  expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
260
339
  });
261
340
  });
262
- describe('createSession with presets', () => {
263
- it('should use default preset when no preset ID specified', async () => {
341
+ describe('createSessionWithDevcontainer', () => {
342
+ beforeEach(() => {
343
+ // Reset shouldFail flag
344
+ const mockExec = vi.mocked(exec);
345
+ mockExec.shouldFail = false;
346
+ // Setup exec mock to work with promisify
347
+ mockExec.mockImplementation(((...args) => {
348
+ const [command, , callback] = args;
349
+ if (callback) {
350
+ // Handle callback style
351
+ if (command.includes('devcontainer up')) {
352
+ if (mockExec.shouldFail) {
353
+ callback(new Error('Container startup failed'));
354
+ }
355
+ else {
356
+ callback(null, '', '');
357
+ }
358
+ }
359
+ }
360
+ }));
361
+ });
362
+ it('should execute devcontainer up command before creating session', async () => {
264
363
  // Setup mock preset
265
364
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
266
365
  id: '1',
267
366
  name: 'Main',
268
367
  command: 'claude',
269
- args: ['--preset-arg'],
368
+ args: ['--resume'],
270
369
  });
271
370
  // Setup spawn mock
272
371
  vi.mocked(spawn).mockReturnValue(mockPty);
273
- // Create session with preset
274
- await sessionManager.createSessionWithPreset('/test/worktree');
275
- // Verify spawn was called with preset config
276
- expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], {
277
- name: 'xterm-color',
278
- cols: expect.any(Number),
279
- rows: expect.any(Number),
280
- cwd: '/test/worktree',
281
- env: process.env,
282
- });
372
+ // Create session with devcontainer
373
+ const devcontainerConfig = {
374
+ upCommand: 'devcontainer up --workspace-folder .',
375
+ execCommand: 'devcontainer exec --workspace-folder .',
376
+ };
377
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
378
+ // Verify spawn was called correctly which proves devcontainer up succeeded
379
+ // Verify spawn was called with devcontainer exec
380
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
283
381
  });
284
382
  it('should use specific preset when ID provided', async () => {
285
383
  // Setup mock preset
@@ -288,76 +386,194 @@ describe('SessionManager', () => {
288
386
  name: 'Development',
289
387
  command: 'claude',
290
388
  args: ['--resume', '--dev'],
291
- fallbackArgs: ['--no-mcp'],
292
389
  });
293
390
  // Setup spawn mock
294
391
  vi.mocked(spawn).mockReturnValue(mockPty);
295
- // Create session with specific preset
296
- await sessionManager.createSessionWithPreset('/test/worktree', '2');
297
- // Verify getPresetById was called with correct ID
392
+ // Create session with devcontainer and specific preset
393
+ const devcontainerConfig = {
394
+ upCommand: 'devcontainer up',
395
+ execCommand: 'devcontainer exec',
396
+ };
397
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig, '2');
398
+ // Verify correct preset was used
298
399
  expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
299
- // Verify spawn was called with preset config
300
- expect(spawn).toHaveBeenCalledWith('claude', ['--resume', '--dev'], {
301
- name: 'xterm-color',
302
- cols: expect.any(Number),
303
- rows: expect.any(Number),
304
- cwd: '/test/worktree',
305
- env: process.env,
400
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--', 'claude', '--resume', '--dev'], expect.any(Object));
401
+ });
402
+ it('should throw error when devcontainer up fails', async () => {
403
+ // Setup exec to fail
404
+ const mockExec = vi.mocked(exec);
405
+ mockExec.shouldFail = true;
406
+ // Create session with devcontainer
407
+ const devcontainerConfig = {
408
+ upCommand: 'devcontainer up',
409
+ execCommand: 'devcontainer exec',
410
+ };
411
+ await expect(sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig)).rejects.toThrow('Failed to start devcontainer: Container startup failed');
412
+ });
413
+ it('should return existing session if already created', async () => {
414
+ // Setup mock preset
415
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
416
+ id: '1',
417
+ name: 'Main',
418
+ command: 'claude',
306
419
  });
420
+ // Setup spawn mock
421
+ vi.mocked(spawn).mockReturnValue(mockPty);
422
+ const devcontainerConfig = {
423
+ upCommand: 'devcontainer up',
424
+ execCommand: 'devcontainer exec',
425
+ };
426
+ // Create session twice
427
+ const session1 = await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
428
+ const session2 = await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
429
+ // Should return the same session
430
+ expect(session1).toBe(session2);
431
+ // spawn should only be called once
432
+ expect(spawn).toHaveBeenCalledTimes(1);
307
433
  });
308
- it('should fall back to default preset if specified preset not found', async () => {
309
- // Setup mocks
310
- vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
434
+ it('should handle complex exec commands with multiple arguments', async () => {
435
+ // Setup mock preset
311
436
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
312
437
  id: '1',
313
438
  name: 'Main',
314
439
  command: 'claude',
440
+ args: ['--model', 'opus'],
315
441
  });
316
442
  // Setup spawn mock
317
443
  vi.mocked(spawn).mockReturnValue(mockPty);
318
- // Create session with non-existent preset
319
- await sessionManager.createSessionWithPreset('/test/worktree', 'invalid');
320
- // Verify fallback to default preset
321
- expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
322
- expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
444
+ // Create session with complex exec command
445
+ const devcontainerConfig = {
446
+ upCommand: 'devcontainer up --workspace-folder . --log-level debug',
447
+ execCommand: 'devcontainer exec --workspace-folder . --container-name mycontainer',
448
+ };
449
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
450
+ // Verify spawn was called with properly parsed exec command
451
+ expect(spawn).toHaveBeenCalledWith('devcontainer', [
452
+ 'exec',
453
+ '--workspace-folder',
454
+ '.',
455
+ '--container-name',
456
+ 'mycontainer',
457
+ '--',
458
+ 'claude',
459
+ '--model',
460
+ 'opus',
461
+ ], expect.any(Object));
323
462
  });
324
- it('should try fallback args with preset if main command fails', async () => {
325
- // Setup mock preset with fallback
463
+ it('should spawn process with devcontainer exec command', async () => {
464
+ // Create a new session manager and reset mocks
465
+ vi.clearAllMocks();
466
+ sessionManager = new SessionManager();
326
467
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
327
468
  id: '1',
328
469
  name: 'Main',
329
470
  command: 'claude',
330
- args: ['--bad-flag'],
331
- fallbackArgs: ['--good-flag'],
471
+ args: [],
332
472
  });
333
- // Mock spawn to fail first, succeed second
334
- let callCount = 0;
335
- vi.mocked(spawn).mockImplementation(() => {
336
- callCount++;
337
- if (callCount === 1) {
338
- throw new Error('Command failed');
473
+ // Setup spawn mock
474
+ vi.mocked(spawn).mockReturnValue(mockPty);
475
+ const mockExec = vi.mocked(exec);
476
+ mockExec.mockImplementation((cmd, options, callback) => {
477
+ if (typeof options === 'function') {
478
+ callback = options;
479
+ options = undefined;
339
480
  }
340
- return mockPty;
481
+ if (callback && typeof callback === 'function') {
482
+ callback(null, 'Container started', '');
483
+ }
484
+ return {};
341
485
  });
342
- // Create session
343
- await sessionManager.createSessionWithPreset('/test/worktree');
344
- // Verify both attempts were made
345
- expect(spawn).toHaveBeenCalledTimes(2);
346
- expect(spawn).toHaveBeenNthCalledWith(1, 'claude', ['--bad-flag'], expect.any(Object));
347
- expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--good-flag'], expect.any(Object));
486
+ await sessionManager.createSessionWithDevcontainer('/test/worktree2', {
487
+ upCommand: 'devcontainer up --workspace-folder .',
488
+ execCommand: 'devcontainer exec --workspace-folder .',
489
+ });
490
+ // Should spawn with devcontainer exec command
491
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({
492
+ cwd: '/test/worktree2',
493
+ }));
494
+ });
495
+ it('should use preset with devcontainer', async () => {
496
+ const mockExec = vi.mocked(exec);
497
+ mockExec.mockImplementation((cmd, options, callback) => {
498
+ if (typeof options === 'function') {
499
+ callback = options;
500
+ options = undefined;
501
+ }
502
+ if (callback && typeof callback === 'function') {
503
+ callback(null, 'Container started', '');
504
+ }
505
+ return {};
506
+ });
507
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', {
508
+ upCommand: 'devcontainer up --workspace-folder .',
509
+ execCommand: 'devcontainer exec --workspace-folder .',
510
+ }, 'custom-preset');
511
+ // Should call createSessionWithPreset internally
512
+ const session = sessionManager.getSession('/test/worktree');
513
+ expect(session).toBeDefined();
514
+ expect(session?.devcontainerConfig).toEqual({
515
+ upCommand: 'devcontainer up --workspace-folder .',
516
+ execCommand: 'devcontainer exec --workspace-folder .',
517
+ });
518
+ });
519
+ it('should parse exec command and append preset command', async () => {
520
+ const mockExec = vi.mocked(exec);
521
+ mockExec.mockImplementation((cmd, options, callback) => {
522
+ if (typeof options === 'function') {
523
+ callback = options;
524
+ options = undefined;
525
+ }
526
+ if (callback && typeof callback === 'function') {
527
+ callback(null, 'Container started', '');
528
+ }
529
+ return {};
530
+ });
531
+ const config = {
532
+ upCommand: 'devcontainer up --workspace-folder /path/to/project',
533
+ execCommand: 'devcontainer exec --workspace-folder /path/to/project --user vscode',
534
+ };
535
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', config);
536
+ expect(spawn).toHaveBeenCalledWith('devcontainer', [
537
+ 'exec',
538
+ '--workspace-folder',
539
+ '/path/to/project',
540
+ '--user',
541
+ 'vscode',
542
+ '--',
543
+ 'claude',
544
+ ], expect.any(Object));
348
545
  });
349
- it('should maintain backward compatibility with createSession', async () => {
350
- // Setup legacy config
351
- vi.mocked(configurationManager.getCommandConfig).mockReturnValue({
546
+ it('should handle preset with args in devcontainer', async () => {
547
+ const mockExec = vi.mocked(exec);
548
+ mockExec.mockImplementation((cmd, options, callback) => {
549
+ if (typeof options === 'function') {
550
+ callback = options;
551
+ options = undefined;
552
+ }
553
+ if (callback && typeof callback === 'function') {
554
+ callback(null, 'Container started', '');
555
+ }
556
+ return {};
557
+ });
558
+ vi.mocked(configurationManager.getPresetById).mockReturnValue({
559
+ id: 'claude-with-args',
560
+ name: 'Claude with Args',
352
561
  command: 'claude',
353
- args: ['--legacy'],
562
+ args: ['-m', 'claude-3-opus'],
354
563
  });
355
- // Setup spawn mock
356
- vi.mocked(spawn).mockReturnValue(mockPty);
357
- // Create session using legacy method
358
- await sessionManager.createSession('/test/worktree');
359
- // Verify legacy method still works
360
- expect(spawn).toHaveBeenCalledWith('claude', ['--legacy'], expect.any(Object));
564
+ await sessionManager.createSessionWithDevcontainer('/test/worktree', {
565
+ upCommand: 'devcontainer up --workspace-folder .',
566
+ execCommand: 'devcontainer exec --workspace-folder .',
567
+ }, 'claude-with-args');
568
+ expect(spawn).toHaveBeenCalledWith('devcontainer', [
569
+ 'exec',
570
+ '--workspace-folder',
571
+ '.',
572
+ '--',
573
+ 'claude',
574
+ '-m',
575
+ 'claude-3-opus',
576
+ ], expect.any(Object));
361
577
  });
362
578
  });
363
579
  });
@@ -26,10 +26,10 @@ export interface Session {
26
26
  isPrimaryCommand?: boolean;
27
27
  commandConfig?: CommandConfig;
28
28
  detectionStrategy?: StateDetectionStrategy;
29
+ devcontainerConfig?: DevcontainerConfig;
29
30
  }
30
31
  export interface SessionManager {
31
32
  sessions: Map<string, Session>;
32
- createSession(worktreePath: string): Promise<Session>;
33
33
  getSession(worktreePath: string): Session | undefined;
34
34
  destroySession(worktreePath: string): void;
35
35
  getAllSessions(): Session[];
@@ -76,6 +76,10 @@ export interface CommandPresetsConfig {
76
76
  defaultPresetId: string;
77
77
  selectPresetOnStart?: boolean;
78
78
  }
79
+ export interface DevcontainerConfig {
80
+ upCommand: string;
81
+ execCommand: string;
82
+ }
79
83
  export interface ConfigurationData {
80
84
  shortcuts?: ShortcutConfig;
81
85
  statusHooks?: StatusHookConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",