Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 72 additions & 29 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,34 +184,44 @@ const HTTP_KEEP_ALIVE_AGENT = new http.Agent({ keepAlive: true });
const HTTPS_KEEP_ALIVE_AGENT = new https.Agent({ keepAlive: true });

function getCodexSkillsDir() {
const joinPath = (basePath, ...segments) => {
const base = typeof basePath === 'string' ? basePath.trim() : '';
const pathApi = base.includes('/') && !base.includes('\\') && path.posix ? path.posix : path;
return pathApi.join(base, ...segments);
};
const envCodexHome = typeof process.env.CODEX_HOME === 'string' ? process.env.CODEX_HOME.trim() : '';
if (envCodexHome) {
const target = path.join(envCodexHome, 'skills');
const target = joinPath(envCodexHome, 'skills');
return resolveExistingDir([target], target);
}
const xdgConfig = typeof process.env.XDG_CONFIG_HOME === 'string' ? process.env.XDG_CONFIG_HOME.trim() : '';
if (xdgConfig) {
const target = path.join(xdgConfig, 'codex', 'skills');
const target = joinPath(xdgConfig, 'codex', 'skills');
return resolveExistingDir([target], target);
}
const homeConfigDir = path.join(os.homedir(), '.config', 'codex', 'skills');
const homeConfigDir = joinPath(os.homedir(), '.config', 'codex', 'skills');
return resolveExistingDir([homeConfigDir], CODEX_SKILLS_DIR);
}

function getClaudeSkillsDir() {
const joinPath = (basePath, ...segments) => {
const base = typeof basePath === 'string' ? basePath.trim() : '';
const pathApi = base.includes('/') && !base.includes('\\') && path.posix ? path.posix : path;
return pathApi.join(base, ...segments);
};
const envClaudeHome = typeof process.env.CLAUDE_HOME === 'string' && process.env.CLAUDE_HOME.trim()
? process.env.CLAUDE_HOME.trim()
: (typeof process.env.CLAUDE_CONFIG_DIR === 'string' ? process.env.CLAUDE_CONFIG_DIR.trim() : '');
if (envClaudeHome) {
const target = path.join(envClaudeHome, 'skills');
const target = joinPath(envClaudeHome, 'skills');
return resolveExistingDir([target], target);
}
const xdgConfig = typeof process.env.XDG_CONFIG_HOME === 'string' ? process.env.XDG_CONFIG_HOME.trim() : '';
if (xdgConfig) {
const target = path.join(xdgConfig, 'claude', 'skills');
const target = joinPath(xdgConfig, 'claude', 'skills');
return resolveExistingDir([target], target);
}
const homeConfigDir = path.join(os.homedir(), '.config', 'claude', 'skills');
const homeConfigDir = joinPath(os.homedir(), '.config', 'claude', 'skills');
return resolveExistingDir([homeConfigDir], CLAUDE_SKILLS_DIR);
}

Expand Down Expand Up @@ -2005,11 +2015,16 @@ function listSkillEntriesByRoot(rootDir) {
}

function scanUnmanagedSkills(params = {}) {
const getPathApi = (basePath) => {
const base = typeof basePath === 'string' ? basePath.trim() : '';
return base.includes('/') && !base.includes('\\') && path.posix ? path.posix : path;
};
const target = resolveSkillTarget(params);
if (!target) {
return { error: '目标宿主不支持' };
}
const targetRoot = resolveCopyTargetRoot(target.dir);
const targetPathApi = getPathApi(targetRoot);
const existing = listSkills({ targetApp: target.app });
if (existing.error) {
return { error: existing.error };
Expand All @@ -2023,7 +2038,7 @@ function scanUnmanagedSkills(params = {}) {
for (const source of sources) {
const sourceEntries = listSkillEntriesByRoot(source.dir);
for (const entry of sourceEntries) {
const targetCandidate = path.join(targetRoot, entry.name);
const targetCandidate = targetPathApi.join(targetRoot, entry.name);
if (fs.existsSync(targetCandidate)) {
continue;
}
Expand Down Expand Up @@ -2070,11 +2085,16 @@ function scanUnmanagedCodexSkills() {
}

function importSkills(params = {}) {
const getPathApi = (basePath) => {
const base = typeof basePath === 'string' ? basePath.trim() : '';
return base.includes('/') && !base.includes('\\') && path.posix ? path.posix : path;
};
const target = resolveSkillTarget(params);
if (!target) {
return { error: '目标宿主不支持' };
}
const targetRoot = resolveCopyTargetRoot(target.dir);
const targetPathApi = getPathApi(targetRoot);
const rawItems = Array.isArray(params.items) ? params.items : [];
if (!rawItems.length) {
return { error: '请先选择要导入的 skill' };
Expand Down Expand Up @@ -2118,9 +2138,10 @@ function importSkills(params = {}) {
}
dedup.add(dedupKey);

const sourcePath = path.join(source.dir, normalizedName.name);
const sourceRelative = path.relative(source.dir, sourcePath);
if (sourceRelative.startsWith('..') || path.isAbsolute(sourceRelative)) {
const sourcePathApi = getPathApi(source.dir);
const sourcePath = sourcePathApi.join(source.dir, normalizedName.name);
const sourceRelative = sourcePathApi.relative(source.dir, sourcePath);
if (sourceRelative.startsWith('..') || sourcePathApi.isAbsolute(sourceRelative)) {
failed.push({
name: normalizedName.name,
sourceApp: source.app,
Expand All @@ -2137,9 +2158,9 @@ function importSkills(params = {}) {
continue;
}

const targetPath = path.join(targetRoot, normalizedName.name);
const targetRelative = path.relative(targetRoot, targetPath);
if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) {
const targetPath = targetPathApi.join(targetRoot, normalizedName.name);
const targetRelative = targetPathApi.relative(targetRoot, targetPath);
if (targetRelative.startsWith('..') || targetPathApi.isAbsolute(targetRelative)) {
failed.push({
name: normalizedName.name,
sourceApp: source.app,
Expand Down Expand Up @@ -2283,12 +2304,18 @@ function resolveSkillNameFromImportedDirectory(skillDir, extractionRoot, fallbac
}

async function importSkillsFromZipFile(zipPath, options = {}) {
const getPathApi = (basePath) => {
const base = typeof basePath === 'string' ? basePath.trim() : '';
return base.includes('/') && !base.includes('\\') && path.posix ? path.posix : path;
};
const fallbackName = typeof options.fallbackName === 'string' ? options.fallbackName : '';
const tempDir = typeof options.tempDir === 'string' ? options.tempDir : '';
const imported = [];
const failed = [];
const dedupNames = new Set();
const extractionRoot = path.join(tempDir || path.dirname(zipPath), 'extract');
const extractionPathApi = getPathApi(tempDir || zipPath);
const extractionBaseDir = tempDir || extractionPathApi.dirname(zipPath);
const extractionRoot = extractionPathApi.join(extractionBaseDir, 'extract');
let target = null;
let targetRoot = '';

Expand All @@ -2298,6 +2325,7 @@ async function importSkillsFromZipFile(zipPath, options = {}) {
return { error: '目标宿主不支持' };
}
targetRoot = resolveCopyTargetRoot(target.dir);
const targetPathApi = getPathApi(targetRoot);
await inspectZipArchiveLimits(zipPath, {
maxEntryCount: MAX_SKILLS_ZIP_ENTRY_COUNT,
maxUncompressedBytes: MAX_SKILLS_ZIP_UNCOMPRESSED_BYTES
Expand Down Expand Up @@ -2328,9 +2356,9 @@ async function importSkillsFromZipFile(zipPath, options = {}) {
}
dedupNames.add(dedupKey);

const targetPath = path.join(targetRoot, normalizedName.name);
const targetRelative = path.relative(targetRoot, targetPath);
if (targetRelative.startsWith('..') || path.isAbsolute(targetRelative)) {
const targetPath = targetPathApi.join(targetRoot, normalizedName.name);
const targetRelative = targetPathApi.relative(targetRoot, targetPath);
if (targetRelative.startsWith('..') || targetPathApi.isAbsolute(targetRelative)) {
failed.push({
name: normalizedName.name,
error: '目标路径非法'
Expand Down Expand Up @@ -3856,27 +3884,30 @@ function isPathInside(targetPath, rootPath) {
if (resolvedTarget === resolvedRoot) {
return true;
}
const rootWithSlash = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
const separator = resolvedRoot.includes('/') && !resolvedRoot.includes('\\') ? '/' : path.sep;
const rootWithSlash = resolvedRoot.endsWith(separator) ? resolvedRoot : resolvedRoot + separator;
return resolvedTarget.startsWith(rootWithSlash);
}

function resolveCopyTargetRoot(targetDir) {
const base = typeof targetDir === 'string' ? targetDir.trim() : '';
const pathApi = base.includes('/') && !base.includes('\\') && path.posix ? path.posix : path;
const suffixSegments = [];
let current = path.resolve(targetDir || '');
let current = pathApi.resolve(base || '');
while (current && !fs.existsSync(current)) {
const parent = path.dirname(current);
const parent = pathApi.dirname(current);
if (!parent || parent === current) {
break;
}
suffixSegments.unshift(path.basename(current));
suffixSegments.unshift(pathApi.basename(current));
current = parent;
}
let resolvedRoot = normalizePathForCompare(current || targetDir);
let resolvedRoot = normalizePathForCompare(current || base);
if (!resolvedRoot) {
resolvedRoot = path.resolve(targetDir || '');
resolvedRoot = pathApi.resolve(base || '');
Comment on lines +3893 to +3907

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# First, let's look at the actual code context in cli.js
sed -n '3893,3911p' cli.js

Repository: SakuraByteCore/codexmate

Length of output: 836


🏁 Script executed:

#!/bin/bash

# Create a simple Node.js script to test path behavior claims
node <<'EOF'
const path = require('path');

// Test case 1: Windows-style path on POSIX semantics
console.log("=== Test: C:/Users/alice on different path APIs ===");
const windowsPath1 = 'C:/Users/alice/.codex';
console.log("path.resolve('" + windowsPath1 + "'):", path.resolve(windowsPath1));
console.log("path.posix.resolve('" + windowsPath1 + "'):", path.posix.resolve(windowsPath1));
console.log("path.posix.dirname('" + windowsPath1 + "'):", path.posix.dirname(windowsPath1));

// Test case 2: UNC path
console.log("\n=== Test: UNC path //server/share ===");
const uncPath = '//server/share/file';
console.log("path.resolve('" + uncPath + "'):", path.resolve(uncPath));
console.log("path.posix.resolve('" + uncPath + "'):", path.posix.resolve(uncPath));

// Test case 3: Pure POSIX path (no Windows indicators)
console.log("\n=== Test: Pure POSIX path /home/user ===");
const posixPath = '/home/user';
console.log("path.resolve('" + posixPath + "'):", path.resolve(posixPath));
console.log("path.posix.resolve('" + posixPath + "'):", path.posix.resolve(posixPath));

// Test case 4: Windows path with backslash
console.log("\n=== Test: Windows path C:\\Users\\alice ===");
const windowsPath2 = 'C:\\Users\\alice';
console.log("path.resolve('" + windowsPath2 + "'):", path.resolve(windowsPath2));
console.log("path.posix.resolve('" + windowsPath2 + "'):", path.posix.resolve(windowsPath2));

// Test case 5: Check if current heuristic would select posix for C:/Users
console.log("\n=== Heuristic evaluation ===");
const testPaths = ['C:/Users/alice', '/home/user', 'C:\\Users\\alice', '//server/share'];
testPaths.forEach(p => {
  const usePosix = p.includes('/') && !p.includes('\\');
  console.log("Path '" + p + "' → includes('/') && !includes('\\\\'):", usePosix);
});

// Test case 6: dirname behavior difference on Windows path
console.log("\n=== dirname behavior on Windows path with / ===");
const testPath = 'C:/Users/alice/.codex/subfolder';
console.log("path.dirname('" + testPath + "'):", path.dirname(testPath));
console.log("path.posix.dirname('" + testPath + "'):", path.posix.dirname(testPath));
EOF

Repository: SakuraByteCore/codexmate

Length of output: 1285


🏁 Script executed:

#!/bin/bash

# Check the actual function context in cli.js to understand how it's being used
rg -A 20 "function resolveCopyTargetRoot" cli.js

Repository: SakuraByteCore/codexmate

Length of output: 905


Don't classify C:/... paths as POSIX.

On Windows, Node's default path API recognizes drive letters like C: as absolute paths, while path.posix uses POSIX semantics exclusively and does not. With the current heuristic, a path like C:/Users/alice/.codex triggers the condition (includes('/') && !includes('\\')), causing path.posix semantics to be incorrectly applied. This miscomputes the copy/import root resolution when such paths are passed to resolveCopyTargetRoot(). Exclude Windows absolute paths (drive letters and UNC paths) from the heuristic and only opt into path.posix for truly POSIX paths.

🔧 Suggested fix
 function resolveCopyTargetRoot(targetDir) {
     const base = typeof targetDir === 'string' ? targetDir.trim() : '';
-    const pathApi = base.includes('/') && !base.includes('\\') && path.posix ? path.posix : path;
+    const looksLikeWindowsPath = process.platform === 'win32'
+        && (/^[A-Za-z]:[\\/]/.test(base) || /^[/\\]{2}[^/\\]/.test(base));
+    const pathApi = !looksLikeWindowsPath && base.includes('/') && !base.includes('\\') && path.posix
+        ? path.posix
+        : path;
     const suffixSegments = [];
     let current = pathApi.resolve(base || '');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli.js` around lines 3893 - 3907, The current heuristic that sets pathApi to
path.posix when base.includes('/') && !base.includes('\\') incorrectly treats
Windows absolute paths like "C:/..." as POSIX; update the check in
resolveCopyTargetRoot (the logic around targetDir, base and pathApi) to
additionally detect and exclude Windows absolute and UNC paths (e.g., strings
matching a drive-letter prefix like /^[A-Za-z]:[\\/]/ or UNC prefixes like
/^\\\\/), and only choose path.posix when base contains '/' but does not match
those Windows patterns so Windows drive/UNC paths continue to use the default
path module.

}
for (const segment of suffixSegments) {
resolvedRoot = path.join(resolvedRoot, segment);
resolvedRoot = pathApi.join(resolvedRoot, segment);
}
return resolvedRoot;
}
Expand Down Expand Up @@ -9991,6 +10022,14 @@ function watchPathsForRestart(targets, onChange) {
const debounceMs = 300;
let timer = null;
const watcherEntries = new Map();
const getPathApi = (targetPath) => {
const value = typeof targetPath === 'string' ? targetPath.trim() : '';
return value.includes('/') && !value.includes('\\') && path.posix ? path.posix : path;
};
const getPathSeparator = (targetPath) => {
const pathApi = getPathApi(targetPath);
return pathApi.sep || (pathApi === path.posix ? '/' : path.sep);
};

const trigger = (info) => {
if (timer) clearTimeout(timer);
Expand All @@ -10013,6 +10052,7 @@ function watchPathsForRestart(targets, onChange) {
const queue = [rootDir];
const directories = [];
const seen = new Set();
const pathApi = getPathApi(rootDir);
while (queue.length) {
const current = queue.shift();
if (!current || seen.has(current) || !fs.existsSync(current)) {
Expand All @@ -10037,15 +10077,16 @@ function watchPathsForRestart(targets, onChange) {
}
for (const entry of entries) {
if (entry && typeof entry.isDirectory === 'function' && entry.isDirectory()) {
queue.push(path.join(current, entry.name));
queue.push(pathApi.join(current, entry.name));
}
}
}
return directories;
};

const isSameOrNestedPath = (candidate, rootDir) => {
return candidate === rootDir || candidate.startsWith(`${rootDir}${path.sep}`);
const separator = getPathSeparator(rootDir);
return candidate === rootDir || candidate.startsWith(`${rootDir}${separator}`);
};

const addWatcher = (target, recursive, isDirectory = false) => {
Expand All @@ -10055,8 +10096,9 @@ function watchPathsForRestart(targets, onChange) {
return true;
}
try {
const basename = isDirectory ? '' : path.basename(target);
const watchTarget = isDirectory ? target : path.dirname(target);
const pathApi = getPathApi(target);
const basename = isDirectory ? '' : pathApi.basename(target);
const watchTarget = isDirectory ? target : pathApi.dirname(target);
const watcher = fs.watch(watchTarget, { recursive }, (eventType, filename) => {
if (isDirectory && !recursive && eventType === 'rename') {
syncDirectoryTree(target);
Expand Down Expand Up @@ -10100,7 +10142,8 @@ function watchPathsForRestart(targets, onChange) {
};

const addMissingDirectoryWatcher = (target) => {
const parentDir = path.dirname(target);
const pathApi = getPathApi(target);
const parentDir = pathApi.dirname(target);
if (!parentDir || parentDir === target || !fs.existsSync(parentDir)) {
return false;
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codexmate",
"version": "0.0.19",
"version": "0.0.20",
"description": "Codex/Claude Code 配置与会话管理 CLI + Web 工具",
"main": "cli.js",
"bin": {
Expand Down
Loading