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