@ia-ccun/code-agent-cli 0.0.52-beta.3 → 0.0.52-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/agent/extensions/aicode-cd.ts +834 -0
- package/config/agent/extensions/aicode-grillMe.ts +198 -0
- package/config/agent/extensions/aicode-safety-guard.ts +550 -0
- package/config/agent/extensions/aicode-todo-progress.ts +488 -0
- package/config/agent/extensions/error-monitor.ts +1 -18
- package/config/agent/extensions/ponytail.ts +441 -0
- package/config/agent/skills/ponytail/SKILL.md +100 -0
- package/config/agent/skills/ponytail-audit/SKILL.md +40 -0
- package/config/agent/skills/ponytail-debt/SKILL.md +44 -0
- package/config/agent/skills/ponytail-help/SKILL.md +68 -0
- package/config/agent/skills/ponytail-review/SKILL.md +56 -0
- package/config/agent/skills/zread/README.md +115 -0
- package/config/agent/skills/zread/SKILL.md +103 -0
- package/config/agent/skills/zread/references/stdio-protocol.md +312 -0
- package/package.json +4 -2
- package/scripts/postinstall.js +0 -1
- package/config/agent/dangerous-patterns.json +0 -106
- package/config/agent/extensions/bash-check.ts +0 -227
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @author: xujianjiang
|
|
3
|
+
* /cd 命令扩展 — 在不重建 prompt cache 的情况下移动会话到不同工作目录
|
|
4
|
+
*
|
|
5
|
+
* 功能说明:
|
|
6
|
+
* - 将会话移动到另一个工作目录,保留对话上下文
|
|
7
|
+
* - 新目录的 CLAUDE.md / AGENTS.md 作为消息附加,不替换系统提示
|
|
8
|
+
* - 会话重新定位到新目录的项目存储
|
|
9
|
+
* - 支持目录历史、别名、模糊搜索、自动补全
|
|
10
|
+
* - 参考 Claude Code v2.1.169 的 /cd 命令设计
|
|
11
|
+
*
|
|
12
|
+
* 使用方法:
|
|
13
|
+
* /cd [path] 切换到指定目录
|
|
14
|
+
* /cd 从历史/别名/子目录中选择
|
|
15
|
+
* /cd --add <name> 添加目录别名
|
|
16
|
+
* /cd --list 列出所有历史
|
|
17
|
+
* /cd --status 显示存储状态
|
|
18
|
+
* /cd --clear-history 清除历史记录
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as os from "node:os";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { SessionManager, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
26
|
+
|
|
27
|
+
// ============== 类型定义 ==============
|
|
28
|
+
|
|
29
|
+
type CdHistoryEntry = {
|
|
30
|
+
path: string;
|
|
31
|
+
uses: number;
|
|
32
|
+
firstUsed: string;
|
|
33
|
+
lastUsed: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type CdStore = {
|
|
37
|
+
version: 1;
|
|
38
|
+
aliases: Record<string, string>;
|
|
39
|
+
history: CdHistoryEntry[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type SuggestionSource = "alias" | "history" | "child" | "special";
|
|
43
|
+
|
|
44
|
+
type CdSuggestion = {
|
|
45
|
+
value: string;
|
|
46
|
+
label: string;
|
|
47
|
+
targetPath: string;
|
|
48
|
+
description: string;
|
|
49
|
+
source: SuggestionSource;
|
|
50
|
+
score: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ============== 常量 ==============
|
|
54
|
+
|
|
55
|
+
const STORE_VERSION = 1;
|
|
56
|
+
const MAX_HISTORY_ENTRIES = 200;
|
|
57
|
+
const MAX_SUGGESTIONS = 24;
|
|
58
|
+
const MAX_CHILDREN_SCANNED = 300;
|
|
59
|
+
const STATUS_KEY = "cd-history";
|
|
60
|
+
|
|
61
|
+
const RESERVED_ALIAS_NAMES = new Set([
|
|
62
|
+
".",
|
|
63
|
+
"..",
|
|
64
|
+
"~",
|
|
65
|
+
"--add",
|
|
66
|
+
"--clear-history",
|
|
67
|
+
"--help",
|
|
68
|
+
"--list",
|
|
69
|
+
"--remove",
|
|
70
|
+
"--rm",
|
|
71
|
+
"--status",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
// ============== 路径工具 ==============
|
|
75
|
+
|
|
76
|
+
function getAgentDir(): string {
|
|
77
|
+
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
78
|
+
return configured ? path.resolve(expandTilde(configured)) : path.join(os.homedir(), ".aicode-cli", "agent");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getStorePath(): string {
|
|
82
|
+
const configured = process.env.PI_CD_HISTORY_STORE_PATH?.trim();
|
|
83
|
+
return configured ? path.resolve(expandTilde(configured)) : path.join(getAgentDir(), "state", "cd-history.json");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function emptyStore(): CdStore {
|
|
87
|
+
return { version: STORE_VERSION, aliases: {}, history: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function expandTilde(input: string): string {
|
|
91
|
+
if (input === "~") return os.homedir();
|
|
92
|
+
if (input.startsWith(`~${path.sep}`) || input.startsWith("~/")) {
|
|
93
|
+
return path.join(os.homedir(), input.slice(2));
|
|
94
|
+
}
|
|
95
|
+
return input;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function canonicalPath(input: string, baseDir?: string): string {
|
|
99
|
+
const expanded = expandTilde(input.trim());
|
|
100
|
+
const resolved = path.resolve(baseDir ?? process.cwd(), expanded);
|
|
101
|
+
try {
|
|
102
|
+
return fs.realpathSync.native(resolved);
|
|
103
|
+
} catch {
|
|
104
|
+
return resolved;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isDirectory(input: string): boolean {
|
|
109
|
+
try {
|
|
110
|
+
return fs.statSync(input).isDirectory();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeDirectory(input: string, baseDir?: string): string | undefined {
|
|
117
|
+
const resolved = canonicalPath(input, baseDir);
|
|
118
|
+
return isDirectory(resolved) ? resolved : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatPath(input: string): string {
|
|
122
|
+
const home = os.homedir();
|
|
123
|
+
const resolvedHome = path.resolve(home);
|
|
124
|
+
const resolved = path.resolve(input);
|
|
125
|
+
if (resolved === resolvedHome) return "~";
|
|
126
|
+
if (resolved.startsWith(`${resolvedHome}${path.sep}`)) {
|
|
127
|
+
return `~/${path.relative(resolvedHome, resolved).split(path.sep).join("/")}`;
|
|
128
|
+
}
|
|
129
|
+
return resolved.split(path.sep).join(path.sep);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatTimestamp(ts: number): string {
|
|
133
|
+
const d = new Date(ts);
|
|
134
|
+
const y = d.getFullYear();
|
|
135
|
+
const mo = String(d.getMonth() + 1).padStart(2, "0");
|
|
136
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
137
|
+
const h = String(d.getHours()).padStart(2, "0");
|
|
138
|
+
const mi = String(d.getMinutes()).padStart(2, "0");
|
|
139
|
+
const s = String(d.getSeconds()).padStart(2, "0");
|
|
140
|
+
return `${y}-${mo}-${day} ${h}:${mi}:${s}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseTimestamp(input: string): number {
|
|
144
|
+
// 支持 "YYYY-MM-DD HH:mm:ss" 格式
|
|
145
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/.exec(input);
|
|
146
|
+
if (match) {
|
|
147
|
+
return new Date(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +match[6]).getTime();
|
|
148
|
+
}
|
|
149
|
+
const fallback = Date.parse(input);
|
|
150
|
+
return Number.isFinite(fallback) ? fallback : Date.now();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatAge(timestamp: string): string {
|
|
154
|
+
const ts = parseTimestamp(timestamp);
|
|
155
|
+
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
156
|
+
if (seconds < 60) return "now";
|
|
157
|
+
const minutes = Math.floor(seconds / 60);
|
|
158
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
159
|
+
const hours = Math.floor(minutes / 60);
|
|
160
|
+
if (hours < 24) return `${hours}h ago`;
|
|
161
|
+
const days = Math.floor(hours / 24);
|
|
162
|
+
if (days < 30) return `${days}d ago`;
|
|
163
|
+
const months = Math.floor(days / 30);
|
|
164
|
+
if (months < 12) return `${months}mo ago`;
|
|
165
|
+
return `${Math.floor(months / 12)}y ago`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============== 存储读写 ==============
|
|
169
|
+
|
|
170
|
+
function readStore(storePath: string): CdStore {
|
|
171
|
+
if (!fs.existsSync(storePath)) return emptyStore();
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(fs.readFileSync(storePath, "utf8")) as unknown;
|
|
175
|
+
if (!parsed || typeof parsed !== "object") return emptyStore();
|
|
176
|
+
|
|
177
|
+
const store = emptyStore();
|
|
178
|
+
const rawAliases = (parsed as { aliases?: unknown }).aliases;
|
|
179
|
+
if (rawAliases && typeof rawAliases === "object" && !Array.isArray(rawAliases)) {
|
|
180
|
+
for (const [name, target] of Object.entries(rawAliases)) {
|
|
181
|
+
if (!isValidAliasName(name) || typeof target !== "string") continue;
|
|
182
|
+
const normalized = normalizeDirectory(target);
|
|
183
|
+
if (normalized) store.aliases[name] = normalized;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const rawHistory = (parsed as { history?: unknown }).history;
|
|
188
|
+
if (Array.isArray(rawHistory)) {
|
|
189
|
+
for (const item of rawHistory) {
|
|
190
|
+
if (!item || typeof item !== "object") continue;
|
|
191
|
+
const record = item as { path?: unknown; uses?: unknown; firstUsed?: unknown; lastUsed?: unknown };
|
|
192
|
+
if (typeof record.path !== "string") continue;
|
|
193
|
+
const normalized = normalizeDirectory(record.path);
|
|
194
|
+
if (!normalized) continue;
|
|
195
|
+
const uses = typeof record.uses === "number" && Number.isFinite(record.uses) ? Math.max(1, Math.floor(record.uses)) : 1;
|
|
196
|
+
const firstUsedRaw = typeof record.firstUsed === "string" ? parseTimestamp(record.firstUsed) : typeof record.firstUsed === "number" ? record.firstUsed : Date.now();
|
|
197
|
+
const lastUsedRaw = typeof record.lastUsed === "string" ? parseTimestamp(record.lastUsed) : typeof record.lastUsed === "number" ? record.lastUsed : firstUsedRaw;
|
|
198
|
+
store.history.push({ path: normalized, uses, firstUsed: formatTimestamp(firstUsedRaw), lastUsed: formatTimestamp(lastUsedRaw) });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return compactStore(store);
|
|
203
|
+
} catch {
|
|
204
|
+
return emptyStore();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function writeStore(storePath: string, store: CdStore): void {
|
|
209
|
+
try {
|
|
210
|
+
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
211
|
+
const compacted = compactStore(store);
|
|
212
|
+
const tempPath = `${storePath}.${process.pid}.${Date.now()}.tmp`;
|
|
213
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(compacted, null, 2)}\n`, "utf8");
|
|
214
|
+
fs.renameSync(tempPath, storePath);
|
|
215
|
+
} catch {
|
|
216
|
+
// Directory changes should still work if persistence fails.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function compactStore(store: CdStore): CdStore {
|
|
221
|
+
const byPath = new Map<string, CdHistoryEntry>();
|
|
222
|
+
for (const entry of store.history) {
|
|
223
|
+
const normalized = normalizeDirectory(entry.path);
|
|
224
|
+
if (!normalized) continue;
|
|
225
|
+
const existing = byPath.get(normalized);
|
|
226
|
+
if (!existing) {
|
|
227
|
+
byPath.set(normalized, { ...entry, path: normalized });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
existing.uses += Math.max(1, entry.uses);
|
|
231
|
+
const ef = parseTimestamp(existing.firstUsed);
|
|
232
|
+
const ef2 = parseTimestamp(entry.firstUsed);
|
|
233
|
+
existing.firstUsed = formatTimestamp(Math.min(ef, ef2));
|
|
234
|
+
const el = parseTimestamp(existing.lastUsed);
|
|
235
|
+
const el2 = parseTimestamp(entry.lastUsed);
|
|
236
|
+
existing.lastUsed = formatTimestamp(Math.max(el, el2));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const aliases: Record<string, string> = {};
|
|
240
|
+
for (const [name, target] of Object.entries(store.aliases)) {
|
|
241
|
+
if (!isValidAliasName(name)) continue;
|
|
242
|
+
const normalized = normalizeDirectory(target);
|
|
243
|
+
if (normalized) aliases[name] = normalized;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const history = Array.from(byPath.values())
|
|
247
|
+
.sort((a, b) => b.lastUsed - a.lastUsed || b.uses - a.uses || a.path.localeCompare(b.path))
|
|
248
|
+
.slice(0, MAX_HISTORY_ENTRIES);
|
|
249
|
+
|
|
250
|
+
return { version: STORE_VERSION, aliases, history };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function recordVisit(store: CdStore, targetPath: string): CdStore {
|
|
254
|
+
const normalized = normalizeDirectory(targetPath);
|
|
255
|
+
if (!normalized) return store;
|
|
256
|
+
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
const next = compactStore(store);
|
|
259
|
+
const existing = next.history.find((entry) => entry.path === normalized);
|
|
260
|
+
if (existing) {
|
|
261
|
+
existing.uses += 1;
|
|
262
|
+
existing.lastUsed = formatTimestamp(now);
|
|
263
|
+
} else {
|
|
264
|
+
next.history.unshift({ path: normalized, uses: 1, firstUsed: formatTimestamp(now), lastUsed: formatTimestamp(now) });
|
|
265
|
+
}
|
|
266
|
+
return compactStore(next);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isValidAliasName(name: string): boolean {
|
|
270
|
+
if (RESERVED_ALIAS_NAMES.has(name)) return false;
|
|
271
|
+
if (name.includes("/") || name.includes("\\")) return false;
|
|
272
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============== 参数解析 ==============
|
|
276
|
+
|
|
277
|
+
function splitArgs(input: string): string[] {
|
|
278
|
+
const words: string[] = [];
|
|
279
|
+
let current = "";
|
|
280
|
+
let quote: "'" | '"' | undefined;
|
|
281
|
+
let escaped = false;
|
|
282
|
+
|
|
283
|
+
for (const char of input) {
|
|
284
|
+
if (escaped) {
|
|
285
|
+
current += char;
|
|
286
|
+
escaped = false;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (char === "\\" && quote !== "'") {
|
|
291
|
+
escaped = true;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (quote) {
|
|
296
|
+
if (char === quote) quote = undefined;
|
|
297
|
+
else current += char;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (char === "'" || char === '"') {
|
|
302
|
+
quote = char;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (/\s/.test(char)) {
|
|
307
|
+
if (current) {
|
|
308
|
+
words.push(current);
|
|
309
|
+
current = "";
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
current += char;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (escaped) current += "\\";
|
|
318
|
+
if (current) words.push(current);
|
|
319
|
+
return words;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ============== 模糊搜索 ==============
|
|
323
|
+
|
|
324
|
+
function fuzzyScore(text: string, query: string): number {
|
|
325
|
+
const lowerText = text.toLowerCase();
|
|
326
|
+
const lowerQuery = query.toLowerCase();
|
|
327
|
+
if (!lowerQuery) return 0;
|
|
328
|
+
|
|
329
|
+
let textIndex = 0;
|
|
330
|
+
let gapPenalty = 0;
|
|
331
|
+
for (const queryChar of lowerQuery) {
|
|
332
|
+
const found = lowerText.indexOf(queryChar, textIndex);
|
|
333
|
+
if (found === -1) return Number.NEGATIVE_INFINITY;
|
|
334
|
+
gapPenalty += found - textIndex;
|
|
335
|
+
textIndex = found + 1;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return 350 - gapPenalty;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function matchScore(suggestion: CdSuggestion, query: string): number {
|
|
342
|
+
const trimmed = query.trim().toLowerCase();
|
|
343
|
+
if (!trimmed) return suggestion.score;
|
|
344
|
+
|
|
345
|
+
const basename = path.basename(suggestion.targetPath).toLowerCase();
|
|
346
|
+
const fields = [suggestion.value, suggestion.label, basename, formatPath(suggestion.targetPath), suggestion.targetPath].map((value) => value.toLowerCase());
|
|
347
|
+
|
|
348
|
+
if (fields.some((field) => field === trimmed)) return suggestion.score + 3000;
|
|
349
|
+
if (suggestion.value.toLowerCase().startsWith(trimmed)) return suggestion.score + 2200;
|
|
350
|
+
if (basename.startsWith(trimmed)) return suggestion.score + 1800;
|
|
351
|
+
if (fields.some((field) => field.includes(trimmed))) return suggestion.score + 1200;
|
|
352
|
+
|
|
353
|
+
const bestFuzzy = Math.max(...fields.map((field) => fuzzyScore(field, trimmed)));
|
|
354
|
+
return Number.isFinite(bestFuzzy) ? suggestion.score + bestFuzzy : Number.NEGATIVE_INFINITY;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ============== 建议生成 ==============
|
|
358
|
+
|
|
359
|
+
function addSuggestion(map: Map<string, CdSuggestion>, suggestion: CdSuggestion): void {
|
|
360
|
+
const key = `${suggestion.source}:${suggestion.value}:${suggestion.targetPath}`;
|
|
361
|
+
const existing = map.get(key);
|
|
362
|
+
if (!existing || suggestion.score > existing.score) map.set(key, suggestion);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function readChildDirectories(cwd: string): CdSuggestion[] {
|
|
366
|
+
let entries: fs.Dirent[];
|
|
367
|
+
try {
|
|
368
|
+
entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
369
|
+
} catch {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return entries
|
|
374
|
+
.filter((entry) => entry.isDirectory())
|
|
375
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
376
|
+
.slice(0, MAX_CHILDREN_SCANNED)
|
|
377
|
+
.map((entry) => {
|
|
378
|
+
const targetPath = canonicalPath(path.join(cwd, entry.name));
|
|
379
|
+
return {
|
|
380
|
+
value: entry.name,
|
|
381
|
+
label: entry.name,
|
|
382
|
+
targetPath,
|
|
383
|
+
description: formatPath(targetPath),
|
|
384
|
+
source: "child" as const,
|
|
385
|
+
score: entry.name.startsWith(".") ? 180 : 260,
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildSuggestions(cwd: string, store: CdStore, query = ""): CdSuggestion[] {
|
|
391
|
+
const suggestions = new Map<string, CdSuggestion>();
|
|
392
|
+
const normalizedCwd = canonicalPath(cwd);
|
|
393
|
+
const parent = path.dirname(normalizedCwd);
|
|
394
|
+
const home = canonicalPath(os.homedir());
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
|
|
397
|
+
// 特殊路径
|
|
398
|
+
addSuggestion(suggestions, {
|
|
399
|
+
value: "..",
|
|
400
|
+
label: "..",
|
|
401
|
+
targetPath: parent,
|
|
402
|
+
description: formatPath(parent),
|
|
403
|
+
source: "special",
|
|
404
|
+
score: 360,
|
|
405
|
+
});
|
|
406
|
+
addSuggestion(suggestions, {
|
|
407
|
+
value: "~",
|
|
408
|
+
label: "~",
|
|
409
|
+
targetPath: home,
|
|
410
|
+
description: formatPath(home),
|
|
411
|
+
source: "special",
|
|
412
|
+
score: 320,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// 别名
|
|
416
|
+
for (const [alias, targetPath] of Object.entries(store.aliases)) {
|
|
417
|
+
if (!isDirectory(targetPath)) continue;
|
|
418
|
+
addSuggestion(suggestions, {
|
|
419
|
+
value: alias,
|
|
420
|
+
label: alias,
|
|
421
|
+
targetPath,
|
|
422
|
+
description: `alias → ${formatPath(targetPath)}`,
|
|
423
|
+
source: "alias",
|
|
424
|
+
score: 1200,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 历史
|
|
429
|
+
for (const entry of store.history) {
|
|
430
|
+
if (!isDirectory(entry.path)) continue;
|
|
431
|
+
const lastUsedMs = parseTimestamp(entry.lastUsed);
|
|
432
|
+
const ageDays = Math.max(0, (now - lastUsedMs) / 86_400_000);
|
|
433
|
+
const recency = Math.max(0, 120 - ageDays * 4);
|
|
434
|
+
addSuggestion(suggestions, {
|
|
435
|
+
value: formatPath(entry.path),
|
|
436
|
+
label: path.basename(entry.path) || entry.path,
|
|
437
|
+
targetPath: entry.path,
|
|
438
|
+
description: `${formatPath(entry.path)} · ${entry.uses} use${entry.uses === 1 ? "" : "s"} · ${formatAge(entry.lastUsed)}`,
|
|
439
|
+
source: "history",
|
|
440
|
+
score: 520 + entry.uses * 24 + recency,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 子目录
|
|
445
|
+
for (const child of readChildDirectories(normalizedCwd)) {
|
|
446
|
+
addSuggestion(suggestions, child);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return Array.from(suggestions.values())
|
|
450
|
+
.map((suggestion) => ({ suggestion, score: matchScore(suggestion, query) }))
|
|
451
|
+
.filter((entry) => Number.isFinite(entry.score))
|
|
452
|
+
.sort((a, b) => b.score - a.score || a.suggestion.label.localeCompare(b.suggestion.label))
|
|
453
|
+
.slice(0, MAX_SUGGESTIONS)
|
|
454
|
+
.map((entry) => entry.suggestion);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function quoteArg(value: string): string {
|
|
458
|
+
if (/^[^\s"'\\]+$/.test(value)) return value;
|
|
459
|
+
return `"${value.replace(/["\\]/g, (match) => `\\${match}`)}"`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function suggestionsToAutocompleteItems(suggestions: CdSuggestion[]): AutocompleteItem[] {
|
|
463
|
+
return suggestions.map((suggestion) => ({
|
|
464
|
+
value: suggestion.value,
|
|
465
|
+
label: suggestion.label,
|
|
466
|
+
description: suggestion.description,
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function resolveTarget(rawArgs: string, cwd: string, store: CdStore): { targetPath?: string; suggestions: CdSuggestion[] } {
|
|
471
|
+
const target = rawArgs.trim();
|
|
472
|
+
if (!target || target === ".") return { targetPath: canonicalPath(cwd), suggestions: [] };
|
|
473
|
+
|
|
474
|
+
// 别名匹配
|
|
475
|
+
const aliasTarget = store.aliases[target];
|
|
476
|
+
if (aliasTarget && isDirectory(aliasTarget)) {
|
|
477
|
+
return { targetPath: aliasTarget, suggestions: [] };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 直接路径匹配
|
|
481
|
+
const direct = normalizeDirectory(target, cwd);
|
|
482
|
+
if (direct) return { targetPath: direct, suggestions: [] };
|
|
483
|
+
|
|
484
|
+
// 模糊匹配
|
|
485
|
+
const suggestions = buildSuggestions(cwd, store, target);
|
|
486
|
+
const exact = suggestions.find(
|
|
487
|
+
(suggestion) =>
|
|
488
|
+
suggestion.value === target ||
|
|
489
|
+
suggestion.label === target ||
|
|
490
|
+
formatPath(suggestion.targetPath) === target ||
|
|
491
|
+
suggestion.targetPath === target,
|
|
492
|
+
);
|
|
493
|
+
if (exact) return { targetPath: exact.targetPath, suggestions };
|
|
494
|
+
|
|
495
|
+
return { suggestions };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ============== 会话管理 ==============
|
|
499
|
+
|
|
500
|
+
function ensureEmptySessionFile(targetPath: string): string {
|
|
501
|
+
const sessionManager = SessionManager.create(targetPath);
|
|
502
|
+
const sessionPath = sessionManager.getSessionFile();
|
|
503
|
+
if (!sessionPath) throw new Error("Unable to create a persistent aicode session for target directory");
|
|
504
|
+
|
|
505
|
+
const internal = sessionManager as unknown as { fileEntries?: unknown[]; _rewriteFile?: () => void };
|
|
506
|
+
if (typeof internal._rewriteFile === "function") {
|
|
507
|
+
internal._rewriteFile();
|
|
508
|
+
return sessionPath;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const entries = internal.fileEntries;
|
|
512
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
513
|
+
throw new Error("Unable to initialize target aicode session header");
|
|
514
|
+
}
|
|
515
|
+
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
|
|
516
|
+
fs.writeFileSync(sessionPath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, { flag: "wx" });
|
|
517
|
+
return sessionPath;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function createTargetSession(currentSessionPath: string | undefined, targetPath: string): string {
|
|
521
|
+
if (currentSessionPath && fs.existsSync(currentSessionPath)) {
|
|
522
|
+
try {
|
|
523
|
+
const forked = SessionManager.forkFrom(currentSessionPath, targetPath);
|
|
524
|
+
const forkedPath = forked.getSessionFile();
|
|
525
|
+
if (forkedPath) return forkedPath;
|
|
526
|
+
} catch {
|
|
527
|
+
// Fall back to a clean target session if the current session file is absent/corrupt.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return ensureEmptySessionFile(targetPath);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ============== UI 交互 ==============
|
|
535
|
+
|
|
536
|
+
async function selectTarget(ctx: ExtensionCommandContext, suggestions: CdSuggestion[], title: string): Promise<CdSuggestion | undefined> {
|
|
537
|
+
if (!ctx.hasUI || suggestions.length === 0) return undefined;
|
|
538
|
+
|
|
539
|
+
const items = suggestions.map((suggestion, index) => {
|
|
540
|
+
const prefix = suggestion.source === "alias" ? "★" : suggestion.source === "history" ? "↺" : suggestion.source === "special" ? "•" : "dir";
|
|
541
|
+
return `${prefix} ${suggestion.label} — ${suggestion.description} [${index + 1}]`;
|
|
542
|
+
});
|
|
543
|
+
const selected = await ctx.ui.select(title, items);
|
|
544
|
+
if (!selected) return undefined;
|
|
545
|
+
const index = items.indexOf(selected);
|
|
546
|
+
return index >= 0 ? suggestions[index] : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function helpText(): string {
|
|
550
|
+
return [
|
|
551
|
+
"/cd [dir|alias] change aicode-cli session cwd, preserving conversation by forking into the target cwd",
|
|
552
|
+
"/cd pick from ranked aliases, history, parent/home, and child directories",
|
|
553
|
+
"/cd --add <name> [dir] add alias so /cd <name> jumps to dir (default: current cwd)",
|
|
554
|
+
"/cd --remove <name> remove an alias",
|
|
555
|
+
"/cd --list pick from aliases/history",
|
|
556
|
+
"/cd --status show store path and counts",
|
|
557
|
+
"/cd --clear-history clear learned directory history (aliases stay)",
|
|
558
|
+
].join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ============== 核心逻辑 ==============
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 读取目标目录的配置文件内容,作为消息附加(不替换系统提示)
|
|
565
|
+
* 参考 Claude Code /cd 的行为:新目录的 CLAUDE.md / AGENTS.md /AICODE.md 作为消息注入
|
|
566
|
+
*/
|
|
567
|
+
function readProjectConfig(targetPath: string): string | null {
|
|
568
|
+
const candidates = ["CLAUDE.md", "AGENTS.md","AICODE.md"];
|
|
569
|
+
const parts: string[] = [];
|
|
570
|
+
|
|
571
|
+
for (const name of candidates) {
|
|
572
|
+
const filePath = path.join(targetPath, name);
|
|
573
|
+
try {
|
|
574
|
+
if (fs.existsSync(filePath)) {
|
|
575
|
+
const content = fs.readFileSync(filePath, "utf8").trim();
|
|
576
|
+
if (content) {
|
|
577
|
+
parts.push(`## ${name}\n\n${content}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
// Skip unreadable files
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return parts.length > 0 ? parts.join("\n\n---\n\n") : null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function changeDirectory(
|
|
589
|
+
ctx: ExtensionCommandContext,
|
|
590
|
+
targetPath: string,
|
|
591
|
+
getStore: () => CdStore,
|
|
592
|
+
setStore: (store: CdStore) => void,
|
|
593
|
+
): Promise<void> {
|
|
594
|
+
await ctx.waitForIdle();
|
|
595
|
+
|
|
596
|
+
const normalizedTarget = normalizeDirectory(targetPath);
|
|
597
|
+
if (!normalizedTarget) {
|
|
598
|
+
ctx.ui.notify(`cd: not a directory: ${targetPath}`, "error");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const current = canonicalPath(ctx.cwd);
|
|
603
|
+
if (normalizedTarget === current) {
|
|
604
|
+
const store = recordVisit(getStore(), normalizedTarget);
|
|
605
|
+
setStore(store);
|
|
606
|
+
ctx.ui.notify(`Already in ${formatPath(normalizedTarget)}`, "info");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// 创建目标目录的会话文件(fork 当前会话)
|
|
611
|
+
const currentSessionPath = ctx.sessionManager.getSessionFile();
|
|
612
|
+
let targetSessionPath: string;
|
|
613
|
+
try {
|
|
614
|
+
targetSessionPath = createTargetSession(currentSessionPath, normalizedTarget);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
617
|
+
ctx.ui.notify(`cd: failed to create target session: ${message}`, "error");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 读取目标目录的配置文件
|
|
622
|
+
const projectConfig = readProjectConfig(normalizedTarget);
|
|
623
|
+
|
|
624
|
+
// 记录访问
|
|
625
|
+
setStore(recordVisit(getStore(), normalizedTarget));
|
|
626
|
+
|
|
627
|
+
// 切换会话(将切换信息作为消息注入,永久保留在会话历史中)
|
|
628
|
+
const result = await ctx.switchSession(targetSessionPath, {
|
|
629
|
+
withSession: async (nextCtx) => {
|
|
630
|
+
nextCtx.ui.setStatus(STATUS_KEY, `cwd ${formatPath(normalizedTarget)}`);
|
|
631
|
+
|
|
632
|
+
// 切换提示消息(红色文字,永久保留在会话历史中)
|
|
633
|
+
const switchMsg = `\x1b[31m⬢ cd: ${formatPath(current)} → ${formatPath(normalizedTarget)} 请注意后续操作! \x1b[0m`;
|
|
634
|
+
await nextCtx.sendUserMessage(switchMsg, { deliverAs: "steer" });
|
|
635
|
+
|
|
636
|
+
// 将新目录的 CLAUDE.md / AGENTS.md /AICODE.md 作为消息附加,不替换系统提示
|
|
637
|
+
if (projectConfig) {
|
|
638
|
+
const configMessage = `\x1b[31m⬢ Loaded project config: CLAUDE.md / AGENTS.md from ${formatPath(normalizedTarget)}\x1b[0m\n\n---\n\n${projectConfig}`;
|
|
639
|
+
await nextCtx.sendUserMessage(configMessage, { deliverAs: "followUp" });
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
if (result.cancelled) {
|
|
645
|
+
ctx.ui.notify(`cd cancelled: ${formatPath(normalizedTarget)}`, "warning");
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function addAlias(store: CdStore, name: string, targetPath: string): CdStore {
|
|
650
|
+
if (!isValidAliasName(name)) {
|
|
651
|
+
throw new Error(`Invalid alias '${name}'. Use letters, numbers, dot, underscore, or dash; start with a letter/number.`);
|
|
652
|
+
}
|
|
653
|
+
const normalized = normalizeDirectory(targetPath);
|
|
654
|
+
if (!normalized) throw new Error(`Not a directory: ${targetPath}`);
|
|
655
|
+
return compactStore({ ...store, aliases: { ...store.aliases, [name]: normalized } });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function removeAlias(store: CdStore, name: string): CdStore {
|
|
659
|
+
const aliases = { ...store.aliases };
|
|
660
|
+
delete aliases[name];
|
|
661
|
+
return compactStore({ ...store, aliases });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ============== 扩展注册 ==============
|
|
665
|
+
|
|
666
|
+
export default function cdHistoryExtension(pi: ExtensionAPI) {
|
|
667
|
+
const storePath = getStorePath();
|
|
668
|
+
let store = readStore(storePath);
|
|
669
|
+
let currentCwd = canonicalPath(process.cwd());
|
|
670
|
+
|
|
671
|
+
const setStore = (nextStore: CdStore) => {
|
|
672
|
+
store = compactStore(nextStore);
|
|
673
|
+
writeStore(storePath, store);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const refreshStore = () => {
|
|
677
|
+
store = readStore(storePath);
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
// 会话启动时初始化
|
|
681
|
+
pi.on("session_start", (_event, ctx) => {
|
|
682
|
+
currentCwd = canonicalPath(ctx.cwd);
|
|
683
|
+
refreshStore();
|
|
684
|
+
ctx.ui.setStatus(STATUS_KEY, `cwd ${formatPath(currentCwd)}`);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// 注册 /cd 命令
|
|
688
|
+
pi.registerCommand("cd", {
|
|
689
|
+
description: "Change aicode-cli working directory with ranked history and aliases",
|
|
690
|
+
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
|
691
|
+
refreshStore();
|
|
692
|
+
const trimmed = prefix.trimStart();
|
|
693
|
+
const words = splitArgs(trimmed);
|
|
694
|
+
const commandOptions = ["--add", "--remove", "--list", "--status", "--clear-history", "--help"];
|
|
695
|
+
|
|
696
|
+
// 选项补全
|
|
697
|
+
if (trimmed.startsWith("--") && words.length <= 1 && !trimmed.endsWith(" ")) {
|
|
698
|
+
const filtered = commandOptions.filter((option) => option.startsWith(trimmed));
|
|
699
|
+
return filtered.length > 0 ? filtered.map((option) => ({ value: option, label: option })) : null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// --remove 别名补全
|
|
703
|
+
if (words[0] === "--remove" || words[0] === "--rm") {
|
|
704
|
+
const option = words[0];
|
|
705
|
+
const partial = words[1] ?? "";
|
|
706
|
+
const items = Object.entries(store.aliases)
|
|
707
|
+
.filter(([name]) => name.startsWith(partial))
|
|
708
|
+
.map(([name, target]) => ({ value: `${option} ${name}`, label: name, description: formatPath(target) }));
|
|
709
|
+
return items.length > 0 ? items : null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// --add 路径补全
|
|
713
|
+
if (words[0] === "--add" && words[1] && (trimmed.endsWith(" ") || words.length >= 3)) {
|
|
714
|
+
const aliasName = words[1];
|
|
715
|
+
const pathPrefix = words.length >= 3 ? words.slice(2).join(" ") : "";
|
|
716
|
+
const items = buildSuggestions(currentCwd, store, pathPrefix).map((suggestion) => ({
|
|
717
|
+
value: `--add ${aliasName} ${quoteArg(formatPath(suggestion.targetPath))}`,
|
|
718
|
+
label: suggestion.label,
|
|
719
|
+
description: suggestion.description,
|
|
720
|
+
}));
|
|
721
|
+
return items.length > 0 ? items : null;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// 普通路径补全
|
|
725
|
+
const items = suggestionsToAutocompleteItems(buildSuggestions(currentCwd, store, prefix));
|
|
726
|
+
return items.length > 0 ? items : null;
|
|
727
|
+
},
|
|
728
|
+
handler: async (args, ctx) => {
|
|
729
|
+
refreshStore();
|
|
730
|
+
currentCwd = canonicalPath(ctx.cwd);
|
|
731
|
+
const trimmed = args.trim();
|
|
732
|
+
|
|
733
|
+
// 帮助
|
|
734
|
+
if (trimmed === "--help" || trimmed === "-h") {
|
|
735
|
+
ctx.ui.notify(helpText(), "info");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// 状态
|
|
740
|
+
if (trimmed === "--status") {
|
|
741
|
+
ctx.ui.notify(
|
|
742
|
+
`cd-history: ${Object.keys(store.aliases).length} aliases · ${store.history.length} history entries · store ${storePath}`,
|
|
743
|
+
"info",
|
|
744
|
+
);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// 清除历史
|
|
749
|
+
if (trimmed === "--clear-history") {
|
|
750
|
+
const ok = !ctx.hasUI || await ctx.ui.confirm("Clear /cd history?", "Aliases will be kept.");
|
|
751
|
+
if (!ok) return;
|
|
752
|
+
setStore({ ...store, history: [] });
|
|
753
|
+
ctx.ui.notify("/cd history cleared", "info");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// 列表选择
|
|
758
|
+
if (trimmed === "--list") {
|
|
759
|
+
const suggestions = buildSuggestions(currentCwd, store);
|
|
760
|
+
const selected = await selectTarget(ctx, suggestions, "/cd aliases and history");
|
|
761
|
+
if (selected) await changeDirectory(ctx, selected.targetPath, () => store, setStore);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const words = splitArgs(trimmed);
|
|
766
|
+
|
|
767
|
+
// 添加别名
|
|
768
|
+
if (words[0] === "--add") {
|
|
769
|
+
const name = words[1];
|
|
770
|
+
if (!name) {
|
|
771
|
+
ctx.ui.notify("Usage: /cd --add <name> [dir]", "error");
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const rawTarget = words.length >= 3 ? words.slice(2).join(" ") : currentCwd;
|
|
775
|
+
try {
|
|
776
|
+
setStore(addAlias(store, name, rawTarget));
|
|
777
|
+
ctx.ui.notify(`Added /cd ${name} → ${formatPath(store.aliases[name] ?? normalizeDirectory(rawTarget, currentCwd) ?? rawTarget)}`, "info");
|
|
778
|
+
} catch (error) {
|
|
779
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
780
|
+
ctx.ui.notify(`cd alias failed: ${message}`, "error");
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// 移除别名
|
|
786
|
+
if (words[0] === "--remove" || words[0] === "--rm") {
|
|
787
|
+
const name = words[1];
|
|
788
|
+
if (!name) {
|
|
789
|
+
ctx.ui.notify("Usage: /cd --remove <name>", "error");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (!store.aliases[name]) {
|
|
793
|
+
ctx.ui.notify(`No /cd alias named '${name}'`, "warning");
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
setStore(removeAlias(store, name));
|
|
797
|
+
ctx.ui.notify(`Removed /cd ${name}`, "info");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// 无参数:显示选择列表
|
|
802
|
+
if (!trimmed) {
|
|
803
|
+
const selected = await selectTarget(ctx, buildSuggestions(currentCwd, store), "Change directory");
|
|
804
|
+
if (selected) await changeDirectory(ctx, selected.targetPath, () => store, setStore);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// 解析目标
|
|
809
|
+
const resolved = resolveTarget(trimmed, currentCwd, store);
|
|
810
|
+
if (resolved.targetPath) {
|
|
811
|
+
await changeDirectory(ctx, resolved.targetPath, () => store, setStore);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 没有精确匹配:显示建议
|
|
816
|
+
const selected = await selectTarget(ctx, resolved.suggestions, `No exact directory for '${trimmed}'. Pick one:`);
|
|
817
|
+
if (selected) {
|
|
818
|
+
await changeDirectory(ctx, selected.targetPath, () => store, setStore);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
ctx.ui.notify(`cd: no directory or alias matched '${trimmed}'`, "error");
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// 注册 /cd-refresh 命令
|
|
827
|
+
pi.registerCommand("cd-refresh", {
|
|
828
|
+
description: "Reload /cd history and alias store",
|
|
829
|
+
handler: async (_args, ctx) => {
|
|
830
|
+
refreshStore();
|
|
831
|
+
ctx.ui.notify(`Reloaded /cd store (${Object.keys(store.aliases).length} aliases, ${store.history.length} history entries)`, "info");
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
}
|