Skip to content

Commit 9c9e57d

Browse files
authored
Ensure slash command paths resolve on Windows platforms (#144)
* Ensure slash command paths work on Windows * Add Linux home path coverage for joinPath
1 parent c7ca76c commit 9c9e57d

File tree

4 files changed

+86
-9
lines changed

4 files changed

+86
-9
lines changed

src/core/configurators/slash/base.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import path from 'path';
21
import { FileSystemUtils } from '../../../utils/file-system.js';
32
import { TemplateManager, SlashCommandId } from '../../templates/index.js';
43
import { OPENSPEC_MARKERS } from '../../config.js';
@@ -28,7 +27,7 @@ export abstract class SlashCommandConfigurator {
2827

2928
for (const target of this.getTargets()) {
3029
const body = TemplateManager.getSlashCommandBody(target.id).trim();
31-
const filePath = path.join(projectPath, target.path);
30+
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
3231

3332
if (await FileSystemUtils.fileExists(filePath)) {
3433
await this.updateBody(filePath, body);
@@ -53,7 +52,7 @@ export abstract class SlashCommandConfigurator {
5352
const updated: string[] = [];
5453

5554
for (const target of this.getTargets()) {
56-
const filePath = path.join(projectPath, target.path);
55+
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
5756
if (await FileSystemUtils.fileExists(filePath)) {
5857
const body = TemplateManager.getSlashCommandBody(target.id).trim();
5958
await this.updateBody(filePath, body);
@@ -71,7 +70,7 @@ export abstract class SlashCommandConfigurator {
7170
// to redirect to tool-specific locations (e.g., global directories).
7271
resolveAbsolutePath(projectPath: string, id: SlashCommandId): string {
7372
const rel = this.getRelativePath(id);
74-
return path.join(projectPath, rel);
73+
return FileSystemUtils.joinPath(projectPath, rel);
7574
}
7675

7776
protected async updateBody(filePath: string, body: string): Promise<void> {

src/core/configurators/slash/codex.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ $ARGUMENTS`,
4949
private getGlobalPromptsDir(): string {
5050
const home = (process.env.CODEX_HOME && process.env.CODEX_HOME.trim())
5151
? process.env.CODEX_HOME.trim()
52-
: path.join(os.homedir(), ".codex");
53-
return path.join(home, "prompts");
52+
: FileSystemUtils.joinPath(os.homedir(), ".codex");
53+
return FileSystemUtils.joinPath(home, "prompts");
5454
}
5555

5656
// Codex discovers prompts globally. Generate directly in the global directory
@@ -60,7 +60,10 @@ $ARGUMENTS`,
6060
for (const target of this.getTargets()) {
6161
const body = TemplateManager.getSlashCommandBody(target.id).trim();
6262
const promptsDir = this.getGlobalPromptsDir();
63-
const filePath = path.join(promptsDir, path.basename(target.path));
63+
const filePath = FileSystemUtils.joinPath(
64+
promptsDir,
65+
path.basename(target.path)
66+
);
6467

6568
await FileSystemUtils.createDirectory(path.dirname(filePath));
6669

@@ -83,7 +86,10 @@ $ARGUMENTS`,
8386
const updated: string[] = [];
8487
for (const target of this.getTargets()) {
8588
const promptsDir = this.getGlobalPromptsDir();
86-
const filePath = path.join(promptsDir, path.basename(target.path));
89+
const filePath = FileSystemUtils.joinPath(
90+
promptsDir,
91+
path.basename(target.path)
92+
);
8793
if (await FileSystemUtils.fileExists(filePath)) {
8894
const body = TemplateManager.getSlashCommandBody(target.id).trim();
8995
await this.updateFullFile(filePath, target.id, body);
@@ -115,6 +121,6 @@ $ARGUMENTS`,
115121
resolveAbsolutePath(_projectPath: string, id: SlashCommandId): string {
116122
const promptsDir = this.getGlobalPromptsDir();
117123
const fileName = path.basename(FILE_PATHS[id]);
118-
return path.join(promptsDir, fileName);
124+
return FileSystemUtils.joinPath(promptsDir, fileName);
119125
}
120126
}

src/utils/file-system.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,30 @@ function findMarkerIndex(
4242
}
4343

4444
export class FileSystemUtils {
45+
private static isWindowsBasePath(basePath: string): boolean {
46+
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
47+
}
48+
49+
private static normalizeSegments(segments: string[]): string[] {
50+
return segments
51+
.flatMap((segment) => segment.split(/[\\/]+/u))
52+
.filter((part) => part.length > 0);
53+
}
54+
55+
static joinPath(basePath: string, ...segments: string[]): string {
56+
const normalizedSegments = this.normalizeSegments(segments);
57+
58+
if (this.isWindowsBasePath(basePath)) {
59+
return normalizedSegments.length
60+
? path.win32.join(basePath, ...normalizedSegments)
61+
: path.win32.normalize(basePath);
62+
}
63+
64+
return normalizedSegments.length
65+
? path.join(basePath, ...normalizedSegments)
66+
: path.join(basePath);
67+
}
68+
4569
static async createDirectory(dirPath: string): Promise<void> {
4670
await fs.mkdir(dirPath, { recursive: true });
4771
}

test/utils/file-system.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,52 @@ describe('FileSystemUtils', () => {
160160
expect(hasPermission).toBe(true);
161161
});
162162
});
163+
164+
describe('joinPath', () => {
165+
it('should join POSIX-style paths', () => {
166+
const result = FileSystemUtils.joinPath(
167+
'/tmp/project',
168+
'.claude/commands/openspec/proposal.md'
169+
);
170+
expect(result).toBe('/tmp/project/.claude/commands/openspec/proposal.md');
171+
});
172+
173+
it('should join Linux home directory paths', () => {
174+
const result = FileSystemUtils.joinPath(
175+
'/home/dev/workspace/openspec',
176+
'.cursor/commands/install.md'
177+
);
178+
expect(result).toBe('/home/dev/workspace/openspec/.cursor/commands/install.md');
179+
});
180+
181+
it('should join Windows drive-letter paths with backslashes', () => {
182+
const result = FileSystemUtils.joinPath(
183+
'C:\\Users\\dev\\project',
184+
'.claude/commands/openspec/proposal.md'
185+
);
186+
expect(result).toBe(
187+
'C:\\Users\\dev\\project\\.claude\\commands\\openspec\\proposal.md'
188+
);
189+
});
190+
191+
it('should join Windows paths that use forward slashes', () => {
192+
const result = FileSystemUtils.joinPath(
193+
'D:/workspace/app',
194+
'.cursor/commands/openspec-apply.md'
195+
);
196+
expect(result).toBe(
197+
'D:\\workspace\\app\\.cursor\\commands\\openspec-apply.md'
198+
);
199+
});
200+
201+
it('should join UNC-style Windows paths', () => {
202+
const result = FileSystemUtils.joinPath(
203+
'\\server\\share\\repo',
204+
'.windsurf/workflows/openspec-archive.md'
205+
);
206+
expect(result).toBe(
207+
'\\server\\share\\repo\\.windsurf\\workflows\\openspec-archive.md'
208+
);
209+
});
210+
});
163211
});

0 commit comments

Comments
 (0)