@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 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` (the default; this can take a minute or more), 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. Selecting the current project scopes discovery to that project. 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.
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
 
@@ -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 discoverResources(options: DiscoveryOptions): Promise<DiscoveryResult>;
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>;
@@ -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
- if (!scopes.home && !scopes.project)
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
- await walk(home, home, async (file) => {
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 { lstat, readdir } from "node:fs/promises";
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
+ }
@@ -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;
@@ -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: "home", label: "HOME and Pi agent directory \u2014 ~ (can take a minute or more)" });
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
- const progress = clackSpinner(inheritedSignal());
447
- progress.start("Searching selected locations (searching all of HOME can take a minute or more)");
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
- progress.error("Search failed");
474
+ indicator.error("Search failed");
454
475
  throw error;
455
476
  }
456
- progress.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
477
+ indicator.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
457
478
  showSearchWarnings(discovery.warnings, roots.homeDir);
458
- if (discovery.excludedPaths.length > 0)
459
- note(discovery.excludedPaths.map((excluded) => displayPath(excluded, homeDir)).join("\n"), "Excluded paths");
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 sortedCandidates = sortCandidates(discovery.candidates);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papillon6814/copi",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Collect and organize reusable Pi coding-agent resources",
5
5
  "license": "MIT",
6
6
  "type": "module",