源码
packages/desktop/src/main/command-import.ts
// Source list and selection/copy/link semantics adapted from ZCode settingsSyncService (Apache-2.0).
import { createHash, randomUUID } from "node:crypto";
import { constants, type Stats } from "node:fs";
import { link, lstat, mkdir, open, readdir, rename, symlink, unlink } from "node:fs/promises";
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
import type {
CommandImportCandidate,
CommandImportDiscovery,
CommandImportItemResult,
CommandImportRequest,
CommandImportResult,
CommandImportSkipReason,
CommandImportSource,
} from "../shared/command-import.ts";
import { isCustomCommandId, parseCustomCommandMarkdown } from "./custom-commands.ts";
import { isPathInside } from "./path-bounds.ts";
/** Deliberately matches ZCode's command list, not its broader skill source list. */
export const COMMAND_IMPORT_SOURCES = [
{ id: "claudeCode", label: "Claude Code", path: ".claude/commands", projectPath: ".claude/commands" },
{ id: "codexCli", label: "Codex CLI", path: ".codex/commands", projectPath: ".codex/commands" },
{ id: "openCode", label: "OpenCode", path: ".config/opencode/commands", projectPath: ".opencode/commands" },
{ id: "openClaw", label: "OpenClaw", path: ".openclaw/commands", projectPath: "commands" },
{ id: "augment", label: "Augment", path: ".augment/commands", projectPath: ".augment/commands" },
{ id: "continue", label: "Continue", path: ".continue/commands", projectPath: ".continue/commands" },
{ id: "goose", label: "Goose", path: ".config/goose/commands", projectPath: ".goose/commands" },
{ id: "qwenCode", label: "Qwen Code", path: ".qwen/commands", projectPath: ".qwen/commands" },
{ id: "qode", label: "Qoder", path: ".qoder/commands", projectPath: ".qoder/commands" },
{ id: "qodeCn", label: "Qoder CN", path: ".qoder-cn/commands", projectPath: ".qoder/commands" },
{
id: "windsurf",
label: "Windsurf",
path: ".codeium/windsurf/commands",
projectPath: ".windsurf/commands",
},
{ id: "trae", label: "Trae", path: ".trae/commands", projectPath: ".trae/commands" },
{ id: "kiroCli", label: "Kiro CLI", path: ".kiro/commands", projectPath: ".kiro/commands" },
{ id: "roo", label: "Roo", path: ".roo/commands", projectPath: ".roo/commands" },
{ id: "codeBuddy", label: "CodeBuddy", path: ".codebuddy/commands", projectPath: ".codebuddy/commands" },
] as const;
const MAX_FILE_BYTES = 256 * 1024;
const MAX_ENTRIES = 10_000;
const MAX_COMMANDS = 1000;
const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
const MAX_DEPTH = 8;
const REGISTRY_FILE = ".import-links.json";
interface ImportSnapshot extends CommandImportCandidate {
sourceRoot: string;
}
interface FileSnapshot {
content: Buffer;
revision: string;
}
export interface CommandImportOptions {
homeDirectory: string;
targetDirectory: string;
workspaceDirectory?: string;
}
function code(error: unknown): string | undefined {
return (error as NodeJS.ErrnoException)?.code;
}
function commandName(path: string): string {
return `/${path.replaceAll(sep, "/").replace(/\.md$/i, "")}`;
}
function nameKey(path: string): string {
return commandName(path).toLowerCase();
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** No I/O in construction. Discovery is called only from the explicitly opened import dialog. */
export class CommandImportService {
private readonly home: string;
private readonly target: string;
private readonly workspace?: string;
private readonly scans = new Map<string, Map<string, ImportSnapshot>>();
private mutation: Promise<unknown> = Promise.resolve();
constructor(options: CommandImportOptions) {
if (!isAbsolute(options.homeDirectory) || !isAbsolute(options.targetDirectory)) {
throw new Error("命令导入需要明确的绝对目录路径。");
}
this.home = resolve(options.homeDirectory);
this.target = resolve(options.targetDirectory);
if (options.workspaceDirectory && !isAbsolute(options.workspaceDirectory)) {
throw new Error("项目命令导入需要明确的绝对目录路径。");
}
this.workspace = options.workspaceDirectory ? resolve(options.workspaceDirectory) : undefined;
if (
this.target !== join(this.home, ".zpi", "commands") &&
(!this.workspace || this.target !== join(this.workspace, ".zpi", "commands"))
) {
throw new Error("命令只能导入到用户或项目的 .zpi/commands 目录。");
}
}
private sourceRoots(): Array<{ id: string; label: string; path: string; scope: "global" | "project" }> {
return [
...COMMAND_IMPORT_SOURCES.map((source) => ({
id: `${source.id}:global`,
label: source.label,
path: join(this.home, source.path),
scope: "global" as const,
})),
...(this.workspace
? COMMAND_IMPORT_SOURCES.map((source) => ({
id: `${source.id}:project`,
label: source.label,
path: join(this.workspace as string, source.projectPath),
scope: "project" as const,
}))
: []),
];
}
/** Require an injected scope, then check every ancestor from the filesystem root. */
private async directories(path: string, create = false): Promise<boolean> {
const anchor = [this.home, ...(this.workspace ? [this.workspace] : [])]
.filter((root) => isPathInside(root, path))
.sort((left, right) => right.length - left.length)[0];
if (!anchor) throw new Error("目录超出了允许范围。");
let current = parse(anchor).root;
const parts = relative(current, path).split(sep).filter(Boolean);
for (let index = -1; index < parts.length; index++) {
if (index >= 0) current = join(current, parts[index]);
let stat: Stats;
try {
stat = await lstat(current);
} catch (error) {
if (code(error) !== "ENOENT") throw error;
if (!create) return false;
try {
await mkdir(current);
} catch (mkdirError) {
if (code(mkdirError) !== "EEXIST") throw mkdirError;
}
stat = await lstat(current);
}
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error("为避免访问目录外的文件,已拒绝符号链接目录。");
}
}
return true;
}
private async snapshot(path: string, root: string, maxBytes = MAX_FILE_BYTES): Promise<FileSnapshot> {
if (
!isPathInside(root, path) ||
path === root ||
(!isPathInside(this.home, root) && (!this.workspace || !isPathInside(this.workspace, root)))
) {
throw new Error("命令文件超出了允许范围。");
}
if (!(await this.directories(dirname(path)))) throw new Error("源命令已不存在,请重新扫描。");
const before = await lstat(path);
if (!before.isFile() || before.isSymbolicLink()) throw new Error("只能导入普通 Markdown 文件。");
if (before.size > maxBytes) throw new Error("命令文件超过 256 KiB 限制。");
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > maxBytes || stat.dev !== before.dev || stat.ino !== before.ino) {
throw new Error("源命令已发生变化,请重新扫描。");
}
const content = Buffer.alloc(stat.size + 1);
const { bytesRead } = await file.read(content, 0, content.length, 0);
const after = await file.stat();
if (
bytesRead !== stat.size ||
after.size !== stat.size ||
after.mtimeMs !== stat.mtimeMs ||
after.ctimeMs !== stat.ctimeMs
) {
throw new Error("源命令已发生变化,请重新扫描。");
}
await this.directories(dirname(path));
const current = await lstat(path);
if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
throw new Error("源命令已发生变化,请重新扫描。");
}
const bytes = content.subarray(0, bytesRead);
const revision = createHash("sha256")
.update(`${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:`)
.update(bytes)
.digest("hex");
return { content: bytes, revision };
} finally {
await file.close();
}
}
private async paths(root: string, allowFileLinks = false): Promise<string[]> {
if (!(await this.directories(root))) return [];
const found: string[] = [];
let count = 0;
const visit = async (directory: string, depth: number): Promise<void> => {
if (depth > MAX_DEPTH) throw new Error("命令目录嵌套超过 8 层限制。");
await this.directories(directory);
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (++count > MAX_ENTRIES) throw new Error("命令目录超过 10,000 个条目限制。");
if (entry.name.startsWith(".")) continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) await visit(path, depth + 1);
else if (
/\.md$/i.test(entry.name) &&
(entry.isFile() || (allowFileLinks && entry.isSymbolicLink()))
) {
found.push(path);
}
}
};
await visit(root, 0);
return found;
}
private async conflict(
path: string,
names: Map<string, string>,
): Promise<CommandImportSkipReason | undefined> {
const existing = names.get(nameKey(path));
if (existing !== undefined) return existing === path ? "targetExists" : "sameNameExists";
try {
await lstat(join(this.target, path));
return "targetExists";
} catch (error) {
if (code(error) !== "ENOENT" && code(error) !== "ENOTDIR") throw error;
}
return undefined;
}
async discover(): Promise<CommandImportDiscovery> {
const scanId = randomUUID();
const snapshots = new Map<string, ImportSnapshot>();
const sources: CommandImportSource[] = [];
const warnings: string[] = [];
const names = new Map(
(await this.paths(this.target, true)).map((path) => {
const id = relative(this.target, path).replaceAll(sep, "/");
return [nameKey(id), id];
}),
);
const seenPaths = new Set<string>();
let totalBytes = 0;
let exhausted = false;
for (const source of this.sourceRoots()) {
const root = source.path;
const commands: CommandImportCandidate[] = [];
try {
for (const path of await this.paths(root)) {
if (seenPaths.has(path)) continue;
seenPaths.add(path);
try {
if (snapshots.size >= MAX_COMMANDS) {
warnings.push("扫描已达到 1,000 个命令限制,后续文件未扫描。");
exhausted = true;
break;
}
const file = await this.snapshot(path, root);
totalBytes += file.content.length;
if (totalBytes > MAX_TOTAL_BYTES) {
warnings.push("扫描已达到 16 MiB 总内容限制,后续文件未扫描。");
exhausted = true;
break;
}
const relativePath = relative(root, path).replaceAll(sep, "/");
if (!isCustomCommandId(relativePath)) throw new Error("命令文件名不受支持。");
const parsed = parseCustomCommandMarkdown(file.content.toString("utf8"));
const skipReason = await this.conflict(relativePath, names);
const candidate: CommandImportCandidate = {
id: randomUUID(),
revision: file.revision,
name: commandName(relativePath),
description: parsed.description,
argumentHint: parsed.argumentHint,
sourcePath: path,
relativePath,
importable: !skipReason,
...(skipReason ? { skipReason } : {}),
};
commands.push(candidate);
snapshots.set(candidate.id, { ...candidate, sourceRoot: root });
} catch (error) {
warnings.push(`${path}: ${message(error)}`);
}
}
} catch (error) {
warnings.push(`${source.label}: ${message(error)}`);
}
if (commands.length)
sources.push({ id: source.id, label: source.label, path: root, scope: source.scope, commands });
if (exhausted) break;
}
this.scans.set(scanId, snapshots);
while (this.scans.size > 8) this.scans.delete(this.scans.keys().next().value as string);
return {
scanId,
targetDirectory: this.target,
targetScope: this.target === join(this.home, ".zpi", "commands") ? "global" : "project",
projectAvailable: Boolean(this.workspace),
sources,
warnings,
};
}
/** Loads explicit link provenance only; does not discover or read external command contents. */
async registeredLinkTargets(): Promise<ReadonlyMap<string, string>> {
if (!(await this.directories(this.target))) return new Map();
let snapshot: FileSnapshot;
try {
snapshot = await this.snapshot(join(this.target, REGISTRY_FILE), this.target, 1024 * 1024);
} catch (error) {
if (code(error) === "ENOENT") return new Map();
throw error;
}
const data: unknown = JSON.parse(snapshot.content.toString("utf8"));
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("导入链接记录无效。");
const registered = new Map<string, string>();
const roots = this.sourceRoots();
for (const [target, source] of Object.entries(data)) {
if (typeof source !== "string" || !isAbsolute(source) || resolve(source) !== source) continue;
const match = roots.find(({ path: root }) => {
return isPathInside(root, source) && relative(root, source).replaceAll(sep, "/") === target;
});
if (match && isCustomCommandId(target)) {
registered.set(target, source);
}
}
return registered;
}
private async saveLinks(links: ReadonlyMap<string, string>): Promise<void> {
await this.directories(this.target, true);
const path = join(this.target, REGISTRY_FILE);
const temporary = join(this.target, `${REGISTRY_FILE}.${randomUUID()}.tmp`);
const file = await open(temporary, "wx", 0o600);
try {
await file.writeFile(`${JSON.stringify(Object.fromEntries(links), null, 2)}\n`, "utf8");
await file.sync();
} finally {
await file.close();
}
try {
await this.directories(this.target);
await rename(temporary, path);
} finally {
await unlink(temporary).catch(() => {});
}
}
importSelected(request: CommandImportRequest): Promise<CommandImportResult> {
const operation = this.mutation.then(() => this.performImport(request));
this.mutation = operation.catch(() => {});
return operation;
}
private async performImport(request: CommandImportRequest): Promise<CommandImportResult> {
if (
!request ||
typeof request.scanId !== "string" ||
(request.mode !== "copy" && request.mode !== "symlink") ||
!Array.isArray(request.selections) ||
!request.selections.length ||
request.selections.length > MAX_COMMANDS
) {
throw new Error("命令导入请求无效。");
}
const scan = this.scans.get(request.scanId);
if (!scan) throw new Error("扫描结果已过期,请重新扫描。");
const selected = new Set<string>();
for (const item of request.selections) {
if (
!item ||
typeof item.id !== "string" ||
typeof item.revision !== "string" ||
selected.has(item.id)
) {
throw new Error("命令选择无效。");
}
const candidate = scan.get(item.id);
if (!candidate || candidate.revision !== item.revision) throw new Error("命令选择已过期,请重新扫描。");
selected.add(item.id);
}
const items: CommandImportItemResult[] = [];
const links = new Map(await this.registeredLinkTargets());
const names = new Map(
(await this.paths(this.target, true)).map((path) => {
const id = relative(this.target, path).replaceAll(sep, "/");
return [nameKey(id), id];
}),
);
for (const id of selected) {
const candidate = scan.get(id) as ImportSnapshot;
const base = { id, name: candidate.name, sourcePath: candidate.sourcePath };
try {
const file = await this.snapshot(candidate.sourcePath, candidate.sourceRoot);
if (file.revision !== candidate.revision) throw new Error("源命令已发生变化,请重新扫描。");
const skipReason = await this.conflict(candidate.relativePath, names);
if (skipReason) {
items.push({ ...base, status: "skipped", skipReason });
continue;
}
const target = join(this.target, candidate.relativePath);
await this.directories(dirname(target), true);
const method = request.mode;
if (method === "symlink") {
await symlink(candidate.sourcePath, target, "file");
links.set(candidate.relativePath, candidate.sourcePath);
try {
const current = await this.snapshot(candidate.sourcePath, candidate.sourceRoot);
if (current.revision !== candidate.revision) throw new Error("源命令已发生变化,请重新扫描。");
await this.saveLinks(links);
} catch (error) {
links.delete(candidate.relativePath);
await unlink(target);
throw error;
}
} else {
const temporary = join(dirname(target), `.command-import-${randomUUID()}.tmp`);
let published = false;
try {
const output = await open(temporary, "wx", 0o600);
try {
await output.writeFile(file.content);
await output.sync();
} finally {
await output.close();
}
await this.directories(dirname(target));
// link is an atomic no-clobber publish; removing the staging name leaves nlink === 1.
await link(temporary, target);
published = true;
await unlink(temporary);
} catch (error) {
if (published) await unlink(target).catch(() => {});
throw error;
} finally {
await unlink(temporary).catch(() => {});
}
}
names.set(nameKey(candidate.relativePath), candidate.relativePath);
items.push({ ...base, status: "imported", method });
} catch (error) {
if (code(error) === "EEXIST") items.push({ ...base, status: "skipped", skipReason: "targetExists" });
else items.push({ ...base, status: "failed", error: message(error) });
}
}
return {
importedCount: items.filter((item) => item.status === "imported").length,
skippedCount: items.filter((item) => item.status === "skipped").length,
failedCount: items.filter((item) => item.status === "failed").length,
items,
};
}
}