@papillon6814/copi 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE.unicode +39 -0
  2. package/README.md +57 -5
  3. package/dist/native/rename-exclusive-arm64 +0 -0
  4. package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
  5. package/dist/src/apply.d.ts +9 -0
  6. package/dist/src/apply.js +110 -9
  7. package/dist/src/catalog.d.ts +21 -4
  8. package/dist/src/catalog.js +265 -133
  9. package/dist/src/cli.d.ts +2 -1
  10. package/dist/src/cli.js +73 -3
  11. package/dist/src/groups.js +8 -3
  12. package/dist/src/inspect.d.ts +3 -1
  13. package/dist/src/inspect.js +16 -6
  14. package/dist/src/instruction-reference.d.ts +6 -0
  15. package/dist/src/instruction-reference.js +21 -0
  16. package/dist/src/manage.d.ts +2 -0
  17. package/dist/src/manage.js +329 -0
  18. package/dist/src/migrate.d.ts +25 -0
  19. package/dist/src/migrate.js +171 -0
  20. package/dist/src/migration-backup.d.ts +22 -0
  21. package/dist/src/migration-backup.js +403 -0
  22. package/dist/src/migration-inventory.d.ts +9 -0
  23. package/dist/src/migration-inventory.js +237 -0
  24. package/dist/src/migration-resolution.d.ts +28 -0
  25. package/dist/src/migration-resolution.js +1618 -0
  26. package/dist/src/migration.d.ts +49 -0
  27. package/dist/src/migration.js +1004 -0
  28. package/dist/src/names.d.ts +11 -0
  29. package/dist/src/names.js +40 -0
  30. package/dist/src/native-placement.d.ts +16 -0
  31. package/dist/src/native-placement.js +557 -0
  32. package/dist/src/refresh.d.ts +20 -0
  33. package/dist/src/refresh.js +143 -0
  34. package/dist/src/resource-mutations.d.ts +45 -0
  35. package/dist/src/resource-mutations.js +225 -0
  36. package/dist/src/source-content.d.ts +4 -0
  37. package/dist/src/source-content.js +208 -0
  38. package/dist/src/storage-journal.d.ts +111 -0
  39. package/dist/src/storage-journal.js +613 -0
  40. package/dist/src/storage-root-lease.d.ts +7 -0
  41. package/dist/src/storage-root-lease.js +92 -0
  42. package/dist/src/storage-transaction.d.ts +100 -0
  43. package/dist/src/storage-transaction.js +3951 -0
  44. package/dist/src/terminal-editor.d.ts +8 -0
  45. package/dist/src/terminal-editor.js +391 -0
  46. package/dist/src/types.d.ts +18 -2
  47. package/dist/src/unicode-case-folding-data.d.ts +1 -0
  48. package/dist/src/unicode-case-folding-data.js +1596 -0
  49. package/dist/src/unicode-case-folding.d.ts +2 -0
  50. package/dist/src/unicode-case-folding.js +9 -0
  51. package/dist/src/wizard.d.ts +12 -3
  52. package/dist/src/wizard.js +287 -19
  53. package/package.json +5 -3
@@ -0,0 +1,2 @@
1
+ /** Unicode 17.0.0 DEFAULT FULL case folding, independent of locale. */
2
+ export declare function foldCaseUnicode17(value: string): string;
@@ -0,0 +1,9 @@
1
+ import { unicode17CaseFolding } from "./unicode-case-folding-data.js";
2
+ /** Unicode 17.0.0 DEFAULT FULL case folding, independent of locale. */
3
+ export function foldCaseUnicode17(value) {
4
+ let result = "";
5
+ for (const character of value) {
6
+ result += unicode17CaseFolding.get(character.codePointAt(0)) ?? character;
7
+ }
8
+ return result;
9
+ }
@@ -1,7 +1,14 @@
1
1
  import { type PreparedInspection, type PreparedResource } from "./catalog.ts";
2
2
  import { type DiscoveryOptions } from "./discovery.ts";
3
3
  import type { ApplyFileOption } from "./apply.ts";
4
- import type { Catalog, ResourceCandidate, SavedItem } from "./types.ts";
4
+ import type { LegacySavedItem, ResourceCandidate, SavedItem, StoredCatalog } from "./types.ts";
5
+ /** Mask display metadata too, and render controls literally rather than executing VT sequences. */
6
+ export declare function safeDisplay(value: string): string;
7
+ /** The editor parses literal paste versus cancel keys; stream EOF/errors/signals stay global. */
8
+ export declare function withEditorInput<T>(operation: () => Promise<T>): Promise<T>;
9
+ export declare const multiselectKeyHint = "(Tab: select, Enter: confirm)";
10
+ /** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
11
+ export declare function withWizardLifecycle(operation: (signal: AbortSignal) => Promise<void>): Promise<void>;
5
12
  export interface SearchRoots {
6
13
  homeDir: string;
7
14
  agentDir: string;
@@ -21,10 +28,12 @@ export interface CandidateReview {
21
28
  }
22
29
  export declare function prepareCandidateForReview(candidate: ResourceCandidate): Promise<CandidateReview>;
23
30
  export declare function saveReviewedCandidate(libraryDir: string, review: CandidateReview, groupIds: string[], approvedSensitivePaths: string[], confirmed: boolean): Promise<SavedItem | null>;
31
+ /** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
32
+ export declare function resolveNameConflicts(libraryDir: string): Promise<void>;
24
33
  export declare function runSetup(libraryDir: string, roots: SearchRoots): Promise<void>;
25
34
  export declare function runGroupManager(libraryDir: string): Promise<void>;
26
- export declare function printGroups(catalog: Catalog): void;
27
- export declare function printItems(items: SavedItem[]): void;
35
+ export declare function printGroups(catalog: StoredCatalog): void;
36
+ export declare function printItems(items: Array<LegacySavedItem | SavedItem>): void;
28
37
  export declare function formatApplyOptionPreview(filePath: string, resourceName: string, option: ApplyFileOption): string;
29
38
  export declare function runApplyDryRun(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
30
39
  export declare function runApplyWizard(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
@@ -1,11 +1,90 @@
1
- import { autocompleteMultiselect, cancel, confirm, intro, isCancel, log, note, outro, select, text } 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, 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
- import { importPreparedResource, loadCatalog, prepareResource, readPreparedResource, saveCatalog } from "./catalog.js";
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
7
  import { inspectPreparedResource, maskSensitiveText } from "./inspect.js";
8
+ import { assertNameAvailable, assertNoNameConflicts, findNameConflicts, nameKey } from "./names.js";
8
9
  import { executeApply, planApply } from "./apply.js";
10
+ /** Mask display metadata too, and render controls literally rather than executing VT sequences. */
11
+ export function safeDisplay(value) {
12
+ return maskSensitiveText(value)
13
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "[internal reference]")
14
+ .replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
15
+ }
16
+ let activeSignal;
17
+ let editorOwnsControlInput = false;
18
+ /** The editor parses literal paste versus cancel keys; stream EOF/errors/signals stay global. */
19
+ export async function withEditorInput(operation) {
20
+ const previous = editorOwnsControlInput;
21
+ editorOwnsControlInput = true;
22
+ try {
23
+ return await operation();
24
+ }
25
+ finally {
26
+ editorOwnsControlInput = previous;
27
+ }
28
+ }
29
+ function inheritedSignal(signal) {
30
+ const current = signal ?? activeSignal;
31
+ return current ? { signal: current } : {};
32
+ }
33
+ const select = (options) => clackSelect({
34
+ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message),
35
+ options: options.options.map((option) => ({ ...option, label: safeDisplay(option.label ?? "Unavailable entry"), ...(option.hint === undefined ? {} : { hint: safeDisplay(option.hint) }) })),
36
+ });
37
+ // Clack's own key legend is dim and below the list; repeat it in the question so Tab-to-select is not missed.
38
+ export const multiselectKeyHint = "(Tab: select, Enter: confirm)";
39
+ const autocompleteMultiselect = (options) => clackMultiselect({
40
+ ...options, ...inheritedSignal(options.signal), message: `${safeDisplay(options.message)} ${multiselectKeyHint}`,
41
+ options: typeof options.options === "function" ? options.options : options.options.map((option) => ({ ...option, label: safeDisplay(option.label ?? "Unavailable entry"), ...(option.hint === undefined ? {} : { hint: safeDisplay(option.hint) }) })),
42
+ });
43
+ const text = (options) => clackText({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
44
+ const confirm = (options) => clackConfirm({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
45
+ const note = (message, title, options) => clackNote(safeDisplay(message ?? ""), safeDisplay(title ?? ""), options);
46
+ const intro = (message, options) => clackIntro(safeDisplay(message ?? ""), options);
47
+ const outro = (message, options) => clackOutro(safeDisplay(message ?? ""), options);
48
+ const cancel = (message, options) => clackCancel(safeDisplay(message ?? ""), options);
49
+ /** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
50
+ export async function withWizardLifecycle(operation) {
51
+ if (activeSignal)
52
+ throw new Error("An interactive wizard is already active.");
53
+ const controller = new AbortController();
54
+ const wasRaw = Boolean(process.stdin.isRaw), wasFlowing = process.stdin.readableFlowing === true;
55
+ const abort = () => controller.abort();
56
+ const eofKey = (chunk) => {
57
+ if (!editorOwnsControlInput && (typeof chunk === "string" ? chunk.includes("\u0004") : chunk.includes(4)))
58
+ abort();
59
+ };
60
+ activeSignal = controller.signal;
61
+ process.stdin.on("end", abort);
62
+ process.stdin.on("close", abort);
63
+ process.stdin.on("error", abort);
64
+ process.stdin.on("data", eofKey);
65
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
66
+ process.on(signal, abort);
67
+ try {
68
+ await operation(controller.signal);
69
+ }
70
+ finally {
71
+ activeSignal = undefined;
72
+ process.stdin.off("end", abort);
73
+ process.stdin.off("close", abort);
74
+ process.stdin.off("error", abort);
75
+ process.stdin.off("data", eofKey);
76
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
77
+ process.off(signal, abort);
78
+ if (process.stdin.isTTY)
79
+ process.stdin.setRawMode(wasRaw);
80
+ // Adding our data listener starts an inactive reader too; restore its original ownership.
81
+ if (wasFlowing)
82
+ process.stdin.resume();
83
+ else
84
+ process.stdin.pause();
85
+ process.stdout.write("\u001b[?25h");
86
+ }
87
+ }
9
88
  const previewMaxBytes = 1_000_000;
10
89
  const previewMaxFiles = 12;
11
90
  const previewMaxLines = 24;
@@ -22,7 +101,7 @@ export function formatMaskedPreview(relativePath, content) {
22
101
  const lines = masked.split(/\r?\n/);
23
102
  const snippet = lines.slice(0, previewMaxLines).join("\n").slice(0, 2_000);
24
103
  const omitted = lines.length > previewMaxLines ? `\n… ${lines.length - previewMaxLines} more lines` : "";
25
- return `${relativePath}:\n${snippet}${omitted}`;
104
+ return safeDisplay(`${relativePath}:\n${snippet}${omitted}`);
26
105
  }
27
106
  function formatPreparedFiles(files) {
28
107
  return files.slice(0, previewMaxFiles).map((file) => {
@@ -59,17 +138,159 @@ function showWarnings(warnings, title) {
59
138
  return;
60
139
  note(warnings.map((warning) => `${warning.path ? `${warning.path}: ` : ""}${warning.message}`).join("\n"), title);
61
140
  }
141
+ function savedItemHint(catalog, item) {
142
+ const groups = catalog.groups.filter((group) => group.itemIds.includes(item.id)).map((group) => group.name).join(", ") || "no groups";
143
+ return `${groups} · ${item.source} · ${item.destination.kind === "project" ? item.destination.path : "saved only"}`;
144
+ }
145
+ async function viewSavedEntry(libraryDir) {
146
+ const catalog = await loadCatalog(libraryDir);
147
+ const entry = await select({
148
+ message: "Select saved resource or group",
149
+ options: [
150
+ ...catalog.items.map((item) => ({ value: `item:${item.id}`, label: `[${item.kind}] ${item.name}`, hint: savedItemHint(catalog, item) })),
151
+ ...catalog.groups.map((group) => ({ value: `group:${group.id}`, label: `[group] ${group.name}`, hint: `${group.itemIds.length} resources` })),
152
+ ],
153
+ });
154
+ if (isCancel(entry))
155
+ return;
156
+ const item = catalog.items.find((item) => `item:${item.id}` === entry);
157
+ if (item) {
158
+ const files = await readSnapshotFiles(libraryDir, item);
159
+ note(formatPreparedFiles(files).join("\n\n") || "No previewable files.", `Masked saved preview: ${item.name}`);
160
+ }
161
+ else {
162
+ const group = catalog.groups.find((group) => `group:${group.id}` === entry);
163
+ if (!group)
164
+ throw new Error("Selected group is no longer available.");
165
+ note(group.itemIds.map((id) => {
166
+ const member = catalog.items.find((item) => item.id === id);
167
+ return member ? `[${member.kind}] ${member.name} · ${savedItemHint(catalog, member)}` : "Unavailable resource";
168
+ }).join("\n") || "No saved resources.", group.name);
169
+ }
170
+ }
171
+ /** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
172
+ export async function resolveNameConflicts(libraryDir) {
173
+ const before = await loadCatalog(libraryDir);
174
+ assertModernCatalog(before);
175
+ const catalog = structuredClone(before);
176
+ if (findNameConflicts(catalog).length === 0) {
177
+ outro("No name conflicts.");
178
+ return;
179
+ }
180
+ while (true) {
181
+ const conflicts = findNameConflicts(catalog);
182
+ if (conflicts.length === 0)
183
+ break;
184
+ const options = conflicts.flatMap((conflict) => conflict.ids.map((id) => {
185
+ if (conflict.namespace === "item") {
186
+ const item = catalog.items.find((entry) => entry.id === id);
187
+ return { value: `item:${id}`, label: `[${item.kind}] ${item.name}`, hint: savedItemHint(catalog, item) };
188
+ }
189
+ const group = catalog.groups.find((entry) => entry.id === id);
190
+ return { value: `group:${id}`, label: `[group] ${group.name}`, hint: `${group.itemIds.length} resources` };
191
+ }));
192
+ const selected = await select({ message: "Select a conflicting name to rename", options });
193
+ if (isCancel(selected)) {
194
+ cancel("Name changes were not saved.");
195
+ return;
196
+ }
197
+ const namespace = selected.startsWith("item:") ? "item" : "group";
198
+ const id = selected.slice(namespace.length + 1);
199
+ const entries = namespace === "item" ? catalog.items : catalog.groups;
200
+ const entry = entries.find((entry) => entry.id === id);
201
+ if (!entry)
202
+ throw new Error("Selected name is no longer available.");
203
+ const name = await text({
204
+ message: `New name for ${entry.name}`,
205
+ validate: (value) => {
206
+ try {
207
+ assertNameAvailable(catalog, namespace, value ?? "", id);
208
+ return undefined;
209
+ }
210
+ catch (error) {
211
+ return error instanceof Error ? error.message : "Choose a unique non-empty name.";
212
+ }
213
+ },
214
+ });
215
+ if (isCancel(name)) {
216
+ cancel("Name changes were not saved.");
217
+ return;
218
+ }
219
+ entry.name = name.trim();
220
+ }
221
+ const changes = [];
222
+ for (const namespace of ["item", "group"]) {
223
+ const oldEntries = namespace === "item" ? before.items : before.groups;
224
+ const nextEntries = namespace === "item" ? catalog.items : catalog.groups;
225
+ for (const entry of nextEntries) {
226
+ const previous = oldEntries.find((previous) => previous.id === entry.id);
227
+ if (previous.name !== entry.name)
228
+ changes.push(`[${namespace}] ${previous.name} → ${entry.name}`);
229
+ }
230
+ }
231
+ note(changes.join("\n"), "Explicit name changes; saved contents and destinations stay unchanged");
232
+ const save = await confirm({ message: "Save these name changes?", initialValue: false });
233
+ if (isCancel(save) || !save) {
234
+ outro("Name changes were not saved.");
235
+ return;
236
+ }
237
+ assertNoNameConflicts(catalog);
238
+ await saveNameChanges(libraryDir, before, catalog);
239
+ outro("Name conflicts resolved. Run setup or groups again to make other changes.");
240
+ }
241
+ async function handleNameConflicts(libraryDir) {
242
+ if (findNameConflicts(await loadCatalog(libraryDir)).length === 0)
243
+ return false;
244
+ while (true) {
245
+ const catalog = await loadCatalog(libraryDir);
246
+ const conflicts = findNameConflicts(catalog);
247
+ if (conflicts.length === 0)
248
+ return true;
249
+ const modern = catalog.version === 2;
250
+ note(conflicts.map((conflict) => {
251
+ const entries = conflict.namespace === "item" ? catalog.items : catalog.groups;
252
+ return `[${conflict.namespace}] ${conflict.ids.map((id) => entries.find((entry) => entry.id === id).name).join(" / ")}`;
253
+ }).join("\n"), modern ? "Duplicate names: only listing, masked viewing, and resolution are allowed" : "Legacy catalog v1 is read-only; duplicate names are not changed here");
254
+ const action = await select({
255
+ message: "Unresolved names",
256
+ options: [
257
+ { value: "view", label: "View saved resources or groups (masked)" },
258
+ ...(modern ? [{ value: "resolve", label: "Resolve name conflicts" }] : []),
259
+ { value: "done", label: "Finish without changes" },
260
+ ],
261
+ });
262
+ if (isCancel(action) || action === "done")
263
+ return true;
264
+ if (action === "view")
265
+ await viewSavedEntry(libraryDir);
266
+ else if (modern)
267
+ await resolveNameConflicts(libraryDir);
268
+ }
269
+ }
62
270
  function groupOptions(groups) {
63
271
  return groups.map((group) => ({ value: group.id, label: group.name, hint: `${group.itemIds.length} saved resources` }));
64
272
  }
65
273
  async function askForGroupName(message) {
66
274
  const value = await text({
67
275
  message,
68
- validate: (name) => name === undefined || name.trim().length === 0 ? "Enter a non-empty group name." : undefined,
276
+ validate: (name) => {
277
+ try {
278
+ nameKey(name ?? "");
279
+ return undefined;
280
+ }
281
+ catch {
282
+ return "Enter a non-empty group name.";
283
+ }
284
+ },
69
285
  });
70
286
  return isCancel(value) ? undefined : value.trim();
71
287
  }
72
288
  export async function runSetup(libraryDir, roots) {
289
+ if (await handleNameConflicts(libraryDir))
290
+ return;
291
+ const initial = await loadCatalog(libraryDir);
292
+ assertModernCatalog(initial);
293
+ assertNoNameConflicts(initial);
73
294
  intro("copi setup");
74
295
  const scopeOptions = [];
75
296
  if (roots.homeDir) {
@@ -111,7 +332,17 @@ export async function runSetup(libraryDir, roots) {
111
332
  outro("Search not authorized; no resources were inspected or saved.");
112
333
  return;
113
334
  }
114
- const discovery = await discoverResources(searchOptions);
335
+ const progress = clackSpinner(inheritedSignal());
336
+ progress.start("Searching selected locations (searching all of HOME can take a minute or more)");
337
+ let discovery;
338
+ try {
339
+ discovery = await discoverResources(searchOptions);
340
+ }
341
+ catch (error) {
342
+ progress.error("Search failed");
343
+ throw error;
344
+ }
345
+ progress.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
115
346
  showWarnings(discovery.warnings, "Search warnings");
116
347
  if (discovery.excludedPaths.length > 0)
117
348
  note(discovery.excludedPaths.join("\n"), "Excluded paths");
@@ -138,6 +369,7 @@ export async function runSetup(libraryDir, roots) {
138
369
  return;
139
370
  }
140
371
  const catalog = await loadCatalog(libraryDir);
372
+ assertModernCatalog(catalog);
141
373
  if (catalog.groups.length === 0) {
142
374
  const name = await askForGroupName("Create the first resource group");
143
375
  if (name === undefined) {
@@ -189,7 +421,7 @@ export async function runSetup(libraryDir, roots) {
189
421
  allSuspectedFilesApproved = false;
190
422
  }
191
423
  if (!allSuspectedFilesApproved) {
192
- log.warn(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`);
424
+ log.warn(safeDisplay(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`));
193
425
  continue;
194
426
  }
195
427
  const groupIds = await autocompleteMultiselect({
@@ -209,7 +441,7 @@ export async function runSetup(libraryDir, roots) {
209
441
  return;
210
442
  }
211
443
  const summary = approvedCandidates.map(({ review, groupIds }) => {
212
- const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? id).join(", ");
444
+ const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
213
445
  return `${review.candidate.name} → ${names}`;
214
446
  }).join("\n");
215
447
  note(summary, "Resources ready to copy");
@@ -221,6 +453,18 @@ export async function runSetup(libraryDir, roots) {
221
453
  outro("Nothing was saved.");
222
454
  return;
223
455
  }
456
+ // Validate the whole batch before saving groups or any snapshot.
457
+ const pending = structuredClone(catalog);
458
+ assertNoNameConflicts(pending);
459
+ const pendingItemNames = new Set(pending.items.map((item) => nameKey(item.name)));
460
+ for (const { review } of approvedCandidates) {
461
+ const candidate = readPreparedResource(review.prepared).candidate;
462
+ assertNameAvailable(pending, "item", candidate.name);
463
+ const candidateName = nameKey(candidate.name);
464
+ if (pendingItemNames.has(candidateName))
465
+ throw new Error("Duplicate item name; choose a unique display name.");
466
+ pendingItemNames.add(candidateName);
467
+ }
224
468
  await saveCatalog(libraryDir, catalog);
225
469
  for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
226
470
  await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
@@ -228,7 +472,10 @@ export async function runSetup(libraryDir, roots) {
228
472
  outro(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryDir}.`);
229
473
  }
230
474
  export async function runGroupManager(libraryDir) {
475
+ if (await handleNameConflicts(libraryDir))
476
+ return;
231
477
  const catalog = await loadCatalog(libraryDir);
478
+ assertModernCatalog(catalog);
232
479
  if (catalog.groups.length === 0) {
233
480
  note("No groups are saved yet.", "Groups");
234
481
  }
@@ -254,6 +501,7 @@ export async function runGroupManager(libraryDir) {
254
501
  }
255
502
  if (action === "done")
256
503
  break;
504
+ assertModernCatalog(catalog);
257
505
  if (action === "create") {
258
506
  const name = await askForGroupName("New group name");
259
507
  if (name === undefined) {
@@ -307,6 +555,7 @@ export async function runGroupManager(libraryDir) {
307
555
  outro("Group changes were not saved.");
308
556
  return;
309
557
  }
558
+ assertModernCatalog(catalog);
310
559
  await saveCatalog(libraryDir, catalog);
311
560
  outro("Group changes saved.");
312
561
  }
@@ -316,7 +565,7 @@ export function printGroups(catalog) {
316
565
  return;
317
566
  }
318
567
  for (const group of catalog.groups)
319
- process.stdout.write(`${group.name} (${group.id}) — ${group.itemIds.length} resource(s)\n`);
568
+ process.stdout.write(`${safeDisplay(group.name)} — ${group.itemIds.length} resource(s)\n`);
320
569
  }
321
570
  export function printItems(items) {
322
571
  if (items.length === 0) {
@@ -324,18 +573,18 @@ export function printItems(items) {
324
573
  return;
325
574
  }
326
575
  for (const item of items) {
327
- process.stdout.write(`${item.name} [${item.kind}] (${item.id}) — ${item.files.length} file(s), ${item.destination.kind === "global-only" ? "saved only" : item.destination.path}\n`);
576
+ process.stdout.write(`${safeDisplay(item.name)} [${item.kind}] — ${item.files.length} file(s), ${safeDisplay(item.destination.kind === "global-only" ? "saved only" : item.destination.path)}\n`);
328
577
  }
329
578
  }
330
579
  function resolveGroup(catalog, value) {
331
580
  const matching = catalog.groups.filter((group) => group.id === value || group.name === value);
332
581
  if (matching.length === 0)
333
- throw new Error(`Unknown group: ${value}`);
582
+ throw new Error("Unknown group; select a saved group with copi manage.");
334
583
  if (matching.length > 1)
335
- throw new Error(`Group name is ambiguous: ${value}; use a group ID.`);
584
+ throw new Error("Group name is ambiguous; resolve name conflicts with copi manage.");
336
585
  const group = matching[0];
337
586
  if (!group)
338
- throw new Error(`Unknown group: ${value}`);
587
+ throw new Error("Selected group is unavailable.");
339
588
  return group;
340
589
  }
341
590
  function formatPlan(plan, catalog) {
@@ -344,26 +593,32 @@ function formatPlan(plan, catalog) {
344
593
  const mark = file.alternatives.some((option) => option.sensitive) ? " [suspected secret; approval required]" : "";
345
594
  const preview = file.preview.kind === "text" ? `\n${file.preview.diff}` : `\n${file.preview.summary}`;
346
595
  const alternatives = file.alternatives.length > 1
347
- ? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ?? option.itemId).join(", ")}`
596
+ ? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ?? "Unavailable resource").join(", ")}`
348
597
  : "";
349
598
  return `${file.status}: ${file.path}${mark}${alternatives}${preview}`;
350
599
  });
600
+ const proposal = plan.referenceProposal;
601
+ const reference = proposal
602
+ ? [`reference (optional, approval required during apply): ${proposal.status}: ${proposal.path}${proposal.preview.kind === "text" ? `\n${proposal.preview.diff}` : `\n${proposal.preview.summary}`}`]
603
+ : [];
351
604
  const warnings = plan.warnings.map((warning) => `warning${warning.path ? ` ${warning.path}` : ""}: ${warning.message}`);
352
- const skipped = plan.skippedItems.map((id) => `save-only resource skipped from apply: ${itemNames.get(id) ?? id}`);
353
- return [...lines, ...warnings, ...skipped].join("\n\n") || "No project files need changes.";
605
+ const skipped = plan.skippedItems.map((id) => `save-only resource skipped from apply: ${itemNames.get(id) ?? "Unavailable resource"}`);
606
+ return [...lines, ...reference, ...warnings, ...skipped].join("\n\n") || "No project files need changes.";
354
607
  }
355
608
  export function formatApplyOptionPreview(filePath, resourceName, option) {
356
609
  const preview = option.preview.kind === "text" ? option.preview.diff : option.preview.summary;
357
- return `${resourceName} → ${filePath}\n${preview}`;
610
+ return safeDisplay(`${resourceName} → ${filePath}\n${preview}`);
358
611
  }
359
612
  export async function runApplyDryRun(libraryDir, groupValue, projectDir) {
360
613
  const catalog = await loadCatalog(libraryDir);
614
+ assertModernCatalog(catalog);
361
615
  const group = resolveGroup(catalog, groupValue);
362
616
  const plan = await planApply(libraryDir, group.id, projectDir);
363
617
  note(formatPlan(plan, catalog), `Dry run: ${group.name} → ${projectDir}`);
364
618
  }
365
619
  export async function runApplyWizard(libraryDir, groupValue, projectDir) {
366
620
  const catalog = await loadCatalog(libraryDir);
621
+ assertModernCatalog(catalog);
367
622
  const group = resolveGroup(catalog, groupValue);
368
623
  const plan = await planApply(libraryDir, group.id, projectDir);
369
624
  if (plan.files.length === 0) {
@@ -383,7 +638,7 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
383
638
  message: `Choose one resource for colliding path ${file.path}`,
384
639
  options: file.alternatives.map((option) => ({
385
640
  value: option.itemId,
386
- label: catalog.items.find((item) => item.id === option.itemId)?.name ?? option.itemId,
641
+ label: catalog.items.find((item) => item.id === option.itemId)?.name ?? "Unavailable resource",
387
642
  hint: option.preview.kind === "text" ? "masked text diff available" : option.preview.summary,
388
643
  })),
389
644
  });
@@ -396,7 +651,7 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
396
651
  throw new Error(`Selected resource is unavailable for ${file.path}.`);
397
652
  collisionDecisions[file.path] = selectedItem;
398
653
  sensitive = selectedOption.sensitive;
399
- const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ?? selectedItem;
654
+ const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ?? "Unavailable resource";
400
655
  note(formatApplyOptionPreview(file.path, selectedName, selectedOption), "Selected colliding resource preview");
401
656
  }
402
657
  let hasExistingFile = file.status === "different";
@@ -450,6 +705,18 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
450
705
  }
451
706
  }
452
707
  decisions.secretApprovals = [...selectedSecrets];
708
+ if (plan.referenceProposal) {
709
+ const proposal = plan.referenceProposal;
710
+ const addReference = await confirm({
711
+ message: `${proposal.status === "new" ? "Create" : "Append to"} ${proposal.path} with a reference line for ${proposal.instructionPaths.join(", ")}? Pi does not load these files automatically.`,
712
+ initialValue: false,
713
+ });
714
+ if (isCancel(addReference)) {
715
+ cancel("Apply cancelled; project files were not changed.");
716
+ return;
717
+ }
718
+ decisions.addReference = addReference;
719
+ }
453
720
  const apply = await confirm({ message: `Apply the selected snapshots to ${projectDir}?`, initialValue: false });
454
721
  if (isCancel(apply) || !apply) {
455
722
  outro("Project files were not changed.");
@@ -460,5 +727,6 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
460
727
  outro("Apply aborted; project files were not changed.");
461
728
  return;
462
729
  }
463
- outro(`Applied ${result.written.length} file(s); kept ${result.kept.length}; saved ${result.backups.length} private backup(s).`);
730
+ const referenceNote = result.referenceAdded ? ` Added a reference line to ${result.referenceAdded}.` : "";
731
+ outro(`Applied ${result.written.length} file(s); kept ${result.kept.length}; saved ${result.backups.length} private backup(s).${referenceNote}`);
464
732
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papillon6814/copi",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Collect and organize reusable Pi coding-agent resources",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,8 +9,10 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/src",
12
+ "dist/native",
12
13
  "README.md",
13
- "LICENSE"
14
+ "LICENSE",
15
+ "LICENSE.unicode"
14
16
  ],
15
17
  "engines": {
16
18
  "node": ">=22.19.0"
@@ -19,7 +21,7 @@
19
21
  "darwin"
20
22
  ],
21
23
  "scripts": {
22
- "build": "tsc -p tsconfig.json",
24
+ "build": "tsc -p tsconfig.json && node scripts/build-native.mjs",
23
25
  "test": "npm run build && node --test dist/test/*.test.js",
24
26
  "check": "npm run build && npm test && npm pack --dry-run"
25
27
  },