@papillon6814/copi 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/README.md +1 -1
- package/dist/src/discovery.d.ts +20 -1
- package/dist/src/discovery.js +81 -12
- package/dist/src/setup-display.d.ts +24 -0
- package/dist/src/setup-display.js +112 -1
- package/dist/src/wizard.d.ts +1 -0
- package/dist/src/wizard.js +49 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ copi migrate --resolve [--dry-run]
|
|
|
26
26
|
copi help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`setup` requires an interactive terminal. It first asks where copi may look: your whole `HOME` (
|
|
29
|
+
`setup` requires an interactive terminal. It first asks where copi may look: only the Pi agent directories (`~/.pi/agent`, `~/.pi/agent-*`, `~/.pi/agent_*` and `~/.agents`; the default and usually fast), your whole `HOME` (this can take a minute or more; a progress bar advances per top-level folder), this project only, or both. Selecting the entire `HOME` is broad: it can inspect supported resources throughout that directory, including Pi profiles and the configured Pi agent directory, but skips worktree checkout copies (`.worktrees`, `.claude/worktrees`, `.config/superpowers/worktrees`, `.treehouse`). Selecting the current project scopes discovery to that project. Candidates with the same kind, name and content are merged into one row marked `(+N identical copies)`; the kept row prefers a source under `~/.pi` or `~/.agents`. Copi then asks for explicit authorization before searching; declining performs no search. Search warnings are summarized (for example, resources found outside the selected roots are counted rather than listed one by one), and missing files are skipped silently.
|
|
30
30
|
|
|
31
31
|
Candidates are listed with a source label, size and modification date, and entries whose name matches an already saved resource are marked `already saved`. After you select resources, name conflicts are resolved right away, before any preview: keep one of the same-named selections, rename the display name only (the files, body and destination are not changed), skip the resource (keeping the saved copy), or go back to the list.
|
|
32
32
|
|
package/dist/src/discovery.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { DiscoveryResult } from "./types.ts";
|
|
|
2
2
|
export interface DiscoveryScopes {
|
|
3
3
|
home: boolean;
|
|
4
4
|
project: boolean;
|
|
5
|
+
/** Only the Pi agent directories under HOME (`~/.pi/agent`, `~/.pi/agent-*`, `~/.pi/agent_*`, `~/.agents`); ignored when `home` is set. */
|
|
6
|
+
piDirs?: boolean;
|
|
5
7
|
}
|
|
6
8
|
export interface DiscoveryOptions {
|
|
7
9
|
homeDir: string;
|
|
@@ -9,4 +11,21 @@ export interface DiscoveryOptions {
|
|
|
9
11
|
projectDir: string;
|
|
10
12
|
scopes?: DiscoveryScopes;
|
|
11
13
|
}
|
|
12
|
-
export declare function
|
|
14
|
+
export declare function shouldExclude(relativePath: string): boolean;
|
|
15
|
+
/** Git worktrees and similar checkout copies duplicate their main repository, so whole-HOME searches skip them. */
|
|
16
|
+
export declare function isWorktreeCopy(relativePath: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The Pi agent directories searched by the `piDirs` scope, sorted. Symlinks are never followed: a symlinked
|
|
19
|
+
* `~/.pi`, profile directory or `~/.agents` is reported in `symlinks` instead (callers warn about it).
|
|
20
|
+
*/
|
|
21
|
+
export declare function piAgentDirectories(homeDir: string): Promise<{
|
|
22
|
+
directories: string[];
|
|
23
|
+
symlinks: string[];
|
|
24
|
+
}>;
|
|
25
|
+
/** HOME search progress: `total` top-level directories, `done` finished so far, `current` the one being searched. */
|
|
26
|
+
export interface DiscoveryProgress {
|
|
27
|
+
done: number;
|
|
28
|
+
total: number;
|
|
29
|
+
current?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function discoverResources(options: DiscoveryOptions, onProgress?: (progress: DiscoveryProgress) => void): Promise<DiscoveryResult>;
|
package/dist/src/discovery.js
CHANGED
|
@@ -17,12 +17,50 @@ function isWithin(root, target) {
|
|
|
17
17
|
const relative = path.relative(root, target);
|
|
18
18
|
return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
|
19
19
|
}
|
|
20
|
-
function shouldExclude(relativePath) {
|
|
20
|
+
export function shouldExclude(relativePath) {
|
|
21
21
|
return relativePath.split(path.sep).some((part) => {
|
|
22
22
|
const lower = part.toLowerCase();
|
|
23
23
|
return excludedNames.has(lower) || lower === "auth.json" || lower === "mcp-auth.json" || lower === "oauth.json" || lower.startsWith(".env");
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
/** Git worktrees and similar checkout copies duplicate their main repository, so whole-HOME searches skip them. */
|
|
27
|
+
export function isWorktreeCopy(relativePath) {
|
|
28
|
+
const parts = relativePath.split(path.sep);
|
|
29
|
+
return parts.some((part, index) => part === ".worktrees" || part === ".treehouse"
|
|
30
|
+
|| (part === "worktrees" && (parts[index - 1] === ".claude" || (parts[index - 1] === "superpowers" && parts[index - 2] === ".config"))));
|
|
31
|
+
}
|
|
32
|
+
const piProfilePattern = /^agent([-_].+)?$/;
|
|
33
|
+
/**
|
|
34
|
+
* The Pi agent directories searched by the `piDirs` scope, sorted. Symlinks are never followed: a symlinked
|
|
35
|
+
* `~/.pi`, profile directory or `~/.agents` is reported in `symlinks` instead (callers warn about it).
|
|
36
|
+
*/
|
|
37
|
+
export async function piAgentDirectories(homeDir) {
|
|
38
|
+
const directories = [];
|
|
39
|
+
const symlinks = [];
|
|
40
|
+
const kind = (p) => lstat(p).then((stat) => stat.isSymbolicLink() ? "link" : stat.isDirectory() ? "dir" : "other").catch(() => "missing");
|
|
41
|
+
const piDir = path.join(homeDir, ".pi");
|
|
42
|
+
const piKind = await kind(piDir);
|
|
43
|
+
if (piKind === "link")
|
|
44
|
+
symlinks.push(piDir);
|
|
45
|
+
if (piKind === "dir") {
|
|
46
|
+
for (const entry of await readdir(piDir, { withFileTypes: true }).catch(() => [])) {
|
|
47
|
+
if (!piProfilePattern.test(entry.name))
|
|
48
|
+
continue;
|
|
49
|
+
if (entry.isSymbolicLink())
|
|
50
|
+
symlinks.push(path.join(piDir, entry.name));
|
|
51
|
+
else if (entry.isDirectory())
|
|
52
|
+
directories.push(path.join(piDir, entry.name));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const shared = path.join(homeDir, ".agents");
|
|
56
|
+
const sharedKind = await kind(shared);
|
|
57
|
+
if (sharedKind === "link")
|
|
58
|
+
symlinks.push(shared);
|
|
59
|
+
if (sharedKind === "dir")
|
|
60
|
+
directories.push(shared);
|
|
61
|
+
const byPath = (left, right) => left.localeCompare(right);
|
|
62
|
+
return { directories: directories.sort(byPath), symlinks: symlinks.sort(byPath) };
|
|
63
|
+
}
|
|
26
64
|
function displayRelative(root, target) {
|
|
27
65
|
return path.relative(root, target) || path.basename(target);
|
|
28
66
|
}
|
|
@@ -121,19 +159,20 @@ async function canonicalDirectory(directory) {
|
|
|
121
159
|
async function exists(target) {
|
|
122
160
|
return lstat(target).then(() => true, (error) => error.code !== "ENOENT");
|
|
123
161
|
}
|
|
124
|
-
export async function discoverResources(options) {
|
|
162
|
+
export async function discoverResources(options, onProgress) {
|
|
125
163
|
const scopes = options.scopes ?? inferSearchScopes(options);
|
|
126
|
-
|
|
164
|
+
const piOnly = Boolean(scopes.piDirs) && !scopes.home;
|
|
165
|
+
if (!scopes.home && !scopes.project && !piOnly)
|
|
127
166
|
return { candidates: [], warnings: [], excludedPaths: [] };
|
|
128
167
|
const warnings = [];
|
|
129
168
|
const excludedPaths = new Set();
|
|
130
169
|
const candidates = [];
|
|
131
170
|
const candidateKeys = new Set();
|
|
132
|
-
const home = scopes.home ? await canonicalDirectory(options.homeDir) : undefined;
|
|
133
|
-
const agent = scopes.home ? await canonicalDirectory(options.agentDir) : undefined;
|
|
171
|
+
const home = scopes.home || piOnly ? await canonicalDirectory(options.homeDir) : undefined;
|
|
172
|
+
const agent = scopes.home || piOnly ? await canonicalDirectory(options.agentDir) : undefined;
|
|
134
173
|
const project = scopes.project ? await canonicalDirectory(options.projectDir) : undefined;
|
|
135
174
|
const packageAgentDir = agent;
|
|
136
|
-
if (scopes.home && !home)
|
|
175
|
+
if ((scopes.home || piOnly) && !home)
|
|
137
176
|
addWarning(warnings, "unavailable-root", options.homeDir, "The explicitly selected home directory is unavailable.");
|
|
138
177
|
if (scopes.home && !agent)
|
|
139
178
|
addWarning(warnings, "unavailable-root", options.agentDir, "The explicitly selected Pi agent directory is unavailable.");
|
|
@@ -178,11 +217,11 @@ export async function discoverResources(options) {
|
|
|
178
217
|
destination,
|
|
179
218
|
});
|
|
180
219
|
};
|
|
181
|
-
const walk = async (root, startPath, onFile) => {
|
|
220
|
+
const walk = async (root, startPath, onFile, progress) => {
|
|
182
221
|
const canonicalRoot = await canonicalDirectory(root);
|
|
183
222
|
if (!canonicalRoot)
|
|
184
223
|
return;
|
|
185
|
-
const visit = async (directory) => {
|
|
224
|
+
const visit = async (directory, topLevel = false) => {
|
|
186
225
|
let entries;
|
|
187
226
|
try {
|
|
188
227
|
entries = await readdir(directory, { withFileTypes: true });
|
|
@@ -192,9 +231,19 @@ export async function discoverResources(options) {
|
|
|
192
231
|
addWarning(warnings, code === "EACCES" ? "permission-denied" : "scan-error", directory, code === "EACCES" ? "Permission denied while searching this directory." : "Unable to search this directory.");
|
|
193
232
|
return;
|
|
194
233
|
}
|
|
234
|
+
const tracked = topLevel && progress
|
|
235
|
+
? entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !shouldExclude(path.relative(canonicalRoot, path.join(directory, entry.name))))
|
|
236
|
+
: [];
|
|
237
|
+
let done = 0;
|
|
238
|
+
if (topLevel && progress)
|
|
239
|
+
progress({ done, total: tracked.length });
|
|
195
240
|
for (const entry of entries) {
|
|
196
241
|
const child = path.join(directory, entry.name);
|
|
197
242
|
const relative = path.relative(canonicalRoot, child);
|
|
243
|
+
if (entry.isDirectory() && isWorktreeCopy(relative)) {
|
|
244
|
+
excludedPaths.add(child);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
198
247
|
if (shouldExclude(relative)) {
|
|
199
248
|
excludedPaths.add(child);
|
|
200
249
|
if (relative.split(path.sep).some((part) => part.toLowerCase() === "auth")) {
|
|
@@ -207,8 +256,14 @@ export async function discoverResources(options) {
|
|
|
207
256
|
addWarning(warnings, target && isWithin(canonicalRoot, target) ? "symlink-skipped" : "external-symlink", child, "Symbolic links are not followed during resource discovery.");
|
|
208
257
|
continue;
|
|
209
258
|
}
|
|
210
|
-
if (entry.isDirectory())
|
|
259
|
+
if (entry.isDirectory()) {
|
|
260
|
+
const isTracked = tracked.includes(entry);
|
|
261
|
+
if (isTracked)
|
|
262
|
+
progress({ done, total: tracked.length, current: child });
|
|
211
263
|
await visit(child);
|
|
264
|
+
if (isTracked)
|
|
265
|
+
progress({ done: ++done, total: tracked.length });
|
|
266
|
+
}
|
|
212
267
|
else if (entry.isFile())
|
|
213
268
|
await onFile(child);
|
|
214
269
|
}
|
|
@@ -234,7 +289,7 @@ export async function discoverResources(options) {
|
|
|
234
289
|
if (stat.isFile())
|
|
235
290
|
await onFile(canonicalStart);
|
|
236
291
|
else if (stat.isDirectory())
|
|
237
|
-
await visit(canonicalStart);
|
|
292
|
+
await visit(canonicalStart, true);
|
|
238
293
|
};
|
|
239
294
|
const addSkillRoot = async (root, startPath, scope, source, destinationRoot) => {
|
|
240
295
|
await walk(root, startPath, async (file) => {
|
|
@@ -490,7 +545,7 @@ export async function discoverResources(options) {
|
|
|
490
545
|
const homeProjects = new Set();
|
|
491
546
|
const homeProfiles = new Set();
|
|
492
547
|
if (home) {
|
|
493
|
-
|
|
548
|
+
const classifyHomeFile = async (file) => {
|
|
494
549
|
const relativeParts = path.relative(home, file).split(path.sep);
|
|
495
550
|
const base = path.basename(file);
|
|
496
551
|
const piIndex = relativeParts.indexOf(".pi");
|
|
@@ -569,7 +624,21 @@ export async function discoverResources(options) {
|
|
|
569
624
|
homeProjects.add(projectRoot);
|
|
570
625
|
await addCandidate("instruction", home, file, base, "project", "project-instructions", { kind: "project", path: base });
|
|
571
626
|
}
|
|
572
|
-
}
|
|
627
|
+
};
|
|
628
|
+
if (scopes.home) {
|
|
629
|
+
await walk(home, home, classifyHomeFile, onProgress);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
const { directories: starts, symlinks } = await piAgentDirectories(home);
|
|
633
|
+
for (const link of symlinks)
|
|
634
|
+
addWarning(warnings, "symlink-skipped", link, "Symbolic links are not followed during resource discovery.");
|
|
635
|
+
onProgress?.({ done: 0, total: starts.length });
|
|
636
|
+
for (const [index, start] of starts.entries()) {
|
|
637
|
+
onProgress?.({ done: index, total: starts.length, current: start });
|
|
638
|
+
await walk(home, start, classifyHomeFile);
|
|
639
|
+
onProgress?.({ done: index + 1, total: starts.length });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
573
642
|
for (const profileRoot of homeProfiles) {
|
|
574
643
|
await scanSettings(path.join(profileRoot, "settings.json"), "user", [profileRoot, home], profileRoot);
|
|
575
644
|
}
|
|
@@ -41,3 +41,27 @@ export declare function findSelectionConflicts(list: readonly ResourceCandidate[
|
|
|
41
41
|
export declare function defaultRenameName(c: ResourceCandidate, group?: readonly ResourceCandidate[]): string;
|
|
42
42
|
/** Validation message for a new display name, or undefined when it is non-empty and its `nameKey` is not taken. */
|
|
43
43
|
export declare function pendingNameError(name: string | undefined, taken: ReadonlySet<string>): string | undefined;
|
|
44
|
+
/** Bar message for the HOME search: `14/36 ~/Library`, or just the count between directories. */
|
|
45
|
+
export declare function formatSearchProgress(progress: {
|
|
46
|
+
done: number;
|
|
47
|
+
total: number;
|
|
48
|
+
current?: string;
|
|
49
|
+
}, home: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* SHA-256 over what saving would copy: for a file its mode and bytes, for a directory every regular file's
|
|
52
|
+
* relative path, mode and bytes (sorted, same exclusions as discovery), each field length-prefixed.
|
|
53
|
+
* `undefined` (never merged) when unreadable, too large, or containing a symlink, which saving refuses.
|
|
54
|
+
*/
|
|
55
|
+
export declare function contentFingerprint(c: ResourceCandidate): Promise<string | undefined>;
|
|
56
|
+
/**
|
|
57
|
+
* Collapse candidates with the same kind, display name (nameKey) and content fingerprint into one row.
|
|
58
|
+
* The kept row prefers a source under `~/.pi` or `~/.agents`, then the shortest path; input order is preserved.
|
|
59
|
+
* `copies` maps a kept candidate id to how many identical candidates it stands for (besides itself).
|
|
60
|
+
*/
|
|
61
|
+
export declare function collapseIdenticalCandidates(list: readonly ResourceCandidate[], fingerprints: ReadonlyMap<string, string | undefined>, home: string): {
|
|
62
|
+
kept: ResourceCandidate[];
|
|
63
|
+
copies: Map<string, number>;
|
|
64
|
+
};
|
|
65
|
+
export declare function identicalCopiesSuffix(count: number | undefined): string;
|
|
66
|
+
/** Map with at most `limit` operations in flight, preserving input order. */
|
|
67
|
+
export declare function mapWithLimit<T, R>(items: readonly T[], limit: number, operation: (item: T) => Promise<R>): Promise<R[]>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { shouldExclude } from "./discovery.js";
|
|
3
5
|
import { nameKey } from "./names.js";
|
|
4
6
|
/** Replace a leading home directory with `~` (only on a path-segment boundary). */
|
|
5
7
|
export function displayPath(p, home) {
|
|
@@ -193,3 +195,112 @@ export function pendingNameError(name, taken) {
|
|
|
193
195
|
}
|
|
194
196
|
return taken.has(key) ? "That name is already used by a saved or selected resource; choose another." : undefined;
|
|
195
197
|
}
|
|
198
|
+
/** Bar message for the HOME search: `14/36 ~/Library`, or just the count between directories. */
|
|
199
|
+
export function formatSearchProgress(progress, home) {
|
|
200
|
+
const count = `${progress.done}/${progress.total}`;
|
|
201
|
+
return progress.current === undefined ? `${count} Searching` : `${count} ${displayPath(progress.current, home)}`;
|
|
202
|
+
}
|
|
203
|
+
const fingerprintMaxFiles = 2000;
|
|
204
|
+
const fingerprintMaxBytes = 50 * 1024 * 1024;
|
|
205
|
+
/**
|
|
206
|
+
* SHA-256 over what saving would copy: for a file its mode and bytes, for a directory every regular file's
|
|
207
|
+
* relative path, mode and bytes (sorted, same exclusions as discovery), each field length-prefixed.
|
|
208
|
+
* `undefined` (never merged) when unreadable, too large, or containing a symlink, which saving refuses.
|
|
209
|
+
*/
|
|
210
|
+
export async function contentFingerprint(c) {
|
|
211
|
+
try {
|
|
212
|
+
const field = (hash, value) => {
|
|
213
|
+
const bytes = typeof value === "string" ? Buffer.from(value) : value;
|
|
214
|
+
hash.update(`${bytes.length}:`).update(bytes);
|
|
215
|
+
};
|
|
216
|
+
const stat = await lstat(c.sourcePath);
|
|
217
|
+
if (stat.isFile()) {
|
|
218
|
+
if (stat.size > fingerprintMaxBytes)
|
|
219
|
+
return undefined;
|
|
220
|
+
const hash = createHash("sha256");
|
|
221
|
+
field(hash, (stat.mode & 0o777).toString(8));
|
|
222
|
+
field(hash, await readFile(c.sourcePath));
|
|
223
|
+
return hash.digest("hex");
|
|
224
|
+
}
|
|
225
|
+
if (!stat.isDirectory())
|
|
226
|
+
return undefined;
|
|
227
|
+
const files = [];
|
|
228
|
+
let bytes = 0;
|
|
229
|
+
const visit = async (dir) => {
|
|
230
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
231
|
+
const child = path.join(dir, entry.name);
|
|
232
|
+
const relative = path.relative(c.sourcePath, child);
|
|
233
|
+
if (shouldExclude(relative))
|
|
234
|
+
continue;
|
|
235
|
+
if (entry.isSymbolicLink())
|
|
236
|
+
return false;
|
|
237
|
+
if (entry.isDirectory()) {
|
|
238
|
+
if (!(await visit(child)))
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
else if (entry.isFile()) {
|
|
242
|
+
files.push(relative);
|
|
243
|
+
bytes += (await lstat(child)).size;
|
|
244
|
+
if (files.length > fingerprintMaxFiles || bytes > fingerprintMaxBytes)
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
};
|
|
250
|
+
if (!(await visit(c.sourcePath)))
|
|
251
|
+
return undefined;
|
|
252
|
+
const hash = createHash("sha256");
|
|
253
|
+
for (const relative of files.sort()) {
|
|
254
|
+
const file = path.join(c.sourcePath, relative);
|
|
255
|
+
field(hash, relative);
|
|
256
|
+
field(hash, ((await lstat(file)).mode & 0o777).toString(8));
|
|
257
|
+
field(hash, await readFile(file));
|
|
258
|
+
}
|
|
259
|
+
return hash.digest("hex");
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Collapse candidates with the same kind, display name (nameKey) and content fingerprint into one row.
|
|
267
|
+
* The kept row prefers a source under `~/.pi` or `~/.agents`, then the shortest path; input order is preserved.
|
|
268
|
+
* `copies` maps a kept candidate id to how many identical candidates it stands for (besides itself).
|
|
269
|
+
*/
|
|
270
|
+
export function collapseIdenticalCandidates(list, fingerprints, home) {
|
|
271
|
+
const preferred = (c) => [".pi", ".agents"].some((dir) => c.sourcePath.startsWith(path.join(home, dir) + path.sep)) ? 0 : 1;
|
|
272
|
+
const better = (a, b) => preferred(a) - preferred(b) || a.sourcePath.length - b.sourcePath.length || a.sourcePath.localeCompare(b.sourcePath);
|
|
273
|
+
const groups = new Map();
|
|
274
|
+
for (const c of list) {
|
|
275
|
+
const fingerprint = fingerprints.get(c.id);
|
|
276
|
+
const key = fingerprint === undefined ? `unique\0${c.id}` : JSON.stringify([c.kind, c.name, c.scope, c.destination, fingerprint]);
|
|
277
|
+
groups.set(key, [...(groups.get(key) ?? []), c]);
|
|
278
|
+
}
|
|
279
|
+
const representative = new Map();
|
|
280
|
+
const copies = new Map();
|
|
281
|
+
for (const group of groups.values()) {
|
|
282
|
+
const best = [...group].sort(better)[0];
|
|
283
|
+
for (const c of group)
|
|
284
|
+
representative.set(c.id, best);
|
|
285
|
+
if (group.length > 1)
|
|
286
|
+
copies.set(best.id, group.length - 1);
|
|
287
|
+
}
|
|
288
|
+
const kept = list.filter((c) => representative.get(c.id).id === c.id);
|
|
289
|
+
return { kept, copies };
|
|
290
|
+
}
|
|
291
|
+
export function identicalCopiesSuffix(count) {
|
|
292
|
+
return count ? ` (+${count} identical ${count === 1 ? "copy" : "copies"})` : "";
|
|
293
|
+
}
|
|
294
|
+
/** Map with at most `limit` operations in flight, preserving input order. */
|
|
295
|
+
export async function mapWithLimit(items, limit, operation) {
|
|
296
|
+
const results = new Array(items.length);
|
|
297
|
+
let next = 0;
|
|
298
|
+
const worker = async () => {
|
|
299
|
+
while (next < items.length) {
|
|
300
|
+
const index = next++;
|
|
301
|
+
results[index] = await operation(items[index]);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
305
|
+
return results;
|
|
306
|
+
}
|
package/dist/src/wizard.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface SearchRoots {
|
|
|
17
17
|
export interface SearchScopes {
|
|
18
18
|
home: boolean;
|
|
19
19
|
project: boolean;
|
|
20
|
+
piDirs?: boolean;
|
|
20
21
|
}
|
|
21
22
|
export declare function buildSearchOptions(roots: SearchRoots, scopes: SearchScopes): DiscoveryOptions | undefined;
|
|
22
23
|
export declare function formatMaskedPreview(relativePath: string, content: string): string;
|
package/dist/src/wizard.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { autocompleteMultiselect as clackMultiselect, cancel as clackCancel, confirm as clackConfirm, intro as clackIntro, isCancel, log, note as clackNote, outro as clackOutro, select as clackSelect, spinner as clackSpinner, text as clackText } from "@clack/prompts";
|
|
1
|
+
import { autocompleteMultiselect as clackMultiselect, cancel as clackCancel, confirm as clackConfirm, intro as clackIntro, isCancel, log, note as clackNote, outro as clackOutro, progress as clackProgress, select as clackSelect, spinner as clackSpinner, text as clackText } from "@clack/prompts";
|
|
2
2
|
import { lstat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { assertModernCatalog, importPreparedResource, loadCatalog, prepareResource, readPreparedResource, readSnapshotFiles, saveCatalog, saveNameChanges } from "./catalog.js";
|
|
5
|
-
import { discoverResources } from "./discovery.js";
|
|
5
|
+
import { discoverResources, isWorktreeCopy, piAgentDirectories } from "./discovery.js";
|
|
6
6
|
import { createGroup, renameGroup, setGroupItems } from "./groups.js";
|
|
7
|
-
import { candidateHint, candidateLabels, defaultRenameName, displayPath, findSelectionConflicts, formatOutsideRootSummary, gatherCandidateStats, pendingNameError, sortCandidates } from "./setup-display.js";
|
|
7
|
+
import { candidateHint, candidateLabels, collapseIdenticalCandidates, contentFingerprint, defaultRenameName, displayPath, findSelectionConflicts, formatSearchProgress, formatOutsideRootSummary, gatherCandidateStats, identicalCopiesSuffix, mapWithLimit, pendingNameError, sortCandidates } from "./setup-display.js";
|
|
8
8
|
import { inspectPreparedResource, maskSensitiveText } from "./inspect.js";
|
|
9
9
|
import { assertNameAvailable, assertNoNameConflicts, findNameConflicts, nameKey } from "./names.js";
|
|
10
10
|
import { executeApply, planApply } from "./apply.js";
|
|
@@ -90,11 +90,11 @@ const previewMaxBytes = 1_000_000;
|
|
|
90
90
|
const previewMaxFiles = 12;
|
|
91
91
|
const previewMaxLines = 24;
|
|
92
92
|
export function buildSearchOptions(roots, scopes) {
|
|
93
|
-
if (!scopes.home && !scopes.project)
|
|
93
|
+
if (!scopes.home && !scopes.project && !scopes.piDirs)
|
|
94
94
|
return undefined;
|
|
95
95
|
return {
|
|
96
96
|
...roots,
|
|
97
|
-
scopes: { home: scopes.home, project: scopes.project },
|
|
97
|
+
scopes: { home: scopes.home, project: scopes.project, ...(scopes.piDirs ? { piDirs: true } : {}) },
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
export function formatMaskedPreview(relativePath, content) {
|
|
@@ -410,8 +410,10 @@ export async function runSetup(libraryDir, roots) {
|
|
|
410
410
|
const projectIsHome = Boolean(homeDir) && roots.projectDir === homeBase;
|
|
411
411
|
const projectInsideHome = Boolean(homeDir) && !projectIsHome && roots.projectDir.startsWith(homeBase + path.sep);
|
|
412
412
|
const locationOptions = [];
|
|
413
|
-
if (homeDir)
|
|
414
|
-
locationOptions.push({ value: "
|
|
413
|
+
if (homeDir) {
|
|
414
|
+
locationOptions.push({ value: "pi", label: "Pi agent directories only \u2014 ~/.pi/agent, ~/.pi/agent-*, ~/.agents (fast)" });
|
|
415
|
+
locationOptions.push({ value: "home", label: "All of HOME \u2014 ~ (can take a minute or more; worktree copies skipped)" });
|
|
416
|
+
}
|
|
415
417
|
if (!projectIsHome)
|
|
416
418
|
locationOptions.push({ value: "project", label: `This project only \u2014 ${projectLabel} (fast)` });
|
|
417
419
|
if (homeDir && !projectIsHome && !projectInsideHome)
|
|
@@ -426,13 +428,15 @@ export async function runSetup(libraryDir, roots) {
|
|
|
426
428
|
cancel("Setup cancelled.");
|
|
427
429
|
return;
|
|
428
430
|
}
|
|
429
|
-
const scopes = { home: choice === "home" || choice === "both", project: choice === "project" || choice === "both" };
|
|
431
|
+
const scopes = { home: choice === "home" || choice === "both", project: choice === "project" || choice === "both", ...(choice === "pi" ? { piDirs: true } : {}) };
|
|
432
|
+
const piDirectories = choice === "pi" ? (await piAgentDirectories(homeDir)).directories : [];
|
|
430
433
|
const searchOptions = buildSearchOptions(roots, scopes); // every non-cancel choice enables at least one scope
|
|
431
434
|
note([
|
|
432
435
|
`HOME: ${scopes.home ? displayPath(roots.homeDir, homeDir) : "not selected"}`,
|
|
433
|
-
`Pi agent directory: ${scopes.home ? displayPath(roots.agentDir, homeDir) : "not selected"}`,
|
|
436
|
+
`Pi agent directory: ${scopes.home ? displayPath(roots.agentDir, homeDir) : choice === "pi" ? (piDirectories.map((dir) => displayPath(dir, homeDir)).join(", ") || "none found") : "not selected"}`,
|
|
434
437
|
`Project: ${scopes.project ? projectLabel : "not selected"}`,
|
|
435
|
-
"Always excluded: entire directories named auth (authentication-specific code may be missing), auth.json, mcp-auth.json, oauth.json, .env* paths, history, cache/caches, .cache, .npm, node_modules, and .git.",
|
|
438
|
+
"Always excluded: entire directories named auth (authentication-specific code may be missing), auth.json, mcp-auth.json, oauth.json, .env* paths, history, cache/caches, .cache, .npm, node_modules, and .git. Folder searches also skip worktree copies (.worktrees, .claude/worktrees, .config/superpowers/worktrees, .treehouse).",
|
|
439
|
+
...(choice === "pi" ? ["Settings and packages declared by these Pi profiles may point elsewhere under HOME; those configured paths are read too."] : []),
|
|
436
440
|
"Secret detection is heuristic and cannot guarantee completeness. Preview text is masked by default.",
|
|
437
441
|
].join("\n"), "Search scope and exclusions");
|
|
438
442
|
const authorized = await confirm({
|
|
@@ -443,25 +447,51 @@ export async function runSetup(libraryDir, roots) {
|
|
|
443
447
|
outro("Search not authorized; no resources were inspected or saved.");
|
|
444
448
|
return;
|
|
445
449
|
}
|
|
446
|
-
|
|
447
|
-
|
|
450
|
+
// A spinner covers the quick phases; once HOME's top-level directories are known, a bar fills one step per directory.
|
|
451
|
+
let indicator = clackSpinner(inheritedSignal());
|
|
452
|
+
indicator.start("Searching selected locations (searching all of HOME can take a minute or more)");
|
|
453
|
+
let bar;
|
|
454
|
+
let barDone = 0;
|
|
455
|
+
const onProgress = (event) => {
|
|
456
|
+
if (!bar) {
|
|
457
|
+
if (event.total === 0)
|
|
458
|
+
return;
|
|
459
|
+
indicator.clear();
|
|
460
|
+
bar = clackProgress({ ...inheritedSignal(), max: event.total });
|
|
461
|
+
indicator = bar;
|
|
462
|
+
bar.start(safeDisplay(formatSearchProgress(event, homeDir)));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const step = event.done - barDone;
|
|
466
|
+
barDone = event.done;
|
|
467
|
+
bar.advance(step, safeDisplay(formatSearchProgress(event, homeDir)));
|
|
468
|
+
};
|
|
448
469
|
let discovery;
|
|
449
470
|
try {
|
|
450
|
-
discovery = await discoverResources(searchOptions);
|
|
471
|
+
discovery = await discoverResources(searchOptions, onProgress);
|
|
451
472
|
}
|
|
452
473
|
catch (error) {
|
|
453
|
-
|
|
474
|
+
indicator.error("Search failed");
|
|
454
475
|
throw error;
|
|
455
476
|
}
|
|
456
|
-
|
|
477
|
+
indicator.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
|
|
457
478
|
showSearchWarnings(discovery.warnings, roots.homeDir);
|
|
458
|
-
|
|
459
|
-
|
|
479
|
+
const worktreeCopies = discovery.excludedPaths.filter((excluded) => isWorktreeCopy(path.relative(homeDir || path.sep, excluded)));
|
|
480
|
+
const otherExcluded = discovery.excludedPaths.filter((excluded) => !worktreeCopies.includes(excluded));
|
|
481
|
+
if (worktreeCopies.length > 0)
|
|
482
|
+
log.info(safeDisplay(`Skipped ${worktreeCopies.length} worktree copy folder(s).`));
|
|
483
|
+
if (otherExcluded.length > 0)
|
|
484
|
+
note(otherExcluded.map((excluded) => displayPath(excluded, homeDir)).join("\n"), "Excluded paths");
|
|
460
485
|
if (discovery.candidates.length === 0) {
|
|
461
486
|
outro("No supported resources were found.");
|
|
462
487
|
return;
|
|
463
488
|
}
|
|
464
|
-
const
|
|
489
|
+
const allSorted = sortCandidates(discovery.candidates);
|
|
490
|
+
const fingerprints = new Map(await mapWithLimit(allSorted, 16, async (candidate) => [candidate.id, await contentFingerprint(candidate)]));
|
|
491
|
+
const { kept: sortedCandidates, copies: identicalCopies } = collapseIdenticalCandidates(allSorted, fingerprints, homeDir);
|
|
492
|
+
const merged = allSorted.length - sortedCandidates.length;
|
|
493
|
+
if (merged > 0)
|
|
494
|
+
log.info(safeDisplay(`Merged ${merged} identical copies; ${sortedCandidates.length} distinct resource(s) to choose from.`));
|
|
465
495
|
const candidateStats = await gatherCandidateStats(sortedCandidates);
|
|
466
496
|
const savedNames = new Set(initial.items.map((item) => nameKey(item.name)));
|
|
467
497
|
const candidateLabelMap = candidateLabels(sortedCandidates, homeDir, (candidate) => savedNames.has(nameKey(candidate.name)), process.stdout.columns);
|
|
@@ -471,7 +501,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
471
501
|
const selectedIds = await autocompleteMultiselect({
|
|
472
502
|
message: "Select resources to review and save as copied snapshots",
|
|
473
503
|
options: sortedCandidates.map((candidate) => {
|
|
474
|
-
const label = candidateLabelMap.get(candidate.id);
|
|
504
|
+
const label = candidateLabelMap.get(candidate.id) + identicalCopiesSuffix(identicalCopies.get(candidate.id));
|
|
475
505
|
return { value: candidate.id, label, hint: candidateHint(candidate, candidateStats.get(candidate.id) ?? {}, homeDir, process.stdout.columns, label.length) };
|
|
476
506
|
}),
|
|
477
507
|
required: false,
|