ccmanager 2.8.0 → 2.9.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 (82) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/components/Session.js +11 -6
  26. package/dist/hooks/useGitStatus.d.ts +11 -0
  27. package/dist/hooks/useGitStatus.js +70 -12
  28. package/dist/hooks/useGitStatus.test.js +30 -23
  29. package/dist/services/configurationManager.d.ts +75 -0
  30. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  31. package/dist/services/configurationManager.effect.test.js +407 -0
  32. package/dist/services/configurationManager.js +246 -0
  33. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  34. package/dist/services/projectManager.d.ts +98 -2
  35. package/dist/services/projectManager.js +228 -59
  36. package/dist/services/projectManager.test.js +242 -2
  37. package/dist/services/sessionManager.d.ts +44 -2
  38. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  39. package/dist/services/sessionManager.effect.test.js +321 -0
  40. package/dist/services/sessionManager.js +216 -65
  41. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  42. package/dist/services/sessionManager.test.js +40 -36
  43. package/dist/services/shortcutManager.d.ts +2 -0
  44. package/dist/services/shortcutManager.js +53 -0
  45. package/dist/services/shortcutManager.test.d.ts +1 -0
  46. package/dist/services/shortcutManager.test.js +30 -0
  47. package/dist/services/worktreeService.d.ts +356 -26
  48. package/dist/services/worktreeService.js +793 -353
  49. package/dist/services/worktreeService.test.js +294 -313
  50. package/dist/types/errors.d.ts +74 -0
  51. package/dist/types/errors.js +31 -0
  52. package/dist/types/errors.test.d.ts +1 -0
  53. package/dist/types/errors.test.js +201 -0
  54. package/dist/types/index.d.ts +5 -17
  55. package/dist/utils/claudeDir.d.ts +58 -6
  56. package/dist/utils/claudeDir.js +103 -8
  57. package/dist/utils/claudeDir.test.d.ts +1 -0
  58. package/dist/utils/claudeDir.test.js +108 -0
  59. package/dist/utils/concurrencyLimit.d.ts +5 -0
  60. package/dist/utils/concurrencyLimit.js +11 -0
  61. package/dist/utils/concurrencyLimit.test.js +40 -1
  62. package/dist/utils/gitStatus.d.ts +36 -8
  63. package/dist/utils/gitStatus.js +170 -88
  64. package/dist/utils/gitStatus.test.js +12 -9
  65. package/dist/utils/hookExecutor.d.ts +41 -6
  66. package/dist/utils/hookExecutor.js +75 -32
  67. package/dist/utils/hookExecutor.test.js +73 -20
  68. package/dist/utils/terminalCapabilities.d.ts +18 -0
  69. package/dist/utils/terminalCapabilities.js +81 -0
  70. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  71. package/dist/utils/terminalCapabilities.test.js +104 -0
  72. package/dist/utils/testHelpers.d.ts +106 -0
  73. package/dist/utils/testHelpers.js +153 -0
  74. package/dist/utils/testHelpers.test.d.ts +1 -0
  75. package/dist/utils/testHelpers.test.js +114 -0
  76. package/dist/utils/worktreeConfig.d.ts +77 -2
  77. package/dist/utils/worktreeConfig.js +156 -16
  78. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  79. package/dist/utils/worktreeConfig.test.js +39 -0
  80. package/package.json +4 -4
  81. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  82. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -0,0 +1,74 @@
1
+ declare const GitError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
2
+ readonly _tag: "GitError";
3
+ } & Readonly<A>;
4
+ /**
5
+ * Git operation errors
6
+ * Used when git commands fail with non-zero exit codes
7
+ */
8
+ export declare class GitError extends GitError_base<{
9
+ readonly command: string;
10
+ readonly exitCode: number;
11
+ readonly stderr: string;
12
+ readonly stdout?: string;
13
+ }> {
14
+ }
15
+ declare const FileSystemError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
16
+ readonly _tag: "FileSystemError";
17
+ } & Readonly<A>;
18
+ /**
19
+ * File system operation errors
20
+ * Used when file system operations (read, write, delete, mkdir, stat) fail
21
+ */
22
+ export declare class FileSystemError extends FileSystemError_base<{
23
+ readonly operation: 'read' | 'write' | 'delete' | 'mkdir' | 'stat';
24
+ readonly path: string;
25
+ readonly cause: string;
26
+ }> {
27
+ }
28
+ declare const ConfigError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
29
+ readonly _tag: "ConfigError";
30
+ } & Readonly<A>;
31
+ /**
32
+ * Configuration errors
33
+ * Used when configuration operations fail (parsing, validation, migration)
34
+ */
35
+ export declare class ConfigError extends ConfigError_base<{
36
+ readonly configPath: string;
37
+ readonly reason: 'parse' | 'validation' | 'missing' | 'migration';
38
+ readonly details: string;
39
+ }> {
40
+ }
41
+ declare const ProcessError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
42
+ readonly _tag: "ProcessError";
43
+ } & Readonly<A>;
44
+ /**
45
+ * Process/PTY errors
46
+ * Used when process spawning or PTY operations fail
47
+ */
48
+ export declare class ProcessError extends ProcessError_base<{
49
+ readonly processId?: number;
50
+ readonly command: string;
51
+ readonly signal?: string;
52
+ readonly exitCode?: number;
53
+ readonly message: string;
54
+ }> {
55
+ }
56
+ declare const ValidationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
57
+ readonly _tag: "ValidationError";
58
+ } & Readonly<A>;
59
+ /**
60
+ * Validation errors
61
+ * Used when input validation fails
62
+ */
63
+ export declare class ValidationError extends ValidationError_base<{
64
+ readonly field: string;
65
+ readonly constraint: string;
66
+ readonly receivedValue: unknown;
67
+ }> {
68
+ }
69
+ /**
70
+ * Union type for all application errors
71
+ * Enables discriminated union type narrowing using _tag property
72
+ */
73
+ export type AppError = GitError | FileSystemError | ConfigError | ProcessError | ValidationError;
74
+ export {};
@@ -0,0 +1,31 @@
1
+ import { Data } from 'effect';
2
+ /**
3
+ * Git operation errors
4
+ * Used when git commands fail with non-zero exit codes
5
+ */
6
+ export class GitError extends Data.TaggedError('GitError') {
7
+ }
8
+ /**
9
+ * File system operation errors
10
+ * Used when file system operations (read, write, delete, mkdir, stat) fail
11
+ */
12
+ export class FileSystemError extends Data.TaggedError('FileSystemError') {
13
+ }
14
+ /**
15
+ * Configuration errors
16
+ * Used when configuration operations fail (parsing, validation, migration)
17
+ */
18
+ export class ConfigError extends Data.TaggedError('ConfigError') {
19
+ }
20
+ /**
21
+ * Process/PTY errors
22
+ * Used when process spawning or PTY operations fail
23
+ */
24
+ export class ProcessError extends Data.TaggedError('ProcessError') {
25
+ }
26
+ /**
27
+ * Validation errors
28
+ * Used when input validation fails
29
+ */
30
+ export class ValidationError extends Data.TaggedError('ValidationError') {
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ /**
3
+ * Tests for structured error types using Effect-ts Data.TaggedError
4
+ */
5
+ describe('Error Types', () => {
6
+ describe('GitError', () => {
7
+ it('should create GitError with required fields', async () => {
8
+ const { GitError } = await import('./errors.js');
9
+ const error = new GitError({
10
+ command: 'git worktree add',
11
+ exitCode: 1,
12
+ stderr: 'fatal: invalid reference',
13
+ });
14
+ expect(error).toBeInstanceOf(Error);
15
+ expect(error._tag).toBe('GitError');
16
+ expect(error.command).toBe('git worktree add');
17
+ expect(error.exitCode).toBe(1);
18
+ expect(error.stderr).toBe('fatal: invalid reference');
19
+ });
20
+ it('should create GitError with optional stdout', async () => {
21
+ const { GitError } = await import('./errors.js');
22
+ const error = new GitError({
23
+ command: 'git status',
24
+ exitCode: 128,
25
+ stderr: 'not a git repository',
26
+ stdout: 'some output',
27
+ });
28
+ expect(error.stdout).toBe('some output');
29
+ });
30
+ it('should have stack trace', async () => {
31
+ const { GitError } = await import('./errors.js');
32
+ const error = new GitError({
33
+ command: 'git log',
34
+ exitCode: 1,
35
+ stderr: 'error',
36
+ });
37
+ expect(error.stack).toBeDefined();
38
+ });
39
+ });
40
+ describe('FileSystemError', () => {
41
+ it('should create FileSystemError with required fields', async () => {
42
+ const { FileSystemError } = await import('./errors.js');
43
+ const error = new FileSystemError({
44
+ operation: 'read',
45
+ path: '/tmp/config.json',
46
+ cause: 'ENOENT: no such file or directory',
47
+ });
48
+ expect(error).toBeInstanceOf(Error);
49
+ expect(error._tag).toBe('FileSystemError');
50
+ expect(error.operation).toBe('read');
51
+ expect(error.path).toBe('/tmp/config.json');
52
+ expect(error.cause).toBe('ENOENT: no such file or directory');
53
+ });
54
+ it('should support all operation types', async () => {
55
+ const { FileSystemError } = await import('./errors.js');
56
+ const operations = ['read', 'write', 'delete', 'mkdir', 'stat'];
57
+ for (const operation of operations) {
58
+ const error = new FileSystemError({
59
+ operation,
60
+ path: '/test',
61
+ cause: 'test',
62
+ });
63
+ expect(error.operation).toBe(operation);
64
+ }
65
+ });
66
+ });
67
+ describe('ConfigError', () => {
68
+ it('should create ConfigError with required fields', async () => {
69
+ const { ConfigError } = await import('./errors.js');
70
+ const error = new ConfigError({
71
+ configPath: '~/.config/ccmanager/config.json',
72
+ reason: 'parse',
73
+ details: 'Unexpected token in JSON at position 42',
74
+ });
75
+ expect(error).toBeInstanceOf(Error);
76
+ expect(error._tag).toBe('ConfigError');
77
+ expect(error.configPath).toBe('~/.config/ccmanager/config.json');
78
+ expect(error.reason).toBe('parse');
79
+ expect(error.details).toBe('Unexpected token in JSON at position 42');
80
+ });
81
+ it('should support all reason types', async () => {
82
+ const { ConfigError } = await import('./errors.js');
83
+ const reasons = [
84
+ 'parse',
85
+ 'validation',
86
+ 'missing',
87
+ 'migration',
88
+ ];
89
+ for (const reason of reasons) {
90
+ const error = new ConfigError({
91
+ configPath: '/test',
92
+ reason,
93
+ details: 'test',
94
+ });
95
+ expect(error.reason).toBe(reason);
96
+ }
97
+ });
98
+ });
99
+ describe('ProcessError', () => {
100
+ it('should create ProcessError with required fields', async () => {
101
+ const { ProcessError } = await import('./errors.js');
102
+ const error = new ProcessError({
103
+ command: 'claude',
104
+ message: 'Failed to spawn process',
105
+ });
106
+ expect(error).toBeInstanceOf(Error);
107
+ expect(error._tag).toBe('ProcessError');
108
+ expect(error.command).toBe('claude');
109
+ expect(error.message).toBe('Failed to spawn process');
110
+ });
111
+ it('should create ProcessError with optional fields', async () => {
112
+ const { ProcessError } = await import('./errors.js');
113
+ const error = new ProcessError({
114
+ processId: 1234,
115
+ command: 'devcontainer exec',
116
+ signal: 'SIGTERM',
117
+ exitCode: 143,
118
+ message: 'Process terminated',
119
+ });
120
+ expect(error.processId).toBe(1234);
121
+ expect(error.signal).toBe('SIGTERM');
122
+ expect(error.exitCode).toBe(143);
123
+ });
124
+ });
125
+ describe('ValidationError', () => {
126
+ it('should create ValidationError with required fields', async () => {
127
+ const { ValidationError } = await import('./errors.js');
128
+ const error = new ValidationError({
129
+ field: 'presetId',
130
+ constraint: 'must be a valid preset ID',
131
+ receivedValue: 'invalid-id',
132
+ });
133
+ expect(error).toBeInstanceOf(Error);
134
+ expect(error._tag).toBe('ValidationError');
135
+ expect(error.field).toBe('presetId');
136
+ expect(error.constraint).toBe('must be a valid preset ID');
137
+ expect(error.receivedValue).toBe('invalid-id');
138
+ });
139
+ it('should handle null and undefined received values', async () => {
140
+ const { ValidationError } = await import('./errors.js');
141
+ const nullError = new ValidationError({
142
+ field: 'name',
143
+ constraint: 'required',
144
+ receivedValue: null,
145
+ });
146
+ expect(nullError.receivedValue).toBeNull();
147
+ const undefinedError = new ValidationError({
148
+ field: 'name',
149
+ constraint: 'required',
150
+ receivedValue: undefined,
151
+ });
152
+ expect(undefinedError.receivedValue).toBeUndefined();
153
+ });
154
+ });
155
+ describe('AppError Union Type', () => {
156
+ it('should support discriminated union via _tag', async () => {
157
+ const { GitError, FileSystemError, ConfigError, ProcessError, ValidationError, } = await import('./errors.js');
158
+ const errors = [
159
+ new GitError({ command: 'git', exitCode: 1, stderr: 'error' }),
160
+ new FileSystemError({ operation: 'read', path: '/test', cause: 'error' }),
161
+ new ConfigError({
162
+ configPath: '/test',
163
+ reason: 'parse',
164
+ details: 'error',
165
+ }),
166
+ new ProcessError({ command: 'cmd', message: 'error' }),
167
+ new ValidationError({
168
+ field: 'test',
169
+ constraint: 'required',
170
+ receivedValue: null,
171
+ }),
172
+ ];
173
+ const tags = errors.map(e => e._tag);
174
+ expect(tags).toEqual([
175
+ 'GitError',
176
+ 'FileSystemError',
177
+ 'ConfigError',
178
+ 'ProcessError',
179
+ 'ValidationError',
180
+ ]);
181
+ });
182
+ it('should be able to narrow types using _tag', async () => {
183
+ const { GitError } = await import('./errors.js');
184
+ // Create a GitError which is a valid AppError
185
+ const error = new GitError({
186
+ command: 'git log',
187
+ exitCode: 1,
188
+ stderr: 'error',
189
+ });
190
+ if (error._tag === 'GitError') {
191
+ // TypeScript should narrow the type here
192
+ expect(error.command).toBe('git log');
193
+ expect(error.exitCode).toBe(1);
194
+ expect(error.stderr).toBe('error');
195
+ }
196
+ else {
197
+ throw new Error('Should be GitError');
198
+ }
199
+ });
200
+ });
201
+ });
@@ -127,7 +127,6 @@ export interface IProjectManager {
127
127
  setMode(mode: MenuMode): void;
128
128
  selectProject(project: GitProject): void;
129
129
  getWorktreeService(projectPath?: string): IWorktreeService;
130
- refreshProjects(): Promise<void>;
131
130
  getRecentProjects(limit?: number): RecentProject[];
132
131
  addRecentProject(project: GitProject): void;
133
132
  clearRecentProjects(): void;
@@ -144,22 +143,11 @@ export declare class AmbiguousBranchError extends Error {
144
143
  constructor(branchName: string, matches: RemoteBranchMatch[]);
145
144
  }
146
145
  export interface IWorktreeService {
147
- getWorktrees(): Worktree[];
146
+ getWorktreesEffect(): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
148
147
  getGitRootPath(): string;
149
- createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Promise<{
150
- success: boolean;
151
- error?: string;
152
- }>;
153
- deleteWorktree(worktreePath: string, options?: {
148
+ createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<Worktree, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError, never>;
149
+ deleteWorktreeEffect(worktreePath: string, options?: {
154
150
  deleteBranch?: boolean;
155
- }): {
156
- success: boolean;
157
- error?: string;
158
- };
159
- mergeWorktree(worktreePath: string, targetBranch?: string): {
160
- success: boolean;
161
- mergedBranch?: string;
162
- error?: string;
163
- deletedWorktree?: boolean;
164
- };
151
+ }): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
152
+ mergeWorktreeEffect(sourceBranch: string, targetBranch: string, useRebase?: boolean): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
165
153
  }
@@ -4,19 +4,71 @@
4
4
  * CLAUDE_CONFIG_DIR environment variable and convert worktree paths to Claude's
5
5
  * project naming convention.
6
6
  */
7
+ import { Effect, Either } from 'effect';
8
+ import { ValidationError, FileSystemError } from '../types/errors.js';
7
9
  /**
8
- * Get the Claude directory path, respecting CLAUDE_CONFIG_DIR environment variable
9
- * @returns The Claude directory path
10
+ * Get the Claude directory path using Either for synchronous validation
11
+ * Returns Either with ValidationError if HOME directory cannot be determined
10
12
  */
11
- export declare function getClaudeDir(): string;
13
+ export declare function getClaudeDir(): Either.Either<string, ValidationError>;
12
14
  /**
13
- * Get the Claude projects directory path
14
- * @returns The Claude projects directory path
15
+ * Get the Claude projects directory path using Either
16
+ *
17
+ * Propagates ValidationError from getClaudeDir if HOME directory cannot be determined.
18
+ *
19
+ * @returns {Either.Either<string, ValidationError>} Either containing projects directory path or ValidationError
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import {Either} from 'effect';
24
+ * import {getClaudeProjectsDir} from './utils/claudeDir.js';
25
+ *
26
+ * const projectsDirEither = getClaudeProjectsDir();
27
+ *
28
+ * if (Either.isRight(projectsDirEither)) {
29
+ * console.log(`Projects directory: ${projectsDirEither.right}`);
30
+ * } else {
31
+ * console.error(`Failed to get directory: ${projectsDirEither.left.field} ${projectsDirEither.left.constraint}`);
32
+ * }
33
+ * ```
15
34
  */
16
- export declare function getClaudeProjectsDir(): string;
35
+ export declare function getClaudeProjectsDir(): Either.Either<string, ValidationError>;
17
36
  /**
18
37
  * Convert a worktree path to Claude's project naming convention
38
+ * Pure transformation, cannot fail
19
39
  * @param worktreePath The path to the worktree
20
40
  * @returns The project name used by Claude
21
41
  */
22
42
  export declare function pathToClaudeProjectName(worktreePath: string): string;
43
+ /**
44
+ * Check if Claude project directory exists using Effect
45
+ *
46
+ * Returns false for ENOENT (directory doesn't exist), fails with FileSystemError for other errors.
47
+ *
48
+ * @param {string} projectName - Claude project name to check
49
+ * @returns {Effect.Effect<boolean, FileSystemError, never>} Effect containing boolean indicating existence or FileSystemError
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * import {Effect} from 'effect';
54
+ * import {claudeDirExists, pathToClaudeProjectName} from './utils/claudeDir.js';
55
+ *
56
+ * const projectName = pathToClaudeProjectName('/path/to/worktree');
57
+ *
58
+ * // Check if directory exists with error handling
59
+ * const exists = await Effect.runPromise(
60
+ * Effect.catchAll(
61
+ * claudeDirExists(projectName),
62
+ * (error) => {
63
+ * console.error(`Failed to check directory: ${error.cause}`);
64
+ * return Effect.succeed(false); // Assume doesn't exist on error
65
+ * }
66
+ * )
67
+ * );
68
+ *
69
+ * if (exists) {
70
+ * console.log('Claude project directory exists');
71
+ * }
72
+ * ```
73
+ */
74
+ export declare function claudeDirExists(projectName: string): Effect.Effect<boolean, FileSystemError>;
@@ -6,27 +6,65 @@
6
6
  */
7
7
  import path from 'path';
8
8
  import os from 'os';
9
+ import { promises as fs } from 'fs';
10
+ import { Effect, Either } from 'effect';
11
+ import { ValidationError, FileSystemError } from '../types/errors.js';
9
12
  /**
10
- * Get the Claude directory path, respecting CLAUDE_CONFIG_DIR environment variable
11
- * @returns The Claude directory path
13
+ * Get the Claude directory path using Either for synchronous validation
14
+ * Returns Either with ValidationError if HOME directory cannot be determined
12
15
  */
13
16
  export function getClaudeDir() {
14
17
  const envConfigDir = process.env['CLAUDE_CONFIG_DIR'];
15
18
  if (envConfigDir) {
16
- return envConfigDir.trim();
19
+ return Either.right(envConfigDir.trim());
20
+ }
21
+ // Try to get home directory
22
+ try {
23
+ const homeDir = os.homedir();
24
+ if (!homeDir) {
25
+ return Either.left(new ValidationError({
26
+ field: 'HOME',
27
+ constraint: 'must be set',
28
+ receivedValue: undefined,
29
+ }));
30
+ }
31
+ return Either.right(path.join(homeDir, '.claude'));
32
+ }
33
+ catch {
34
+ return Either.left(new ValidationError({
35
+ field: 'HOME',
36
+ constraint: 'must be accessible',
37
+ receivedValue: undefined,
38
+ }));
17
39
  }
18
- // Default to ~/.claude for backward compatibility and when not set
19
- return path.join(os.homedir(), '.claude');
20
40
  }
21
41
  /**
22
- * Get the Claude projects directory path
23
- * @returns The Claude projects directory path
42
+ * Get the Claude projects directory path using Either
43
+ *
44
+ * Propagates ValidationError from getClaudeDir if HOME directory cannot be determined.
45
+ *
46
+ * @returns {Either.Either<string, ValidationError>} Either containing projects directory path or ValidationError
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * import {Either} from 'effect';
51
+ * import {getClaudeProjectsDir} from './utils/claudeDir.js';
52
+ *
53
+ * const projectsDirEither = getClaudeProjectsDir();
54
+ *
55
+ * if (Either.isRight(projectsDirEither)) {
56
+ * console.log(`Projects directory: ${projectsDirEither.right}`);
57
+ * } else {
58
+ * console.error(`Failed to get directory: ${projectsDirEither.left.field} ${projectsDirEither.left.constraint}`);
59
+ * }
60
+ * ```
24
61
  */
25
62
  export function getClaudeProjectsDir() {
26
- return path.join(getClaudeDir(), 'projects');
63
+ return Either.map(getClaudeDir(), dir => path.join(dir, 'projects'));
27
64
  }
28
65
  /**
29
66
  * Convert a worktree path to Claude's project naming convention
67
+ * Pure transformation, cannot fail
30
68
  * @param worktreePath The path to the worktree
31
69
  * @returns The project name used by Claude
32
70
  */
@@ -37,3 +75,60 @@ export function pathToClaudeProjectName(worktreePath) {
37
75
  // Handle both forward slashes (Linux/macOS) and backslashes (Windows)
38
76
  return resolved.replace(/[/\\.]/g, '-');
39
77
  }
78
+ /**
79
+ * Check if Claude project directory exists using Effect
80
+ *
81
+ * Returns false for ENOENT (directory doesn't exist), fails with FileSystemError for other errors.
82
+ *
83
+ * @param {string} projectName - Claude project name to check
84
+ * @returns {Effect.Effect<boolean, FileSystemError, never>} Effect containing boolean indicating existence or FileSystemError
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * import {Effect} from 'effect';
89
+ * import {claudeDirExists, pathToClaudeProjectName} from './utils/claudeDir.js';
90
+ *
91
+ * const projectName = pathToClaudeProjectName('/path/to/worktree');
92
+ *
93
+ * // Check if directory exists with error handling
94
+ * const exists = await Effect.runPromise(
95
+ * Effect.catchAll(
96
+ * claudeDirExists(projectName),
97
+ * (error) => {
98
+ * console.error(`Failed to check directory: ${error.cause}`);
99
+ * return Effect.succeed(false); // Assume doesn't exist on error
100
+ * }
101
+ * )
102
+ * );
103
+ *
104
+ * if (exists) {
105
+ * console.log('Claude project directory exists');
106
+ * }
107
+ * ```
108
+ */
109
+ export function claudeDirExists(projectName) {
110
+ const claudeDirEither = getClaudeProjectsDir();
111
+ if (Either.isLeft(claudeDirEither)) {
112
+ // If we can't determine the projects directory, return false
113
+ return Effect.succeed(false);
114
+ }
115
+ const projectPath = path.join(claudeDirEither.right, projectName);
116
+ return Effect.catchAll(Effect.tryPromise({
117
+ try: () => fs.stat(projectPath).then(() => true),
118
+ catch: error => error,
119
+ }), error => {
120
+ // ENOENT means directory doesn't exist, which is not an error
121
+ if (typeof error === 'object' &&
122
+ error !== null &&
123
+ 'code' in error &&
124
+ error.code === 'ENOENT') {
125
+ return Effect.succeed(false);
126
+ }
127
+ // Other errors are filesystem issues
128
+ return Effect.fail(new FileSystemError({
129
+ operation: 'stat',
130
+ path: projectPath,
131
+ cause: error instanceof Error ? error.message : String(error),
132
+ }));
133
+ });
134
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Effect, Either } from 'effect';
3
+ import { getClaudeDir, getClaudeProjectsDir, pathToClaudeProjectName, claudeDirExists, } from './claudeDir.js';
4
+ describe('claudeDir', () => {
5
+ const originalEnv = process.env;
6
+ beforeEach(() => {
7
+ process.env = { ...originalEnv };
8
+ });
9
+ afterEach(() => {
10
+ process.env = originalEnv;
11
+ });
12
+ describe('getClaudeDir', () => {
13
+ it('should return Either.right with CLAUDE_CONFIG_DIR when set', () => {
14
+ process.env['CLAUDE_CONFIG_DIR'] = '/custom/claude';
15
+ const result = getClaudeDir();
16
+ expect(Either.isRight(result)).toBe(true);
17
+ if (Either.isRight(result)) {
18
+ expect(result.right).toBe('/custom/claude');
19
+ }
20
+ });
21
+ it('should trim whitespace from CLAUDE_CONFIG_DIR', () => {
22
+ process.env['CLAUDE_CONFIG_DIR'] = ' /custom/claude ';
23
+ const result = getClaudeDir();
24
+ expect(Either.isRight(result)).toBe(true);
25
+ if (Either.isRight(result)) {
26
+ expect(result.right).toBe('/custom/claude');
27
+ }
28
+ });
29
+ it('should return Either.right with default ~/.claude when env var not set', () => {
30
+ delete process.env['CLAUDE_CONFIG_DIR'];
31
+ const result = getClaudeDir();
32
+ expect(Either.isRight(result)).toBe(true);
33
+ if (Either.isRight(result)) {
34
+ expect(result.right).toContain('.claude');
35
+ }
36
+ });
37
+ it('should return Either.right even when HOME vars deleted (os.homedir has fallbacks)', () => {
38
+ delete process.env['CLAUDE_CONFIG_DIR'];
39
+ delete process.env['HOME'];
40
+ delete process.env['USERPROFILE'];
41
+ const result = getClaudeDir();
42
+ // os.homedir() has multiple fallback mechanisms, so this will likely still succeed
43
+ expect(Either.isRight(result)).toBe(true);
44
+ });
45
+ });
46
+ describe('getClaudeProjectsDir', () => {
47
+ it('should return Either.right with projects subdirectory', () => {
48
+ process.env['CLAUDE_CONFIG_DIR'] = '/custom/claude';
49
+ const result = getClaudeProjectsDir();
50
+ expect(Either.isRight(result)).toBe(true);
51
+ if (Either.isRight(result)) {
52
+ expect(result.right).toBe('/custom/claude/projects');
53
+ }
54
+ });
55
+ it('should return Either.right from getClaudeDir fallbacks', () => {
56
+ delete process.env['CLAUDE_CONFIG_DIR'];
57
+ delete process.env['HOME'];
58
+ delete process.env['USERPROFILE'];
59
+ const result = getClaudeProjectsDir();
60
+ // Since getClaudeDir has fallbacks, this should succeed
61
+ expect(Either.isRight(result)).toBe(true);
62
+ });
63
+ });
64
+ describe('pathToClaudeProjectName', () => {
65
+ it('should convert absolute path to Claude naming convention', () => {
66
+ const result = pathToClaudeProjectName('/home/user/projects/myapp');
67
+ expect(result).toBe('-home-user-projects-myapp');
68
+ });
69
+ it('should replace forward slashes with dashes', () => {
70
+ const result = pathToClaudeProjectName('/a/b/c');
71
+ expect(result).toBe('-a-b-c');
72
+ });
73
+ it('should replace backslashes with dashes (Windows)', () => {
74
+ const result = pathToClaudeProjectName('C:\\Users\\test\\app');
75
+ expect(result).toContain('-');
76
+ expect(result).not.toContain('\\');
77
+ });
78
+ it('should replace dots with dashes', () => {
79
+ const result = pathToClaudeProjectName('/home/user/my.app');
80
+ expect(result).toBe('-home-user-my-app');
81
+ });
82
+ });
83
+ describe('claudeDirExists', () => {
84
+ it('should return Effect with false for nonexistent directory', async () => {
85
+ const effect = claudeDirExists('nonexistent-dir-12345-test-project');
86
+ const result = await Effect.runPromise(effect);
87
+ expect(result).toBe(false);
88
+ });
89
+ it('should check in Claude projects directory', async () => {
90
+ // This test verifies the path construction, not the existence
91
+ // Since ~/.claude/projects likely doesn't exist in test environment
92
+ const effect = claudeDirExists('any-project-name');
93
+ const result = await Effect.runPromise(effect);
94
+ // Should return false (directory doesn't exist) not throw an error
95
+ expect(result).toBe(false);
96
+ });
97
+ it('should fail with FileSystemError on access error', async () => {
98
+ // Mock fs.stat to throw a non-ENOENT error
99
+ const { promises: fs } = await import('fs');
100
+ vi.spyOn(fs, 'stat').mockRejectedValue(Object.assign(new Error('Permission denied'), { code: 'EACCES' }));
101
+ const effect = claudeDirExists('test-project');
102
+ const exit = await Effect.runPromiseExit(effect);
103
+ expect(exit._tag).toBe('Failure');
104
+ // Restore original
105
+ vi.mocked(fs.stat).mockRestore();
106
+ });
107
+ });
108
+ });