fix(coding-agent,tui): stabilize windows shell/path handling
fixes #1775 fixes #1769
This commit is contained in:
@@ -125,6 +125,10 @@ function toPosixPath(p: string): string {
|
||||
return p.split(sep).join("/");
|
||||
}
|
||||
|
||||
function getHomeDir(): string {
|
||||
return process.env.HOME || homedir();
|
||||
}
|
||||
|
||||
function prefixIgnorePattern(line: string, prefix: string): string | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -512,44 +516,55 @@ function collectResourceFiles(dir: string, resourceType: ResourceType): string[]
|
||||
}
|
||||
|
||||
function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean {
|
||||
const rel = relative(baseDir, filePath);
|
||||
const rel = toPosixPath(relative(baseDir, filePath));
|
||||
const name = basename(filePath);
|
||||
const filePathPosix = toPosixPath(filePath);
|
||||
const isSkillFile = name === "SKILL.md";
|
||||
const parentDir = isSkillFile ? dirname(filePath) : undefined;
|
||||
const parentRel = isSkillFile ? relative(baseDir, parentDir!) : undefined;
|
||||
const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined;
|
||||
const parentName = isSkillFile ? basename(parentDir!) : undefined;
|
||||
const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined;
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
if (minimatch(rel, pattern) || minimatch(name, pattern) || minimatch(filePath, pattern)) {
|
||||
const normalizedPattern = toPosixPath(pattern);
|
||||
if (
|
||||
minimatch(rel, normalizedPattern) ||
|
||||
minimatch(name, normalizedPattern) ||
|
||||
minimatch(filePathPosix, normalizedPattern)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!isSkillFile) return false;
|
||||
return minimatch(parentRel!, pattern) || minimatch(parentName!, pattern) || minimatch(parentDir!, pattern);
|
||||
return (
|
||||
minimatch(parentRel!, normalizedPattern) ||
|
||||
minimatch(parentName!, normalizedPattern) ||
|
||||
minimatch(parentDirPosix!, normalizedPattern)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExactPattern(pattern: string): string {
|
||||
if (pattern.startsWith("./") || pattern.startsWith(".\\")) {
|
||||
return pattern.slice(2);
|
||||
}
|
||||
return pattern;
|
||||
const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern;
|
||||
return toPosixPath(normalized);
|
||||
}
|
||||
|
||||
function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
|
||||
if (patterns.length === 0) return false;
|
||||
const rel = relative(baseDir, filePath);
|
||||
const rel = toPosixPath(relative(baseDir, filePath));
|
||||
const name = basename(filePath);
|
||||
const filePathPosix = toPosixPath(filePath);
|
||||
const isSkillFile = name === "SKILL.md";
|
||||
const parentDir = isSkillFile ? dirname(filePath) : undefined;
|
||||
const parentRel = isSkillFile ? relative(baseDir, parentDir!) : undefined;
|
||||
const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined;
|
||||
const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined;
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
const normalized = normalizeExactPattern(pattern);
|
||||
if (normalized === rel || normalized === filePath) {
|
||||
if (normalized === rel || normalized === filePathPosix) {
|
||||
return true;
|
||||
}
|
||||
if (!isSkillFile) return false;
|
||||
return normalized === parentRel || normalized === parentDir;
|
||||
return normalized === parentRel || normalized === parentDirPosix;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1385,17 +1400,17 @@ export class DefaultPackageManager implements PackageManager {
|
||||
|
||||
private resolvePath(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "~") return homedir();
|
||||
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
|
||||
if (trimmed === "~") return getHomeDir();
|
||||
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
|
||||
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
|
||||
return resolve(this.cwd, trimmed);
|
||||
}
|
||||
|
||||
private resolvePathFromBase(input: string, baseDir: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "~") return homedir();
|
||||
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
|
||||
if (trimmed === "~") return getHomeDir();
|
||||
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
|
||||
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
|
||||
return resolve(baseDir, trimmed);
|
||||
}
|
||||
|
||||
@@ -1632,7 +1647,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
prompts: join(projectBaseDir, "prompts"),
|
||||
themes: join(projectBaseDir, "themes"),
|
||||
};
|
||||
const userAgentsSkillsDir = join(homedir(), ".agents", "skills");
|
||||
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
|
||||
const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter(
|
||||
(dir) => resolve(dir) !== resolve(userAgentsSkillsDir),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Used by auth-storage.ts and model-registry.ts.
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { execSync, spawnSync } from "child_process";
|
||||
import { getShellConfig } from "../utils/shell.js";
|
||||
|
||||
// Cache for shell command results (persists for process lifetime)
|
||||
const commandResultCache = new Map<string, string | undefined>();
|
||||
@@ -21,23 +22,62 @@ export function resolveConfigValue(config: string): string | undefined {
|
||||
return envValue || config;
|
||||
}
|
||||
|
||||
function executeCommand(commandConfig: string): string | undefined {
|
||||
if (commandResultCache.has(commandConfig)) {
|
||||
return commandResultCache.get(commandConfig);
|
||||
}
|
||||
function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } {
|
||||
try {
|
||||
const { shell, args } = getShellConfig();
|
||||
const result = spawnSync(shell, [...args, command], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const command = commandConfig.slice(1);
|
||||
let result: string | undefined;
|
||||
if (result.error) {
|
||||
const error = result.error as NodeJS.ErrnoException;
|
||||
if (error.code === "ENOENT") {
|
||||
return { executed: false, value: undefined };
|
||||
}
|
||||
return { executed: true, value: undefined };
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
return { executed: true, value: undefined };
|
||||
}
|
||||
|
||||
const value = (result.stdout ?? "").trim();
|
||||
return { executed: true, value: value || undefined };
|
||||
} catch {
|
||||
return { executed: false, value: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function executeWithDefaultShell(command: string): string | undefined {
|
||||
try {
|
||||
const output = execSync(command, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
result = output.trim() || undefined;
|
||||
return output.trim() || undefined;
|
||||
} catch {
|
||||
result = undefined;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function executeCommand(commandConfig: string): string | undefined {
|
||||
if (commandResultCache.has(commandConfig)) {
|
||||
return commandResultCache.get(commandConfig);
|
||||
}
|
||||
|
||||
const command = commandConfig.slice(1);
|
||||
const result =
|
||||
process.platform === "win32"
|
||||
? (() => {
|
||||
const configuredResult = executeWithConfiguredShell(command);
|
||||
return configuredResult.executed ? configuredResult.value : executeWithDefaultShell(command);
|
||||
})()
|
||||
: executeWithDefaultShell(command);
|
||||
|
||||
commandResultCache.set(commandConfig, result);
|
||||
return result;
|
||||
|
||||
@@ -8,6 +8,10 @@ import { ensureTool } from "../../utils/tools-manager.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
const findSchema = Type.Object({
|
||||
pattern: Type.String({
|
||||
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
|
||||
@@ -102,9 +106,9 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
// Relativize paths
|
||||
const relativized = results.map((p) => {
|
||||
if (p.startsWith(searchPath)) {
|
||||
return p.slice(searchPath.length + 1);
|
||||
return toPosixPath(p.slice(searchPath.length + 1));
|
||||
}
|
||||
return path.relative(searchPath, p);
|
||||
return toPosixPath(path.relative(searchPath, p));
|
||||
});
|
||||
|
||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||
@@ -228,7 +232,7 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
|
||||
relativePath += "/";
|
||||
}
|
||||
|
||||
relativized.push(relativePath);
|
||||
relativized.push(toPosixPath(relativePath));
|
||||
}
|
||||
|
||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||
|
||||
@@ -30,6 +30,10 @@ describe("AuthStorage", () => {
|
||||
writeFileSync(authJsonPath, JSON.stringify(data));
|
||||
}
|
||||
|
||||
function toShPath(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
describe("API key resolution", () => {
|
||||
test("literal API key is returned directly", async () => {
|
||||
writeAuthJson({
|
||||
@@ -161,7 +165,8 @@ describe("AuthStorage", () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
@@ -182,7 +187,8 @@ describe("AuthStorage", () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
@@ -203,7 +209,8 @@ describe("AuthStorage", () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
@@ -239,7 +246,8 @@ describe("AuthStorage", () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; exit 1'`;
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; exit 1'`;
|
||||
writeAuthJson({
|
||||
anthropic: { type: "api_key", key: command },
|
||||
});
|
||||
|
||||
@@ -127,9 +127,12 @@ describe("ExtensionRunner", () => {
|
||||
});
|
||||
|
||||
it("warns but allows when extension uses non-reserved built-in shortcut", async () => {
|
||||
const pasteImageKey = Array.isArray(DEFAULT_KEYBINDINGS.pasteImage)
|
||||
? (DEFAULT_KEYBINDINGS.pasteImage[0] ?? "")
|
||||
: DEFAULT_KEYBINDINGS.pasteImage;
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.registerShortcut("ctrl+v", {
|
||||
pi.registerShortcut("${pasteImageKey}", {
|
||||
description: "Overrides non-reserved",
|
||||
handler: async () => {},
|
||||
});
|
||||
@@ -144,7 +147,7 @@ describe("ExtensionRunner", () => {
|
||||
const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("built-in shortcut for pasteImage"));
|
||||
expect(shortcuts.has("ctrl+v")).toBe(true);
|
||||
expect(shortcuts.has(pasteImageKey as KeyId)).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -57,6 +57,10 @@ describe("ModelRegistry", () => {
|
||||
return registry.getAll().filter((m) => m.provider === provider);
|
||||
}
|
||||
|
||||
function toShPath(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** Create a baseUrl-only override (no custom models) */
|
||||
function overrideConfig(baseUrl: string, headers?: Record<string, string>) {
|
||||
return { baseUrl, ...(headers && { headers }) };
|
||||
@@ -897,7 +901,8 @@ describe("ModelRegistry", () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(command),
|
||||
});
|
||||
@@ -918,7 +923,8 @@ describe("ModelRegistry", () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(command),
|
||||
});
|
||||
@@ -939,7 +945,8 @@ describe("ModelRegistry", () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(command),
|
||||
});
|
||||
@@ -975,7 +982,8 @@ describe("ModelRegistry", () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; exit 1'`;
|
||||
const counterPath = toShPath(counterFile);
|
||||
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; exit 1'`;
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(command),
|
||||
});
|
||||
|
||||
@@ -5,12 +5,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
|
||||
// Helper to check if a resource is enabled
|
||||
const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") =>
|
||||
matchFn === "endsWith" ? r.path.endsWith(pathMatch) && r.enabled : r.path.includes(pathMatch) && r.enabled;
|
||||
function normalizeForMatch(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
const isDisabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") =>
|
||||
matchFn === "endsWith" ? r.path.endsWith(pathMatch) && !r.enabled : r.path.includes(pathMatch) && !r.enabled;
|
||||
function pathEndsWith(actualPath: string, suffix: string): boolean {
|
||||
return normalizeForMatch(actualPath).endsWith(normalizeForMatch(suffix));
|
||||
}
|
||||
|
||||
// Helper to check if a resource is enabled
|
||||
const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => {
|
||||
const normalizedPath = normalizeForMatch(r.path);
|
||||
const normalizedMatch = normalizeForMatch(pathMatch);
|
||||
return matchFn === "endsWith"
|
||||
? normalizedPath.endsWith(normalizedMatch) && r.enabled
|
||||
: normalizedPath.includes(normalizedMatch) && r.enabled;
|
||||
};
|
||||
|
||||
const isDisabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => {
|
||||
const normalizedPath = normalizeForMatch(r.path);
|
||||
const normalizedMatch = normalizeForMatch(pathMatch);
|
||||
return matchFn === "endsWith"
|
||||
? normalizedPath.endsWith(normalizedMatch) && !r.enabled
|
||||
: normalizedPath.includes(normalizedMatch) && !r.enabled;
|
||||
};
|
||||
|
||||
describe("DefaultPackageManager", () => {
|
||||
let tempDir: string;
|
||||
@@ -155,7 +173,7 @@ Content`,
|
||||
);
|
||||
|
||||
// Should NOT find helper.ts (not declared in manifest)
|
||||
expect(result.extensions.some((r) => r.path.endsWith("helper.ts"))).toBe(false);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "helper.ts"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,8 +349,8 @@ Content`,
|
||||
writeFileSync(join(pkgDir, "themes", "dark.json"), "{}");
|
||||
|
||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||
expect(result.extensions.some((r) => r.path.endsWith("main.ts") && r.enabled)).toBe(true);
|
||||
expect(result.themes.some((r) => r.path.endsWith("dark.json") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "main.ts") && r.enabled)).toBe(true);
|
||||
expect(result.themes.some((r) => pathEndsWith(r.path, "dark.json") && r.enabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -731,7 +749,7 @@ Content`,
|
||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||
expect(result.extensions.some((r) => isEnabled(r, "local.ts"))).toBe(true);
|
||||
expect(result.extensions.some((r) => isEnabled(r, "remote.ts"))).toBe(true);
|
||||
expect(result.extensions.some((r) => r.path.endsWith("skip.ts"))).toBe(false);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "skip.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should support glob patterns in manifest skills", async () => {
|
||||
@@ -798,7 +816,7 @@ Content`,
|
||||
// bar.ts should be excluded (by user)
|
||||
expect(result.extensions.some((r) => isDisabled(r, "bar.ts"))).toBe(true);
|
||||
// baz.ts should be excluded (by manifest)
|
||||
expect(result.extensions.some((r) => r.path.endsWith("baz.ts"))).toBe(false);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "baz.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should exclude extensions from package with ! pattern", async () => {
|
||||
@@ -1194,11 +1212,11 @@ export default function(api) { api.registerTool({ name: "test", description: "te
|
||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||
|
||||
// Should find the index.ts and standalone.ts
|
||||
expect(result.extensions.some((r) => r.path.endsWith("subagent/index.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => r.path.endsWith("standalone.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "subagent/index.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "standalone.ts") && r.enabled)).toBe(true);
|
||||
|
||||
// Should NOT find agents.ts as a standalone extension
|
||||
expect(result.extensions.some((r) => r.path.endsWith("agents.ts"))).toBe(false);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "agents.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should respect package.json pi.extensions manifest in subdirectories", async () => {
|
||||
@@ -1220,10 +1238,10 @@ export default function(api) { api.registerTool({ name: "test", description: "te
|
||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||
|
||||
// Should find main.ts declared in manifest
|
||||
expect(result.extensions.some((r) => r.path.endsWith("custom/main.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "custom/main.ts") && r.enabled)).toBe(true);
|
||||
|
||||
// Should NOT find utils.ts (not declared in manifest)
|
||||
expect(result.extensions.some((r) => r.path.endsWith("utils.ts"))).toBe(false);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "utils.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle mixed top-level files and subdirectories", async () => {
|
||||
@@ -1244,12 +1262,12 @@ export default function(api) { api.registerTool({ name: "test", description: "te
|
||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||
|
||||
// Should find simple.ts and complex/index.ts
|
||||
expect(result.extensions.some((r) => r.path.endsWith("simple.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => r.path.endsWith("complex/index.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "simple.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "complex/index.ts") && r.enabled)).toBe(true);
|
||||
|
||||
// Should NOT find helper modules
|
||||
expect(result.extensions.some((r) => r.path.endsWith("complex/a.ts"))).toBe(false);
|
||||
expect(result.extensions.some((r) => r.path.endsWith("complex/b.ts"))).toBe(false);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "complex/a.ts"))).toBe(false);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "complex/b.ts"))).toBe(false);
|
||||
|
||||
// Total should be exactly 2
|
||||
expect(result.extensions.filter((r) => r.enabled).length).toBe(2);
|
||||
@@ -1269,7 +1287,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te
|
||||
const result = await packageManager.resolveExtensionSources([pkgDir]);
|
||||
|
||||
// Should only find the valid top-level extension
|
||||
expect(result.extensions.some((r) => r.path.endsWith("valid.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "valid.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.filter((r) => r.enabled).length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1299,7 +1317,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te
|
||||
const refreshTemporaryGitSourceSpy = vi.spyOn(packageManager as any, "refreshTemporaryGitSource");
|
||||
|
||||
const result = await packageManager.resolveExtensionSources([gitSource], { temporary: true });
|
||||
expect(result.extensions.some((r) => r.path.endsWith("extensions/index.ts") && r.enabled)).toBe(true);
|
||||
expect(result.extensions.some((r) => pathEndsWith(r.path, "extensions/index.ts") && r.enabled)).toBe(true);
|
||||
expect(refreshTemporaryGitSourceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.js";
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("path-utils", () => {
|
||||
|
||||
it("should resolve relative paths against cwd", () => {
|
||||
const result = resolveToCwd("relative/file.txt", "/some/cwd");
|
||||
expect(result).toBe("/some/cwd/relative/file.txt");
|
||||
expect(result).toBe(resolve("/some/cwd", "relative/file.txt"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionHeader } from "../src/core/session-manager.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
@@ -74,7 +74,7 @@ describe("SessionInfo.modified", () => {
|
||||
timestamp: msgTime,
|
||||
});
|
||||
|
||||
const sessions = await SessionManager.list("/tmp", filePath.replace(/\/[^/]+$/, ""));
|
||||
const sessions = await SessionManager.list("/tmp", dirname(filePath));
|
||||
const s = sessions.find((x) => x.path === filePath);
|
||||
expect(s).toBeDefined();
|
||||
expect(s!.modified.getTime()).toBe(msgTime);
|
||||
|
||||
@@ -6,6 +6,42 @@ import { fuzzyFilter } from "./fuzzy.js";
|
||||
|
||||
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
|
||||
|
||||
function toDisplayPath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function buildFdPathQuery(query: string): string {
|
||||
const normalized = toDisplayPath(query);
|
||||
if (!normalized.includes("/")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const hasTrailingSeparator = normalized.endsWith("/");
|
||||
const trimmed = normalized.replace(/^\/+|\/+$/g, "");
|
||||
if (!trimmed) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const separatorPattern = "[\\\\/]";
|
||||
const segments = trimmed
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => escapeRegex(segment));
|
||||
if (segments.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
let pattern = segments.join(separatorPattern);
|
||||
if (hasTrailingSeparator) {
|
||||
pattern += separatorPattern;
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
function findLastDelimiter(text: string): number {
|
||||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
||||
if (PATH_DELIMITERS.has(text[i] ?? "")) {
|
||||
@@ -112,7 +148,7 @@ function walkDirectoryWithFd(
|
||||
|
||||
// Add query as pattern if provided
|
||||
if (query) {
|
||||
args.push(query);
|
||||
args.push(buildFdPathQuery(query));
|
||||
}
|
||||
|
||||
const result = spawnSync(fdPath, args, {
|
||||
@@ -129,15 +165,17 @@ function walkDirectoryWithFd(
|
||||
const results: Array<{ path: string; isDirectory: boolean }> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const normalizedPath = line.endsWith("/") ? line.slice(0, -1) : line;
|
||||
const displayLine = toDisplayPath(line);
|
||||
const hasTrailingSeparator = displayLine.endsWith("/");
|
||||
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
|
||||
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fd outputs directories with trailing /
|
||||
const isDirectory = line.endsWith("/");
|
||||
const isDirectory = hasTrailingSeparator;
|
||||
results.push({
|
||||
path: line,
|
||||
path: displayLine,
|
||||
isDirectory,
|
||||
});
|
||||
}
|
||||
@@ -448,13 +486,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
|
||||
private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null {
|
||||
const slashIndex = rawQuery.lastIndexOf("/");
|
||||
const normalizedQuery = toDisplayPath(rawQuery);
|
||||
const slashIndex = normalizedQuery.lastIndexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayBase = rawQuery.slice(0, slashIndex + 1);
|
||||
const query = rawQuery.slice(slashIndex + 1);
|
||||
const displayBase = normalizedQuery.slice(0, slashIndex + 1);
|
||||
const query = normalizedQuery.slice(slashIndex + 1);
|
||||
|
||||
let baseDir: string;
|
||||
if (displayBase.startsWith("~/")) {
|
||||
@@ -477,10 +516,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
|
||||
private scopedPathForDisplay(displayBase: string, relativePath: string): string {
|
||||
const normalizedRelativePath = toDisplayPath(relativePath);
|
||||
if (displayBase === "/") {
|
||||
return `/${relativePath}`;
|
||||
return `/${normalizedRelativePath}`;
|
||||
}
|
||||
return `${displayBase}${relativePath}`;
|
||||
return `${toDisplayPath(displayBase)}${normalizedRelativePath}`;
|
||||
}
|
||||
|
||||
// Get file/directory suggestions for a given path prefix
|
||||
@@ -559,7 +599,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
if (displayPrefix.endsWith("/")) {
|
||||
// If prefix ends with /, append entry to the prefix
|
||||
relativePath = displayPrefix + name;
|
||||
} else if (displayPrefix.includes("/")) {
|
||||
} else if (displayPrefix.includes("/") || displayPrefix.includes("\\")) {
|
||||
// Preserve ~/ format for home directory paths
|
||||
if (displayPrefix.startsWith("~/")) {
|
||||
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
|
||||
@@ -589,6 +629,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
}
|
||||
|
||||
relativePath = toDisplayPath(relativePath);
|
||||
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
|
||||
const value = buildCompletionValue(pathValue, {
|
||||
isDirectory,
|
||||
|
||||
Reference in New Issue
Block a user