ccmanager 0.0.1

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.
Files changed (40) hide show
  1. package/README.md +85 -0
  2. package/dist/app.d.ts +6 -0
  3. package/dist/app.js +57 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +24 -0
  6. package/dist/components/App.d.ts +3 -0
  7. package/dist/components/App.js +228 -0
  8. package/dist/components/ConfigureShortcuts.d.ts +6 -0
  9. package/dist/components/ConfigureShortcuts.js +139 -0
  10. package/dist/components/Confirmation.d.ts +12 -0
  11. package/dist/components/Confirmation.js +42 -0
  12. package/dist/components/DeleteWorktree.d.ts +7 -0
  13. package/dist/components/DeleteWorktree.js +116 -0
  14. package/dist/components/Menu.d.ts +9 -0
  15. package/dist/components/Menu.js +154 -0
  16. package/dist/components/MergeWorktree.d.ts +7 -0
  17. package/dist/components/MergeWorktree.js +142 -0
  18. package/dist/components/NewWorktree.d.ts +7 -0
  19. package/dist/components/NewWorktree.js +49 -0
  20. package/dist/components/Session.d.ts +10 -0
  21. package/dist/components/Session.js +121 -0
  22. package/dist/constants/statusIcons.d.ts +18 -0
  23. package/dist/constants/statusIcons.js +27 -0
  24. package/dist/services/sessionManager.d.ts +16 -0
  25. package/dist/services/sessionManager.js +190 -0
  26. package/dist/services/sessionManager.test.d.ts +1 -0
  27. package/dist/services/sessionManager.test.js +99 -0
  28. package/dist/services/shortcutManager.d.ts +17 -0
  29. package/dist/services/shortcutManager.js +167 -0
  30. package/dist/services/worktreeService.d.ts +24 -0
  31. package/dist/services/worktreeService.js +220 -0
  32. package/dist/types/index.d.ts +36 -0
  33. package/dist/types/index.js +4 -0
  34. package/dist/utils/logger.d.ts +14 -0
  35. package/dist/utils/logger.js +21 -0
  36. package/dist/utils/promptDetector.d.ts +1 -0
  37. package/dist/utils/promptDetector.js +20 -0
  38. package/dist/utils/promptDetector.test.d.ts +1 -0
  39. package/dist/utils/promptDetector.test.js +81 -0
  40. package/package.json +70 -0
@@ -0,0 +1,220 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import path from 'path';
4
+ export class WorktreeService {
5
+ constructor(rootPath) {
6
+ Object.defineProperty(this, "rootPath", {
7
+ enumerable: true,
8
+ configurable: true,
9
+ writable: true,
10
+ value: void 0
11
+ });
12
+ this.rootPath = rootPath || process.cwd();
13
+ }
14
+ getWorktrees() {
15
+ try {
16
+ const output = execSync('git worktree list --porcelain', {
17
+ cwd: this.rootPath,
18
+ encoding: 'utf8',
19
+ });
20
+ const worktrees = [];
21
+ const lines = output.trim().split('\n');
22
+ let currentWorktree = {};
23
+ for (const line of lines) {
24
+ if (line.startsWith('worktree ')) {
25
+ if (currentWorktree.path) {
26
+ worktrees.push(currentWorktree);
27
+ }
28
+ currentWorktree = {
29
+ path: line.substring(9),
30
+ isMainWorktree: false,
31
+ hasSession: false,
32
+ };
33
+ }
34
+ else if (line.startsWith('branch ')) {
35
+ currentWorktree.branch = line.substring(7);
36
+ }
37
+ else if (line === 'bare') {
38
+ currentWorktree.isMainWorktree = true;
39
+ }
40
+ }
41
+ if (currentWorktree.path) {
42
+ worktrees.push(currentWorktree);
43
+ }
44
+ // Mark the first worktree as main if none are marked
45
+ if (worktrees.length > 0 && !worktrees.some(w => w.isMainWorktree)) {
46
+ worktrees[0].isMainWorktree = true;
47
+ }
48
+ return worktrees;
49
+ }
50
+ catch (_error) {
51
+ // If git worktree command fails, assume we're in a regular git repo
52
+ return [
53
+ {
54
+ path: this.rootPath,
55
+ branch: this.getCurrentBranch(),
56
+ isMainWorktree: true,
57
+ hasSession: false,
58
+ },
59
+ ];
60
+ }
61
+ }
62
+ getCurrentBranch() {
63
+ try {
64
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
65
+ cwd: this.rootPath,
66
+ encoding: 'utf8',
67
+ }).trim();
68
+ return branch;
69
+ }
70
+ catch {
71
+ return 'unknown';
72
+ }
73
+ }
74
+ isGitRepository() {
75
+ return existsSync(path.join(this.rootPath, '.git'));
76
+ }
77
+ createWorktree(worktreePath, branch) {
78
+ try {
79
+ // Check if branch exists
80
+ let branchExists = false;
81
+ try {
82
+ execSync(`git rev-parse --verify ${branch}`, {
83
+ cwd: this.rootPath,
84
+ encoding: 'utf8',
85
+ });
86
+ branchExists = true;
87
+ }
88
+ catch {
89
+ // Branch doesn't exist
90
+ }
91
+ // Create the worktree
92
+ const command = branchExists
93
+ ? `git worktree add "${worktreePath}" "${branch}"`
94
+ : `git worktree add -b "${branch}" "${worktreePath}"`;
95
+ execSync(command, {
96
+ cwd: this.rootPath,
97
+ encoding: 'utf8',
98
+ });
99
+ return { success: true };
100
+ }
101
+ catch (error) {
102
+ return {
103
+ success: false,
104
+ error: error instanceof Error ? error.message : 'Failed to create worktree',
105
+ };
106
+ }
107
+ }
108
+ deleteWorktree(worktreePath) {
109
+ try {
110
+ // Get the worktree info to find the branch
111
+ const worktrees = this.getWorktrees();
112
+ const worktree = worktrees.find(wt => wt.path === worktreePath);
113
+ if (!worktree) {
114
+ return {
115
+ success: false,
116
+ error: 'Worktree not found',
117
+ };
118
+ }
119
+ if (worktree.isMainWorktree) {
120
+ return {
121
+ success: false,
122
+ error: 'Cannot delete the main worktree',
123
+ };
124
+ }
125
+ // Remove the worktree
126
+ execSync(`git worktree remove "${worktreePath}" --force`, {
127
+ cwd: this.rootPath,
128
+ encoding: 'utf8',
129
+ });
130
+ // Delete the branch if it exists
131
+ const branchName = worktree.branch.replace('refs/heads/', '');
132
+ try {
133
+ execSync(`git branch -D "${branchName}"`, {
134
+ cwd: this.rootPath,
135
+ encoding: 'utf8',
136
+ });
137
+ }
138
+ catch {
139
+ // Branch might not exist or might be checked out elsewhere
140
+ // This is not a fatal error
141
+ }
142
+ return { success: true };
143
+ }
144
+ catch (error) {
145
+ return {
146
+ success: false,
147
+ error: error instanceof Error ? error.message : 'Failed to delete worktree',
148
+ };
149
+ }
150
+ }
151
+ mergeWorktree(sourceBranch, targetBranch, useRebase = false) {
152
+ try {
153
+ // Get worktrees to find the target worktree path
154
+ const worktrees = this.getWorktrees();
155
+ const targetWorktree = worktrees.find(wt => wt.branch.replace('refs/heads/', '') === targetBranch);
156
+ if (!targetWorktree) {
157
+ return {
158
+ success: false,
159
+ error: 'Target branch worktree not found',
160
+ };
161
+ }
162
+ // Perform the merge or rebase in the target worktree
163
+ if (useRebase) {
164
+ // For rebase, we need to checkout source branch and rebase it onto target
165
+ const sourceWorktree = worktrees.find(wt => wt.branch.replace('refs/heads/', '') === sourceBranch);
166
+ if (!sourceWorktree) {
167
+ return {
168
+ success: false,
169
+ error: 'Source branch worktree not found',
170
+ };
171
+ }
172
+ // Rebase source branch onto target branch
173
+ execSync(`git rebase "${targetBranch}"`, {
174
+ cwd: sourceWorktree.path,
175
+ encoding: 'utf8',
176
+ });
177
+ }
178
+ else {
179
+ // Regular merge
180
+ execSync(`git merge --no-ff "${sourceBranch}"`, {
181
+ cwd: targetWorktree.path,
182
+ encoding: 'utf8',
183
+ });
184
+ }
185
+ return { success: true };
186
+ }
187
+ catch (error) {
188
+ return {
189
+ success: false,
190
+ error: error instanceof Error
191
+ ? error.message
192
+ : useRebase
193
+ ? 'Failed to rebase branches'
194
+ : 'Failed to merge branches',
195
+ };
196
+ }
197
+ }
198
+ deleteWorktreeByBranch(branch) {
199
+ try {
200
+ // Get worktrees to find the worktree by branch
201
+ const worktrees = this.getWorktrees();
202
+ const worktree = worktrees.find(wt => wt.branch.replace('refs/heads/', '') === branch);
203
+ if (!worktree) {
204
+ return {
205
+ success: false,
206
+ error: 'Worktree not found for branch',
207
+ };
208
+ }
209
+ return this.deleteWorktree(worktree.path);
210
+ }
211
+ catch (error) {
212
+ return {
213
+ success: false,
214
+ error: error instanceof Error
215
+ ? error.message
216
+ : 'Failed to delete worktree by branch',
217
+ };
218
+ }
219
+ }
220
+ }
@@ -0,0 +1,36 @@
1
+ import { IPty } from 'node-pty';
2
+ export type SessionState = 'idle' | 'busy' | 'waiting_input';
3
+ export interface Worktree {
4
+ path: string;
5
+ branch: string;
6
+ isMainWorktree: boolean;
7
+ hasSession: boolean;
8
+ }
9
+ export interface Session {
10
+ id: string;
11
+ worktreePath: string;
12
+ process: IPty;
13
+ state: SessionState;
14
+ output: string[];
15
+ outputHistory: Buffer[];
16
+ lastActivity: Date;
17
+ isActive: boolean;
18
+ }
19
+ export interface SessionManager {
20
+ sessions: Map<string, Session>;
21
+ createSession(worktreePath: string): Session;
22
+ getSession(worktreePath: string): Session | undefined;
23
+ destroySession(worktreePath: string): void;
24
+ getAllSessions(): Session[];
25
+ }
26
+ export interface ShortcutKey {
27
+ ctrl?: boolean;
28
+ alt?: boolean;
29
+ shift?: boolean;
30
+ key: string;
31
+ }
32
+ export interface ShortcutConfig {
33
+ returnToMenu: ShortcutKey;
34
+ cancel: ShortcutKey;
35
+ }
36
+ export declare const DEFAULT_SHORTCUTS: ShortcutConfig;
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_SHORTCUTS = {
2
+ returnToMenu: { ctrl: true, key: 'e' },
3
+ cancel: { key: 'escape' },
4
+ };
@@ -0,0 +1,14 @@
1
+ export declare const log: {
2
+ log: (...args: unknown[]) => void;
3
+ info: (...args: unknown[]) => void;
4
+ warn: (...args: unknown[]) => void;
5
+ error: (...args: unknown[]) => void;
6
+ debug: (...args: unknown[]) => void;
7
+ };
8
+ export declare const logger: {
9
+ log: (...args: unknown[]) => void;
10
+ info: (...args: unknown[]) => void;
11
+ warn: (...args: unknown[]) => void;
12
+ error: (...args: unknown[]) => void;
13
+ debug: (...args: unknown[]) => void;
14
+ };
@@ -0,0 +1,21 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { format } from 'util';
4
+ const LOG_FILE = path.join(process.cwd(), 'ccmanager.log');
5
+ // Clear log file on startup
6
+ fs.writeFileSync(LOG_FILE, '', 'utf8');
7
+ function writeLog(level, args) {
8
+ const timestamp = new Date().toISOString();
9
+ const message = format(...args);
10
+ const logLine = `[${timestamp}] [${level}] ${message}\n`;
11
+ fs.appendFileSync(LOG_FILE, logLine, 'utf8');
12
+ }
13
+ export const log = {
14
+ log: (...args) => writeLog('LOG', args),
15
+ info: (...args) => writeLog('INFO', args),
16
+ warn: (...args) => writeLog('WARN', args),
17
+ error: (...args) => writeLog('ERROR', args),
18
+ debug: (...args) => writeLog('DEBUG', args),
19
+ };
20
+ // Alias for console.log style usage
21
+ export const logger = log;
@@ -0,0 +1 @@
1
+ export declare function includesPromptBoxBottomBorder(output: string): boolean;
@@ -0,0 +1,20 @@
1
+ export function includesPromptBoxBottomBorder(output) {
2
+ // Check if the output includes a prompt box bottom border
3
+ return output
4
+ .trim()
5
+ .split('\n')
6
+ .some(line => {
7
+ // Accept patterns:
8
+ // - `──╯` (ends with ╯)
9
+ // - `╰───╯` (starts with ╰ and ends with ╯)
10
+ // Reject if:
11
+ // - vertical line exists after ╯
12
+ // - line starts with ╰ but doesn't end with ╯
13
+ // Check if line ends with ╯ but not followed by │
14
+ if (line.endsWith('╯') && !line.includes('╯ │')) {
15
+ // Accept if it's just ──╯ or ╰───╯ pattern
16
+ return /─+╯$/.test(line) || /^╰─+╯$/.test(line);
17
+ }
18
+ return false;
19
+ });
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { includesPromptBoxBottomBorder } from './promptDetector.js';
3
+ describe('includesPromptBoxBottomBorder', () => {
4
+ it('should return false for empty output', () => {
5
+ expect(includesPromptBoxBottomBorder('')).toBe(false);
6
+ expect(includesPromptBoxBottomBorder(' ')).toBe(false);
7
+ expect(includesPromptBoxBottomBorder('\n\n')).toBe(false);
8
+ });
9
+ it('should accept lines ending with ╯', () => {
10
+ // Basic pattern
11
+ expect(includesPromptBoxBottomBorder('──╯')).toBe(true);
12
+ expect(includesPromptBoxBottomBorder('────────╯')).toBe(true);
13
+ expect(includesPromptBoxBottomBorder('─╯')).toBe(true);
14
+ });
15
+ it('should accept complete bottom border (╰───╯)', () => {
16
+ expect(includesPromptBoxBottomBorder('╰───╯')).toBe(true);
17
+ expect(includesPromptBoxBottomBorder('╰─────────────╯')).toBe(true);
18
+ expect(includesPromptBoxBottomBorder('╰─╯')).toBe(true);
19
+ });
20
+ it('should accept when part of multi-line output', () => {
21
+ const output1 = `Some text
22
+ ──╯
23
+ More text`;
24
+ expect(includesPromptBoxBottomBorder(output1)).toBe(true);
25
+ const output2 = `First line
26
+ ╰─────────────╯
27
+ Last line`;
28
+ expect(includesPromptBoxBottomBorder(output2)).toBe(true);
29
+ });
30
+ it('should accept with leading/trailing whitespace', () => {
31
+ expect(includesPromptBoxBottomBorder(' ──╯ ')).toBe(true);
32
+ expect(includesPromptBoxBottomBorder('\t╰───╯\t')).toBe(true);
33
+ expect(includesPromptBoxBottomBorder('\n──╯\n')).toBe(true);
34
+ });
35
+ it('should reject when ╯ is followed by │', () => {
36
+ expect(includesPromptBoxBottomBorder('──╯ │')).toBe(false);
37
+ expect(includesPromptBoxBottomBorder('╰───╯ │')).toBe(false);
38
+ expect(includesPromptBoxBottomBorder('──╯ │ more text')).toBe(false);
39
+ });
40
+ it('should reject when line starts with ╰ but does not end with ╯', () => {
41
+ expect(includesPromptBoxBottomBorder('╰───')).toBe(false);
42
+ expect(includesPromptBoxBottomBorder('╰─────────')).toBe(false);
43
+ expect(includesPromptBoxBottomBorder('╰─── some text')).toBe(false);
44
+ });
45
+ it('should reject lines that do not match the pattern', () => {
46
+ // Missing ─ characters
47
+ expect(includesPromptBoxBottomBorder('╯')).toBe(false);
48
+ expect(includesPromptBoxBottomBorder('╰╯')).toBe(false);
49
+ // Wrong characters
50
+ expect(includesPromptBoxBottomBorder('===╯')).toBe(false);
51
+ expect(includesPromptBoxBottomBorder('╰===╯')).toBe(false);
52
+ expect(includesPromptBoxBottomBorder('---╯')).toBe(false);
53
+ // Top border pattern
54
+ expect(includesPromptBoxBottomBorder('╭───╮')).toBe(false);
55
+ // Middle line pattern
56
+ expect(includesPromptBoxBottomBorder('│ > │')).toBe(false);
57
+ // Random text
58
+ expect(includesPromptBoxBottomBorder('Some random text')).toBe(false);
59
+ expect(includesPromptBoxBottomBorder('Exit code: 0')).toBe(false);
60
+ });
61
+ it('should handle complex multi-line scenarios correctly', () => {
62
+ const validOutput = `
63
+ ╭────────────────────╮
64
+ │ > hello │
65
+ ╰────────────────────╯
66
+ Some status text`;
67
+ expect(includesPromptBoxBottomBorder(validOutput)).toBe(true);
68
+ const invalidOutput = `
69
+ ╭────────────────────╮
70
+ │ > hello │
71
+ ╰────────────────────
72
+ Some other text`;
73
+ expect(includesPromptBoxBottomBorder(invalidOutput)).toBe(false);
74
+ });
75
+ it('should handle partial border at end of line', () => {
76
+ const partialBorder = `Some output text ──╯`;
77
+ expect(includesPromptBoxBottomBorder(partialBorder)).toBe(true);
78
+ const partialInvalid = `Some output text ──╯ │`;
79
+ expect(includesPromptBoxBottomBorder(partialInvalid)).toBe(false);
80
+ });
81
+ });
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "ccmanager",
3
+ "version": "0.0.1",
4
+ "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
+ "license": "MIT",
6
+ "author": "Kodai Kabasawa",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/kbwo/ccmanager.git"
10
+ },
11
+ "keywords": [
12
+ "claude",
13
+ "code",
14
+ "worktree",
15
+ "git",
16
+ "tui",
17
+ "cli"
18
+ ],
19
+ "bin": {
20
+ "ccmanager": "dist/cli.js"
21
+ },
22
+ "type": "module",
23
+ "engines": {
24
+ "node": ">=16"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "dev": "tsc --watch",
29
+ "start": "node dist/cli.js",
30
+ "test": "vitest",
31
+ "test:run": "vitest run",
32
+ "lint": "eslint src",
33
+ "lint:fix": "eslint src --fix",
34
+ "typecheck": "tsc --noEmit",
35
+ "prepublishOnly": "npm run lint && npm run typecheck && npm run test:run && npm run build",
36
+ "prepare": "npm run build"
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "dependencies": {
42
+ "ink": "^4.1.0",
43
+ "ink-select-input": "^5.0.0",
44
+ "ink-text-input": "^5.0.1",
45
+ "meow": "^11.0.0",
46
+ "node-pty": "^1.0.0",
47
+ "react": "^18.2.0"
48
+ },
49
+ "devDependencies": {
50
+ "@eslint/js": "^9.28.0",
51
+ "@sindresorhus/tsconfig": "^3.0.1",
52
+ "@types/node": "^20.0.0",
53
+ "@types/react": "^18.0.32",
54
+ "@typescript-eslint/eslint-plugin": "^8.33.1",
55
+ "@typescript-eslint/parser": "^8.33.1",
56
+ "@vdemedes/prettier-config": "^2.0.1",
57
+ "chalk": "^5.2.0",
58
+ "eslint": "^9.28.0",
59
+ "eslint-config-prettier": "^10.1.5",
60
+ "eslint-plugin-prettier": "^5.4.1",
61
+ "eslint-plugin-react": "^7.32.2",
62
+ "eslint-plugin-react-hooks": "^5.2.0",
63
+ "ink-testing-library": "^3.0.0",
64
+ "prettier": "^3.0.0",
65
+ "ts-node": "^10.9.1",
66
+ "typescript": "^5.0.3",
67
+ "vitest": "^3.2.2"
68
+ },
69
+ "prettier": "@vdemedes/prettier-config"
70
+ }