@papillon6814/copi 0.2.1 → 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 +5 -1
- package/dist/src/discovery.d.ts +20 -1
- package/dist/src/discovery.js +89 -12
- package/dist/src/setup-display.d.ts +67 -0
- package/dist/src/setup-display.js +306 -0
- package/dist/src/wizard.d.ts +9 -0
- package/dist/src/wizard.js +275 -63
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,11 @@ copi migrate --resolve [--dry-run]
|
|
|
26
26
|
copi help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`setup` requires an interactive terminal. It asks
|
|
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
|
+
|
|
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
|
+
|
|
33
|
+
A group is a named set of saved resources that you later apply together with `copi apply <group> <project>`. If no group exists yet, setup asks you to create the first one. With several resources, setup asks once how to group them: by default all of them go into the same group(s); you can instead choose groups for each resource. Each resource is then reviewed in turn with a masked preview titled with its progress (for example `1/3`) and its source directory. A suspected sensitive file needs its own approval; answering No means that resource is not saved at all. Finally, one confirmation lists everything that will be copied into the library; the original files are never changed. When it is done, setup prints the next step, for example `copi apply <group> <project> --dry-run`.
|
|
30
34
|
|
|
31
35
|
`manage` requires both stdin and stdout to be an interactive TTY. Choose saved resources and groups by name; UUID input is not required and normal screens identify resources by display name, kind, groups and destination. It provides masked viewing, terminal text editing, display-name changes, separate destination changes, source refresh, group management and apply. Running without arguments, or with `help`, `--help` or `-h`, still shows help.
|
|
32
36
|
|
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
|
}
|
|
@@ -117,19 +155,24 @@ async function canonicalDirectory(directory) {
|
|
|
117
155
|
return undefined;
|
|
118
156
|
}
|
|
119
157
|
}
|
|
120
|
-
|
|
158
|
+
/** False only when the entry itself does not exist (lstat ENOENT); dangling symlinks count as existing. */
|
|
159
|
+
async function exists(target) {
|
|
160
|
+
return lstat(target).then(() => true, (error) => error.code !== "ENOENT");
|
|
161
|
+
}
|
|
162
|
+
export async function discoverResources(options, onProgress) {
|
|
121
163
|
const scopes = options.scopes ?? inferSearchScopes(options);
|
|
122
|
-
|
|
164
|
+
const piOnly = Boolean(scopes.piDirs) && !scopes.home;
|
|
165
|
+
if (!scopes.home && !scopes.project && !piOnly)
|
|
123
166
|
return { candidates: [], warnings: [], excludedPaths: [] };
|
|
124
167
|
const warnings = [];
|
|
125
168
|
const excludedPaths = new Set();
|
|
126
169
|
const candidates = [];
|
|
127
170
|
const candidateKeys = new Set();
|
|
128
|
-
const home = scopes.home ? await canonicalDirectory(options.homeDir) : undefined;
|
|
129
|
-
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;
|
|
130
173
|
const project = scopes.project ? await canonicalDirectory(options.projectDir) : undefined;
|
|
131
174
|
const packageAgentDir = agent;
|
|
132
|
-
if (scopes.home && !home)
|
|
175
|
+
if ((scopes.home || piOnly) && !home)
|
|
133
176
|
addWarning(warnings, "unavailable-root", options.homeDir, "The explicitly selected home directory is unavailable.");
|
|
134
177
|
if (scopes.home && !agent)
|
|
135
178
|
addWarning(warnings, "unavailable-root", options.agentDir, "The explicitly selected Pi agent directory is unavailable.");
|
|
@@ -143,6 +186,8 @@ export async function discoverResources(options) {
|
|
|
143
186
|
if (project)
|
|
144
187
|
roots.push({ path: project, scope: "project", source: "project", destinationRoot: ".pi/skills", destinationKind: "project" });
|
|
145
188
|
const addCandidate = async (kind, root, sourcePath, name, scope, source, destination) => {
|
|
189
|
+
if (!(await exists(sourcePath)))
|
|
190
|
+
return;
|
|
146
191
|
const canonicalSource = await realpath(sourcePath).catch(() => undefined);
|
|
147
192
|
const canonicalRoot = await realpath(root).catch(() => undefined);
|
|
148
193
|
if (!canonicalSource || !canonicalRoot || !isWithin(canonicalRoot, canonicalSource)) {
|
|
@@ -172,11 +217,11 @@ export async function discoverResources(options) {
|
|
|
172
217
|
destination,
|
|
173
218
|
});
|
|
174
219
|
};
|
|
175
|
-
const walk = async (root, startPath, onFile) => {
|
|
220
|
+
const walk = async (root, startPath, onFile, progress) => {
|
|
176
221
|
const canonicalRoot = await canonicalDirectory(root);
|
|
177
222
|
if (!canonicalRoot)
|
|
178
223
|
return;
|
|
179
|
-
const visit = async (directory) => {
|
|
224
|
+
const visit = async (directory, topLevel = false) => {
|
|
180
225
|
let entries;
|
|
181
226
|
try {
|
|
182
227
|
entries = await readdir(directory, { withFileTypes: true });
|
|
@@ -186,9 +231,19 @@ export async function discoverResources(options) {
|
|
|
186
231
|
addWarning(warnings, code === "EACCES" ? "permission-denied" : "scan-error", directory, code === "EACCES" ? "Permission denied while searching this directory." : "Unable to search this directory.");
|
|
187
232
|
return;
|
|
188
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 });
|
|
189
240
|
for (const entry of entries) {
|
|
190
241
|
const child = path.join(directory, entry.name);
|
|
191
242
|
const relative = path.relative(canonicalRoot, child);
|
|
243
|
+
if (entry.isDirectory() && isWorktreeCopy(relative)) {
|
|
244
|
+
excludedPaths.add(child);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
192
247
|
if (shouldExclude(relative)) {
|
|
193
248
|
excludedPaths.add(child);
|
|
194
249
|
if (relative.split(path.sep).some((part) => part.toLowerCase() === "auth")) {
|
|
@@ -201,14 +256,22 @@ export async function discoverResources(options) {
|
|
|
201
256
|
addWarning(warnings, target && isWithin(canonicalRoot, target) ? "symlink-skipped" : "external-symlink", child, "Symbolic links are not followed during resource discovery.");
|
|
202
257
|
continue;
|
|
203
258
|
}
|
|
204
|
-
if (entry.isDirectory())
|
|
259
|
+
if (entry.isDirectory()) {
|
|
260
|
+
const isTracked = tracked.includes(entry);
|
|
261
|
+
if (isTracked)
|
|
262
|
+
progress({ done, total: tracked.length, current: child });
|
|
205
263
|
await visit(child);
|
|
264
|
+
if (isTracked)
|
|
265
|
+
progress({ done: ++done, total: tracked.length });
|
|
266
|
+
}
|
|
206
267
|
else if (entry.isFile())
|
|
207
268
|
await onFile(child);
|
|
208
269
|
}
|
|
209
270
|
};
|
|
210
271
|
const canonicalStart = await realpath(startPath).catch(() => undefined);
|
|
211
272
|
if (!canonicalStart || !isWithin(canonicalRoot, canonicalStart)) {
|
|
273
|
+
if (!(await exists(startPath)))
|
|
274
|
+
return;
|
|
212
275
|
addWarning(warnings, "resource-outside-root", startPath, "A configured resource root resolves outside its allowed search root.");
|
|
213
276
|
return;
|
|
214
277
|
}
|
|
@@ -226,7 +289,7 @@ export async function discoverResources(options) {
|
|
|
226
289
|
if (stat.isFile())
|
|
227
290
|
await onFile(canonicalStart);
|
|
228
291
|
else if (stat.isDirectory())
|
|
229
|
-
await visit(canonicalStart);
|
|
292
|
+
await visit(canonicalStart, true);
|
|
230
293
|
};
|
|
231
294
|
const addSkillRoot = async (root, startPath, scope, source, destinationRoot) => {
|
|
232
295
|
await walk(root, startPath, async (file) => {
|
|
@@ -482,7 +545,7 @@ export async function discoverResources(options) {
|
|
|
482
545
|
const homeProjects = new Set();
|
|
483
546
|
const homeProfiles = new Set();
|
|
484
547
|
if (home) {
|
|
485
|
-
|
|
548
|
+
const classifyHomeFile = async (file) => {
|
|
486
549
|
const relativeParts = path.relative(home, file).split(path.sep);
|
|
487
550
|
const base = path.basename(file);
|
|
488
551
|
const piIndex = relativeParts.indexOf(".pi");
|
|
@@ -561,7 +624,21 @@ export async function discoverResources(options) {
|
|
|
561
624
|
homeProjects.add(projectRoot);
|
|
562
625
|
await addCandidate("instruction", home, file, base, "project", "project-instructions", { kind: "project", path: base });
|
|
563
626
|
}
|
|
564
|
-
}
|
|
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
|
+
}
|
|
565
642
|
for (const profileRoot of homeProfiles) {
|
|
566
643
|
await scanSettings(path.join(profileRoot, "settings.json"), "user", [profileRoot, home], profileRoot);
|
|
567
644
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ResourceCandidate } from "./types.ts";
|
|
2
|
+
/** Replace a leading home directory with `~` (only on a path-segment boundary). */
|
|
3
|
+
export declare function displayPath(p: string, home: string): string;
|
|
4
|
+
/** Keep head and tail joined by `…`; return `s` unchanged when `max` is missing, tiny, or not smaller than the length. */
|
|
5
|
+
export declare function truncateMiddle(s: string, max: number | undefined): string;
|
|
6
|
+
export declare function formatBytes(n: number): string;
|
|
7
|
+
/** Summarize skipped out-of-root resources into one note (undefined when none). */
|
|
8
|
+
export declare function formatOutsideRootSummary(paths: readonly string[], home: string): {
|
|
9
|
+
title: string;
|
|
10
|
+
body: string;
|
|
11
|
+
} | undefined;
|
|
12
|
+
export interface CandidateStats {
|
|
13
|
+
size?: string;
|
|
14
|
+
mtime?: Date;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `[kind] name — ~/source/dir`, plus `(already saved)`. Shared with conflict-resolution options.
|
|
18
|
+
* When `columns` is given the source directory is middle-truncated (tail kept) so the row does not wrap.
|
|
19
|
+
*/
|
|
20
|
+
export declare function candidateLabel(c: ResourceCandidate, home: string, alreadySaved: boolean, columns?: number): string;
|
|
21
|
+
/**
|
|
22
|
+
* Labels for a whole list. Rows sharing kind + nameKey must stay distinguishable: when truncation makes their
|
|
23
|
+
* source labels collide, that group falls back to whole trailing segments (`…/a/b`), widened until they differ.
|
|
24
|
+
*/
|
|
25
|
+
export declare function candidateLabels(list: readonly ResourceCandidate[], home: string, isSaved: (c: ResourceCandidate) => boolean, columns?: number): Map<string, string>;
|
|
26
|
+
/** Sort by kind, then nameKey, then source path (new array). */
|
|
27
|
+
export declare function sortCandidates(list: readonly ResourceCandidate[]): ResourceCandidate[];
|
|
28
|
+
export declare function formatLocalDate(d: Date): string;
|
|
29
|
+
/** `<size> · modified <date> · <destination>`; missing parts are omitted. Truncated to the terminal width minus label and prompt chrome. */
|
|
30
|
+
export declare function candidateHint(c: ResourceCandidate, stats: CandidateStats, home: string, columns: number | undefined, labelLength: number): string;
|
|
31
|
+
/** Cheap, never-throwing stats for the shown candidates, gathered in parallel. */
|
|
32
|
+
export declare function gatherCandidateStats(list: readonly ResourceCandidate[]): Promise<Map<string, CandidateStats>>;
|
|
33
|
+
/** Groups of 2+ selected candidates sharing a `nameKey` (any kind), each in input order; groups ordered by first member. */
|
|
34
|
+
export declare function findSelectionConflicts(list: readonly ResourceCandidate[]): ResourceCandidate[][];
|
|
35
|
+
/**
|
|
36
|
+
* Suggested rename `<name> (<segment>)`. Within a conflict group the segment is the nearest ancestor directory
|
|
37
|
+
* (walking up from the source's parent) whose name tells this member apart from the others — unique if possible,
|
|
38
|
+
* otherwise the first level where the members differ at all. Without such a level (or alone), the segment is the
|
|
39
|
+
* grandparent of the source, falling back to its parent, e.g. `jandel.md (agent-rbx)`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function defaultRenameName(c: ResourceCandidate, group?: readonly ResourceCandidate[]): string;
|
|
42
|
+
/** Validation message for a new display name, or undefined when it is non-empty and its `nameKey` is not taken. */
|
|
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[]>;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { shouldExclude } from "./discovery.js";
|
|
5
|
+
import { nameKey } from "./names.js";
|
|
6
|
+
/** Replace a leading home directory with `~` (only on a path-segment boundary). */
|
|
7
|
+
export function displayPath(p, home) {
|
|
8
|
+
if (!home)
|
|
9
|
+
return p;
|
|
10
|
+
const base = home.length > 1 && home.endsWith(path.sep) ? home.slice(0, -1) : home;
|
|
11
|
+
if (p === base)
|
|
12
|
+
return "~";
|
|
13
|
+
if (p.startsWith(base + path.sep))
|
|
14
|
+
return `~${p.slice(base.length)}`;
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
/** Keep head and tail joined by `…`; return `s` unchanged when `max` is missing, tiny, or not smaller than the length. */
|
|
18
|
+
export function truncateMiddle(s, max) {
|
|
19
|
+
if (max === undefined || !Number.isFinite(max) || max < 4 || s.length <= max)
|
|
20
|
+
return s;
|
|
21
|
+
const keep = max - 1;
|
|
22
|
+
const head = Math.ceil(keep / 2);
|
|
23
|
+
const tail = keep - head;
|
|
24
|
+
return `${s.slice(0, head)}…${tail > 0 ? s.slice(s.length - tail) : ""}`;
|
|
25
|
+
}
|
|
26
|
+
export function formatBytes(n) {
|
|
27
|
+
if (n < 1024)
|
|
28
|
+
return `${n} B`;
|
|
29
|
+
if (n < 1024 * 1024)
|
|
30
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
31
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
32
|
+
}
|
|
33
|
+
const outsideRootPreview = 5;
|
|
34
|
+
/** Summarize skipped out-of-root resources into one note (undefined when none). */
|
|
35
|
+
export function formatOutsideRootSummary(paths, home) {
|
|
36
|
+
if (paths.length === 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
const lines = paths.slice(0, outsideRootPreview).map((p) => displayPath(p, home));
|
|
39
|
+
if (paths.length > outsideRootPreview)
|
|
40
|
+
lines.push(`+${paths.length - outsideRootPreview} more`);
|
|
41
|
+
return { title: `Skipped ${paths.length} resource(s) outside the selected locations`, body: lines.join("\n") };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `[kind] name — ~/source/dir`, plus `(already saved)`. Shared with conflict-resolution options.
|
|
45
|
+
* When `columns` is given the source directory is middle-truncated (tail kept) so the row does not wrap.
|
|
46
|
+
*/
|
|
47
|
+
export function candidateLabel(c, home, alreadySaved, columns) {
|
|
48
|
+
const head = `[${c.kind}] ${c.name} \u2014 `;
|
|
49
|
+
const tail = alreadySaved ? " (already saved)" : "";
|
|
50
|
+
let dir = displayPath(path.dirname(c.sourcePath), home);
|
|
51
|
+
if (columns !== undefined && Number.isFinite(columns))
|
|
52
|
+
dir = truncateMiddle(dir, Math.max(columns - head.length - tail.length - 6, 8));
|
|
53
|
+
return `${head}${dir}${tail}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Labels for a whole list. Rows sharing kind + nameKey must stay distinguishable: when truncation makes their
|
|
57
|
+
* source labels collide, that group falls back to whole trailing segments (`…/a/b`), widened until they differ.
|
|
58
|
+
*/
|
|
59
|
+
export function candidateLabels(list, home, isSaved, columns) {
|
|
60
|
+
const out = new Map();
|
|
61
|
+
const groups = new Map();
|
|
62
|
+
for (const c of list) {
|
|
63
|
+
out.set(c.id, candidateLabel(c, home, isSaved(c), columns));
|
|
64
|
+
const key = `${c.kind}\0${nameKey(c.name)}`;
|
|
65
|
+
groups.set(key, [...(groups.get(key) ?? []), c]);
|
|
66
|
+
}
|
|
67
|
+
for (const group of groups.values()) {
|
|
68
|
+
if (group.length < 2)
|
|
69
|
+
continue;
|
|
70
|
+
const labels = group.map((c) => out.get(c.id));
|
|
71
|
+
if (new Set(labels).size === labels.length)
|
|
72
|
+
continue;
|
|
73
|
+
const dirs = group.map((c) => displayPath(path.dirname(c.sourcePath), home));
|
|
74
|
+
const segs = dirs.map((d) => d.split(path.sep));
|
|
75
|
+
const max = Math.max(...segs.map((x) => x.length));
|
|
76
|
+
for (let k = 1; k <= max; k += 1) {
|
|
77
|
+
const cut = segs.map((x, i) => (x.length <= k ? dirs[i] : `\u2026${path.sep}${x.slice(-k).join(path.sep)}`));
|
|
78
|
+
const rendered = group.map((c, i) => `[${c.kind}] ${c.name} \u2014 ${cut[i]}${isSaved(c) ? " (already saved)" : ""}`);
|
|
79
|
+
if (new Set(rendered).size === rendered.length || k === max) {
|
|
80
|
+
group.forEach((c, i) => out.set(c.id, rendered[i]));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Sort by kind, then nameKey, then source path (new array). */
|
|
88
|
+
export function sortCandidates(list) {
|
|
89
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
90
|
+
return [...list].sort((a, b) => cmp(a.kind, b.kind) || cmp(nameKey(a.name), nameKey(b.name)) || cmp(a.sourcePath, b.sourcePath));
|
|
91
|
+
}
|
|
92
|
+
export function formatLocalDate(d) {
|
|
93
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
94
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
95
|
+
}
|
|
96
|
+
/** `<size> · modified <date> · <destination>`; missing parts are omitted. Truncated to the terminal width minus label and prompt chrome. */
|
|
97
|
+
export function candidateHint(c, stats, home, columns, labelLength) {
|
|
98
|
+
const parts = [];
|
|
99
|
+
if (stats.size !== undefined)
|
|
100
|
+
parts.push(stats.size);
|
|
101
|
+
if (stats.mtime !== undefined)
|
|
102
|
+
parts.push(`modified ${formatLocalDate(stats.mtime)}`);
|
|
103
|
+
parts.push(c.destination.kind === "global-only" ? "save only" : displayPath(c.destination.path, home));
|
|
104
|
+
const hint = parts.join(" · ");
|
|
105
|
+
if (columns === undefined || !Number.isFinite(columns))
|
|
106
|
+
return hint;
|
|
107
|
+
return truncateMiddle(hint, Math.max(columns - labelLength - 10, 12));
|
|
108
|
+
}
|
|
109
|
+
const countCap = 1000;
|
|
110
|
+
async function countFiles(dir, budget) {
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
if (budget.left <= 0)
|
|
120
|
+
return;
|
|
121
|
+
if (entry.isDirectory())
|
|
122
|
+
await countFiles(path.join(dir, entry.name), budget);
|
|
123
|
+
else if (entry.isFile())
|
|
124
|
+
budget.left -= 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function hasMoreFiles(dir, cap) {
|
|
128
|
+
const budget = { left: cap + 1 };
|
|
129
|
+
await countFiles(dir, budget);
|
|
130
|
+
return budget.left <= 0;
|
|
131
|
+
}
|
|
132
|
+
async function statOne(c) {
|
|
133
|
+
try {
|
|
134
|
+
const st = await lstat(c.sourcePath);
|
|
135
|
+
if (st.isDirectory()) {
|
|
136
|
+
const budget = { left: countCap };
|
|
137
|
+
await countFiles(c.sourcePath, budget);
|
|
138
|
+
const n = countCap - budget.left;
|
|
139
|
+
return { size: budget.left <= 0 && (await hasMoreFiles(c.sourcePath, countCap)) ? `${countCap}+ files` : `${n} file${n === 1 ? "" : "s"}`, mtime: st.mtime };
|
|
140
|
+
}
|
|
141
|
+
return { size: formatBytes(st.size), mtime: st.mtime };
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Cheap, never-throwing stats for the shown candidates, gathered in parallel. */
|
|
148
|
+
export async function gatherCandidateStats(list) {
|
|
149
|
+
const entries = await Promise.all(list.map(async (c) => [c.id, await statOne(c)]));
|
|
150
|
+
return new Map(entries);
|
|
151
|
+
}
|
|
152
|
+
/** Groups of 2+ selected candidates sharing a `nameKey` (any kind), each in input order; groups ordered by first member. */
|
|
153
|
+
export function findSelectionConflicts(list) {
|
|
154
|
+
const groups = new Map();
|
|
155
|
+
for (const c of list) {
|
|
156
|
+
const key = nameKey(c.name);
|
|
157
|
+
groups.set(key, [...(groups.get(key) ?? []), c]);
|
|
158
|
+
}
|
|
159
|
+
return [...groups.values()].filter((group) => group.length > 1);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Suggested rename `<name> (<segment>)`. Within a conflict group the segment is the nearest ancestor directory
|
|
163
|
+
* (walking up from the source's parent) whose name tells this member apart from the others — unique if possible,
|
|
164
|
+
* otherwise the first level where the members differ at all. Without such a level (or alone), the segment is the
|
|
165
|
+
* grandparent of the source, falling back to its parent, e.g. `jandel.md (agent-rbx)`.
|
|
166
|
+
*/
|
|
167
|
+
export function defaultRenameName(c, group = [c]) {
|
|
168
|
+
const ancestors = (x) => {
|
|
169
|
+
const out = [];
|
|
170
|
+
for (let dir = path.dirname(x.sourcePath); path.basename(dir); dir = path.dirname(dir))
|
|
171
|
+
out.push(path.basename(dir));
|
|
172
|
+
return out;
|
|
173
|
+
};
|
|
174
|
+
const mine = ancestors(c);
|
|
175
|
+
const others = group.filter((x) => x.id !== c.id).map(ancestors);
|
|
176
|
+
let segment;
|
|
177
|
+
if (others.length > 0) {
|
|
178
|
+
const unique = mine.findIndex((name, level) => others.every((other) => other[level] !== name));
|
|
179
|
+
const differing = mine.findIndex((name, level) => others.some((other) => other[level] !== name));
|
|
180
|
+
const level = unique !== -1 ? unique : differing;
|
|
181
|
+
if (level !== -1)
|
|
182
|
+
segment = mine[level];
|
|
183
|
+
}
|
|
184
|
+
segment ??= mine[1] ?? mine[0];
|
|
185
|
+
return segment ? `${c.name} (${segment})` : c.name;
|
|
186
|
+
}
|
|
187
|
+
/** Validation message for a new display name, or undefined when it is non-empty and its `nameKey` is not taken. */
|
|
188
|
+
export function pendingNameError(name, taken) {
|
|
189
|
+
let key;
|
|
190
|
+
try {
|
|
191
|
+
key = nameKey(name ?? "");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return "Enter a non-empty display name.";
|
|
195
|
+
}
|
|
196
|
+
return taken.has(key) ? "That name is already used by a saved or selected resource; choose another." : undefined;
|
|
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;
|
|
@@ -30,6 +31,14 @@ export declare function prepareCandidateForReview(candidate: ResourceCandidate):
|
|
|
30
31
|
export declare function saveReviewedCandidate(libraryDir: string, review: CandidateReview, groupIds: string[], approvedSensitivePaths: string[], confirmed: boolean): Promise<SavedItem | null>;
|
|
31
32
|
/** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
|
|
32
33
|
export declare function resolveNameConflicts(libraryDir: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Rename prompt text. The suggestion is prefilled only when it renders unchanged through `safeDisplay`
|
|
36
|
+
* (`initialValue` is echoed raw); otherwise the field stays empty and the masked suggestion is shown in the message.
|
|
37
|
+
*/
|
|
38
|
+
export declare function renamePrompt(name: string, sourceDir: string, suggestion: string): {
|
|
39
|
+
message: string;
|
|
40
|
+
initialValue?: string;
|
|
41
|
+
};
|
|
33
42
|
export declare function runSetup(libraryDir: string, roots: SearchRoots): Promise<void>;
|
|
34
43
|
export declare function runGroupManager(libraryDir: string): Promise<void>;
|
|
35
44
|
export declare function printGroups(catalog: StoredCatalog): void;
|
package/dist/src/wizard.js
CHANGED
|
@@ -1,9 +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, collapseIdenticalCandidates, contentFingerprint, defaultRenameName, displayPath, findSelectionConflicts, formatSearchProgress, formatOutsideRootSummary, gatherCandidateStats, identicalCopiesSuffix, mapWithLimit, pendingNameError, sortCandidates } from "./setup-display.js";
|
|
7
8
|
import { inspectPreparedResource, maskSensitiveText } from "./inspect.js";
|
|
8
9
|
import { assertNameAvailable, assertNoNameConflicts, findNameConflicts, nameKey } from "./names.js";
|
|
9
10
|
import { executeApply, planApply } from "./apply.js";
|
|
@@ -89,11 +90,11 @@ const previewMaxBytes = 1_000_000;
|
|
|
89
90
|
const previewMaxFiles = 12;
|
|
90
91
|
const previewMaxLines = 24;
|
|
91
92
|
export function buildSearchOptions(roots, scopes) {
|
|
92
|
-
if (!scopes.home && !scopes.project)
|
|
93
|
+
if (!scopes.home && !scopes.project && !scopes.piDirs)
|
|
93
94
|
return undefined;
|
|
94
95
|
return {
|
|
95
96
|
...roots,
|
|
96
|
-
scopes: { home: scopes.home, project: scopes.project },
|
|
97
|
+
scopes: { home: scopes.home, project: scopes.project, ...(scopes.piDirs ? { piDirs: true } : {}) },
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
export function formatMaskedPreview(relativePath, content) {
|
|
@@ -133,6 +134,12 @@ export async function saveReviewedCandidate(libraryDir, review, groupIds, approv
|
|
|
133
134
|
approvedSensitivePaths,
|
|
134
135
|
});
|
|
135
136
|
}
|
|
137
|
+
function showSearchWarnings(warnings, home) {
|
|
138
|
+
const summary = formatOutsideRootSummary(warnings.filter((warning) => warning.code === "resource-outside-root" && warning.path !== undefined).map((warning) => warning.path), home);
|
|
139
|
+
if (summary)
|
|
140
|
+
note(summary.body, summary.title);
|
|
141
|
+
showWarnings(warnings.filter((warning) => warning.code !== "resource-outside-root" || warning.path === undefined), "Search warnings");
|
|
142
|
+
}
|
|
136
143
|
function showWarnings(warnings, title) {
|
|
137
144
|
if (warnings.length === 0)
|
|
138
145
|
return;
|
|
@@ -267,12 +274,17 @@ async function handleNameConflicts(libraryDir) {
|
|
|
267
274
|
await resolveNameConflicts(libraryDir);
|
|
268
275
|
}
|
|
269
276
|
}
|
|
270
|
-
function groupOptions(groups) {
|
|
271
|
-
return groups.map((group) => ({
|
|
277
|
+
function groupOptions(groups, pendingCounts) {
|
|
278
|
+
return groups.map((group) => ({
|
|
279
|
+
value: group.id,
|
|
280
|
+
label: group.name,
|
|
281
|
+
hint: pendingCounts ? `${group.itemIds.length} saved \u00b7 ${pendingCounts.get(group.id) ?? 0} pending` : `${group.itemIds.length} saved resources`,
|
|
282
|
+
}));
|
|
272
283
|
}
|
|
273
|
-
async function askForGroupName(message) {
|
|
284
|
+
async function askForGroupName(message, placeholder) {
|
|
274
285
|
const value = await text({
|
|
275
286
|
message,
|
|
287
|
+
...(placeholder !== undefined ? { placeholder } : {}),
|
|
276
288
|
validate: (name) => {
|
|
277
289
|
try {
|
|
278
290
|
nameKey(name ?? "");
|
|
@@ -285,6 +297,106 @@ async function askForGroupName(message) {
|
|
|
285
297
|
});
|
|
286
298
|
return isCancel(value) ? undefined : value.trim();
|
|
287
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Rename prompt text. The suggestion is prefilled only when it renders unchanged through `safeDisplay`
|
|
302
|
+
* (`initialValue` is echoed raw); otherwise the field stays empty and the masked suggestion is shown in the message.
|
|
303
|
+
*/
|
|
304
|
+
export function renamePrompt(name, sourceDir, suggestion) {
|
|
305
|
+
const message = `New display name for ${name} \u2014 ${sourceDir} (display name only; files are not changed)`;
|
|
306
|
+
return safeDisplay(suggestion) === suggestion
|
|
307
|
+
? { message, initialValue: suggestion }
|
|
308
|
+
: { message: `${message}. Suggestion: ${suggestion}` };
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Resolve display-name conflicts right after selection (#9): within the selection first, one group at a time,
|
|
312
|
+
* then against saved library names. Returns the candidates to review (renamed ones are copies with a new
|
|
313
|
+
* display name only), "back" to re-show the list, or "cancel".
|
|
314
|
+
*/
|
|
315
|
+
async function resolveSelectedNames(selection, library, labels, homeDir) {
|
|
316
|
+
let pending = [...selection];
|
|
317
|
+
const libraryKeys = new Set(library.items.map((item) => nameKey(item.name)));
|
|
318
|
+
const askNewName = async (candidate, group = [candidate]) => {
|
|
319
|
+
// Taken: every saved name and every other pending name (assertNameAvailable semantics for the library).
|
|
320
|
+
const taken = new Set([...libraryKeys, ...pending.filter((other) => other.id !== candidate.id).map((other) => nameKey(other.name))]);
|
|
321
|
+
const value = await text({
|
|
322
|
+
...renamePrompt(candidate.name, displayPath(path.dirname(candidate.sourcePath), homeDir), defaultRenameName(candidate, group)),
|
|
323
|
+
validate: (name) => {
|
|
324
|
+
const error = pendingNameError(name, taken);
|
|
325
|
+
if (error)
|
|
326
|
+
return error;
|
|
327
|
+
try {
|
|
328
|
+
assertNameAvailable(library, "item", name ?? "");
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return "That name is already used by a saved or selected resource; choose another.";
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
return isCancel(value) ? undefined : value.trim();
|
|
337
|
+
};
|
|
338
|
+
const rename = (candidate, name) => {
|
|
339
|
+
pending = pending.map((entry) => (entry.id === candidate.id ? { ...entry, name } : entry));
|
|
340
|
+
};
|
|
341
|
+
for (const group of findSelectionConflicts(pending)) {
|
|
342
|
+
const choice = await select({
|
|
343
|
+
message: `${group.length} selected resources share the name ${group[0].name}; choose what to save`,
|
|
344
|
+
options: [
|
|
345
|
+
...group.map((candidate) => ({ value: `keep:${candidate.id}`, label: `Keep ${labels.get(candidate.id) ?? candidate.name}` })),
|
|
346
|
+
{ value: "rename", label: "Keep all and rename the others" },
|
|
347
|
+
{ value: "back", label: "Go back to the list" },
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
if (isCancel(choice))
|
|
351
|
+
return "cancel";
|
|
352
|
+
if (choice === "back")
|
|
353
|
+
return "back";
|
|
354
|
+
if (choice === "rename") {
|
|
355
|
+
for (const candidate of group.slice(1)) {
|
|
356
|
+
const name = await askNewName(pending.find((entry) => entry.id === candidate.id), group);
|
|
357
|
+
if (name === undefined)
|
|
358
|
+
return "cancel";
|
|
359
|
+
rename(candidate, name);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
const kept = choice.slice("keep:".length);
|
|
364
|
+
const dropped = new Set(group.filter((candidate) => candidate.id !== kept).map((candidate) => candidate.id));
|
|
365
|
+
pending = pending.filter((candidate) => !dropped.has(candidate.id));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const candidate of [...pending]) {
|
|
369
|
+
if (!libraryKeys.has(nameKey(candidate.name)))
|
|
370
|
+
continue;
|
|
371
|
+
const choice = await select({
|
|
372
|
+
message: `${candidate.name} \u2014 ${displayPath(path.dirname(candidate.sourcePath), homeDir)}: a saved resource already uses this name. To update the saved copy, use Refresh in copi manage.`,
|
|
373
|
+
options: [
|
|
374
|
+
{ value: "skip", label: "Skip (keep the saved copy)" },
|
|
375
|
+
{ value: "rename", label: "Rename" },
|
|
376
|
+
{ value: "back", label: "Go back to the list" },
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
if (isCancel(choice))
|
|
380
|
+
return "cancel";
|
|
381
|
+
if (choice === "back")
|
|
382
|
+
return "back";
|
|
383
|
+
if (choice === "skip") {
|
|
384
|
+
pending = pending.filter((entry) => entry.id !== candidate.id);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const name = await askNewName(candidate);
|
|
388
|
+
if (name === undefined)
|
|
389
|
+
return "cancel";
|
|
390
|
+
rename(candidate, name);
|
|
391
|
+
}
|
|
392
|
+
return pending;
|
|
393
|
+
}
|
|
394
|
+
/** Quote for POSIX shells only when needed (single quotes, embedded quotes escaped as '\\''). */
|
|
395
|
+
function shellQuote(value) {
|
|
396
|
+
if (value === "<group>" || /^[A-Za-z0-9_.\/@%+=:,-]+$/.test(value))
|
|
397
|
+
return value;
|
|
398
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
399
|
+
}
|
|
288
400
|
export async function runSetup(libraryDir, roots) {
|
|
289
401
|
if (await handleNameConflicts(libraryDir))
|
|
290
402
|
return;
|
|
@@ -292,36 +404,39 @@ export async function runSetup(libraryDir, roots) {
|
|
|
292
404
|
assertModernCatalog(initial);
|
|
293
405
|
assertNoNameConflicts(initial);
|
|
294
406
|
intro("copi setup");
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
407
|
+
const homeDir = roots.homeDir;
|
|
408
|
+
const projectLabel = displayPath(roots.projectDir, homeDir);
|
|
409
|
+
const homeBase = homeDir.length > 1 && homeDir.endsWith(path.sep) ? homeDir.slice(0, -1) : homeDir;
|
|
410
|
+
const projectIsHome = Boolean(homeDir) && roots.projectDir === homeBase;
|
|
411
|
+
const projectInsideHome = Boolean(homeDir) && !projectIsHome && roots.projectDir.startsWith(homeBase + path.sep);
|
|
412
|
+
const locationOptions = [];
|
|
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
|
+
}
|
|
417
|
+
if (!projectIsHome)
|
|
418
|
+
locationOptions.push({ value: "project", label: `This project only \u2014 ${projectLabel} (fast)` });
|
|
419
|
+
if (homeDir && !projectIsHome && !projectInsideHome)
|
|
420
|
+
locationOptions.push({ value: "both", label: "Both" });
|
|
421
|
+
locationOptions.push({ value: "cancel", label: "Cancel" });
|
|
422
|
+
const choice = await select({
|
|
305
423
|
message: "Choose the locations copi may inspect",
|
|
306
|
-
options:
|
|
307
|
-
|
|
308
|
-
placeholder: "Search locations...",
|
|
424
|
+
options: locationOptions,
|
|
425
|
+
initialValue: locationOptions[0].value,
|
|
309
426
|
});
|
|
310
|
-
if (isCancel(
|
|
427
|
+
if (isCancel(choice) || choice === "cancel") {
|
|
311
428
|
cancel("Setup cancelled.");
|
|
312
429
|
return;
|
|
313
430
|
}
|
|
314
|
-
const scopes = { home:
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
outro("No locations selected; nothing was searched or saved.");
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
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 : [];
|
|
433
|
+
const searchOptions = buildSearchOptions(roots, scopes); // every non-cancel choice enables at least one scope
|
|
320
434
|
note([
|
|
321
|
-
`HOME: ${scopes.home ? roots.homeDir : "not selected"}`,
|
|
322
|
-
`Pi agent directory: ${scopes.home ? roots.agentDir : "not selected"}`,
|
|
323
|
-
`Project: ${scopes.project ?
|
|
324
|
-
"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.",
|
|
435
|
+
`HOME: ${scopes.home ? displayPath(roots.homeDir, 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"}`,
|
|
437
|
+
`Project: ${scopes.project ? projectLabel : "not selected"}`,
|
|
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."] : []),
|
|
325
440
|
"Secret detection is heuristic and cannot guarantee completeness. Preview text is masked by default.",
|
|
326
441
|
].join("\n"), "Search scope and exclusions");
|
|
327
442
|
const authorized = await confirm({
|
|
@@ -332,46 +447,90 @@ export async function runSetup(libraryDir, roots) {
|
|
|
332
447
|
outro("Search not authorized; no resources were inspected or saved.");
|
|
333
448
|
return;
|
|
334
449
|
}
|
|
335
|
-
|
|
336
|
-
|
|
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
|
+
};
|
|
337
469
|
let discovery;
|
|
338
470
|
try {
|
|
339
|
-
discovery = await discoverResources(searchOptions);
|
|
471
|
+
discovery = await discoverResources(searchOptions, onProgress);
|
|
340
472
|
}
|
|
341
473
|
catch (error) {
|
|
342
|
-
|
|
474
|
+
indicator.error("Search failed");
|
|
343
475
|
throw error;
|
|
344
476
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
477
|
+
indicator.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
|
|
478
|
+
showSearchWarnings(discovery.warnings, roots.homeDir);
|
|
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");
|
|
349
485
|
if (discovery.candidates.length === 0) {
|
|
350
486
|
outro("No supported resources were found.");
|
|
351
487
|
return;
|
|
352
488
|
}
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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.`));
|
|
495
|
+
const candidateStats = await gatherCandidateStats(sortedCandidates);
|
|
496
|
+
const savedNames = new Set(initial.items.map((item) => nameKey(item.name)));
|
|
497
|
+
const candidateLabelMap = candidateLabels(sortedCandidates, homeDir, (candidate) => savedNames.has(nameKey(candidate.name)), process.stdout.columns);
|
|
498
|
+
let previousSelection = [];
|
|
499
|
+
let resolvedCandidates;
|
|
500
|
+
while (true) {
|
|
501
|
+
const selectedIds = await autocompleteMultiselect({
|
|
502
|
+
message: "Select resources to review and save as copied snapshots",
|
|
503
|
+
options: sortedCandidates.map((candidate) => {
|
|
504
|
+
const label = candidateLabelMap.get(candidate.id) + identicalCopiesSuffix(identicalCopies.get(candidate.id));
|
|
505
|
+
return { value: candidate.id, label, hint: candidateHint(candidate, candidateStats.get(candidate.id) ?? {}, homeDir, process.stdout.columns, label.length) };
|
|
506
|
+
}),
|
|
507
|
+
required: false,
|
|
508
|
+
initialValues: previousSelection,
|
|
509
|
+
placeholder: "Filter resources...",
|
|
510
|
+
});
|
|
511
|
+
if (isCancel(selectedIds)) {
|
|
512
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
previousSelection = selectedIds;
|
|
516
|
+
const outcome = await resolveSelectedNames(sortedCandidates.filter((candidate) => selectedIds.includes(candidate.id)), initial, candidateLabelMap, homeDir);
|
|
517
|
+
if (outcome === "cancel") {
|
|
518
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (outcome === "back")
|
|
522
|
+
continue;
|
|
523
|
+
resolvedCandidates = outcome;
|
|
524
|
+
break;
|
|
366
525
|
}
|
|
367
|
-
if (
|
|
526
|
+
if (resolvedCandidates.length === 0) {
|
|
368
527
|
outro("No resources selected; nothing was saved.");
|
|
369
528
|
return;
|
|
370
529
|
}
|
|
371
530
|
const catalog = await loadCatalog(libraryDir);
|
|
372
531
|
assertModernCatalog(catalog);
|
|
373
532
|
if (catalog.groups.length === 0) {
|
|
374
|
-
const name = await askForGroupName("Create the first resource group");
|
|
533
|
+
const name = await askForGroupName("Create the first resource group \u2014 used later with: copi apply <group>", "e.g. game-review");
|
|
375
534
|
if (name === undefined) {
|
|
376
535
|
cancel("Setup cancelled; no resources were saved.");
|
|
377
536
|
return;
|
|
@@ -395,12 +554,53 @@ export async function runSetup(libraryDir, roots) {
|
|
|
395
554
|
createGroup(catalog, name);
|
|
396
555
|
}
|
|
397
556
|
}
|
|
398
|
-
|
|
557
|
+
// Keep discovery order for review; renamed copies replace their originals by id.
|
|
558
|
+
const resolvedById = new Map(resolvedCandidates.map((candidate) => [candidate.id, candidate]));
|
|
559
|
+
const selectedCandidates = discovery.candidates.flatMap((candidate) => resolvedById.get(candidate.id) ?? []);
|
|
399
560
|
const approvedCandidates = [];
|
|
400
|
-
|
|
561
|
+
// Decide the assignment mode once; "bulk" assigns every approved resource to bulkGroupIds.
|
|
562
|
+
let bulkGroupIds;
|
|
563
|
+
const count = selectedCandidates.length;
|
|
564
|
+
const onlyGroup = catalog.groups.length === 1 ? catalog.groups[0] : undefined;
|
|
565
|
+
let bulk = count === 1;
|
|
566
|
+
if (count >= 2) {
|
|
567
|
+
const mode = await select({
|
|
568
|
+
message: "How should these resources be grouped?",
|
|
569
|
+
options: [
|
|
570
|
+
{ value: "all", label: onlyGroup ? `Put all ${count} resources in "${onlyGroup.name}"` : `Put all ${count} resources in the same groups` },
|
|
571
|
+
{ value: "each", label: "Choose groups for each resource" },
|
|
572
|
+
],
|
|
573
|
+
initialValue: "all",
|
|
574
|
+
});
|
|
575
|
+
if (isCancel(mode)) {
|
|
576
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
bulk = mode === "all";
|
|
580
|
+
}
|
|
581
|
+
if (bulk) {
|
|
582
|
+
if (onlyGroup)
|
|
583
|
+
bulkGroupIds = [onlyGroup.id];
|
|
584
|
+
else {
|
|
585
|
+
const chosen = await autocompleteMultiselect({
|
|
586
|
+
message: count === 1 ? `Assign ${selectedCandidates[0].name} to one or more groups` : `Put all ${count} resources in these groups`,
|
|
587
|
+
options: groupOptions(catalog.groups),
|
|
588
|
+
required: true,
|
|
589
|
+
placeholder: "Filter groups...",
|
|
590
|
+
});
|
|
591
|
+
if (isCancel(chosen)) {
|
|
592
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
bulkGroupIds = chosen;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
for (const [index, candidate] of selectedCandidates.entries()) {
|
|
401
599
|
const review = await prepareCandidateForReview(candidate);
|
|
402
|
-
|
|
403
|
-
|
|
600
|
+
const previewTitle = `Masked preview (${index + 1}/${count}): ${review.candidate.name} \u2014 ${displayPath(path.dirname(candidate.sourcePath), homeDir)}`;
|
|
601
|
+
note(review.preview.length > 0 ? review.preview.join("\n\n") : "No previewable files.", previewTitle);
|
|
602
|
+
// The heuristic caveat is stated once in the scope note and the summary; per-resource it would only repeat.
|
|
603
|
+
showWarnings(review.inspection.warnings.filter((warning) => warning.code !== "secret-detection-limited"), `${review.candidate.name} inspection warnings`);
|
|
404
604
|
if (review.inspection.excludedPaths.length > 0) {
|
|
405
605
|
note(review.inspection.excludedPaths.join("\n"), `${review.candidate.name} excluded paths`);
|
|
406
606
|
}
|
|
@@ -408,7 +608,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
408
608
|
const approvedSensitivePaths = [];
|
|
409
609
|
for (const relativePath of review.inspection.sensitivePaths) {
|
|
410
610
|
const approval = await confirm({
|
|
411
|
-
message: `Save suspected sensitive file ${relativePath} from ${review.candidate.name}? Detection is heuristic; the file content remains masked in previews.`,
|
|
611
|
+
message: `Save suspected sensitive file ${relativePath} from ${review.candidate.name}? Detection is heuristic; the file content remains masked in previews. No = ${review.candidate.name} will not be saved at all.`,
|
|
412
612
|
initialValue: false,
|
|
413
613
|
});
|
|
414
614
|
if (isCancel(approval)) {
|
|
@@ -424,9 +624,17 @@ export async function runSetup(libraryDir, roots) {
|
|
|
424
624
|
log.warn(safeDisplay(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`));
|
|
425
625
|
continue;
|
|
426
626
|
}
|
|
627
|
+
if (bulkGroupIds) {
|
|
628
|
+
approvedCandidates.push({ review, groupIds: bulkGroupIds, approvedSensitivePaths });
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const pendingCounts = new Map();
|
|
632
|
+
for (const approved of approvedCandidates)
|
|
633
|
+
for (const id of approved.groupIds)
|
|
634
|
+
pendingCounts.set(id, (pendingCounts.get(id) ?? 0) + 1);
|
|
427
635
|
const groupIds = await autocompleteMultiselect({
|
|
428
636
|
message: `Assign ${review.candidate.name} to one or more groups`,
|
|
429
|
-
options: groupOptions(catalog.groups),
|
|
637
|
+
options: groupOptions(catalog.groups, pendingCounts),
|
|
430
638
|
required: true,
|
|
431
639
|
placeholder: "Filter groups...",
|
|
432
640
|
});
|
|
@@ -444,9 +652,10 @@ export async function runSetup(libraryDir, roots) {
|
|
|
444
652
|
const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
|
|
445
653
|
return `${review.candidate.name} → ${names}`;
|
|
446
654
|
}).join("\n");
|
|
447
|
-
note(summary
|
|
655
|
+
note(`${summary}\n\nSecret detection is heuristic and cannot guarantee completeness.`, "Resources ready to copy");
|
|
656
|
+
const libraryLabel = displayPath(libraryDir, homeDir);
|
|
448
657
|
const save = await confirm({
|
|
449
|
-
message: `Save ${approvedCandidates.length} copied resource(s) in ${
|
|
658
|
+
message: `Save ${approvedCandidates.length} copied resource(s) in ${libraryLabel}? Original files will not be changed.`,
|
|
450
659
|
initialValue: false,
|
|
451
660
|
});
|
|
452
661
|
if (isCancel(save) || !save) {
|
|
@@ -469,7 +678,10 @@ export async function runSetup(libraryDir, roots) {
|
|
|
469
678
|
for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
|
|
470
679
|
await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
|
|
471
680
|
}
|
|
472
|
-
|
|
681
|
+
const usedGroupIds = new Set(approvedCandidates.flatMap(({ groupIds }) => groupIds));
|
|
682
|
+
const usedGroups = catalog.groups.filter((group) => usedGroupIds.has(group.id)).map((group) => group.name);
|
|
683
|
+
log.info(safeDisplay(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryLabel} (group: ${usedGroups.join(", ")}).`));
|
|
684
|
+
outro(`Next: copi apply ${shellQuote(usedGroups[0] ?? "<group>")} <project> --dry-run (edit or rename: copi manage)`);
|
|
473
685
|
}
|
|
474
686
|
export async function runGroupManager(libraryDir) {
|
|
475
687
|
if (await handleNameConflicts(libraryDir))
|