ccmanager 3.7.0 → 3.7.2

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Mentioned in Awesome Gemini CLI](https://awesome.re/mentioned-badge.svg)](https://github.com/Piebald-AI/awesome-gemini-cli)
4
4
 
5
- CCManager is a CLI application for managing multiple AI coding assistant sessions (Claude Code, Gemini CLI, Codex CLI) across Git worktrees and projects.
5
+ CCManager is a CLI application for managing multiple AI coding assistant sessions (Claude Code, Gemini CLI, Codex CLI, Cursor Agent, Copilot CLI, Cline CLI, OpenCode, Kimi CLI) across Git worktrees and projects.
6
6
 
7
7
  https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
8
8
 
@@ -10,7 +10,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
10
10
 
11
11
  - Run multiple AI assistant sessions in parallel across different Git worktrees
12
12
  - **Multi-project support**: Manage multiple git repositories from a single interface
13
- - Support for multiple AI coding assistants (Claude Code, Gemini CLI)
13
+ - Support for multiple AI coding assistants (Claude Code, Gemini CLI, Codex CLI, Cursor Agent, Copilot CLI, Cline CLI, OpenCode, Kimi CLI)
14
14
  - Switch between sessions seamlessly
15
15
  - Visual status indicators for session states (busy, waiting, idle)
16
16
  - Create, merge, and delete worktrees from within the app
@@ -129,6 +129,7 @@ CCManager supports multiple AI coding assistants with tailored state detection f
129
129
  | Copilot CLI | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
130
130
  | Cline CLI | `cline` | [github.com/cline/cline](https://github.com/cline/cline) |
131
131
  | OpenCode | `opencode` | [opencode.ai/docs](https://opencode.ai/docs) |
132
+ | Kimi CLI | `kimi` | [kimi-cli.com](https://www.kimi-cli.com/en/) |
132
133
 
133
134
  Each assistant has its own state detection strategy to properly track:
134
135
  - **Idle**: Ready for new input
@@ -172,7 +172,7 @@ describe('App component loading state machine', () => {
172
172
  resolveWorktree?.();
173
173
  await createPromise;
174
174
  await selectPromise;
175
- await flush(20);
175
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
176
176
  expect(lastFrame()).toContain('Menu View');
177
177
  unmount();
178
178
  });
@@ -201,7 +201,7 @@ describe('App component loading state machine', () => {
201
201
  resolveDelete?.();
202
202
  await deletePromise;
203
203
  await selectPromise;
204
- await flush(20);
204
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
205
205
  expect(lastFrame()).toContain('Menu View');
206
206
  unmount();
207
207
  });
@@ -1,15 +1,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useState, useEffect } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import SelectInput from 'ink-select-input';
5
5
  import { shortcutManager } from '../services/shortcutManager.js';
6
6
  import Confirmation from './Confirmation.js';
7
+ import { hasUncommittedChanges } from '../utils/gitUtils.js';
7
8
  const DeleteConfirmation = ({ worktrees, onConfirm, onCancel, }) => {
8
9
  // Check if any worktrees have branches
9
10
  const hasAnyBranches = worktrees.some(wt => wt.branch);
10
11
  const [deleteBranch, setDeleteBranch] = useState(true);
11
- const [view, setView] = useState(hasAnyBranches ? 'options' : 'confirm');
12
+ const [view, setView] = useState('checking-uncommitted');
12
13
  const [focusedOption, setFocusedOption] = useState(deleteBranch ? 'deleteBranch' : 'keepBranch');
14
+ const [worktreesWithChanges, setWorktreesWithChanges] = useState([]);
15
+ // Check for uncommitted changes on mount
16
+ useEffect(() => {
17
+ const checkUncommittedChanges = () => {
18
+ const withChanges = worktrees.filter(wt => hasUncommittedChanges(wt.path));
19
+ setWorktreesWithChanges(withChanges);
20
+ if (withChanges.length > 0) {
21
+ setView('uncommitted-warning');
22
+ }
23
+ else if (hasAnyBranches) {
24
+ setView('options');
25
+ }
26
+ else {
27
+ setView('confirm');
28
+ }
29
+ };
30
+ checkUncommittedChanges();
31
+ }, [worktrees, hasAnyBranches]);
13
32
  // Menu items for branch options
14
33
  const branchOptions = [
15
34
  {
@@ -53,7 +72,12 @@ const DeleteConfirmation = ({ worktrees, onConfirm, onCancel, }) => {
53
72
  return false;
54
73
  };
55
74
  useInput((input, key) => {
56
- if (hasAnyBranches && view === 'options') {
75
+ if (view === 'uncommitted-warning') {
76
+ if (shortcutManager.matchesShortcut('cancel', input, key)) {
77
+ onCancel();
78
+ }
79
+ }
80
+ else if (hasAnyBranches && view === 'options') {
57
81
  if (handleBranchOptionsInput(input, key)) {
58
82
  return;
59
83
  }
@@ -66,6 +90,33 @@ const DeleteConfirmation = ({ worktrees, onConfirm, onCancel, }) => {
66
90
  const title = (_jsx(Text, { bold: true, color: "red", children: "\u26A0\uFE0F Delete Confirmation" }));
67
91
  // Message component
68
92
  const message = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "You are about to delete the following worktrees:" }), worktrees.length <= 10 ? (worktrees.map(wt => (_jsxs(Text, { color: "red", children: ["\u2022 ", wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached', " (", wt.path, ")"] }, wt.path)))) : (_jsxs(_Fragment, { children: [worktrees.slice(0, 8).map(wt => (_jsxs(Text, { color: "red", children: ["\u2022 ", wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached', ' ', "(", wt.path, ")"] }, wt.path))), _jsxs(Text, { color: "red", dimColor: true, children: ["... and ", worktrees.length - 8, " more worktrees"] })] }))] }));
93
+ // Loading state while checking uncommitted changes
94
+ if (view === 'checking-uncommitted') {
95
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Checking for uncommitted changes..." }) }));
96
+ }
97
+ // Uncommitted changes warning view
98
+ if (view === 'uncommitted-warning') {
99
+ const warningTitle = (_jsx(Text, { bold: true, color: "yellow", children: "\u26A0\uFE0F Warning: Uncommitted Changes" }));
100
+ const warningMessage = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "The following worktrees have uncommitted changes that will be lost:" }), worktreesWithChanges.length <= 10 ? (worktreesWithChanges.map(wt => (_jsxs(Text, { color: "yellow", children: ["\u2022 ", wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached', ' ', "(", wt.path, ")"] }, wt.path)))) : (_jsxs(_Fragment, { children: [worktreesWithChanges.slice(0, 8).map(wt => (_jsxs(Text, { color: "yellow", children: ["\u2022", ' ', wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached', ' ', "(", wt.path, ")"] }, wt.path))), _jsxs(Text, { color: "yellow", dimColor: true, children: ["... and ", worktreesWithChanges.length - 8, " more worktrees"] })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Do you want to continue?" }) })] }));
101
+ const warningHint = (_jsxs(Text, { dimColor: true, children: ["Use \u2191\u2193/j/k to navigate, Enter to select,", ' ', shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }));
102
+ const handleWarningSelect = (value) => {
103
+ if (value === 'yes') {
104
+ if (hasAnyBranches) {
105
+ setView('options');
106
+ }
107
+ else {
108
+ setView('confirm');
109
+ }
110
+ }
111
+ else {
112
+ onCancel();
113
+ }
114
+ };
115
+ return (_jsx(Confirmation, { title: warningTitle, message: warningMessage, options: [
116
+ { label: 'Yes', value: 'yes', color: 'green' },
117
+ { label: 'No', value: 'no', color: 'red' },
118
+ ], onSelect: handleWarningSelect, initialIndex: 1, hint: warningHint, onCancel: onCancel }));
119
+ }
69
120
  if (hasAnyBranches && view === 'options') {
70
121
  return (_jsxs(Box, { flexDirection: "column", children: [title, _jsx(Box, { marginTop: 1, marginBottom: 1, children: message }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What do you want to do with the associated branches?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: branchOptions, onSelect: handleBranchSelect, onHighlight: (item) => {
71
122
  setFocusedOption(item.value);
@@ -7,6 +7,7 @@ import { WorktreeService } from '../services/worktreeService.js';
7
7
  import Confirmation, { SimpleConfirmation } from './Confirmation.js';
8
8
  import { shortcutManager } from '../services/shortcutManager.js';
9
9
  import { GitError } from '../types/errors.js';
10
+ import { hasUncommittedChanges } from '../utils/gitUtils.js';
10
11
  const MergeWorktree = ({ onComplete, onCancel, }) => {
11
12
  const [step, setStep] = useState('select-source');
12
13
  const [sourceBranch, setSourceBranch] = useState('');
@@ -83,8 +84,8 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
83
84
  const performMerge = async () => {
84
85
  try {
85
86
  await Effect.runPromise(worktreeService.mergeWorktreeEffect(sourceBranch, targetBranch, useRebase));
86
- // Merge successful, ask about deleting source branch
87
- setStep('delete-confirm');
87
+ // Merge successful, check for uncommitted changes before asking about deletion
88
+ setStep('check-uncommitted');
88
89
  }
89
90
  catch (err) {
90
91
  // Merge failed, show error
@@ -99,6 +100,29 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
99
100
  };
100
101
  performMerge();
101
102
  }, [step, sourceBranch, targetBranch, useRebase, worktreeService]);
103
+ // Check for uncommitted changes in source worktree when entering check-uncommitted step
104
+ useEffect(() => {
105
+ if (step !== 'check-uncommitted')
106
+ return;
107
+ const checkUncommitted = async () => {
108
+ try {
109
+ // Find the worktree path for the source branch
110
+ const worktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
111
+ const sourceWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === sourceBranch);
112
+ if (sourceWorktree && hasUncommittedChanges(sourceWorktree.path)) {
113
+ setStep('uncommitted-warning');
114
+ }
115
+ else {
116
+ setStep('delete-confirm');
117
+ }
118
+ }
119
+ catch {
120
+ // On error, proceed to delete-confirm
121
+ setStep('delete-confirm');
122
+ }
123
+ };
124
+ checkUncommitted();
125
+ }, [step, sourceBranch, worktreeService]);
102
126
  if (isLoading) {
103
127
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Loading worktrees..." }) }));
104
128
  }
@@ -134,6 +158,27 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
134
158
  if (step === 'merge-error') {
135
159
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "red", children: [useRebase ? 'Rebase' : 'Merge', " Failed"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: mergeError }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press any key to return to menu" }) })] }));
136
160
  }
161
+ if (step === 'check-uncommitted') {
162
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Checking for uncommitted changes..." }) }));
163
+ }
164
+ if (step === 'uncommitted-warning') {
165
+ const warningTitle = (_jsx(Text, { bold: true, color: "yellow", children: "Warning: Uncommitted Changes" }));
166
+ const warningMessage = (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["The source branch ", _jsx(Text, { color: "yellow", children: sourceBranch }), " has uncommitted changes that will be lost if you delete it."] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Do you still want to delete it?" }) })] }));
167
+ const warningHint = (_jsxs(Text, { dimColor: true, children: ["Use \u2191\u2193/j/k to navigate, Enter to select,", ' ', shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }));
168
+ const handleWarningSelect = (value) => {
169
+ if (value === 'yes') {
170
+ setStep('delete-confirm');
171
+ }
172
+ else {
173
+ // User chose not to delete, complete without deletion
174
+ onComplete();
175
+ }
176
+ };
177
+ return (_jsx(Confirmation, { title: warningTitle, message: warningMessage, options: [
178
+ { label: 'Yes', value: 'yes', color: 'green' },
179
+ { label: 'No', value: 'no', color: 'red' },
180
+ ], onSelect: handleWarningSelect, initialIndex: 1, hint: warningHint, onCancel: onCancel }));
181
+ }
137
182
  if (step === 'delete-confirm') {
138
183
  const deleteMessage = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Delete Source Branch?" }) }), _jsxs(Text, { children: ["Delete the merged branch ", _jsx(Text, { color: "yellow", children: sourceBranch }), ' ', "and its worktree?"] })] }));
139
184
  return (_jsx(SimpleConfirmation, { message: deleteMessage, onConfirm: async () => {
@@ -2,7 +2,7 @@ import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { StateDetector } from './types.js';
3
3
  export declare abstract class BaseStateDetector implements StateDetector {
4
4
  abstract detectState(terminal: Terminal, currentState: SessionState): SessionState;
5
- protected getTerminalLines(terminal: Terminal, maxLines?: number): string[];
6
- protected getTerminalContent(terminal: Terminal, maxLines?: number): string;
5
+ protected getTerminalLines(terminal: Terminal, maxLines: number): string[];
6
+ protected getTerminalContent(terminal: Terminal, maxLines: number): string;
7
7
  abstract detectBackgroundTask(terminal: Terminal): number;
8
8
  }
@@ -1,10 +1,10 @@
1
1
  import { getTerminalScreenContent } from '../../utils/screenCapture.js';
2
2
  export class BaseStateDetector {
3
- getTerminalLines(terminal, maxLines = 30) {
3
+ getTerminalLines(terminal, maxLines) {
4
4
  const content = getTerminalScreenContent(terminal, maxLines);
5
5
  return content.split('\n');
6
6
  }
7
- getTerminalContent(terminal, maxLines = 30) {
7
+ getTerminalContent(terminal, maxLines) {
8
8
  return getTerminalScreenContent(terminal, maxLines);
9
9
  }
10
10
  }
@@ -7,7 +7,7 @@ import { BaseStateDetector } from './base.js';
7
7
  */
8
8
  export class KimiStateDetector extends BaseStateDetector {
9
9
  detectState(terminal, _currentState) {
10
- const content = this.getTerminalContent(terminal);
10
+ const content = this.getTerminalContent(terminal, 30);
11
11
  const lowerContent = content.toLowerCase();
12
12
  // Check for permission/confirmation prompts - waiting_input state
13
13
  // Kimi CLI uses prompts like "Allow?", "Confirm?", "Yes/No" patterns
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Check if a worktree or repository has uncommitted changes.
3
+ * This includes unstaged changes, staged changes, and untracked files.
4
+ *
5
+ * @param worktreePath - The path to the worktree or repository
6
+ * @returns true if there are uncommitted changes, false if clean
7
+ */
8
+ export declare function hasUncommittedChanges(worktreePath: string): boolean;
1
9
  /**
2
10
  * Get the git repository root path from a given directory.
3
11
  * For worktrees, this returns the main repository root (parent of .git).
@@ -1,5 +1,26 @@
1
1
  import path from 'path';
2
2
  import { execSync } from 'child_process';
3
+ /**
4
+ * Check if a worktree or repository has uncommitted changes.
5
+ * This includes unstaged changes, staged changes, and untracked files.
6
+ *
7
+ * @param worktreePath - The path to the worktree or repository
8
+ * @returns true if there are uncommitted changes, false if clean
9
+ */
10
+ export function hasUncommittedChanges(worktreePath) {
11
+ try {
12
+ const output = execSync('git status --porcelain', {
13
+ cwd: worktreePath,
14
+ encoding: 'utf8',
15
+ stdio: ['pipe', 'pipe', 'pipe'],
16
+ }).trim();
17
+ return output.length > 0;
18
+ }
19
+ catch {
20
+ // Conservative default on error - treat as having changes
21
+ return true;
22
+ }
23
+ }
3
24
  /**
4
25
  * Get the git repository root path from a given directory.
5
26
  * For worktrees, this returns the main repository root (parent of .git).
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { execSync } from 'child_process';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import os from 'os';
6
+ import { hasUncommittedChanges } from './gitUtils.js';
7
+ describe('hasUncommittedChanges', () => {
8
+ // Use os.tmpdir() and unique suffix to avoid conflicts with parallel tests
9
+ // Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS)
10
+ const testDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-uncommitted-test-')));
11
+ const mainRepoDir = path.join(testDir, 'main-repo');
12
+ const worktreeDir = path.join(testDir, 'worktree-1');
13
+ beforeAll(() => {
14
+ // Clean up if exists
15
+ if (fs.existsSync(testDir)) {
16
+ fs.rmSync(testDir, { recursive: true, force: true });
17
+ }
18
+ // Create test directory structure
19
+ fs.mkdirSync(testDir, { recursive: true });
20
+ // Create main repository
21
+ fs.mkdirSync(mainRepoDir, { recursive: true });
22
+ execSync('git init', { cwd: mainRepoDir });
23
+ // Set git user for CI environment
24
+ execSync('git config user.email "test@test.com"', { cwd: mainRepoDir });
25
+ execSync('git config user.name "Test User"', { cwd: mainRepoDir });
26
+ fs.writeFileSync(path.join(mainRepoDir, 'README.md'), '# Main Repo');
27
+ execSync('git add README.md', { cwd: mainRepoDir });
28
+ execSync('git commit -m "Initial commit"', { cwd: mainRepoDir });
29
+ // Create a branch and worktree
30
+ execSync('git branch feature-branch', { cwd: mainRepoDir });
31
+ execSync(`git worktree add ${worktreeDir} feature-branch`, {
32
+ cwd: mainRepoDir,
33
+ });
34
+ });
35
+ afterAll(() => {
36
+ // Clean up worktree first
37
+ try {
38
+ execSync(`git worktree remove ${worktreeDir} --force`, {
39
+ cwd: mainRepoDir,
40
+ });
41
+ }
42
+ catch {
43
+ // Ignore errors
44
+ }
45
+ // Clean up
46
+ if (fs.existsSync(testDir)) {
47
+ fs.rmSync(testDir, { recursive: true, force: true });
48
+ }
49
+ });
50
+ it('should return false for a clean worktree', () => {
51
+ const result = hasUncommittedChanges(worktreeDir);
52
+ expect(result).toBe(false);
53
+ });
54
+ it('should return true for a worktree with unstaged changes', () => {
55
+ // Create an unstaged change
56
+ fs.writeFileSync(path.join(worktreeDir, 'README.md'), '# Modified');
57
+ const result = hasUncommittedChanges(worktreeDir);
58
+ expect(result).toBe(true);
59
+ // Restore the file
60
+ execSync('git checkout README.md', { cwd: worktreeDir });
61
+ });
62
+ it('should return true for a worktree with staged but uncommitted changes', () => {
63
+ // Create a staged change
64
+ fs.writeFileSync(path.join(worktreeDir, 'new-file.txt'), 'new content');
65
+ execSync('git add new-file.txt', { cwd: worktreeDir });
66
+ const result = hasUncommittedChanges(worktreeDir);
67
+ expect(result).toBe(true);
68
+ // Reset the change
69
+ execSync('git reset HEAD new-file.txt', { cwd: worktreeDir });
70
+ fs.unlinkSync(path.join(worktreeDir, 'new-file.txt'));
71
+ });
72
+ it('should return true for a worktree with untracked files', () => {
73
+ // Create an untracked file
74
+ fs.writeFileSync(path.join(worktreeDir, 'untracked.txt'), 'untracked');
75
+ const result = hasUncommittedChanges(worktreeDir);
76
+ expect(result).toBe(true);
77
+ // Clean up
78
+ fs.unlinkSync(path.join(worktreeDir, 'untracked.txt'));
79
+ });
80
+ it('should return false for a clean main repository', () => {
81
+ const result = hasUncommittedChanges(mainRepoDir);
82
+ expect(result).toBe(false);
83
+ });
84
+ it('should return true for a main repository with uncommitted changes', () => {
85
+ // Create an unstaged change
86
+ fs.writeFileSync(path.join(mainRepoDir, 'README.md'), '# Modified Main');
87
+ const result = hasUncommittedChanges(mainRepoDir);
88
+ expect(result).toBe(true);
89
+ // Restore the file
90
+ execSync('git checkout README.md', { cwd: mainRepoDir });
91
+ });
92
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "3.7.0",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.7.0",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.7.0",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.7.0",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.7.0"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "3.7.2",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "3.7.2",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "3.7.2",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "3.7.2",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "3.7.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",