@papillon6814/copi 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,11 @@ copi migrate --resolve [--dry-run]
26
26
  copi help
27
27
  ```
28
28
 
29
- `setup` requires an interactive terminal. It asks which locations may be searched and asks for explicit authorization before searching. 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. The wizard then lets you choose individual resources, review masked previews, assign each saved resource to one or more groups, and confirm before anything is copied into the library. Declining authorization performs no search.
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.
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
 
@@ -117,6 +117,10 @@ async function canonicalDirectory(directory) {
117
117
  return undefined;
118
118
  }
119
119
  }
120
+ /** False only when the entry itself does not exist (lstat ENOENT); dangling symlinks count as existing. */
121
+ async function exists(target) {
122
+ return lstat(target).then(() => true, (error) => error.code !== "ENOENT");
123
+ }
120
124
  export async function discoverResources(options) {
121
125
  const scopes = options.scopes ?? inferSearchScopes(options);
122
126
  if (!scopes.home && !scopes.project)
@@ -143,6 +147,8 @@ export async function discoverResources(options) {
143
147
  if (project)
144
148
  roots.push({ path: project, scope: "project", source: "project", destinationRoot: ".pi/skills", destinationKind: "project" });
145
149
  const addCandidate = async (kind, root, sourcePath, name, scope, source, destination) => {
150
+ if (!(await exists(sourcePath)))
151
+ return;
146
152
  const canonicalSource = await realpath(sourcePath).catch(() => undefined);
147
153
  const canonicalRoot = await realpath(root).catch(() => undefined);
148
154
  if (!canonicalSource || !canonicalRoot || !isWithin(canonicalRoot, canonicalSource)) {
@@ -209,6 +215,8 @@ export async function discoverResources(options) {
209
215
  };
210
216
  const canonicalStart = await realpath(startPath).catch(() => undefined);
211
217
  if (!canonicalStart || !isWithin(canonicalRoot, canonicalStart)) {
218
+ if (!(await exists(startPath)))
219
+ return;
212
220
  addWarning(warnings, "resource-outside-root", startPath, "A configured resource root resolves outside its allowed search root.");
213
221
  return;
214
222
  }
@@ -0,0 +1,43 @@
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;
@@ -0,0 +1,195 @@
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { nameKey } from "./names.js";
4
+ /** Replace a leading home directory with `~` (only on a path-segment boundary). */
5
+ export function displayPath(p, home) {
6
+ if (!home)
7
+ return p;
8
+ const base = home.length > 1 && home.endsWith(path.sep) ? home.slice(0, -1) : home;
9
+ if (p === base)
10
+ return "~";
11
+ if (p.startsWith(base + path.sep))
12
+ return `~${p.slice(base.length)}`;
13
+ return p;
14
+ }
15
+ /** Keep head and tail joined by `…`; return `s` unchanged when `max` is missing, tiny, or not smaller than the length. */
16
+ export function truncateMiddle(s, max) {
17
+ if (max === undefined || !Number.isFinite(max) || max < 4 || s.length <= max)
18
+ return s;
19
+ const keep = max - 1;
20
+ const head = Math.ceil(keep / 2);
21
+ const tail = keep - head;
22
+ return `${s.slice(0, head)}…${tail > 0 ? s.slice(s.length - tail) : ""}`;
23
+ }
24
+ export function formatBytes(n) {
25
+ if (n < 1024)
26
+ return `${n} B`;
27
+ if (n < 1024 * 1024)
28
+ return `${(n / 1024).toFixed(1)} KB`;
29
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
30
+ }
31
+ const outsideRootPreview = 5;
32
+ /** Summarize skipped out-of-root resources into one note (undefined when none). */
33
+ export function formatOutsideRootSummary(paths, home) {
34
+ if (paths.length === 0)
35
+ return undefined;
36
+ const lines = paths.slice(0, outsideRootPreview).map((p) => displayPath(p, home));
37
+ if (paths.length > outsideRootPreview)
38
+ lines.push(`+${paths.length - outsideRootPreview} more`);
39
+ return { title: `Skipped ${paths.length} resource(s) outside the selected locations`, body: lines.join("\n") };
40
+ }
41
+ /**
42
+ * `[kind] name — ~/source/dir`, plus `(already saved)`. Shared with conflict-resolution options.
43
+ * When `columns` is given the source directory is middle-truncated (tail kept) so the row does not wrap.
44
+ */
45
+ export function candidateLabel(c, home, alreadySaved, columns) {
46
+ const head = `[${c.kind}] ${c.name} \u2014 `;
47
+ const tail = alreadySaved ? " (already saved)" : "";
48
+ let dir = displayPath(path.dirname(c.sourcePath), home);
49
+ if (columns !== undefined && Number.isFinite(columns))
50
+ dir = truncateMiddle(dir, Math.max(columns - head.length - tail.length - 6, 8));
51
+ return `${head}${dir}${tail}`;
52
+ }
53
+ /**
54
+ * Labels for a whole list. Rows sharing kind + nameKey must stay distinguishable: when truncation makes their
55
+ * source labels collide, that group falls back to whole trailing segments (`…/a/b`), widened until they differ.
56
+ */
57
+ export function candidateLabels(list, home, isSaved, columns) {
58
+ const out = new Map();
59
+ const groups = new Map();
60
+ for (const c of list) {
61
+ out.set(c.id, candidateLabel(c, home, isSaved(c), columns));
62
+ const key = `${c.kind}\0${nameKey(c.name)}`;
63
+ groups.set(key, [...(groups.get(key) ?? []), c]);
64
+ }
65
+ for (const group of groups.values()) {
66
+ if (group.length < 2)
67
+ continue;
68
+ const labels = group.map((c) => out.get(c.id));
69
+ if (new Set(labels).size === labels.length)
70
+ continue;
71
+ const dirs = group.map((c) => displayPath(path.dirname(c.sourcePath), home));
72
+ const segs = dirs.map((d) => d.split(path.sep));
73
+ const max = Math.max(...segs.map((x) => x.length));
74
+ for (let k = 1; k <= max; k += 1) {
75
+ const cut = segs.map((x, i) => (x.length <= k ? dirs[i] : `\u2026${path.sep}${x.slice(-k).join(path.sep)}`));
76
+ const rendered = group.map((c, i) => `[${c.kind}] ${c.name} \u2014 ${cut[i]}${isSaved(c) ? " (already saved)" : ""}`);
77
+ if (new Set(rendered).size === rendered.length || k === max) {
78
+ group.forEach((c, i) => out.set(c.id, rendered[i]));
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+ /** Sort by kind, then nameKey, then source path (new array). */
86
+ export function sortCandidates(list) {
87
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
88
+ return [...list].sort((a, b) => cmp(a.kind, b.kind) || cmp(nameKey(a.name), nameKey(b.name)) || cmp(a.sourcePath, b.sourcePath));
89
+ }
90
+ export function formatLocalDate(d) {
91
+ const p = (n) => String(n).padStart(2, "0");
92
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
93
+ }
94
+ /** `<size> · modified <date> · <destination>`; missing parts are omitted. Truncated to the terminal width minus label and prompt chrome. */
95
+ export function candidateHint(c, stats, home, columns, labelLength) {
96
+ const parts = [];
97
+ if (stats.size !== undefined)
98
+ parts.push(stats.size);
99
+ if (stats.mtime !== undefined)
100
+ parts.push(`modified ${formatLocalDate(stats.mtime)}`);
101
+ parts.push(c.destination.kind === "global-only" ? "save only" : displayPath(c.destination.path, home));
102
+ const hint = parts.join(" · ");
103
+ if (columns === undefined || !Number.isFinite(columns))
104
+ return hint;
105
+ return truncateMiddle(hint, Math.max(columns - labelLength - 10, 12));
106
+ }
107
+ const countCap = 1000;
108
+ async function countFiles(dir, budget) {
109
+ let entries;
110
+ try {
111
+ entries = await readdir(dir, { withFileTypes: true });
112
+ }
113
+ catch {
114
+ return;
115
+ }
116
+ for (const entry of entries) {
117
+ if (budget.left <= 0)
118
+ return;
119
+ if (entry.isDirectory())
120
+ await countFiles(path.join(dir, entry.name), budget);
121
+ else if (entry.isFile())
122
+ budget.left -= 1;
123
+ }
124
+ }
125
+ async function hasMoreFiles(dir, cap) {
126
+ const budget = { left: cap + 1 };
127
+ await countFiles(dir, budget);
128
+ return budget.left <= 0;
129
+ }
130
+ async function statOne(c) {
131
+ try {
132
+ const st = await lstat(c.sourcePath);
133
+ if (st.isDirectory()) {
134
+ const budget = { left: countCap };
135
+ await countFiles(c.sourcePath, budget);
136
+ const n = countCap - budget.left;
137
+ return { size: budget.left <= 0 && (await hasMoreFiles(c.sourcePath, countCap)) ? `${countCap}+ files` : `${n} file${n === 1 ? "" : "s"}`, mtime: st.mtime };
138
+ }
139
+ return { size: formatBytes(st.size), mtime: st.mtime };
140
+ }
141
+ catch {
142
+ return {};
143
+ }
144
+ }
145
+ /** Cheap, never-throwing stats for the shown candidates, gathered in parallel. */
146
+ export async function gatherCandidateStats(list) {
147
+ const entries = await Promise.all(list.map(async (c) => [c.id, await statOne(c)]));
148
+ return new Map(entries);
149
+ }
150
+ /** Groups of 2+ selected candidates sharing a `nameKey` (any kind), each in input order; groups ordered by first member. */
151
+ export function findSelectionConflicts(list) {
152
+ const groups = new Map();
153
+ for (const c of list) {
154
+ const key = nameKey(c.name);
155
+ groups.set(key, [...(groups.get(key) ?? []), c]);
156
+ }
157
+ return [...groups.values()].filter((group) => group.length > 1);
158
+ }
159
+ /**
160
+ * Suggested rename `<name> (<segment>)`. Within a conflict group the segment is the nearest ancestor directory
161
+ * (walking up from the source's parent) whose name tells this member apart from the others — unique if possible,
162
+ * otherwise the first level where the members differ at all. Without such a level (or alone), the segment is the
163
+ * grandparent of the source, falling back to its parent, e.g. `jandel.md (agent-rbx)`.
164
+ */
165
+ export function defaultRenameName(c, group = [c]) {
166
+ const ancestors = (x) => {
167
+ const out = [];
168
+ for (let dir = path.dirname(x.sourcePath); path.basename(dir); dir = path.dirname(dir))
169
+ out.push(path.basename(dir));
170
+ return out;
171
+ };
172
+ const mine = ancestors(c);
173
+ const others = group.filter((x) => x.id !== c.id).map(ancestors);
174
+ let segment;
175
+ if (others.length > 0) {
176
+ const unique = mine.findIndex((name, level) => others.every((other) => other[level] !== name));
177
+ const differing = mine.findIndex((name, level) => others.some((other) => other[level] !== name));
178
+ const level = unique !== -1 ? unique : differing;
179
+ if (level !== -1)
180
+ segment = mine[level];
181
+ }
182
+ segment ??= mine[1] ?? mine[0];
183
+ return segment ? `${c.name} (${segment})` : c.name;
184
+ }
185
+ /** Validation message for a new display name, or undefined when it is non-empty and its `nameKey` is not taken. */
186
+ export function pendingNameError(name, taken) {
187
+ let key;
188
+ try {
189
+ key = nameKey(name ?? "");
190
+ }
191
+ catch {
192
+ return "Enter a non-empty display name.";
193
+ }
194
+ return taken.has(key) ? "That name is already used by a saved or selected resource; choose another." : undefined;
195
+ }
@@ -30,6 +30,14 @@ export declare function prepareCandidateForReview(candidate: ResourceCandidate):
30
30
  export declare function saveReviewedCandidate(libraryDir: string, review: CandidateReview, groupIds: string[], approvedSensitivePaths: string[], confirmed: boolean): Promise<SavedItem | null>;
31
31
  /** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
32
32
  export declare function resolveNameConflicts(libraryDir: string): Promise<void>;
33
+ /**
34
+ * Rename prompt text. The suggestion is prefilled only when it renders unchanged through `safeDisplay`
35
+ * (`initialValue` is echoed raw); otherwise the field stays empty and the masked suggestion is shown in the message.
36
+ */
37
+ export declare function renamePrompt(name: string, sourceDir: string, suggestion: string): {
38
+ message: string;
39
+ initialValue?: string;
40
+ };
33
41
  export declare function runSetup(libraryDir: string, roots: SearchRoots): Promise<void>;
34
42
  export declare function runGroupManager(libraryDir: string): Promise<void>;
35
43
  export declare function printGroups(catalog: StoredCatalog): void;
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { assertModernCatalog, importPreparedResource, loadCatalog, prepareResource, readPreparedResource, readSnapshotFiles, saveCatalog, saveNameChanges } from "./catalog.js";
5
5
  import { discoverResources } 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
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";
@@ -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) => ({ value: group.id, label: group.name, hint: `${group.itemIds.length} saved resources` }));
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,35 +404,34 @@ export async function runSetup(libraryDir, roots) {
292
404
  assertModernCatalog(initial);
293
405
  assertNoNameConflicts(initial);
294
406
  intro("copi setup");
295
- const scopeOptions = [];
296
- if (roots.homeDir) {
297
- scopeOptions.push({
298
- value: "home",
299
- label: `Search entire HOME: ${roots.homeDir}`,
300
- hint: `Includes supported Pi resources under HOME and Pi agent directory ${roots.agentDir}`,
301
- });
302
- }
303
- scopeOptions.push({ value: "project", label: `Search current project: ${roots.projectDir}`, hint: "Supported Pi resources and instruction files in this project" });
304
- const selectedScopes = await autocompleteMultiselect({
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: "home", label: "HOME and Pi agent directory \u2014 ~ (can take a minute or more)" });
415
+ if (!projectIsHome)
416
+ locationOptions.push({ value: "project", label: `This project only \u2014 ${projectLabel} (fast)` });
417
+ if (homeDir && !projectIsHome && !projectInsideHome)
418
+ locationOptions.push({ value: "both", label: "Both" });
419
+ locationOptions.push({ value: "cancel", label: "Cancel" });
420
+ const choice = await select({
305
421
  message: "Choose the locations copi may inspect",
306
- options: scopeOptions,
307
- required: false,
308
- placeholder: "Search locations...",
422
+ options: locationOptions,
423
+ initialValue: locationOptions[0].value,
309
424
  });
310
- if (isCancel(selectedScopes)) {
425
+ if (isCancel(choice) || choice === "cancel") {
311
426
  cancel("Setup cancelled.");
312
427
  return;
313
428
  }
314
- const scopes = { home: selectedScopes.includes("home"), project: selectedScopes.includes("project") };
315
- const searchOptions = buildSearchOptions(roots, scopes);
316
- if (!searchOptions) {
317
- outro("No locations selected; nothing was searched or saved.");
318
- return;
319
- }
429
+ const scopes = { home: choice === "home" || choice === "both", project: choice === "project" || choice === "both" };
430
+ const searchOptions = buildSearchOptions(roots, scopes); // every non-cancel choice enables at least one scope
320
431
  note([
321
- `HOME: ${scopes.home ? roots.homeDir : "not selected"}`,
322
- `Pi agent directory: ${scopes.home ? roots.agentDir : "not selected"}`,
323
- `Project: ${scopes.project ? roots.projectDir : "not selected"}`,
432
+ `HOME: ${scopes.home ? displayPath(roots.homeDir, homeDir) : "not selected"}`,
433
+ `Pi agent directory: ${scopes.home ? displayPath(roots.agentDir, homeDir) : "not selected"}`,
434
+ `Project: ${scopes.project ? projectLabel : "not selected"}`,
324
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.",
325
436
  "Secret detection is heuristic and cannot guarantee completeness. Preview text is masked by default.",
326
437
  ].join("\n"), "Search scope and exclusions");
@@ -343,35 +454,53 @@ export async function runSetup(libraryDir, roots) {
343
454
  throw error;
344
455
  }
345
456
  progress.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
346
- showWarnings(discovery.warnings, "Search warnings");
457
+ showSearchWarnings(discovery.warnings, roots.homeDir);
347
458
  if (discovery.excludedPaths.length > 0)
348
- note(discovery.excludedPaths.join("\n"), "Excluded paths");
459
+ note(discovery.excludedPaths.map((excluded) => displayPath(excluded, homeDir)).join("\n"), "Excluded paths");
349
460
  if (discovery.candidates.length === 0) {
350
461
  outro("No supported resources were found.");
351
462
  return;
352
463
  }
353
- const selectedIds = await autocompleteMultiselect({
354
- message: "Select resources to review and save as copied snapshots",
355
- options: discovery.candidates.map((candidate) => ({
356
- value: candidate.id,
357
- label: `[${candidate.kind}] ${candidate.name}`,
358
- hint: `${candidate.scope} · ${candidate.sourcePath} · ${candidate.destination.kind === "global-only" ? "save only" : candidate.destination.path}`,
359
- })),
360
- required: false,
361
- placeholder: "Filter resources...",
362
- });
363
- if (isCancel(selectedIds)) {
364
- cancel("Setup cancelled; no resources were saved.");
365
- return;
464
+ const sortedCandidates = sortCandidates(discovery.candidates);
465
+ const candidateStats = await gatherCandidateStats(sortedCandidates);
466
+ const savedNames = new Set(initial.items.map((item) => nameKey(item.name)));
467
+ const candidateLabelMap = candidateLabels(sortedCandidates, homeDir, (candidate) => savedNames.has(nameKey(candidate.name)), process.stdout.columns);
468
+ let previousSelection = [];
469
+ let resolvedCandidates;
470
+ while (true) {
471
+ const selectedIds = await autocompleteMultiselect({
472
+ message: "Select resources to review and save as copied snapshots",
473
+ options: sortedCandidates.map((candidate) => {
474
+ const label = candidateLabelMap.get(candidate.id);
475
+ return { value: candidate.id, label, hint: candidateHint(candidate, candidateStats.get(candidate.id) ?? {}, homeDir, process.stdout.columns, label.length) };
476
+ }),
477
+ required: false,
478
+ initialValues: previousSelection,
479
+ placeholder: "Filter resources...",
480
+ });
481
+ if (isCancel(selectedIds)) {
482
+ cancel("Setup cancelled; no resources were saved.");
483
+ return;
484
+ }
485
+ previousSelection = selectedIds;
486
+ const outcome = await resolveSelectedNames(sortedCandidates.filter((candidate) => selectedIds.includes(candidate.id)), initial, candidateLabelMap, homeDir);
487
+ if (outcome === "cancel") {
488
+ cancel("Setup cancelled; no resources were saved.");
489
+ return;
490
+ }
491
+ if (outcome === "back")
492
+ continue;
493
+ resolvedCandidates = outcome;
494
+ break;
366
495
  }
367
- if (selectedIds.length === 0) {
496
+ if (resolvedCandidates.length === 0) {
368
497
  outro("No resources selected; nothing was saved.");
369
498
  return;
370
499
  }
371
500
  const catalog = await loadCatalog(libraryDir);
372
501
  assertModernCatalog(catalog);
373
502
  if (catalog.groups.length === 0) {
374
- const name = await askForGroupName("Create the first resource group");
503
+ const name = await askForGroupName("Create the first resource group \u2014 used later with: copi apply <group>", "e.g. game-review");
375
504
  if (name === undefined) {
376
505
  cancel("Setup cancelled; no resources were saved.");
377
506
  return;
@@ -395,12 +524,53 @@ export async function runSetup(libraryDir, roots) {
395
524
  createGroup(catalog, name);
396
525
  }
397
526
  }
398
- const selectedCandidates = discovery.candidates.filter((candidate) => selectedIds.includes(candidate.id));
527
+ // Keep discovery order for review; renamed copies replace their originals by id.
528
+ const resolvedById = new Map(resolvedCandidates.map((candidate) => [candidate.id, candidate]));
529
+ const selectedCandidates = discovery.candidates.flatMap((candidate) => resolvedById.get(candidate.id) ?? []);
399
530
  const approvedCandidates = [];
400
- for (const candidate of selectedCandidates) {
531
+ // Decide the assignment mode once; "bulk" assigns every approved resource to bulkGroupIds.
532
+ let bulkGroupIds;
533
+ const count = selectedCandidates.length;
534
+ const onlyGroup = catalog.groups.length === 1 ? catalog.groups[0] : undefined;
535
+ let bulk = count === 1;
536
+ if (count >= 2) {
537
+ const mode = await select({
538
+ message: "How should these resources be grouped?",
539
+ options: [
540
+ { value: "all", label: onlyGroup ? `Put all ${count} resources in "${onlyGroup.name}"` : `Put all ${count} resources in the same groups` },
541
+ { value: "each", label: "Choose groups for each resource" },
542
+ ],
543
+ initialValue: "all",
544
+ });
545
+ if (isCancel(mode)) {
546
+ cancel("Setup cancelled; no resources were saved.");
547
+ return;
548
+ }
549
+ bulk = mode === "all";
550
+ }
551
+ if (bulk) {
552
+ if (onlyGroup)
553
+ bulkGroupIds = [onlyGroup.id];
554
+ else {
555
+ const chosen = await autocompleteMultiselect({
556
+ message: count === 1 ? `Assign ${selectedCandidates[0].name} to one or more groups` : `Put all ${count} resources in these groups`,
557
+ options: groupOptions(catalog.groups),
558
+ required: true,
559
+ placeholder: "Filter groups...",
560
+ });
561
+ if (isCancel(chosen)) {
562
+ cancel("Setup cancelled; no resources were saved.");
563
+ return;
564
+ }
565
+ bulkGroupIds = chosen;
566
+ }
567
+ }
568
+ for (const [index, candidate] of selectedCandidates.entries()) {
401
569
  const review = await prepareCandidateForReview(candidate);
402
- note(review.preview.length > 0 ? review.preview.join("\n\n") : "No previewable files.", `Masked preview: ${review.candidate.name}`);
403
- showWarnings(review.inspection.warnings, `${review.candidate.name} inspection warnings`);
570
+ const previewTitle = `Masked preview (${index + 1}/${count}): ${review.candidate.name} \u2014 ${displayPath(path.dirname(candidate.sourcePath), homeDir)}`;
571
+ note(review.preview.length > 0 ? review.preview.join("\n\n") : "No previewable files.", previewTitle);
572
+ // The heuristic caveat is stated once in the scope note and the summary; per-resource it would only repeat.
573
+ showWarnings(review.inspection.warnings.filter((warning) => warning.code !== "secret-detection-limited"), `${review.candidate.name} inspection warnings`);
404
574
  if (review.inspection.excludedPaths.length > 0) {
405
575
  note(review.inspection.excludedPaths.join("\n"), `${review.candidate.name} excluded paths`);
406
576
  }
@@ -408,7 +578,7 @@ export async function runSetup(libraryDir, roots) {
408
578
  const approvedSensitivePaths = [];
409
579
  for (const relativePath of review.inspection.sensitivePaths) {
410
580
  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.`,
581
+ 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
582
  initialValue: false,
413
583
  });
414
584
  if (isCancel(approval)) {
@@ -424,9 +594,17 @@ export async function runSetup(libraryDir, roots) {
424
594
  log.warn(safeDisplay(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`));
425
595
  continue;
426
596
  }
597
+ if (bulkGroupIds) {
598
+ approvedCandidates.push({ review, groupIds: bulkGroupIds, approvedSensitivePaths });
599
+ continue;
600
+ }
601
+ const pendingCounts = new Map();
602
+ for (const approved of approvedCandidates)
603
+ for (const id of approved.groupIds)
604
+ pendingCounts.set(id, (pendingCounts.get(id) ?? 0) + 1);
427
605
  const groupIds = await autocompleteMultiselect({
428
606
  message: `Assign ${review.candidate.name} to one or more groups`,
429
- options: groupOptions(catalog.groups),
607
+ options: groupOptions(catalog.groups, pendingCounts),
430
608
  required: true,
431
609
  placeholder: "Filter groups...",
432
610
  });
@@ -444,9 +622,10 @@ export async function runSetup(libraryDir, roots) {
444
622
  const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
445
623
  return `${review.candidate.name} → ${names}`;
446
624
  }).join("\n");
447
- note(summary, "Resources ready to copy");
625
+ note(`${summary}\n\nSecret detection is heuristic and cannot guarantee completeness.`, "Resources ready to copy");
626
+ const libraryLabel = displayPath(libraryDir, homeDir);
448
627
  const save = await confirm({
449
- message: `Save ${approvedCandidates.length} copied resource(s) in ${libraryDir}? Original files will not be changed.`,
628
+ message: `Save ${approvedCandidates.length} copied resource(s) in ${libraryLabel}? Original files will not be changed.`,
450
629
  initialValue: false,
451
630
  });
452
631
  if (isCancel(save) || !save) {
@@ -469,7 +648,10 @@ export async function runSetup(libraryDir, roots) {
469
648
  for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
470
649
  await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
471
650
  }
472
- outro(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryDir}.`);
651
+ const usedGroupIds = new Set(approvedCandidates.flatMap(({ groupIds }) => groupIds));
652
+ const usedGroups = catalog.groups.filter((group) => usedGroupIds.has(group.id)).map((group) => group.name);
653
+ log.info(safeDisplay(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryLabel} (group: ${usedGroups.join(", ")}).`));
654
+ outro(`Next: copi apply ${shellQuote(usedGroups[0] ?? "<group>")} <project> --dry-run (edit or rename: copi manage)`);
473
655
  }
474
656
  export async function runGroupManager(libraryDir) {
475
657
  if (await handleNameConflicts(libraryDir))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papillon6814/copi",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Collect and organize reusable Pi coding-agent resources",
5
5
  "license": "MIT",
6
6
  "type": "module",