From a84682cd35e94b0408f6c6a990af0732c2acf03f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:58:42 -0700 Subject: [PATCH 1/2] fix(targets): nest colon-separated command names into directories On Windows/NTFS, colons are reserved for alternate data streams, so filenames like "ce:plan.md" are invalid. Split colon-separated command names into nested directories (e.g. "ce:plan" -> "ce/plan.md"), matching the approach already used by the Qwen target. Applied to opencode, droid, and gemini targets. Fixes #226 Co-Authored-By: Claude Opus 4.6 --- src/targets/droid.ts | 11 ++++++++++- src/targets/gemini.ts | 11 ++++++++++- src/targets/opencode.ts | 12 +++++++++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/targets/droid.ts b/src/targets/droid.ts index 85600766..bdf72f43 100644 --- a/src/targets/droid.ts +++ b/src/targets/droid.ts @@ -9,7 +9,16 @@ export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle): if (bundle.commands.length > 0) { await ensureDir(paths.commandsDir) for (const command of bundle.commands) { - await writeText(path.join(paths.commandsDir, `${command.name}.md`), command.content + "\n") + // Split colon-separated names into nested directories (e.g. "ce:plan" -> "ce/plan.md") + // to avoid colons in filenames which are invalid on Windows/NTFS + const parts = command.name.split(":") + if (parts.length > 1) { + const nestedDir = path.join(paths.commandsDir, ...parts.slice(0, -1)) + await ensureDir(nestedDir) + await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.md`), command.content + "\n") + } else { + await writeText(path.join(paths.commandsDir, `${command.name}.md`), command.content + "\n") + } } } diff --git a/src/targets/gemini.ts b/src/targets/gemini.ts index 0bc8c666..e818d1e9 100644 --- a/src/targets/gemini.ts +++ b/src/targets/gemini.ts @@ -20,7 +20,16 @@ export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle if (bundle.commands.length > 0) { for (const command of bundle.commands) { - await writeText(path.join(paths.commandsDir, `${command.name}.toml`), command.content + "\n") + // Split colon-separated names into nested directories (e.g. "ce:plan" -> "ce/plan.toml") + // to avoid colons in filenames which are invalid on Windows/NTFS + const parts = command.name.split(":") + if (parts.length > 1) { + const nestedDir = path.join(paths.commandsDir, ...parts.slice(0, -1)) + await ensureDir(nestedDir) + await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.toml`), command.content + "\n") + } else { + await writeText(path.join(paths.commandsDir, `${command.name}.toml`), command.content + "\n") + } } } diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts index b4bf53e7..894cf2ce 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -75,7 +75,17 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu } for (const commandFile of bundle.commandFiles) { - const dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`) + // Split colon-separated names into nested directories (e.g. "ce:plan" -> "ce/plan.md") + // to avoid colons in filenames which are invalid on Windows/NTFS + const parts = commandFile.name.split(":") + let dest: string + if (parts.length > 1) { + const nestedDir = path.join(openCodePaths.commandDir, ...parts.slice(0, -1)) + await ensureDir(nestedDir) + dest = path.join(nestedDir, `${parts[parts.length - 1]}.md`) + } else { + dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`) + } const cmdBackupPath = await backupFile(dest) if (cmdBackupPath) { console.log(`Backed up existing command file to ${cmdBackupPath}`) From 1886c747d072fa5948d0c422bd4dbb2fd55b082c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:08:07 -0700 Subject: [PATCH 2/2] refactor: extract shared resolveCommandPath helper for colon-splitting Deduplicate colon-separated command name logic across all 4 targets (opencode, droid, gemini, qwen) into a single resolveCommandPath() helper in utils/files.ts. Addresses review feedback on PR #251. Co-Authored-By: Claude Opus 4.6 --- src/targets/droid.ts | 14 +++----------- src/targets/gemini.ts | 14 +++----------- src/targets/opencode.ts | 14 ++------------ src/targets/qwen.ts | 13 +++---------- src/utils/files.ts | 15 +++++++++++++++ 5 files changed, 26 insertions(+), 44 deletions(-) diff --git a/src/targets/droid.ts b/src/targets/droid.ts index bdf72f43..23bd46e5 100644 --- a/src/targets/droid.ts +++ b/src/targets/droid.ts @@ -1,5 +1,5 @@ import path from "path" -import { copyDir, ensureDir, writeText } from "../utils/files" +import { copyDir, ensureDir, resolveCommandPath, writeText } from "../utils/files" import type { DroidBundle } from "../types/droid" export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle): Promise { @@ -9,16 +9,8 @@ export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle): if (bundle.commands.length > 0) { await ensureDir(paths.commandsDir) for (const command of bundle.commands) { - // Split colon-separated names into nested directories (e.g. "ce:plan" -> "ce/plan.md") - // to avoid colons in filenames which are invalid on Windows/NTFS - const parts = command.name.split(":") - if (parts.length > 1) { - const nestedDir = path.join(paths.commandsDir, ...parts.slice(0, -1)) - await ensureDir(nestedDir) - await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.md`), command.content + "\n") - } else { - await writeText(path.join(paths.commandsDir, `${command.name}.md`), command.content + "\n") - } + const dest = await resolveCommandPath(paths.commandsDir, command.name, ".md") + await writeText(dest, command.content + "\n") } } diff --git a/src/targets/gemini.ts b/src/targets/gemini.ts index e818d1e9..0df7d514 100644 --- a/src/targets/gemini.ts +++ b/src/targets/gemini.ts @@ -1,5 +1,5 @@ import path from "path" -import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files" +import { backupFile, copyDir, ensureDir, pathExists, readJson, resolveCommandPath, writeJson, writeText } from "../utils/files" import type { GeminiBundle } from "../types/gemini" export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle): Promise { @@ -20,16 +20,8 @@ export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle if (bundle.commands.length > 0) { for (const command of bundle.commands) { - // Split colon-separated names into nested directories (e.g. "ce:plan" -> "ce/plan.toml") - // to avoid colons in filenames which are invalid on Windows/NTFS - const parts = command.name.split(":") - if (parts.length > 1) { - const nestedDir = path.join(paths.commandsDir, ...parts.slice(0, -1)) - await ensureDir(nestedDir) - await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.toml`), command.content + "\n") - } else { - await writeText(path.join(paths.commandsDir, `${command.name}.toml`), command.content + "\n") - } + const dest = await resolveCommandPath(paths.commandsDir, command.name, ".toml") + await writeText(dest, command.content + "\n") } } diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts index 894cf2ce..cff2931a 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -1,5 +1,5 @@ import path from "path" -import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files" +import { backupFile, copyDir, ensureDir, pathExists, readJson, resolveCommandPath, writeJson, writeText } from "../utils/files" import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode" // Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002. @@ -75,17 +75,7 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu } for (const commandFile of bundle.commandFiles) { - // Split colon-separated names into nested directories (e.g. "ce:plan" -> "ce/plan.md") - // to avoid colons in filenames which are invalid on Windows/NTFS - const parts = commandFile.name.split(":") - let dest: string - if (parts.length > 1) { - const nestedDir = path.join(openCodePaths.commandDir, ...parts.slice(0, -1)) - await ensureDir(nestedDir) - dest = path.join(nestedDir, `${parts[parts.length - 1]}.md`) - } else { - dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`) - } + const dest = await resolveCommandPath(openCodePaths.commandDir, commandFile.name, ".md") const cmdBackupPath = await backupFile(dest) if (cmdBackupPath) { console.log(`Backed up existing command file to ${cmdBackupPath}`) diff --git a/src/targets/qwen.ts b/src/targets/qwen.ts index a8228574..22fe2966 100644 --- a/src/targets/qwen.ts +++ b/src/targets/qwen.ts @@ -1,5 +1,5 @@ import path from "path" -import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files" +import { backupFile, copyDir, ensureDir, resolveCommandPath, writeJson, writeText } from "../utils/files" import type { QwenBundle, QwenExtensionConfig } from "../types/qwen" export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise { @@ -31,15 +31,8 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P const commandsDir = qwenPaths.commandsDir await ensureDir(commandsDir) for (const commandFile of bundle.commandFiles) { - // Support nested commands with colon separator - const parts = commandFile.name.split(":") - if (parts.length > 1) { - const nestedDir = path.join(commandsDir, ...parts.slice(0, -1)) - await ensureDir(nestedDir) - await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.md`), commandFile.content + "\n") - } else { - await writeText(path.join(commandsDir, `${commandFile.name}.md`), commandFile.content + "\n") - } + const dest = await resolveCommandPath(commandsDir, commandFile.name, ".md") + await writeText(dest, commandFile.content + "\n") } // Copy skills diff --git a/src/utils/files.ts b/src/utils/files.ts index e4a2a4ab..8ca608ae 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -75,6 +75,21 @@ export async function walkFiles(root: string): Promise { return results } +/** + * Resolve a colon-separated command name into a filesystem path. + * e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md" + * Creates intermediate directories as needed. + */ +export async function resolveCommandPath(dir: string, name: string, ext: string): Promise { + const parts = name.split(":") + if (parts.length > 1) { + const nestedDir = path.join(dir, ...parts.slice(0, -1)) + await ensureDir(nestedDir) + return path.join(nestedDir, `${parts[parts.length - 1]}${ext}`) + } + return path.join(dir, `${name}${ext}`) +} + export async function copyDir(sourceDir: string, targetDir: string): Promise { await ensureDir(targetDir) const entries = await fs.readdir(sourceDir, { withFileTypes: true })