@papillon6814/copi 0.1.0 → 0.2.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.
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 +11 -3
  52. package/dist/src/wizard.js +274 -18
  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,13 @@
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
+ /** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
10
+ export declare function withWizardLifecycle(operation: (signal: AbortSignal) => Promise<void>): Promise<void>;
5
11
  export interface SearchRoots {
6
12
  homeDir: string;
7
13
  agentDir: string;
@@ -21,10 +27,12 @@ export interface CandidateReview {
21
27
  }
22
28
  export declare function prepareCandidateForReview(candidate: ResourceCandidate): Promise<CandidateReview>;
23
29
  export declare function saveReviewedCandidate(libraryDir: string, review: CandidateReview, groupIds: string[], approvedSensitivePaths: string[], confirmed: boolean): Promise<SavedItem | null>;
30
+ /** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
31
+ export declare function resolveNameConflicts(libraryDir: string): Promise<void>;
24
32
  export declare function runSetup(libraryDir: string, roots: SearchRoots): Promise<void>;
25
33
  export declare function runGroupManager(libraryDir: string): Promise<void>;
26
- export declare function printGroups(catalog: Catalog): void;
27
- export declare function printItems(items: SavedItem[]): void;
34
+ export declare function printGroups(catalog: StoredCatalog): void;
35
+ export declare function printItems(items: Array<LegacySavedItem | SavedItem>): void;
28
36
  export declare function formatApplyOptionPreview(filePath: string, resourceName: string, option: ApplyFileOption): string;
29
37
  export declare function runApplyDryRun(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
30
38
  export declare function runApplyWizard(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
@@ -1,11 +1,88 @@
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, 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
+ const autocompleteMultiselect = (options) => clackMultiselect({
38
+ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message),
39
+ 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) }) })),
40
+ });
41
+ const text = (options) => clackText({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
42
+ const confirm = (options) => clackConfirm({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
43
+ const note = (message, title, options) => clackNote(safeDisplay(message ?? ""), safeDisplay(title ?? ""), options);
44
+ const intro = (message, options) => clackIntro(safeDisplay(message ?? ""), options);
45
+ const outro = (message, options) => clackOutro(safeDisplay(message ?? ""), options);
46
+ const cancel = (message, options) => clackCancel(safeDisplay(message ?? ""), options);
47
+ /** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
48
+ export async function withWizardLifecycle(operation) {
49
+ if (activeSignal)
50
+ throw new Error("An interactive wizard is already active.");
51
+ const controller = new AbortController();
52
+ const wasRaw = Boolean(process.stdin.isRaw), wasFlowing = process.stdin.readableFlowing === true;
53
+ const abort = () => controller.abort();
54
+ const eofKey = (chunk) => {
55
+ if (!editorOwnsControlInput && (typeof chunk === "string" ? chunk.includes("\u0004") : chunk.includes(4)))
56
+ abort();
57
+ };
58
+ activeSignal = controller.signal;
59
+ process.stdin.on("end", abort);
60
+ process.stdin.on("close", abort);
61
+ process.stdin.on("error", abort);
62
+ process.stdin.on("data", eofKey);
63
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
64
+ process.on(signal, abort);
65
+ try {
66
+ await operation(controller.signal);
67
+ }
68
+ finally {
69
+ activeSignal = undefined;
70
+ process.stdin.off("end", abort);
71
+ process.stdin.off("close", abort);
72
+ process.stdin.off("error", abort);
73
+ process.stdin.off("data", eofKey);
74
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
75
+ process.off(signal, abort);
76
+ if (process.stdin.isTTY)
77
+ process.stdin.setRawMode(wasRaw);
78
+ // Adding our data listener starts an inactive reader too; restore its original ownership.
79
+ if (wasFlowing)
80
+ process.stdin.resume();
81
+ else
82
+ process.stdin.pause();
83
+ process.stdout.write("\u001b[?25h");
84
+ }
85
+ }
9
86
  const previewMaxBytes = 1_000_000;
10
87
  const previewMaxFiles = 12;
11
88
  const previewMaxLines = 24;
@@ -22,7 +99,7 @@ export function formatMaskedPreview(relativePath, content) {
22
99
  const lines = masked.split(/\r?\n/);
23
100
  const snippet = lines.slice(0, previewMaxLines).join("\n").slice(0, 2_000);
24
101
  const omitted = lines.length > previewMaxLines ? `\n… ${lines.length - previewMaxLines} more lines` : "";
25
- return `${relativePath}:\n${snippet}${omitted}`;
102
+ return safeDisplay(`${relativePath}:\n${snippet}${omitted}`);
26
103
  }
27
104
  function formatPreparedFiles(files) {
28
105
  return files.slice(0, previewMaxFiles).map((file) => {
@@ -59,17 +136,159 @@ function showWarnings(warnings, title) {
59
136
  return;
60
137
  note(warnings.map((warning) => `${warning.path ? `${warning.path}: ` : ""}${warning.message}`).join("\n"), title);
61
138
  }
139
+ function savedItemHint(catalog, item) {
140
+ const groups = catalog.groups.filter((group) => group.itemIds.includes(item.id)).map((group) => group.name).join(", ") || "no groups";
141
+ return `${groups} · ${item.source} · ${item.destination.kind === "project" ? item.destination.path : "saved only"}`;
142
+ }
143
+ async function viewSavedEntry(libraryDir) {
144
+ const catalog = await loadCatalog(libraryDir);
145
+ const entry = await select({
146
+ message: "Select saved resource or group",
147
+ options: [
148
+ ...catalog.items.map((item) => ({ value: `item:${item.id}`, label: `[${item.kind}] ${item.name}`, hint: savedItemHint(catalog, item) })),
149
+ ...catalog.groups.map((group) => ({ value: `group:${group.id}`, label: `[group] ${group.name}`, hint: `${group.itemIds.length} resources` })),
150
+ ],
151
+ });
152
+ if (isCancel(entry))
153
+ return;
154
+ const item = catalog.items.find((item) => `item:${item.id}` === entry);
155
+ if (item) {
156
+ const files = await readSnapshotFiles(libraryDir, item);
157
+ note(formatPreparedFiles(files).join("\n\n") || "No previewable files.", `Masked saved preview: ${item.name}`);
158
+ }
159
+ else {
160
+ const group = catalog.groups.find((group) => `group:${group.id}` === entry);
161
+ if (!group)
162
+ throw new Error("Selected group is no longer available.");
163
+ note(group.itemIds.map((id) => {
164
+ const member = catalog.items.find((item) => item.id === id);
165
+ return member ? `[${member.kind}] ${member.name} · ${savedItemHint(catalog, member)}` : "Unavailable resource";
166
+ }).join("\n") || "No saved resources.", group.name);
167
+ }
168
+ }
169
+ /** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
170
+ export async function resolveNameConflicts(libraryDir) {
171
+ const before = await loadCatalog(libraryDir);
172
+ assertModernCatalog(before);
173
+ const catalog = structuredClone(before);
174
+ if (findNameConflicts(catalog).length === 0) {
175
+ outro("No name conflicts.");
176
+ return;
177
+ }
178
+ while (true) {
179
+ const conflicts = findNameConflicts(catalog);
180
+ if (conflicts.length === 0)
181
+ break;
182
+ const options = conflicts.flatMap((conflict) => conflict.ids.map((id) => {
183
+ if (conflict.namespace === "item") {
184
+ const item = catalog.items.find((entry) => entry.id === id);
185
+ return { value: `item:${id}`, label: `[${item.kind}] ${item.name}`, hint: savedItemHint(catalog, item) };
186
+ }
187
+ const group = catalog.groups.find((entry) => entry.id === id);
188
+ return { value: `group:${id}`, label: `[group] ${group.name}`, hint: `${group.itemIds.length} resources` };
189
+ }));
190
+ const selected = await select({ message: "Select a conflicting name to rename", options });
191
+ if (isCancel(selected)) {
192
+ cancel("Name changes were not saved.");
193
+ return;
194
+ }
195
+ const namespace = selected.startsWith("item:") ? "item" : "group";
196
+ const id = selected.slice(namespace.length + 1);
197
+ const entries = namespace === "item" ? catalog.items : catalog.groups;
198
+ const entry = entries.find((entry) => entry.id === id);
199
+ if (!entry)
200
+ throw new Error("Selected name is no longer available.");
201
+ const name = await text({
202
+ message: `New name for ${entry.name}`,
203
+ validate: (value) => {
204
+ try {
205
+ assertNameAvailable(catalog, namespace, value ?? "", id);
206
+ return undefined;
207
+ }
208
+ catch (error) {
209
+ return error instanceof Error ? error.message : "Choose a unique non-empty name.";
210
+ }
211
+ },
212
+ });
213
+ if (isCancel(name)) {
214
+ cancel("Name changes were not saved.");
215
+ return;
216
+ }
217
+ entry.name = name.trim();
218
+ }
219
+ const changes = [];
220
+ for (const namespace of ["item", "group"]) {
221
+ const oldEntries = namespace === "item" ? before.items : before.groups;
222
+ const nextEntries = namespace === "item" ? catalog.items : catalog.groups;
223
+ for (const entry of nextEntries) {
224
+ const previous = oldEntries.find((previous) => previous.id === entry.id);
225
+ if (previous.name !== entry.name)
226
+ changes.push(`[${namespace}] ${previous.name} → ${entry.name}`);
227
+ }
228
+ }
229
+ note(changes.join("\n"), "Explicit name changes; saved contents and destinations stay unchanged");
230
+ const save = await confirm({ message: "Save these name changes?", initialValue: false });
231
+ if (isCancel(save) || !save) {
232
+ outro("Name changes were not saved.");
233
+ return;
234
+ }
235
+ assertNoNameConflicts(catalog);
236
+ await saveNameChanges(libraryDir, before, catalog);
237
+ outro("Name conflicts resolved. Run setup or groups again to make other changes.");
238
+ }
239
+ async function handleNameConflicts(libraryDir) {
240
+ if (findNameConflicts(await loadCatalog(libraryDir)).length === 0)
241
+ return false;
242
+ while (true) {
243
+ const catalog = await loadCatalog(libraryDir);
244
+ const conflicts = findNameConflicts(catalog);
245
+ if (conflicts.length === 0)
246
+ return true;
247
+ const modern = catalog.version === 2;
248
+ note(conflicts.map((conflict) => {
249
+ const entries = conflict.namespace === "item" ? catalog.items : catalog.groups;
250
+ return `[${conflict.namespace}] ${conflict.ids.map((id) => entries.find((entry) => entry.id === id).name).join(" / ")}`;
251
+ }).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");
252
+ const action = await select({
253
+ message: "Unresolved names",
254
+ options: [
255
+ { value: "view", label: "View saved resources or groups (masked)" },
256
+ ...(modern ? [{ value: "resolve", label: "Resolve name conflicts" }] : []),
257
+ { value: "done", label: "Finish without changes" },
258
+ ],
259
+ });
260
+ if (isCancel(action) || action === "done")
261
+ return true;
262
+ if (action === "view")
263
+ await viewSavedEntry(libraryDir);
264
+ else if (modern)
265
+ await resolveNameConflicts(libraryDir);
266
+ }
267
+ }
62
268
  function groupOptions(groups) {
63
269
  return groups.map((group) => ({ value: group.id, label: group.name, hint: `${group.itemIds.length} saved resources` }));
64
270
  }
65
271
  async function askForGroupName(message) {
66
272
  const value = await text({
67
273
  message,
68
- validate: (name) => name === undefined || name.trim().length === 0 ? "Enter a non-empty group name." : undefined,
274
+ validate: (name) => {
275
+ try {
276
+ nameKey(name ?? "");
277
+ return undefined;
278
+ }
279
+ catch {
280
+ return "Enter a non-empty group name.";
281
+ }
282
+ },
69
283
  });
70
284
  return isCancel(value) ? undefined : value.trim();
71
285
  }
72
286
  export async function runSetup(libraryDir, roots) {
287
+ if (await handleNameConflicts(libraryDir))
288
+ return;
289
+ const initial = await loadCatalog(libraryDir);
290
+ assertModernCatalog(initial);
291
+ assertNoNameConflicts(initial);
73
292
  intro("copi setup");
74
293
  const scopeOptions = [];
75
294
  if (roots.homeDir) {
@@ -138,6 +357,7 @@ export async function runSetup(libraryDir, roots) {
138
357
  return;
139
358
  }
140
359
  const catalog = await loadCatalog(libraryDir);
360
+ assertModernCatalog(catalog);
141
361
  if (catalog.groups.length === 0) {
142
362
  const name = await askForGroupName("Create the first resource group");
143
363
  if (name === undefined) {
@@ -189,7 +409,7 @@ export async function runSetup(libraryDir, roots) {
189
409
  allSuspectedFilesApproved = false;
190
410
  }
191
411
  if (!allSuspectedFilesApproved) {
192
- log.warn(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`);
412
+ log.warn(safeDisplay(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`));
193
413
  continue;
194
414
  }
195
415
  const groupIds = await autocompleteMultiselect({
@@ -209,7 +429,7 @@ export async function runSetup(libraryDir, roots) {
209
429
  return;
210
430
  }
211
431
  const summary = approvedCandidates.map(({ review, groupIds }) => {
212
- const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? id).join(", ");
432
+ const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
213
433
  return `${review.candidate.name} → ${names}`;
214
434
  }).join("\n");
215
435
  note(summary, "Resources ready to copy");
@@ -221,6 +441,18 @@ export async function runSetup(libraryDir, roots) {
221
441
  outro("Nothing was saved.");
222
442
  return;
223
443
  }
444
+ // Validate the whole batch before saving groups or any snapshot.
445
+ const pending = structuredClone(catalog);
446
+ assertNoNameConflicts(pending);
447
+ const pendingItemNames = new Set(pending.items.map((item) => nameKey(item.name)));
448
+ for (const { review } of approvedCandidates) {
449
+ const candidate = readPreparedResource(review.prepared).candidate;
450
+ assertNameAvailable(pending, "item", candidate.name);
451
+ const candidateName = nameKey(candidate.name);
452
+ if (pendingItemNames.has(candidateName))
453
+ throw new Error("Duplicate item name; choose a unique display name.");
454
+ pendingItemNames.add(candidateName);
455
+ }
224
456
  await saveCatalog(libraryDir, catalog);
225
457
  for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
226
458
  await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
@@ -228,7 +460,10 @@ export async function runSetup(libraryDir, roots) {
228
460
  outro(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryDir}.`);
229
461
  }
230
462
  export async function runGroupManager(libraryDir) {
463
+ if (await handleNameConflicts(libraryDir))
464
+ return;
231
465
  const catalog = await loadCatalog(libraryDir);
466
+ assertModernCatalog(catalog);
232
467
  if (catalog.groups.length === 0) {
233
468
  note("No groups are saved yet.", "Groups");
234
469
  }
@@ -254,6 +489,7 @@ export async function runGroupManager(libraryDir) {
254
489
  }
255
490
  if (action === "done")
256
491
  break;
492
+ assertModernCatalog(catalog);
257
493
  if (action === "create") {
258
494
  const name = await askForGroupName("New group name");
259
495
  if (name === undefined) {
@@ -307,6 +543,7 @@ export async function runGroupManager(libraryDir) {
307
543
  outro("Group changes were not saved.");
308
544
  return;
309
545
  }
546
+ assertModernCatalog(catalog);
310
547
  await saveCatalog(libraryDir, catalog);
311
548
  outro("Group changes saved.");
312
549
  }
@@ -316,7 +553,7 @@ export function printGroups(catalog) {
316
553
  return;
317
554
  }
318
555
  for (const group of catalog.groups)
319
- process.stdout.write(`${group.name} (${group.id}) — ${group.itemIds.length} resource(s)\n`);
556
+ process.stdout.write(`${safeDisplay(group.name)} — ${group.itemIds.length} resource(s)\n`);
320
557
  }
321
558
  export function printItems(items) {
322
559
  if (items.length === 0) {
@@ -324,18 +561,18 @@ export function printItems(items) {
324
561
  return;
325
562
  }
326
563
  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`);
564
+ 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
565
  }
329
566
  }
330
567
  function resolveGroup(catalog, value) {
331
568
  const matching = catalog.groups.filter((group) => group.id === value || group.name === value);
332
569
  if (matching.length === 0)
333
- throw new Error(`Unknown group: ${value}`);
570
+ throw new Error("Unknown group; select a saved group with copi manage.");
334
571
  if (matching.length > 1)
335
- throw new Error(`Group name is ambiguous: ${value}; use a group ID.`);
572
+ throw new Error("Group name is ambiguous; resolve name conflicts with copi manage.");
336
573
  const group = matching[0];
337
574
  if (!group)
338
- throw new Error(`Unknown group: ${value}`);
575
+ throw new Error("Selected group is unavailable.");
339
576
  return group;
340
577
  }
341
578
  function formatPlan(plan, catalog) {
@@ -344,26 +581,32 @@ function formatPlan(plan, catalog) {
344
581
  const mark = file.alternatives.some((option) => option.sensitive) ? " [suspected secret; approval required]" : "";
345
582
  const preview = file.preview.kind === "text" ? `\n${file.preview.diff}` : `\n${file.preview.summary}`;
346
583
  const alternatives = file.alternatives.length > 1
347
- ? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ?? option.itemId).join(", ")}`
584
+ ? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ?? "Unavailable resource").join(", ")}`
348
585
  : "";
349
586
  return `${file.status}: ${file.path}${mark}${alternatives}${preview}`;
350
587
  });
588
+ const proposal = plan.referenceProposal;
589
+ const reference = proposal
590
+ ? [`reference (optional, approval required during apply): ${proposal.status}: ${proposal.path}${proposal.preview.kind === "text" ? `\n${proposal.preview.diff}` : `\n${proposal.preview.summary}`}`]
591
+ : [];
351
592
  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.";
593
+ const skipped = plan.skippedItems.map((id) => `save-only resource skipped from apply: ${itemNames.get(id) ?? "Unavailable resource"}`);
594
+ return [...lines, ...reference, ...warnings, ...skipped].join("\n\n") || "No project files need changes.";
354
595
  }
355
596
  export function formatApplyOptionPreview(filePath, resourceName, option) {
356
597
  const preview = option.preview.kind === "text" ? option.preview.diff : option.preview.summary;
357
- return `${resourceName} → ${filePath}\n${preview}`;
598
+ return safeDisplay(`${resourceName} → ${filePath}\n${preview}`);
358
599
  }
359
600
  export async function runApplyDryRun(libraryDir, groupValue, projectDir) {
360
601
  const catalog = await loadCatalog(libraryDir);
602
+ assertModernCatalog(catalog);
361
603
  const group = resolveGroup(catalog, groupValue);
362
604
  const plan = await planApply(libraryDir, group.id, projectDir);
363
605
  note(formatPlan(plan, catalog), `Dry run: ${group.name} → ${projectDir}`);
364
606
  }
365
607
  export async function runApplyWizard(libraryDir, groupValue, projectDir) {
366
608
  const catalog = await loadCatalog(libraryDir);
609
+ assertModernCatalog(catalog);
367
610
  const group = resolveGroup(catalog, groupValue);
368
611
  const plan = await planApply(libraryDir, group.id, projectDir);
369
612
  if (plan.files.length === 0) {
@@ -383,7 +626,7 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
383
626
  message: `Choose one resource for colliding path ${file.path}`,
384
627
  options: file.alternatives.map((option) => ({
385
628
  value: option.itemId,
386
- label: catalog.items.find((item) => item.id === option.itemId)?.name ?? option.itemId,
629
+ label: catalog.items.find((item) => item.id === option.itemId)?.name ?? "Unavailable resource",
387
630
  hint: option.preview.kind === "text" ? "masked text diff available" : option.preview.summary,
388
631
  })),
389
632
  });
@@ -396,7 +639,7 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
396
639
  throw new Error(`Selected resource is unavailable for ${file.path}.`);
397
640
  collisionDecisions[file.path] = selectedItem;
398
641
  sensitive = selectedOption.sensitive;
399
- const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ?? selectedItem;
642
+ const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ?? "Unavailable resource";
400
643
  note(formatApplyOptionPreview(file.path, selectedName, selectedOption), "Selected colliding resource preview");
401
644
  }
402
645
  let hasExistingFile = file.status === "different";
@@ -450,6 +693,18 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
450
693
  }
451
694
  }
452
695
  decisions.secretApprovals = [...selectedSecrets];
696
+ if (plan.referenceProposal) {
697
+ const proposal = plan.referenceProposal;
698
+ const addReference = await confirm({
699
+ message: `${proposal.status === "new" ? "Create" : "Append to"} ${proposal.path} with a reference line for ${proposal.instructionPaths.join(", ")}? Pi does not load these files automatically.`,
700
+ initialValue: false,
701
+ });
702
+ if (isCancel(addReference)) {
703
+ cancel("Apply cancelled; project files were not changed.");
704
+ return;
705
+ }
706
+ decisions.addReference = addReference;
707
+ }
453
708
  const apply = await confirm({ message: `Apply the selected snapshots to ${projectDir}?`, initialValue: false });
454
709
  if (isCancel(apply) || !apply) {
455
710
  outro("Project files were not changed.");
@@ -460,5 +715,6 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
460
715
  outro("Apply aborted; project files were not changed.");
461
716
  return;
462
717
  }
463
- outro(`Applied ${result.written.length} file(s); kept ${result.kept.length}; saved ${result.backups.length} private backup(s).`);
718
+ const referenceNote = result.referenceAdded ? ` Added a reference line to ${result.referenceAdded}.` : "";
719
+ outro(`Applied ${result.written.length} file(s); kept ${result.kept.length}; saved ${result.backups.length} private backup(s).${referenceNote}`);
464
720
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papillon6814/copi",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
  },