planmode 0.3.0 → 0.4.0
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/dist/index.js +408 -7
- package/dist/mcp.js +491 -115
- package/package.json +1 -1
- package/src/commands/context.ts +111 -0
- package/src/commands/interactive.ts +107 -0
- package/src/index.ts +3 -1
- package/src/lib/context.ts +265 -0
- package/src/mcp.ts +146 -0
- package/src/types/index.ts +28 -0
package/package.json
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import { addContextRepo, removeContextRepo, reindexContext, getContextSummary, formatSize } from "../lib/context.js";
|
|
4
|
+
import { logger } from "../lib/logger.js";
|
|
5
|
+
import { isInteractive, withSpinner } from "../lib/prompts.js";
|
|
6
|
+
|
|
7
|
+
export const contextCommand = new Command("context")
|
|
8
|
+
.description("Manage project document context for AI");
|
|
9
|
+
|
|
10
|
+
contextCommand
|
|
11
|
+
.command("add <path>")
|
|
12
|
+
.description("Add a document directory to the project context")
|
|
13
|
+
.option("--name <name>", "Human-readable label for this directory")
|
|
14
|
+
.action(async (dirPath: string, options: { name?: string }) => {
|
|
15
|
+
try {
|
|
16
|
+
const interactive = isInteractive();
|
|
17
|
+
|
|
18
|
+
if (interactive) {
|
|
19
|
+
await withSpinner(
|
|
20
|
+
"Indexing documents...",
|
|
21
|
+
async () => addContextRepo(dirPath, { name: options.name }),
|
|
22
|
+
"Indexing complete",
|
|
23
|
+
);
|
|
24
|
+
} else {
|
|
25
|
+
logger.blank();
|
|
26
|
+
addContextRepo(dirPath, { name: options.name });
|
|
27
|
+
logger.blank();
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
logger.error((err as Error).message);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
contextCommand
|
|
36
|
+
.command("remove <path-or-name>")
|
|
37
|
+
.description("Remove a directory from the project context")
|
|
38
|
+
.action((pathOrName: string) => {
|
|
39
|
+
try {
|
|
40
|
+
logger.blank();
|
|
41
|
+
removeContextRepo(pathOrName);
|
|
42
|
+
logger.blank();
|
|
43
|
+
} catch (err) {
|
|
44
|
+
logger.error((err as Error).message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
contextCommand
|
|
50
|
+
.command("list")
|
|
51
|
+
.description("Show all directories in the project context")
|
|
52
|
+
.option("--json", "Output as JSON")
|
|
53
|
+
.action((options: { json?: boolean }) => {
|
|
54
|
+
try {
|
|
55
|
+
const summary = getContextSummary();
|
|
56
|
+
|
|
57
|
+
if (options.json) {
|
|
58
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
logger.blank();
|
|
63
|
+
|
|
64
|
+
if (summary.totalRepos === 0) {
|
|
65
|
+
logger.info("No context repos configured. Run `planmode context add <path>` to add one.");
|
|
66
|
+
logger.blank();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
logger.bold(`${summary.totalRepos} context repo(s) — ${summary.totalFiles} file(s), ${formatSize(summary.totalSize)}`);
|
|
71
|
+
logger.blank();
|
|
72
|
+
|
|
73
|
+
for (const repo of summary.repos) {
|
|
74
|
+
logger.info(`${repo.name}`);
|
|
75
|
+
logger.dim(` Path: ${repo.path}`);
|
|
76
|
+
logger.dim(` Files: ${repo.fileCount} (${formatSize(repo.totalSize)})`);
|
|
77
|
+
if (repo.typeBreakdown.length > 0) {
|
|
78
|
+
logger.dim(` Types: ${repo.typeBreakdown.join(", ")}`);
|
|
79
|
+
}
|
|
80
|
+
logger.dim(` Indexed: ${repo.indexedAt}`);
|
|
81
|
+
logger.blank();
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
logger.error((err as Error).message);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
contextCommand
|
|
90
|
+
.command("reindex [path-or-name]")
|
|
91
|
+
.description("Re-scan files in one or all context directories")
|
|
92
|
+
.action(async (pathOrName?: string) => {
|
|
93
|
+
try {
|
|
94
|
+
const interactive = isInteractive();
|
|
95
|
+
|
|
96
|
+
if (interactive) {
|
|
97
|
+
await withSpinner(
|
|
98
|
+
"Re-scanning documents...",
|
|
99
|
+
async () => reindexContext(pathOrName),
|
|
100
|
+
"Reindex complete",
|
|
101
|
+
);
|
|
102
|
+
} else {
|
|
103
|
+
logger.blank();
|
|
104
|
+
reindexContext(pathOrName);
|
|
105
|
+
logger.blank();
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.error((err as Error).message);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
@@ -7,6 +7,7 @@ import { searchPackages, fetchPackageMetadata, fetchIndex } from "../lib/registr
|
|
|
7
7
|
import { installPackage } from "../lib/installer.js";
|
|
8
8
|
import { readLockfile } from "../lib/lockfile.js";
|
|
9
9
|
import { runDoctor } from "../lib/doctor.js";
|
|
10
|
+
import { addContextRepo, removeContextRepo, reindexContext, getContextSummary, formatSize, readContextIndex } from "../lib/context.js";
|
|
10
11
|
import type { PackageSummary } from "../types/index.js";
|
|
11
12
|
|
|
12
13
|
type Action =
|
|
@@ -15,6 +16,7 @@ type Action =
|
|
|
15
16
|
| "install"
|
|
16
17
|
| "create"
|
|
17
18
|
| "list"
|
|
19
|
+
| "context"
|
|
18
20
|
| "doctor"
|
|
19
21
|
| "exit";
|
|
20
22
|
|
|
@@ -195,6 +197,7 @@ async function mainMenu(): Promise<void> {
|
|
|
195
197
|
{ value: "install" as Action, label: "Install a package", hint: "install by name" },
|
|
196
198
|
{ value: "create" as Action, label: "Create a new package" },
|
|
197
199
|
{ value: "list" as Action, label: "My installed packages" },
|
|
200
|
+
{ value: "context" as Action, label: "Manage context", hint: "document directories for AI" },
|
|
198
201
|
{ value: "doctor" as Action, label: "Health check" },
|
|
199
202
|
{ value: "exit" as Action, label: "Exit" },
|
|
200
203
|
],
|
|
@@ -219,6 +222,9 @@ async function mainMenu(): Promise<void> {
|
|
|
219
222
|
case "list":
|
|
220
223
|
listFlow();
|
|
221
224
|
break;
|
|
225
|
+
case "context":
|
|
226
|
+
await contextFlow();
|
|
227
|
+
break;
|
|
222
228
|
case "doctor":
|
|
223
229
|
doctorFlow();
|
|
224
230
|
break;
|
|
@@ -407,6 +413,107 @@ async function installFlow(): Promise<void> {
|
|
|
407
413
|
}
|
|
408
414
|
}
|
|
409
415
|
|
|
416
|
+
type ContextAction = "add" | "remove" | "reindex" | "back";
|
|
417
|
+
|
|
418
|
+
async function contextFlow(): Promise<void> {
|
|
419
|
+
const summary = getContextSummary();
|
|
420
|
+
|
|
421
|
+
if (summary.totalRepos === 0) {
|
|
422
|
+
p.log.info("No context repos yet.");
|
|
423
|
+
} else {
|
|
424
|
+
const lines = summary.repos.map(
|
|
425
|
+
(r) => `${r.name} — ${r.fileCount} file(s), ${formatSize(r.totalSize)}`,
|
|
426
|
+
);
|
|
427
|
+
p.note(lines.join("\n"), `${summary.totalRepos} context repo(s)`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const action = handleCancel(
|
|
431
|
+
await p.select<ContextAction>({
|
|
432
|
+
message: "What would you like to do?",
|
|
433
|
+
options: [
|
|
434
|
+
{ value: "add" as ContextAction, label: "Add a directory" },
|
|
435
|
+
{ value: "remove" as ContextAction, label: "Remove a directory" },
|
|
436
|
+
{ value: "reindex" as ContextAction, label: "Re-index all" },
|
|
437
|
+
{ value: "back" as ContextAction, label: "Back" },
|
|
438
|
+
],
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (action === "back") return;
|
|
443
|
+
|
|
444
|
+
if (action === "add") {
|
|
445
|
+
const dirPath = handleCancel(
|
|
446
|
+
await p.text({
|
|
447
|
+
message: "Path to document directory:",
|
|
448
|
+
placeholder: "e.g. docs, specs, ./reference",
|
|
449
|
+
validate(input) {
|
|
450
|
+
if (!input) return "Please enter a directory path";
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
const name = handleCancel(
|
|
456
|
+
await p.text({
|
|
457
|
+
message: "Label (optional):",
|
|
458
|
+
placeholder: "e.g. Project Documentation",
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
await withSpinner(
|
|
464
|
+
"Indexing documents...",
|
|
465
|
+
async () => addContextRepo(dirPath, { name: name || undefined }),
|
|
466
|
+
"Indexing complete",
|
|
467
|
+
);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
p.log.error((err as Error).message);
|
|
470
|
+
}
|
|
471
|
+
} else if (action === "remove") {
|
|
472
|
+
if (summary.totalRepos === 0) {
|
|
473
|
+
p.log.warn("No context repos to remove.");
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const selected = handleCancel(
|
|
478
|
+
await p.select({
|
|
479
|
+
message: "Select a repo to remove:",
|
|
480
|
+
options: [
|
|
481
|
+
...summary.repos.map((r) => ({
|
|
482
|
+
value: r.name,
|
|
483
|
+
label: r.name,
|
|
484
|
+
hint: `${r.fileCount} files, ${formatSize(r.totalSize)}`,
|
|
485
|
+
})),
|
|
486
|
+
{ value: "__back__", label: "Back" },
|
|
487
|
+
],
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
if (selected === "__back__") return;
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
removeContextRepo(selected);
|
|
495
|
+
p.log.success(`Removed "${selected}"`);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
p.log.error((err as Error).message);
|
|
498
|
+
}
|
|
499
|
+
} else if (action === "reindex") {
|
|
500
|
+
if (summary.totalRepos === 0) {
|
|
501
|
+
p.log.warn("No context repos to reindex.");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
await withSpinner(
|
|
507
|
+
"Re-scanning documents...",
|
|
508
|
+
async () => reindexContext(),
|
|
509
|
+
"Reindex complete",
|
|
510
|
+
);
|
|
511
|
+
} catch (err) {
|
|
512
|
+
p.log.error((err as Error).message);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
410
517
|
function listFlow(): void {
|
|
411
518
|
const lockfile = readLockfile();
|
|
412
519
|
const entries = Object.entries(lockfile.packages);
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { doctorCommand } from "./commands/doctor.js";
|
|
|
14
14
|
import { testCommand } from "./commands/test.js";
|
|
15
15
|
import { recordCommand } from "./commands/record.js";
|
|
16
16
|
import { snapshotCommand } from "./commands/snapshot.js";
|
|
17
|
+
import { contextCommand } from "./commands/context.js";
|
|
17
18
|
import { isInteractive } from "./lib/prompts.js";
|
|
18
19
|
|
|
19
20
|
const program = new Command();
|
|
@@ -21,7 +22,7 @@ const program = new Command();
|
|
|
21
22
|
program
|
|
22
23
|
.name("planmode")
|
|
23
24
|
.description("The open source package manager for AI plans, rules, and prompts.")
|
|
24
|
-
.version("0.
|
|
25
|
+
.version("0.4.0");
|
|
25
26
|
|
|
26
27
|
program.addCommand(installCommand);
|
|
27
28
|
program.addCommand(uninstallCommand);
|
|
@@ -38,6 +39,7 @@ program.addCommand(doctorCommand);
|
|
|
38
39
|
program.addCommand(testCommand);
|
|
39
40
|
program.addCommand(recordCommand);
|
|
40
41
|
program.addCommand(snapshotCommand);
|
|
42
|
+
program.addCommand(contextCommand);
|
|
41
43
|
|
|
42
44
|
// If no args and interactive TTY, show the interactive menu
|
|
43
45
|
if (process.argv.length <= 2 && isInteractive()) {
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import type { ContextIndex, ContextRepoIndex, IndexedFile } from "../types/index.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
|
|
7
|
+
const CONTEXT_DIR = ".planmode";
|
|
8
|
+
const CONTEXT_FILE = "context.yaml";
|
|
9
|
+
|
|
10
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
11
|
+
".txt", ".md", ".markdown", ".pdf", ".rtf",
|
|
12
|
+
".doc", ".docx", ".csv", ".tsv", ".json",
|
|
13
|
+
".yaml", ".yml", ".xml", ".html", ".htm",
|
|
14
|
+
".rst", ".org", ".tex", ".log",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const IGNORED_DIRS = new Set([
|
|
18
|
+
"node_modules", ".git", "dist", "build", ".next",
|
|
19
|
+
"__pycache__", ".venv", "venv", ".tox",
|
|
20
|
+
"target", "out", ".cache", ".turbo",
|
|
21
|
+
"coverage", ".nyc_output",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function getContextPath(projectDir: string): string {
|
|
25
|
+
return path.join(projectDir, CONTEXT_DIR, CONTEXT_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptyIndex(): ContextIndex {
|
|
29
|
+
return { version: 1, repos: [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readContextIndex(projectDir: string = process.cwd()): ContextIndex {
|
|
33
|
+
const filePath = getContextPath(projectDir);
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
36
|
+
const data = parse(raw) as ContextIndex;
|
|
37
|
+
return data ?? emptyIndex();
|
|
38
|
+
} catch {
|
|
39
|
+
return emptyIndex();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeContextIndex(index: ContextIndex, projectDir: string = process.cwd()): void {
|
|
44
|
+
const dirPath = path.join(projectDir, CONTEXT_DIR);
|
|
45
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
46
|
+
const filePath = getContextPath(projectDir);
|
|
47
|
+
fs.writeFileSync(filePath, stringify(index), "utf-8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function walkDirectory(dirPath: string): IndexedFile[] {
|
|
51
|
+
const files: IndexedFile[] = [];
|
|
52
|
+
|
|
53
|
+
function walk(currentPath: string): void {
|
|
54
|
+
let entries: fs.Dirent[];
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.name.startsWith(".") && IGNORED_DIRS.has(entry.name)) continue;
|
|
63
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
64
|
+
|
|
65
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
66
|
+
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
walk(fullPath);
|
|
69
|
+
} else if (entry.isFile()) {
|
|
70
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
71
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const stat = fs.statSync(fullPath);
|
|
75
|
+
const relativePath = path.relative(dirPath, fullPath);
|
|
76
|
+
files.push({
|
|
77
|
+
path: relativePath,
|
|
78
|
+
extension: ext,
|
|
79
|
+
size: stat.size,
|
|
80
|
+
modified_at: stat.mtime.toISOString(),
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
// Skip files we can't stat
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
walk(dirPath);
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function addContextRepo(
|
|
94
|
+
repoPath: string,
|
|
95
|
+
options: { name?: string; projectDir?: string } = {},
|
|
96
|
+
): ContextRepoIndex {
|
|
97
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
98
|
+
const absolutePath = path.resolve(projectDir, repoPath);
|
|
99
|
+
|
|
100
|
+
if (!fs.existsSync(absolutePath)) {
|
|
101
|
+
throw new Error(`Directory not found: ${repoPath}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!fs.statSync(absolutePath).isDirectory()) {
|
|
105
|
+
throw new Error(`Not a directory: ${repoPath}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const index = readContextIndex(projectDir);
|
|
109
|
+
|
|
110
|
+
// Store relative path if inside project, absolute otherwise
|
|
111
|
+
const relative = path.relative(projectDir, absolutePath);
|
|
112
|
+
const isInsideProject = !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
113
|
+
const storedPath = isInsideProject ? relative : absolutePath;
|
|
114
|
+
|
|
115
|
+
// Check if already added
|
|
116
|
+
const existing = index.repos.find(
|
|
117
|
+
(r) => r.repo.path === storedPath || r.repo.name === options.name,
|
|
118
|
+
);
|
|
119
|
+
if (existing) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Context repo already exists: ${existing.repo.name ?? existing.repo.path}. Use \`planmode context reindex\` to refresh.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
logger.info(`Scanning ${absolutePath}...`);
|
|
126
|
+
const files = walkDirectory(absolutePath);
|
|
127
|
+
|
|
128
|
+
const now = new Date().toISOString();
|
|
129
|
+
const repoIndex: ContextRepoIndex = {
|
|
130
|
+
repo: {
|
|
131
|
+
path: storedPath,
|
|
132
|
+
name: options.name,
|
|
133
|
+
added_at: now,
|
|
134
|
+
},
|
|
135
|
+
files,
|
|
136
|
+
indexed_at: now,
|
|
137
|
+
file_count: files.length,
|
|
138
|
+
total_size: files.reduce((sum, f) => sum + f.size, 0),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
index.repos.push(repoIndex);
|
|
142
|
+
writeContextIndex(index, projectDir);
|
|
143
|
+
|
|
144
|
+
logger.success(`Added "${options.name ?? storedPath}" — ${files.length} file(s), ${formatSize(repoIndex.total_size)}`);
|
|
145
|
+
|
|
146
|
+
// Log type breakdown
|
|
147
|
+
const breakdown = getTypeBreakdown(files);
|
|
148
|
+
if (breakdown.length > 0) {
|
|
149
|
+
logger.dim(` ${breakdown.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return repoIndex;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function removeContextRepo(
|
|
156
|
+
pathOrName: string,
|
|
157
|
+
projectDir: string = process.cwd(),
|
|
158
|
+
): void {
|
|
159
|
+
const index = readContextIndex(projectDir);
|
|
160
|
+
|
|
161
|
+
const idx = index.repos.findIndex(
|
|
162
|
+
(r) => r.repo.path === pathOrName || r.repo.name === pathOrName,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (idx === -1) {
|
|
166
|
+
throw new Error(`Context repo not found: ${pathOrName}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const removed = index.repos[idx]!;
|
|
170
|
+
index.repos.splice(idx, 1);
|
|
171
|
+
writeContextIndex(index, projectDir);
|
|
172
|
+
|
|
173
|
+
logger.success(`Removed "${removed.repo.name ?? removed.repo.path}"`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function reindexContext(
|
|
177
|
+
pathOrName?: string,
|
|
178
|
+
projectDir: string = process.cwd(),
|
|
179
|
+
): void {
|
|
180
|
+
const index = readContextIndex(projectDir);
|
|
181
|
+
|
|
182
|
+
if (index.repos.length === 0) {
|
|
183
|
+
throw new Error("No context repos configured. Use `planmode context add <path>` first.");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const targets = pathOrName
|
|
187
|
+
? index.repos.filter(
|
|
188
|
+
(r) => r.repo.path === pathOrName || r.repo.name === pathOrName,
|
|
189
|
+
)
|
|
190
|
+
: index.repos;
|
|
191
|
+
|
|
192
|
+
if (pathOrName && targets.length === 0) {
|
|
193
|
+
throw new Error(`Context repo not found: ${pathOrName}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const repo of targets) {
|
|
197
|
+
const absolutePath = path.resolve(projectDir, repo.repo.path);
|
|
198
|
+
|
|
199
|
+
if (!fs.existsSync(absolutePath)) {
|
|
200
|
+
logger.warn(`Directory not found, skipping: ${repo.repo.path}`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
logger.info(`Re-scanning ${repo.repo.name ?? repo.repo.path}...`);
|
|
205
|
+
const files = walkDirectory(absolutePath);
|
|
206
|
+
|
|
207
|
+
repo.files = files;
|
|
208
|
+
repo.indexed_at = new Date().toISOString();
|
|
209
|
+
repo.file_count = files.length;
|
|
210
|
+
repo.total_size = files.reduce((sum, f) => sum + f.size, 0);
|
|
211
|
+
|
|
212
|
+
logger.success(`Reindexed "${repo.repo.name ?? repo.repo.path}" — ${files.length} file(s), ${formatSize(repo.total_size)}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
writeContextIndex(index, projectDir);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ContextSummary {
|
|
219
|
+
totalRepos: number;
|
|
220
|
+
totalFiles: number;
|
|
221
|
+
totalSize: number;
|
|
222
|
+
repos: Array<{
|
|
223
|
+
name: string;
|
|
224
|
+
path: string;
|
|
225
|
+
fileCount: number;
|
|
226
|
+
totalSize: number;
|
|
227
|
+
typeBreakdown: string[];
|
|
228
|
+
indexedAt: string;
|
|
229
|
+
}>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function getContextSummary(projectDir: string = process.cwd()): ContextSummary {
|
|
233
|
+
const index = readContextIndex(projectDir);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
totalRepos: index.repos.length,
|
|
237
|
+
totalFiles: index.repos.reduce((sum, r) => sum + r.file_count, 0),
|
|
238
|
+
totalSize: index.repos.reduce((sum, r) => sum + r.total_size, 0),
|
|
239
|
+
repos: index.repos.map((r) => ({
|
|
240
|
+
name: r.repo.name ?? r.repo.path,
|
|
241
|
+
path: r.repo.path,
|
|
242
|
+
fileCount: r.file_count,
|
|
243
|
+
totalSize: r.total_size,
|
|
244
|
+
typeBreakdown: getTypeBreakdown(r.files),
|
|
245
|
+
indexedAt: r.indexed_at,
|
|
246
|
+
})),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getTypeBreakdown(files: IndexedFile[]): string[] {
|
|
251
|
+
const counts = new Map<string, number>();
|
|
252
|
+
for (const file of files) {
|
|
253
|
+
counts.set(file.extension, (counts.get(file.extension) ?? 0) + 1);
|
|
254
|
+
}
|
|
255
|
+
return Array.from(counts.entries())
|
|
256
|
+
.sort((a, b) => b[1] - a[1])
|
|
257
|
+
.map(([ext, count]) => `${ext}: ${count}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function formatSize(bytes: number): string {
|
|
261
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
262
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
263
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
264
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
265
|
+
}
|