@papillon6814/copi 0.3.0 → 0.4.1

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: the Pi agent directories (`~/.pi/agent`, `~/.pi/agent-*`, `~/.pi/agent_*` and `~/.agents`; the default), this project only, or both. Settings and packages declared by those Pi profiles are expanded too, so configured paths elsewhere under `HOME` may be read; setup never walks your whole `HOME`. Symbolic links are not followed, and worktree checkout copies (`.worktrees`, `.claude/worktrees`, `.config/superpowers/worktrees`, `.treehouse`) are skipped. Selecting the current project scopes discovery to that project; when the current directory is `HOME` itself, only the Pi agent directories are offered. 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) {
@@ -404,18 +404,26 @@ export async function runSetup(libraryDir, roots) {
404
404
  assertModernCatalog(initial);
405
405
  assertNoNameConflicts(initial);
406
406
  intro("copi setup");
407
+ note([
408
+ "Copi keeps private copies of your Pi resources",
409
+ `(skills, subagents, instructions, settings) in ${displayPath(libraryDir, roots.homeDir)}`,
410
+ "and applies them to projects by group.",
411
+ "setup: find \u2192 review masked previews \u2192 save to groups.",
412
+ "Later: copi apply <group> <project>.",
413
+ "Your original files are never changed.",
414
+ ].join("\n"), "What copi does");
407
415
  const homeDir = roots.homeDir;
408
416
  const projectLabel = displayPath(roots.projectDir, homeDir);
409
417
  const homeBase = homeDir.length > 1 && homeDir.endsWith(path.sep) ? homeDir.slice(0, -1) : homeDir;
410
418
  const projectIsHome = Boolean(homeDir) && roots.projectDir === homeBase;
411
- const projectInsideHome = Boolean(homeDir) && !projectIsHome && roots.projectDir.startsWith(homeBase + path.sep);
412
419
  const locationOptions = [];
420
+ // Searching a project that is HOME itself would mean searching all of HOME, which setup no longer offers.
413
421
  if (homeDir)
414
- locationOptions.push({ value: "home", label: "HOME and Pi agent directory \u2014 ~ (can take a minute or more)" });
422
+ locationOptions.push({ value: "pi", label: "Pi agent directories only \u2014 ~/.pi/agent, ~/.pi/agent-*, ~/.agents (fast)" });
415
423
  if (!projectIsHome)
416
424
  locationOptions.push({ value: "project", label: `This project only \u2014 ${projectLabel} (fast)` });
417
- if (homeDir && !projectIsHome && !projectInsideHome)
418
- locationOptions.push({ value: "both", label: "Both" });
425
+ if (homeDir && !projectIsHome)
426
+ locationOptions.push({ value: "both", label: "Both \u2014 Pi agent directories and this project" });
419
427
  locationOptions.push({ value: "cancel", label: "Cancel" });
420
428
  const choice = await select({
421
429
  message: "Choose the locations copi may inspect",
@@ -426,13 +434,15 @@ export async function runSetup(libraryDir, roots) {
426
434
  cancel("Setup cancelled.");
427
435
  return;
428
436
  }
429
- const scopes = { home: choice === "home" || choice === "both", project: choice === "project" || choice === "both" };
437
+ const searchesPiDirs = choice === "pi" || choice === "both";
438
+ const scopes = { home: false, project: choice === "project" || choice === "both", ...(searchesPiDirs ? { piDirs: true } : {}) };
439
+ const piDirectories = searchesPiDirs ? (await piAgentDirectories(homeDir)).directories : [];
430
440
  const searchOptions = buildSearchOptions(roots, scopes); // every non-cancel choice enables at least one scope
431
441
  note([
432
- `HOME: ${scopes.home ? displayPath(roots.homeDir, homeDir) : "not selected"}`,
433
- `Pi agent directory: ${scopes.home ? displayPath(roots.agentDir, homeDir) : "not selected"}`,
442
+ `Pi agent directories: ${searchesPiDirs ? (piDirectories.map((dir) => displayPath(dir, homeDir)).join(", ") || "none found") : "not selected"}`,
434
443
  `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.",
444
+ "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).",
445
+ ...(searchesPiDirs ? ["Settings and packages declared by these Pi profiles may point elsewhere under HOME; those configured paths are read too."] : []),
436
446
  "Secret detection is heuristic and cannot guarantee completeness. Preview text is masked by default.",
437
447
  ].join("\n"), "Search scope and exclusions");
438
448
  const authorized = await confirm({
@@ -443,25 +453,51 @@ export async function runSetup(libraryDir, roots) {
443
453
  outro("Search not authorized; no resources were inspected or saved.");
444
454
  return;
445
455
  }
446
- const progress = clackSpinner(inheritedSignal());
447
- progress.start("Searching selected locations (searching all of HOME can take a minute or more)");
456
+ // A spinner covers the quick phases; once HOME's top-level directories are known, a bar fills one step per directory.
457
+ let indicator = clackSpinner(inheritedSignal());
458
+ indicator.start("Searching selected locations");
459
+ let bar;
460
+ let barDone = 0;
461
+ const onProgress = (event) => {
462
+ if (!bar) {
463
+ if (event.total === 0)
464
+ return;
465
+ indicator.clear();
466
+ bar = clackProgress({ ...inheritedSignal(), max: event.total });
467
+ indicator = bar;
468
+ bar.start(safeDisplay(formatSearchProgress(event, homeDir)));
469
+ return;
470
+ }
471
+ const step = event.done - barDone;
472
+ barDone = event.done;
473
+ bar.advance(step, safeDisplay(formatSearchProgress(event, homeDir)));
474
+ };
448
475
  let discovery;
449
476
  try {
450
- discovery = await discoverResources(searchOptions);
477
+ discovery = await discoverResources(searchOptions, onProgress);
451
478
  }
452
479
  catch (error) {
453
- progress.error("Search failed");
480
+ indicator.error("Search failed");
454
481
  throw error;
455
482
  }
456
- progress.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
483
+ indicator.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
457
484
  showSearchWarnings(discovery.warnings, roots.homeDir);
458
- if (discovery.excludedPaths.length > 0)
459
- note(discovery.excludedPaths.map((excluded) => displayPath(excluded, homeDir)).join("\n"), "Excluded paths");
485
+ const worktreeCopies = discovery.excludedPaths.filter((excluded) => isWorktreeCopy(path.relative(homeDir || path.sep, excluded)));
486
+ const otherExcluded = discovery.excludedPaths.filter((excluded) => !worktreeCopies.includes(excluded));
487
+ if (worktreeCopies.length > 0)
488
+ log.info(safeDisplay(`Skipped ${worktreeCopies.length} worktree copy folder(s).`));
489
+ if (otherExcluded.length > 0)
490
+ note(otherExcluded.map((excluded) => displayPath(excluded, homeDir)).join("\n"), "Excluded paths");
460
491
  if (discovery.candidates.length === 0) {
461
492
  outro("No supported resources were found.");
462
493
  return;
463
494
  }
464
- const sortedCandidates = sortCandidates(discovery.candidates);
495
+ const allSorted = sortCandidates(discovery.candidates);
496
+ const fingerprints = new Map(await mapWithLimit(allSorted, 16, async (candidate) => [candidate.id, await contentFingerprint(candidate)]));
497
+ const { kept: sortedCandidates, copies: identicalCopies } = collapseIdenticalCandidates(allSorted, fingerprints, homeDir);
498
+ const merged = allSorted.length - sortedCandidates.length;
499
+ if (merged > 0)
500
+ log.info(safeDisplay(`Merged ${merged} identical copies; ${sortedCandidates.length} distinct resource(s) to choose from.`));
465
501
  const candidateStats = await gatherCandidateStats(sortedCandidates);
466
502
  const savedNames = new Set(initial.items.map((item) => nameKey(item.name)));
467
503
  const candidateLabelMap = candidateLabels(sortedCandidates, homeDir, (candidate) => savedNames.has(nameKey(candidate.name)), process.stdout.columns);
@@ -471,7 +507,7 @@ export async function runSetup(libraryDir, roots) {
471
507
  const selectedIds = await autocompleteMultiselect({
472
508
  message: "Select resources to review and save as copied snapshots",
473
509
  options: sortedCandidates.map((candidate) => {
474
- const label = candidateLabelMap.get(candidate.id);
510
+ const label = candidateLabelMap.get(candidate.id) + identicalCopiesSuffix(identicalCopies.get(candidate.id));
475
511
  return { value: candidate.id, label, hint: candidateHint(candidate, candidateStats.get(candidate.id) ?? {}, homeDir, process.stdout.columns, label.length) };
476
512
  }),
477
513
  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.1",
4
4
  "description": "Collect and organize reusable Pi coding-agent resources",
5
5
  "license": "MIT",
6
6
  "type": "module",