@papillon6814/copi 0.7.0 → 0.8.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
@@ -31,7 +31,7 @@ copi help
31
31
 
32
32
  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.
33
33
 
34
- 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`.
34
+ 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. The group list itself ends with **+ New group…** and **✎ Rename or delete groups…**: create, rename or delete groups right there and return to the list with your selection kept. Deleting a group never deletes resources; saved resources only leave that group, and a group that holds saved resources or resources already assigned in this run asks first (default No). Like everything else in setup, group changes are written only when you answer Yes at the final confirmation. 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`.
35
35
 
36
36
  `add` is the non-interactive way to register resources, meant for scripts and AI agents (for example, tell your agent to run `copi add .pi/skills/my-skill --group game --json`). It reads only the paths you name and saves each one into the named group, creating the group if needed. The kind is detected (a directory with `SKILL.md` is a skill, `agents/*.md` a subagent, `AGENTS.md`/`CLAUDE.md`/`SYSTEM.md` an instruction) or set with `--kind`. A path is refused, and left for a person to review with `copi setup`, when its inspection finds suspected sensitive content, when it is or (for a skill directory) contains a settings or credential file (`settings.json`, `mcp.json`, `.mcp.json`, `models.json`, `keybindings.json`, SSH keys, `*.pem`, `.netrc` and similar; names compared case-insensitively), when the path or any parent directory is a symbolic link, when it lies under an excluded folder (`auth`, `.env*`, caches, history, sessions, `.git`, `node_modules`) or inside the copi library, when an `instruction`/`subagent` is not a Markdown file, or when its display name is already used (`--name` sets another). Each path gets its own result; if saving fails midway, the remaining paths are reported as errors and a group created by that command is removed again when it stayed empty. JSON output is not passed through display masking, so treat it as data. `--dry-run` reports without saving and `--json` prints machine-readable results; the exit status is 1 if any path was refused. Because secret detection is heuristic, a secret it misses is saved like any other content, without the per-file approval `setup` asks for.
37
37
 
@@ -39,7 +39,7 @@ A group is a named set of saved resources that you later apply together with `co
39
39
 
40
40
  Resource display names and group names are each unique across the library, in separate namespaces. Comparison uses `trim → NFKC → Unicode 17.0.0 default full case folding → NFKC` (not locale-specific); display names and body text are not automatically normalized or rewritten. For example, `A` and `a`, or `Straße` and `STRASSE`, conflict within one namespace. Legacy duplicate catalogs remain readable: use **Resolve name conflicts** to rename them manually. Until resolved, only listing, masked viewing and conflict resolution are allowed; no automatic renaming occurs.
41
41
 
42
- `groups` lists groups in a non-interactive terminal; group changes require a TTY and are saved only after confirmation. `list` is read-only. `apply --dry-run` prints the planned paths, masked text diffs, and warnings without changing the target. A real apply requires a TTY and confirmation.
42
+ `groups` lists groups in a non-interactive terminal; group changes (create, rename, edit membership, delete) require a TTY and are saved only after confirmation. Deleting a group keeps its resources in the library. `list` is read-only. `apply --dry-run` prints the planned paths, masked text diffs, and warnings without changing the target. A real apply requires a TTY and confirmation.
43
43
 
44
44
  Pi automatically loads only context files (`AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`) and `.pi/SYSTEM.md` / `.pi/APPEND_SYSTEM.md`. When a group applies another instruction (for example `RULES.md`) and no context file at the project root mentions its path, `apply` warns. It offers to append one reference line per instruction (`- 作業を始める前に \`RULES.md\` を読み、その指示に従うこと。`) to the root context file Pi prefers (`AGENTS.override.md` first); if no root context file exists, it offers to create `AGENTS.md`. The offer is a separate default-no approval; declining still applies the other files. An existing file is backed up privately before the append. `--dry-run` prints the same warning and diff. No reference is offered when the file Pi prefers is a symbolic link, not a regular file, not UTF-8, or larger than 1 MB, or when the same group also places a root context file (matched case-insensitively); the warning then shows the line to add yourself. Only the project root is checked; parent directories and the Pi agent directory are not.
45
45
 
@@ -0,0 +1,51 @@
1
+ import { Prompt } from "@clack/core";
2
+ import { CANCEL_SYMBOL } from "@clack/prompts";
3
+ export interface GroupRow {
4
+ id: string;
5
+ /** Already safe for display. */
6
+ label: string;
7
+ /** Already safe for display, e.g. "4 saved · 1 pending". */
8
+ hint: string;
9
+ }
10
+ /** `groupIds` is always the current selection, so an action can return to the list with it intact. */
11
+ export type GroupPickerResult = {
12
+ kind: "assign" | "new" | "edit";
13
+ groupIds: string[];
14
+ filter: string;
15
+ };
16
+ export interface GroupPickerOptions {
17
+ /** Already safe for display. */
18
+ message: string;
19
+ groups: readonly GroupRow[];
20
+ initialValues?: readonly string[];
21
+ signal?: AbortSignal;
22
+ input?: NodeJS.ReadableStream;
23
+ output?: NodeJS.WritableStream;
24
+ }
25
+ type Row = {
26
+ kind: "group";
27
+ group: GroupRow;
28
+ } | {
29
+ kind: "new";
30
+ } | {
31
+ kind: "edit";
32
+ };
33
+ export declare const newGroupLabel = "\uFF0B New group\u2026";
34
+ export declare const editGroupsLabel = "\u270E Rename or delete groups\u2026";
35
+ /**
36
+ * Multi-select of groups with two action rows at the end. ↑/↓ move, Tab or Space toggles, typing filters groups,
37
+ * Enter on a group row confirms the selection (at least one), Enter on an action row returns that action.
38
+ */
39
+ export declare class GroupPickerPrompt extends Prompt<GroupPickerResult> {
40
+ private readonly options;
41
+ cursor: number;
42
+ filter: string;
43
+ readonly selected: Set<string>;
44
+ constructor(options: GroupPickerOptions);
45
+ rows(): Row[];
46
+ private updateValue;
47
+ private handleKey;
48
+ draw(): string;
49
+ }
50
+ export declare function groupPicker(options: GroupPickerOptions): Promise<GroupPickerResult | typeof CANCEL_SYMBOL>;
51
+ export {};
@@ -0,0 +1,123 @@
1
+ import { isCancel as isCoreCancel, Prompt } from "@clack/core";
2
+ import { CANCEL_SYMBOL, S_BAR, S_BAR_END, S_CHECKBOX_INACTIVE, S_CHECKBOX_SELECTED, symbol } from "@clack/prompts";
3
+ import stringWidth from "fast-string-width";
4
+ import { styleText } from "node:util";
5
+ import { clip } from "./resource-picker.js";
6
+ export const newGroupLabel = "+ New group…";
7
+ export const editGroupsLabel = "✎ Rename or delete groups…";
8
+ /**
9
+ * Multi-select of groups with two action rows at the end. ↑/↓ move, Tab or Space toggles, typing filters groups,
10
+ * Enter on a group row confirms the selection (at least one), Enter on an action row returns that action.
11
+ */
12
+ export class GroupPickerPrompt extends Prompt {
13
+ options;
14
+ cursor = 0;
15
+ filter = "";
16
+ selected;
17
+ constructor(options) {
18
+ super({
19
+ render() { return this.draw(); },
20
+ validate(value) {
21
+ return value?.kind === "assign" && value.groupIds.length === 0 ? "Select at least one group (Tab), or create one." : undefined;
22
+ },
23
+ ...(options.signal ? { signal: options.signal } : {}),
24
+ ...(options.input ? { input: options.input } : {}),
25
+ ...(options.output ? { output: options.output } : {}),
26
+ }, false);
27
+ this.options = options;
28
+ const known = new Set(options.groups.map((group) => group.id));
29
+ this.selected = new Set((options.initialValues ?? []).filter((id) => known.has(id)));
30
+ this.updateValue();
31
+ this.on("key", (char, key) => this.handleKey(char, key.name));
32
+ }
33
+ rows() {
34
+ const needle = this.filter.toLowerCase();
35
+ const groups = this.options.groups.filter((group) => !needle || group.label.toLowerCase().includes(needle));
36
+ return [
37
+ ...groups.map((group) => ({ kind: "group", group })),
38
+ { kind: "new" },
39
+ ...(this.options.groups.length > 0 ? [{ kind: "edit" }] : []),
40
+ ];
41
+ }
42
+ updateValue() {
43
+ const focused = this.rows()[this.cursor];
44
+ const groupIds = this.options.groups.map((group) => group.id).filter((id) => this.selected.has(id));
45
+ this.value = { kind: focused && focused.kind !== "group" ? focused.kind : "assign", groupIds, filter: this.filter.trim() };
46
+ }
47
+ handleKey(char, name) {
48
+ const rows = this.rows();
49
+ if (name === "up")
50
+ this.cursor = (this.cursor + rows.length - 1) % rows.length;
51
+ else if (name === "down")
52
+ this.cursor = (this.cursor + 1) % rows.length;
53
+ // Space toggles while the search is empty; once typing has started it is part of the search text.
54
+ else if (name === "tab" || (name === "space" && this.filter === "")) {
55
+ const row = rows[this.cursor];
56
+ if (row?.kind === "group") {
57
+ if (this.selected.has(row.group.id))
58
+ this.selected.delete(row.group.id);
59
+ else
60
+ this.selected.add(row.group.id);
61
+ }
62
+ }
63
+ else if (name === "backspace") {
64
+ this.filter = this.filter.slice(0, -1);
65
+ this.cursor = 0;
66
+ }
67
+ else if (name !== "return" && char !== undefined && [...char].length === 1 && char >= " " && !/[\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/u.test(char)) {
68
+ this.filter += char;
69
+ this.cursor = 0;
70
+ }
71
+ this.updateValue();
72
+ }
73
+ draw() {
74
+ const width = Math.max(20, (process.stdout.columns ?? 100) - 4);
75
+ const title = `${symbol(this.state)} ${this.options.message}`;
76
+ const chosen = this.options.groups.filter((group) => this.selected.has(group.id)).map((group) => group.label).join(", ");
77
+ if (this.state === "submit") {
78
+ const summary = this.value?.kind === "assign" ? chosen : this.value?.kind === "new" ? "New group" : "Rename or delete groups";
79
+ return `${styleText("gray", S_BAR)}\n${title}\n${styleText("gray", S_BAR)} ${styleText("dim", clip(summary, width))}`;
80
+ }
81
+ if (this.state === "cancel")
82
+ return `${styleText("gray", S_BAR)}\n${title}\n${styleText("gray", S_BAR)} ${styleText(["strikethrough", "dim"], clip(chosen || "Cancelled", width))}`;
83
+ const color = this.state === "error" ? "yellow" : "cyan";
84
+ const bar = styleText(color, S_BAR);
85
+ const lines = [styleText("gray", S_BAR), title];
86
+ lines.push(`${bar} ${styleText("dim", "Search:")} ${clip(this.filter, width - 10)}${styleText("inverse", " ")}`);
87
+ const rows = this.rows();
88
+ if (!rows.some((row) => row.kind === "group") && this.filter)
89
+ lines.push(`${bar} ${styleText("yellow", "No matching groups \u2014 Enter on New group uses the search text as its name")}`);
90
+ // Keep the focused row visible when there are more groups than the terminal can show.
91
+ const maxRows = Math.max(4, Math.min(12, (process.stdout.rows ?? 30) - 12));
92
+ const start = Math.min(Math.max(0, this.cursor - Math.floor(maxRows / 2)), Math.max(0, rows.length - maxRows));
93
+ if (start > 0)
94
+ lines.push(`${bar} ${styleText("dim", "\u2026")}`);
95
+ for (const [offset, row] of rows.slice(start, start + maxRows).entries()) {
96
+ const focused = start + offset === this.cursor;
97
+ if (row.kind === "group") {
98
+ const box = this.selected.has(row.group.id) ? styleText("green", S_CHECKBOX_SELECTED) : styleText("dim", S_CHECKBOX_INACTIVE);
99
+ const hint = `(${row.group.hint})`;
100
+ const label = clip(row.group.label, Math.max(8, width - 3 - Math.min(stringWidth(hint), 24) - 1));
101
+ const room = width - 3 - stringWidth(label) - 1;
102
+ lines.push(`${bar} ${box} ${focused ? styleText("underline", label) : label}${room >= 4 ? ` ${styleText("dim", clip(hint, room))}` : ""}`);
103
+ }
104
+ else {
105
+ const label = row.kind === "new" ? newGroupLabel : editGroupsLabel;
106
+ lines.push(`${bar} ${focused ? styleText(["cyan", "underline"], label) : styleText("dim", label)}`);
107
+ }
108
+ }
109
+ if (start + maxRows < rows.length)
110
+ lines.push(`${bar} ${styleText("dim", "\u2026")}`);
111
+ if (this.state === "error")
112
+ lines.push(`${bar} ${styleText("yellow", clip(this.error, width))}`);
113
+ lines.push(`${bar} ${styleText("dim", clip("↑/↓ move • Tab/Space: select • Enter: confirm or run the focused action • Type: to search", width))}`);
114
+ lines.push(styleText(color, S_BAR_END));
115
+ return lines.join("\n");
116
+ }
117
+ }
118
+ export async function groupPicker(options) {
119
+ const result = await new GroupPickerPrompt(options).prompt();
120
+ if (isCoreCancel(result) || result === undefined)
121
+ return CANCEL_SYMBOL;
122
+ return result;
123
+ }
@@ -2,3 +2,5 @@ import type { Catalog, Group } from "./types.ts";
2
2
  export declare function createGroup(catalog: Catalog, name: string): Group;
3
3
  export declare function renameGroup(catalog: Catalog, groupId: string, name: string): void;
4
4
  export declare function setGroupItems(catalog: Catalog, groupId: string, itemIds: string[]): void;
5
+ /** Remove a group. Its saved resources stay in the library; they only leave this group. */
6
+ export declare function deleteGroup(catalog: Catalog, groupId: string): void;
@@ -33,3 +33,8 @@ export function setGroupItems(catalog, groupId, itemIds) {
33
33
  throw new Error("Group membership references an unknown saved item.");
34
34
  group.itemIds = [...itemIds];
35
35
  }
36
+ /** Remove a group. Its saved resources stay in the library; they only leave this group. */
37
+ export function deleteGroup(catalog, groupId) {
38
+ const group = findGroup(catalog, groupId);
39
+ catalog.groups = catalog.groups.filter((entry) => entry !== group);
40
+ }
@@ -31,4 +31,5 @@ export declare function askPiForSuggestions(candidates: readonly ResourceCandida
31
31
  command: string;
32
32
  timeoutMs?: number;
33
33
  signal?: AbortSignal;
34
+ onProgress?: (done: number, total: number) => void;
34
35
  }): Promise<SuggestionResult>;
@@ -164,6 +164,7 @@ export async function askPiForSuggestions(candidates, options) {
164
164
  if (suggestion)
165
165
  suggestions.set(item.candidate.id, suggestion);
166
166
  }
167
+ options.onProgress?.(offset + chunk.length, candidates.length);
167
168
  }
168
169
  return { ok: true, suggestions };
169
170
  }
@@ -47,6 +47,10 @@ export declare function formatSearchProgress(progress: {
47
47
  total: number;
48
48
  current?: string;
49
49
  }, home: string): string;
50
+ /** Elapsed time as `42s` or `1m05s`. */
51
+ export declare function formatElapsed(ms: number): string;
52
+ /** Bar text while Pi reviews candidates: `40/77 resources · 1m05s`. */
53
+ export declare function formatPiProgress(done: number, total: number, elapsedMs: number): string;
50
54
  /**
51
55
  * SHA-256 over what saving would copy: for a file its mode and bytes, for a directory every regular file's
52
56
  * relative path, mode and bytes (sorted, same exclusions as discovery), each field length-prefixed.
@@ -200,6 +200,15 @@ export function formatSearchProgress(progress, home) {
200
200
  const count = `${progress.done}/${progress.total}`;
201
201
  return progress.current === undefined ? `${count} Searching` : `${count} ${displayPath(progress.current, home)}`;
202
202
  }
203
+ /** Elapsed time as `42s` or `1m05s`. */
204
+ export function formatElapsed(ms) {
205
+ const seconds = Math.max(0, Math.floor(ms / 1000));
206
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
207
+ }
208
+ /** Bar text while Pi reviews candidates: `40/77 resources · 1m05s`. */
209
+ export function formatPiProgress(done, total, elapsedMs) {
210
+ return `${done}/${total} resources \u00b7 ${formatElapsed(elapsedMs)}`;
211
+ }
203
212
  const fingerprintMaxFiles = 2000;
204
213
  const fingerprintMaxBytes = 50 * 1024 * 1024;
205
214
  /**
@@ -633,12 +633,14 @@ function validateCatalogOnlyTransition(previous, next) {
633
633
  throw new Error("A catalog-only transaction may change only item names and legal destinations; source metadata, descriptors, and content are fixed.");
634
634
  }
635
635
  }
636
- if (next.groups.length < previous.groups.length)
637
- throw new Error("A catalog-only transaction cannot remove groups.");
638
- for (const [index, oldGroup] of previous.groups.entries()) {
639
- const nextGroup = next.groups[index];
640
- if (!nextGroup || nextGroup.id !== oldGroup.id)
641
- throw new Error("A catalog-only transaction must preserve existing group order and identity.");
636
+ // Groups may be deleted (their items stay); surviving groups keep their order and identity, new ones are appended.
637
+ const previousIds = new Set(previous.groups.map((group) => group.id));
638
+ const survivors = next.groups.filter((group) => previousIds.has(group.id)).map((group) => group.id);
639
+ const survivorSet = new Set(survivors);
640
+ const expected = previous.groups.map((group) => group.id).filter((id) => survivorSet.has(id));
641
+ const firstNew = next.groups.findIndex((group) => !previousIds.has(group.id));
642
+ if (JSON.stringify(survivors) !== JSON.stringify(expected) || (firstNew >= 0 && next.groups.slice(firstNew).some((group) => previousIds.has(group.id)))) {
643
+ throw new Error("A catalog-only transaction must preserve existing group order and identity.");
642
644
  }
643
645
  }
644
646
  function validateSourcesCatalogTransition(previous, next, operations) {
@@ -3,8 +3,9 @@ import { lstat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { assertModernCatalog, importPreparedResource, loadCatalog, prepareResource, readPreparedResource, readSnapshotFiles, saveCatalog, saveNameChanges } from "./catalog.js";
5
5
  import { discoverResources, isWorktreeCopy, piAgentDirectories } from "./discovery.js";
6
- import { createGroup, renameGroup, setGroupItems } from "./groups.js";
7
- import { candidateHint, candidateLabels, collapseIdenticalCandidates, contentFingerprint, defaultRenameName, displayPath, findSelectionConflicts, formatSearchProgress, formatOutsideRootSummary, gatherCandidateStats, identicalCopiesSuffix, mapWithLimit, pendingNameError, sortCandidates } from "./setup-display.js";
6
+ import { createGroup, deleteGroup, renameGroup, setGroupItems } from "./groups.js";
7
+ import { groupPicker } from "./group-picker.js";
8
+ import { candidateHint, candidateLabels, collapseIdenticalCandidates, contentFingerprint, defaultRenameName, displayPath, findSelectionConflicts, formatPiProgress, formatSearchProgress, formatOutsideRootSummary, gatherCandidateStats, identicalCopiesSuffix, mapWithLimit, pendingNameError, sortCandidates } from "./setup-display.js";
8
9
  import { inspectPreparedResource, maskSensitiveText } from "./inspect.js";
9
10
  import { assertNameAvailable, assertNoNameConflicts, findNameConflicts, nameKey } from "./names.js";
10
11
  import { executeApply, planApply } from "./apply.js";
@@ -283,10 +284,95 @@ function groupOptions(groups, pendingCounts) {
283
284
  hint: pendingCounts ? `${group.itemIds.length} saved \u00b7 ${pendingCounts.get(group.id) ?? 0} pending` : `${group.itemIds.length} saved resources`,
284
285
  }));
285
286
  }
286
- async function askForGroupName(message, placeholder) {
287
+ /**
288
+ * The setup assignment list: pick groups, or create, rename and delete groups in place and come back with the
289
+ * selection kept. Deleting also drops the group from resources already assigned in this setup.
290
+ * Returns undefined when the person cancels the list itself.
291
+ */
292
+ async function chooseGroups(catalog, message, initialValues, assigned) {
293
+ let selection = initialValues;
294
+ while (true) {
295
+ const pendingCounts = new Map();
296
+ for (const entry of assigned)
297
+ for (const id of entry.groupIds)
298
+ pendingCounts.set(id, (pendingCounts.get(id) ?? 0) + 1);
299
+ const result = await groupPicker({
300
+ message: `${safeDisplay(message)} ${multiselectKeyHint}`,
301
+ groups: groupOptions(catalog.groups, pendingCounts).map((option) => ({ id: option.value, label: safeDisplay(option.label), hint: safeDisplay(option.hint) })),
302
+ initialValues: selection,
303
+ ...inheritedSignal(),
304
+ });
305
+ if (isCancel(result))
306
+ return undefined;
307
+ if (result.kind === "assign")
308
+ return result.groupIds;
309
+ selection = result.groupIds;
310
+ if (result.kind === "new") {
311
+ // Esc at the name goes back to the list rather than ending setup.
312
+ // Typing a name that matches nothing and pressing Enter lands on "New group" with that text as the name.
313
+ const name = await askForGroupName("New group name (Esc: back to the list)", undefined, result.filter);
314
+ if (name === undefined)
315
+ continue;
316
+ try {
317
+ selection = [...selection, createGroup(catalog, name).id];
318
+ }
319
+ catch (error) {
320
+ log.warn(safeDisplay(error instanceof Error ? error.message : String(error)));
321
+ }
322
+ continue;
323
+ }
324
+ const groupId = await select({
325
+ message: "Which group?",
326
+ options: [...groupOptions(catalog.groups, pendingCounts), { value: "", label: "Back to the list" }],
327
+ });
328
+ if (isCancel(groupId) || groupId === "")
329
+ continue;
330
+ const group = catalog.groups.find((entry) => entry.id === groupId);
331
+ if (!group)
332
+ continue;
333
+ const action = await select({
334
+ message: `${group.name}: what do you want to do?`,
335
+ options: [
336
+ { value: "rename", label: "Rename" },
337
+ { value: "delete", label: "Delete" },
338
+ { value: "back", label: "Back to the list" },
339
+ ],
340
+ });
341
+ if (isCancel(action) || action === "back")
342
+ continue;
343
+ if (action === "rename") {
344
+ const name = await askForGroupName(`New name for ${group.name} (Esc: back to the list)`);
345
+ if (name === undefined)
346
+ continue;
347
+ try {
348
+ renameGroup(catalog, group.id, name);
349
+ }
350
+ catch (error) {
351
+ log.warn(safeDisplay(error instanceof Error ? error.message : String(error)));
352
+ }
353
+ continue;
354
+ }
355
+ const pending = pendingCounts.get(group.id) ?? 0;
356
+ if (group.itemIds.length > 0 || pending > 0) {
357
+ const effects = [
358
+ group.itemIds.length > 0 ? `its ${group.itemIds.length} saved resource(s) stay in the library but leave this group` : "",
359
+ pending > 0 ? `${pending} resource(s) already assigned in this setup lose this group` : "",
360
+ ].filter(Boolean).join("; ");
361
+ const sure = await confirm({ message: `Delete group ${group.name}? ${effects}. Takes effect only if you save at the end.`, initialValue: false });
362
+ if (isCancel(sure) || !sure)
363
+ continue;
364
+ }
365
+ deleteGroup(catalog, group.id);
366
+ for (const entry of assigned)
367
+ entry.groupIds = entry.groupIds.filter((id) => id !== group.id);
368
+ selection = selection.filter((id) => id !== group.id);
369
+ }
370
+ }
371
+ async function askForGroupName(message, placeholder, initialValue) {
287
372
  const value = await text({
288
373
  message,
289
374
  ...(placeholder !== undefined ? { placeholder } : {}),
375
+ ...(initialValue ? { initialValue } : {}),
290
376
  validate: (name) => {
291
377
  try {
292
378
  nameKey(name ?? "");
@@ -523,9 +609,29 @@ export async function runSetup(libraryDir, roots) {
523
609
  return;
524
610
  }
525
611
  if (ask) {
526
- const asking = clackSpinner(inheritedSignal());
527
- asking.start(`Asking Pi about ${sortedCandidates.length} resource(s)`);
528
- const answer = await askPiForSuggestions(sortedCandidates, { command: piCommand, ...inheritedSignal() });
612
+ // Pi answers a whole chunk at once, so the bar moves per chunk; the elapsed time keeps ticking in between.
613
+ const total = sortedCandidates.length;
614
+ const asking = clackProgress({ ...inheritedSignal(), max: total });
615
+ const startedAt = Date.now();
616
+ let answered = 0;
617
+ const label = () => `Asking Pi ${formatPiProgress(answered, total, Date.now() - startedAt)}`;
618
+ asking.start(label());
619
+ const ticker = setInterval(() => asking.message(label()), 1000);
620
+ let answer;
621
+ try {
622
+ answer = await askPiForSuggestions(sortedCandidates, {
623
+ command: piCommand,
624
+ ...inheritedSignal(),
625
+ onProgress: (done) => {
626
+ const step = done - answered;
627
+ answered = done;
628
+ asking.advance(step, label());
629
+ },
630
+ });
631
+ }
632
+ finally {
633
+ clearInterval(ticker);
634
+ }
529
635
  if (answer.ok) {
530
636
  suggestions = answer.suggestions;
531
637
  const kept = [...suggestions.values()].filter((suggestion) => suggestion.keep).length;
@@ -577,6 +683,7 @@ export async function runSetup(libraryDir, roots) {
577
683
  }
578
684
  const catalog = await loadCatalog(libraryDir);
579
685
  assertModernCatalog(catalog);
686
+ const groupsAtStart = catalog.groups.map((group) => ({ id: group.id, name: group.name, saved: group.itemIds.length }));
580
687
  if (catalog.groups.length === 0) {
581
688
  const name = await askForGroupName("Create the first resource group \u2014 used later with: copi apply <group>", "e.g. game-review");
582
689
  if (name === undefined) {
@@ -585,23 +692,6 @@ export async function runSetup(libraryDir, roots) {
585
692
  }
586
693
  createGroup(catalog, name);
587
694
  }
588
- else {
589
- while (true) {
590
- const add = await confirm({ message: "Create another group before assigning resources?", initialValue: false });
591
- if (isCancel(add)) {
592
- cancel("Setup cancelled; no resources were saved.");
593
- return;
594
- }
595
- if (!add)
596
- break;
597
- const name = await askForGroupName("New group name");
598
- if (name === undefined) {
599
- cancel("Setup cancelled; no resources were saved.");
600
- return;
601
- }
602
- createGroup(catalog, name);
603
- }
604
- }
605
695
  // Keep discovery order for review; renamed copies replace their originals by id.
606
696
  const resolvedById = new Map(resolvedCandidates.map((candidate) => [candidate.id, candidate]));
607
697
  const selectedCandidates = discovery.candidates.flatMap((candidate) => resolvedById.get(candidate.id) ?? []);
@@ -610,12 +700,14 @@ export async function runSetup(libraryDir, roots) {
610
700
  let bulkGroupIds;
611
701
  const count = selectedCandidates.length;
612
702
  const onlyGroup = catalog.groups.length === 1 ? catalog.groups[0] : undefined;
703
+ // Groups can be created, renamed and deleted from the assignment list; nothing reaches disk before the final Yes.
704
+ const pickGroups = (message, initialValues) => chooseGroups(catalog, message, initialValues, approvedCandidates);
613
705
  let bulk = count === 1;
614
706
  if (count >= 2) {
615
707
  const mode = await select({
616
708
  message: "How should these resources be grouped?",
617
709
  options: [
618
- { value: "all", label: onlyGroup ? `Put all ${count} resources in "${onlyGroup.name}"` : `Put all ${count} resources in the same groups` },
710
+ { value: "all", label: `Put all ${count} resources in the same groups` },
619
711
  { value: "each", label: "Choose groups for each resource" },
620
712
  ],
621
713
  initialValue: "all",
@@ -627,21 +719,12 @@ export async function runSetup(libraryDir, roots) {
627
719
  bulk = mode === "all";
628
720
  }
629
721
  if (bulk) {
630
- if (onlyGroup)
631
- bulkGroupIds = [onlyGroup.id];
632
- else {
633
- const chosen = await autocompleteMultiselect({
634
- message: count === 1 ? `Assign ${selectedCandidates[0].name} to one or more groups` : `Put all ${count} resources in these groups`,
635
- options: groupOptions(catalog.groups),
636
- required: true,
637
- placeholder: "Filter groups...",
638
- });
639
- if (isCancel(chosen)) {
640
- cancel("Setup cancelled; no resources were saved.");
641
- return;
642
- }
643
- bulkGroupIds = chosen;
722
+ const chosen = await pickGroups(count === 1 ? `Assign ${selectedCandidates[0].name} to one or more groups` : `Put all ${count} resources in these groups`, onlyGroup ? [onlyGroup.id] : []);
723
+ if (chosen === undefined) {
724
+ cancel("Setup cancelled; no resources were saved.");
725
+ return;
644
726
  }
727
+ bulkGroupIds = chosen;
645
728
  }
646
729
  for (const [index, candidate] of selectedCandidates.entries()) {
647
730
  const review = await prepareCandidateForReview(candidate);
@@ -676,17 +759,8 @@ export async function runSetup(libraryDir, roots) {
676
759
  approvedCandidates.push({ review, groupIds: bulkGroupIds, approvedSensitivePaths });
677
760
  continue;
678
761
  }
679
- const pendingCounts = new Map();
680
- for (const approved of approvedCandidates)
681
- for (const id of approved.groupIds)
682
- pendingCounts.set(id, (pendingCounts.get(id) ?? 0) + 1);
683
- const groupIds = await autocompleteMultiselect({
684
- message: `Assign ${review.candidate.name} to one or more groups`,
685
- options: groupOptions(catalog.groups, pendingCounts),
686
- required: true,
687
- placeholder: "Filter groups...",
688
- });
689
- if (isCancel(groupIds)) {
762
+ const groupIds = await pickGroups(`Assign ${review.candidate.name} to one or more groups`, []);
763
+ if (groupIds === undefined) {
690
764
  cancel("Setup cancelled; nothing was saved.");
691
765
  return;
692
766
  }
@@ -698,9 +772,19 @@ export async function runSetup(libraryDir, roots) {
698
772
  }
699
773
  const summary = approvedCandidates.map(({ review, groupIds }) => {
700
774
  const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
701
- return `${review.candidate.name} → ${names}`;
775
+ return `${review.candidate.name} → ${names || "no group"}`;
702
776
  }).join("\n");
703
- note(`${summary}\n\nSecret detection is heuristic and cannot guarantee completeness.`, "Resources ready to copy");
777
+ // Deletions and renames of saved groups happen at the same final Yes, so show them here too.
778
+ const groupChanges = [
779
+ ...catalog.groups.filter((group) => !groupsAtStart.some((start) => start.id === group.id)).map((group) => `created ${group.name}`),
780
+ ...groupsAtStart.flatMap((start) => {
781
+ const now = catalog.groups.find((group) => group.id === start.id);
782
+ if (!now)
783
+ return [`deleted ${start.name}${start.saved > 0 ? ` (its ${start.saved} saved resource(s) stay in the library)` : ""}`];
784
+ return now.name === start.name ? [] : [`renamed ${start.name} → ${now.name}`];
785
+ }),
786
+ ];
787
+ note(`${summary}${groupChanges.length > 0 ? `\n\nGroup changes: ${groupChanges.join("; ")}` : ""}\n\nSecret detection is heuristic and cannot guarantee completeness.`, "Resources ready to copy");
704
788
  const libraryLabel = displayPath(libraryDir, homeDir);
705
789
  const save = await confirm({
706
790
  message: `Save ${approvedCandidates.length} copied resource(s) in ${libraryLabel}? Original files will not be changed.`,
@@ -723,13 +807,26 @@ export async function runSetup(libraryDir, roots) {
723
807
  pendingItemNames.add(candidateName);
724
808
  }
725
809
  await saveCatalog(libraryDir, catalog);
726
- for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
727
- await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
810
+ let savedCount = 0;
811
+ try {
812
+ for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
813
+ await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
814
+ savedCount += 1;
815
+ }
816
+ }
817
+ catch (error) {
818
+ log.error(safeDisplay(`Group changes were saved; ${savedCount} of ${approvedCandidates.length} resource(s) were saved before this error.`));
819
+ throw error;
728
820
  }
729
821
  const usedGroupIds = new Set(approvedCandidates.flatMap(({ groupIds }) => groupIds));
730
822
  const usedGroups = catalog.groups.filter((group) => usedGroupIds.has(group.id)).map((group) => group.name);
823
+ if (usedGroups.length === 0) {
824
+ log.info(safeDisplay(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryLabel}, not in any group.`));
825
+ outro("Next: add them to a group with copi manage, then copi apply <group> <project> --dry-run");
826
+ return;
827
+ }
731
828
  log.info(safeDisplay(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryLabel} (group: ${usedGroups.join(", ")}).`));
732
- outro(`Next: copi apply ${shellQuote(usedGroups[0] ?? "<group>")} <project> --dry-run (edit or rename: copi manage)`);
829
+ outro(`Next: copi apply ${shellQuote(usedGroups[0])} <project> --dry-run (edit or rename: copi manage)`);
733
830
  }
734
831
  export async function runGroupManager(libraryDir) {
735
832
  if (await handleNameConflicts(libraryDir))
@@ -751,6 +848,7 @@ export async function runGroupManager(libraryDir) {
751
848
  ...(catalog.groups.length > 0 ? [
752
849
  { value: "rename", label: "Rename group" },
753
850
  { value: "assign", label: "Edit group membership" },
851
+ { value: "delete", label: "Delete group" },
754
852
  ] : []),
755
853
  { value: "done", label: "Finish" },
756
854
  ],
@@ -776,6 +874,22 @@ export async function runGroupManager(libraryDir) {
776
874
  cancel("Group changes were not saved.");
777
875
  return;
778
876
  }
877
+ if (action === "delete") {
878
+ const group = catalog.groups.find((entry) => entry.id === groupId);
879
+ if (!group)
880
+ throw new Error("Selected group is no longer available.");
881
+ const sure = await confirm({
882
+ message: `Delete group ${group.name}?${group.itemIds.length > 0 ? ` Its ${group.itemIds.length} saved resource(s) stay in the library; they only leave this group.` : ""}`,
883
+ initialValue: false,
884
+ });
885
+ if (isCancel(sure)) {
886
+ cancel("Group changes were not saved.");
887
+ return;
888
+ }
889
+ if (sure)
890
+ deleteGroup(catalog, groupId);
891
+ continue;
892
+ }
779
893
  if (action === "rename") {
780
894
  const group = catalog.groups.find((entry) => entry.id === groupId);
781
895
  if (!group)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papillon6814/copi",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Collect and organize reusable Pi coding-agent resources",
5
5
  "license": "MIT",
6
6
  "type": "module",