@papillon6814/copi 0.7.1 → 0.8.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.
- package/README.md +2 -2
- package/dist/src/group-picker.d.ts +56 -0
- package/dist/src/group-picker.js +125 -0
- package/dist/src/groups.d.ts +2 -0
- package/dist/src/groups.js +5 -0
- package/dist/src/storage-transaction.js +8 -6
- package/dist/src/wizard.js +152 -50
- package/package.json +1 -1
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; that list shows how many are left (for example `2/5 · 3 left`) and ends with **↷ Skip**, which leaves the resource out of this run. 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,56 @@
|
|
|
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" | "skip";
|
|
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
|
+
/** Adds a last row that skips this resource (it is not saved). */
|
|
22
|
+
allowSkip?: boolean;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
input?: NodeJS.ReadableStream;
|
|
25
|
+
output?: NodeJS.WritableStream;
|
|
26
|
+
}
|
|
27
|
+
type Row = {
|
|
28
|
+
kind: "group";
|
|
29
|
+
group: GroupRow;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "new";
|
|
32
|
+
} | {
|
|
33
|
+
kind: "edit";
|
|
34
|
+
} | {
|
|
35
|
+
kind: "skip";
|
|
36
|
+
};
|
|
37
|
+
export declare const newGroupLabel = "\uFF0B New group\u2026";
|
|
38
|
+
export declare const editGroupsLabel = "\u270E Rename or delete groups\u2026";
|
|
39
|
+
export declare const skipLabel = "\u21B7 Skip \u2014 don't save this resource";
|
|
40
|
+
/**
|
|
41
|
+
* Multi-select of groups with two action rows at the end. ↑/↓ move, Tab or Space toggles, typing filters groups,
|
|
42
|
+
* Enter on a group row confirms the selection (at least one), Enter on an action row returns that action.
|
|
43
|
+
*/
|
|
44
|
+
export declare class GroupPickerPrompt extends Prompt<GroupPickerResult> {
|
|
45
|
+
private readonly options;
|
|
46
|
+
cursor: number;
|
|
47
|
+
filter: string;
|
|
48
|
+
readonly selected: Set<string>;
|
|
49
|
+
constructor(options: GroupPickerOptions);
|
|
50
|
+
rows(): Row[];
|
|
51
|
+
private updateValue;
|
|
52
|
+
private handleKey;
|
|
53
|
+
draw(): string;
|
|
54
|
+
}
|
|
55
|
+
export declare function groupPicker(options: GroupPickerOptions): Promise<GroupPickerResult | typeof CANCEL_SYMBOL>;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
export const skipLabel = "↷ Skip \u2014 don't save this resource";
|
|
9
|
+
/**
|
|
10
|
+
* Multi-select of groups with two action rows at the end. ↑/↓ move, Tab or Space toggles, typing filters groups,
|
|
11
|
+
* Enter on a group row confirms the selection (at least one), Enter on an action row returns that action.
|
|
12
|
+
*/
|
|
13
|
+
export class GroupPickerPrompt extends Prompt {
|
|
14
|
+
options;
|
|
15
|
+
cursor = 0;
|
|
16
|
+
filter = "";
|
|
17
|
+
selected;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
super({
|
|
20
|
+
render() { return this.draw(); },
|
|
21
|
+
validate(value) {
|
|
22
|
+
return value?.kind === "assign" && value.groupIds.length === 0 ? "Select at least one group (Tab), or create one." : undefined;
|
|
23
|
+
},
|
|
24
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
25
|
+
...(options.input ? { input: options.input } : {}),
|
|
26
|
+
...(options.output ? { output: options.output } : {}),
|
|
27
|
+
}, false);
|
|
28
|
+
this.options = options;
|
|
29
|
+
const known = new Set(options.groups.map((group) => group.id));
|
|
30
|
+
this.selected = new Set((options.initialValues ?? []).filter((id) => known.has(id)));
|
|
31
|
+
this.updateValue();
|
|
32
|
+
this.on("key", (char, key) => this.handleKey(char, key.name));
|
|
33
|
+
}
|
|
34
|
+
rows() {
|
|
35
|
+
const needle = this.filter.toLowerCase();
|
|
36
|
+
const groups = this.options.groups.filter((group) => !needle || group.label.toLowerCase().includes(needle));
|
|
37
|
+
return [
|
|
38
|
+
...groups.map((group) => ({ kind: "group", group })),
|
|
39
|
+
{ kind: "new" },
|
|
40
|
+
...(this.options.groups.length > 0 ? [{ kind: "edit" }] : []),
|
|
41
|
+
...(this.options.allowSkip ? [{ kind: "skip" }] : []),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
updateValue() {
|
|
45
|
+
const focused = this.rows()[this.cursor];
|
|
46
|
+
const groupIds = this.options.groups.map((group) => group.id).filter((id) => this.selected.has(id));
|
|
47
|
+
this.value = { kind: focused && focused.kind !== "group" ? focused.kind : "assign", groupIds, filter: this.filter.trim() };
|
|
48
|
+
}
|
|
49
|
+
handleKey(char, name) {
|
|
50
|
+
const rows = this.rows();
|
|
51
|
+
if (name === "up")
|
|
52
|
+
this.cursor = (this.cursor + rows.length - 1) % rows.length;
|
|
53
|
+
else if (name === "down")
|
|
54
|
+
this.cursor = (this.cursor + 1) % rows.length;
|
|
55
|
+
// Space toggles while the search is empty; once typing has started it is part of the search text.
|
|
56
|
+
else if (name === "tab" || (name === "space" && this.filter === "")) {
|
|
57
|
+
const row = rows[this.cursor];
|
|
58
|
+
if (row?.kind === "group") {
|
|
59
|
+
if (this.selected.has(row.group.id))
|
|
60
|
+
this.selected.delete(row.group.id);
|
|
61
|
+
else
|
|
62
|
+
this.selected.add(row.group.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (name === "backspace") {
|
|
66
|
+
this.filter = this.filter.slice(0, -1);
|
|
67
|
+
this.cursor = 0;
|
|
68
|
+
}
|
|
69
|
+
else if (name !== "return" && char !== undefined && [...char].length === 1 && char >= " " && !/[\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/u.test(char)) {
|
|
70
|
+
this.filter += char;
|
|
71
|
+
this.cursor = 0;
|
|
72
|
+
}
|
|
73
|
+
this.updateValue();
|
|
74
|
+
}
|
|
75
|
+
draw() {
|
|
76
|
+
const width = Math.max(20, (process.stdout.columns ?? 100) - 4);
|
|
77
|
+
const title = `${symbol(this.state)} ${this.options.message}`;
|
|
78
|
+
const chosen = this.options.groups.filter((group) => this.selected.has(group.id)).map((group) => group.label).join(", ");
|
|
79
|
+
if (this.state === "submit") {
|
|
80
|
+
const summary = this.value?.kind === "assign" ? chosen : this.value?.kind === "new" ? "New group" : this.value?.kind === "skip" ? "Skipped" : "Rename or delete groups";
|
|
81
|
+
return `${styleText("gray", S_BAR)}\n${title}\n${styleText("gray", S_BAR)} ${styleText("dim", clip(summary, width))}`;
|
|
82
|
+
}
|
|
83
|
+
if (this.state === "cancel")
|
|
84
|
+
return `${styleText("gray", S_BAR)}\n${title}\n${styleText("gray", S_BAR)} ${styleText(["strikethrough", "dim"], clip(chosen || "Cancelled", width))}`;
|
|
85
|
+
const color = this.state === "error" ? "yellow" : "cyan";
|
|
86
|
+
const bar = styleText(color, S_BAR);
|
|
87
|
+
const lines = [styleText("gray", S_BAR), title];
|
|
88
|
+
lines.push(`${bar} ${styleText("dim", "Search:")} ${clip(this.filter, width - 10)}${styleText("inverse", " ")}`);
|
|
89
|
+
const rows = this.rows();
|
|
90
|
+
if (!rows.some((row) => row.kind === "group") && this.filter)
|
|
91
|
+
lines.push(`${bar} ${styleText("yellow", "No matching groups \u2014 Enter on New group uses the search text as its name")}`);
|
|
92
|
+
// Keep the focused row visible when there are more groups than the terminal can show.
|
|
93
|
+
const maxRows = Math.max(4, Math.min(12, (process.stdout.rows ?? 30) - 12));
|
|
94
|
+
const start = Math.min(Math.max(0, this.cursor - Math.floor(maxRows / 2)), Math.max(0, rows.length - maxRows));
|
|
95
|
+
if (start > 0)
|
|
96
|
+
lines.push(`${bar} ${styleText("dim", "\u2026")}`);
|
|
97
|
+
for (const [offset, row] of rows.slice(start, start + maxRows).entries()) {
|
|
98
|
+
const focused = start + offset === this.cursor;
|
|
99
|
+
if (row.kind === "group") {
|
|
100
|
+
const box = this.selected.has(row.group.id) ? styleText("green", S_CHECKBOX_SELECTED) : styleText("dim", S_CHECKBOX_INACTIVE);
|
|
101
|
+
const hint = `(${row.group.hint})`;
|
|
102
|
+
const label = clip(row.group.label, Math.max(8, width - 3 - Math.min(stringWidth(hint), 24) - 1));
|
|
103
|
+
const room = width - 3 - stringWidth(label) - 1;
|
|
104
|
+
lines.push(`${bar} ${box} ${focused ? styleText("underline", label) : label}${room >= 4 ? ` ${styleText("dim", clip(hint, room))}` : ""}`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const label = row.kind === "new" ? newGroupLabel : row.kind === "edit" ? editGroupsLabel : skipLabel;
|
|
108
|
+
lines.push(`${bar} ${focused ? styleText(["cyan", "underline"], label) : styleText("dim", label)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (start + maxRows < rows.length)
|
|
112
|
+
lines.push(`${bar} ${styleText("dim", "\u2026")}`);
|
|
113
|
+
if (this.state === "error")
|
|
114
|
+
lines.push(`${bar} ${styleText("yellow", clip(this.error, width))}`);
|
|
115
|
+
lines.push(`${bar} ${styleText("dim", clip("↑/↓ move • Tab/Space: select • Enter: confirm or run the focused action • Type: to search", width))}`);
|
|
116
|
+
lines.push(styleText(color, S_BAR_END));
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export async function groupPicker(options) {
|
|
121
|
+
const result = await new GroupPickerPrompt(options).prompt();
|
|
122
|
+
if (isCoreCancel(result) || result === undefined)
|
|
123
|
+
return CANCEL_SYMBOL;
|
|
124
|
+
return result;
|
|
125
|
+
}
|
package/dist/src/groups.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/groups.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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) {
|
package/dist/src/wizard.js
CHANGED
|
@@ -3,7 +3,8 @@ 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";
|
|
6
|
+
import { createGroup, deleteGroup, renameGroup, setGroupItems } from "./groups.js";
|
|
7
|
+
import { groupPicker } from "./group-picker.js";
|
|
7
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";
|
|
@@ -283,10 +284,98 @@ 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
|
-
|
|
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, "skip" when they skip the resource (allowSkip only).
|
|
291
|
+
*/
|
|
292
|
+
async function chooseGroups(catalog, message, initialValues, assigned, allowSkip = false) {
|
|
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
|
+
allowSkip,
|
|
304
|
+
...inheritedSignal(),
|
|
305
|
+
});
|
|
306
|
+
if (isCancel(result))
|
|
307
|
+
return undefined;
|
|
308
|
+
if (result.kind === "assign")
|
|
309
|
+
return result.groupIds;
|
|
310
|
+
if (result.kind === "skip")
|
|
311
|
+
return "skip";
|
|
312
|
+
selection = result.groupIds;
|
|
313
|
+
if (result.kind === "new") {
|
|
314
|
+
// Esc at the name goes back to the list rather than ending setup.
|
|
315
|
+
// Typing a name that matches nothing and pressing Enter lands on "New group" with that text as the name.
|
|
316
|
+
const name = await askForGroupName("New group name (Esc: back to the list)", undefined, result.filter);
|
|
317
|
+
if (name === undefined)
|
|
318
|
+
continue;
|
|
319
|
+
try {
|
|
320
|
+
selection = [...selection, createGroup(catalog, name).id];
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
log.warn(safeDisplay(error instanceof Error ? error.message : String(error)));
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const groupId = await select({
|
|
328
|
+
message: "Which group?",
|
|
329
|
+
options: [...groupOptions(catalog.groups, pendingCounts), { value: "", label: "Back to the list" }],
|
|
330
|
+
});
|
|
331
|
+
if (isCancel(groupId) || groupId === "")
|
|
332
|
+
continue;
|
|
333
|
+
const group = catalog.groups.find((entry) => entry.id === groupId);
|
|
334
|
+
if (!group)
|
|
335
|
+
continue;
|
|
336
|
+
const action = await select({
|
|
337
|
+
message: `${group.name}: what do you want to do?`,
|
|
338
|
+
options: [
|
|
339
|
+
{ value: "rename", label: "Rename" },
|
|
340
|
+
{ value: "delete", label: "Delete" },
|
|
341
|
+
{ value: "back", label: "Back to the list" },
|
|
342
|
+
],
|
|
343
|
+
});
|
|
344
|
+
if (isCancel(action) || action === "back")
|
|
345
|
+
continue;
|
|
346
|
+
if (action === "rename") {
|
|
347
|
+
const name = await askForGroupName(`New name for ${group.name} (Esc: back to the list)`);
|
|
348
|
+
if (name === undefined)
|
|
349
|
+
continue;
|
|
350
|
+
try {
|
|
351
|
+
renameGroup(catalog, group.id, name);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
log.warn(safeDisplay(error instanceof Error ? error.message : String(error)));
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const pending = pendingCounts.get(group.id) ?? 0;
|
|
359
|
+
if (group.itemIds.length > 0 || pending > 0) {
|
|
360
|
+
const effects = [
|
|
361
|
+
group.itemIds.length > 0 ? `its ${group.itemIds.length} saved resource(s) stay in the library but leave this group` : "",
|
|
362
|
+
pending > 0 ? `${pending} resource(s) already assigned in this setup lose this group` : "",
|
|
363
|
+
].filter(Boolean).join("; ");
|
|
364
|
+
const sure = await confirm({ message: `Delete group ${group.name}? ${effects}. Takes effect only if you save at the end.`, initialValue: false });
|
|
365
|
+
if (isCancel(sure) || !sure)
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
deleteGroup(catalog, group.id);
|
|
369
|
+
for (const entry of assigned)
|
|
370
|
+
entry.groupIds = entry.groupIds.filter((id) => id !== group.id);
|
|
371
|
+
selection = selection.filter((id) => id !== group.id);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function askForGroupName(message, placeholder, initialValue) {
|
|
287
375
|
const value = await text({
|
|
288
376
|
message,
|
|
289
377
|
...(placeholder !== undefined ? { placeholder } : {}),
|
|
378
|
+
...(initialValue ? { initialValue } : {}),
|
|
290
379
|
validate: (name) => {
|
|
291
380
|
try {
|
|
292
381
|
nameKey(name ?? "");
|
|
@@ -597,6 +686,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
597
686
|
}
|
|
598
687
|
const catalog = await loadCatalog(libraryDir);
|
|
599
688
|
assertModernCatalog(catalog);
|
|
689
|
+
const groupsAtStart = catalog.groups.map((group) => ({ id: group.id, name: group.name, saved: group.itemIds.length }));
|
|
600
690
|
if (catalog.groups.length === 0) {
|
|
601
691
|
const name = await askForGroupName("Create the first resource group \u2014 used later with: copi apply <group>", "e.g. game-review");
|
|
602
692
|
if (name === undefined) {
|
|
@@ -605,23 +695,6 @@ export async function runSetup(libraryDir, roots) {
|
|
|
605
695
|
}
|
|
606
696
|
createGroup(catalog, name);
|
|
607
697
|
}
|
|
608
|
-
else {
|
|
609
|
-
while (true) {
|
|
610
|
-
const add = await confirm({ message: "Create another group before assigning resources?", initialValue: false });
|
|
611
|
-
if (isCancel(add)) {
|
|
612
|
-
cancel("Setup cancelled; no resources were saved.");
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
if (!add)
|
|
616
|
-
break;
|
|
617
|
-
const name = await askForGroupName("New group name");
|
|
618
|
-
if (name === undefined) {
|
|
619
|
-
cancel("Setup cancelled; no resources were saved.");
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
createGroup(catalog, name);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
698
|
// Keep discovery order for review; renamed copies replace their originals by id.
|
|
626
699
|
const resolvedById = new Map(resolvedCandidates.map((candidate) => [candidate.id, candidate]));
|
|
627
700
|
const selectedCandidates = discovery.candidates.flatMap((candidate) => resolvedById.get(candidate.id) ?? []);
|
|
@@ -630,12 +703,14 @@ export async function runSetup(libraryDir, roots) {
|
|
|
630
703
|
let bulkGroupIds;
|
|
631
704
|
const count = selectedCandidates.length;
|
|
632
705
|
const onlyGroup = catalog.groups.length === 1 ? catalog.groups[0] : undefined;
|
|
706
|
+
// Groups can be created, renamed and deleted from the assignment list; nothing reaches disk before the final Yes.
|
|
707
|
+
const pickGroups = (message, initialValues, allowSkip = false) => chooseGroups(catalog, message, initialValues, approvedCandidates, allowSkip);
|
|
633
708
|
let bulk = count === 1;
|
|
634
709
|
if (count >= 2) {
|
|
635
710
|
const mode = await select({
|
|
636
711
|
message: "How should these resources be grouped?",
|
|
637
712
|
options: [
|
|
638
|
-
{ value: "all", label:
|
|
713
|
+
{ value: "all", label: `Put all ${count} resources in the same groups` },
|
|
639
714
|
{ value: "each", label: "Choose groups for each resource" },
|
|
640
715
|
],
|
|
641
716
|
initialValue: "all",
|
|
@@ -647,21 +722,12 @@ export async function runSetup(libraryDir, roots) {
|
|
|
647
722
|
bulk = mode === "all";
|
|
648
723
|
}
|
|
649
724
|
if (bulk) {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
message: count === 1 ? `Assign ${selectedCandidates[0].name} to one or more groups` : `Put all ${count} resources in these groups`,
|
|
655
|
-
options: groupOptions(catalog.groups),
|
|
656
|
-
required: true,
|
|
657
|
-
placeholder: "Filter groups...",
|
|
658
|
-
});
|
|
659
|
-
if (isCancel(chosen)) {
|
|
660
|
-
cancel("Setup cancelled; no resources were saved.");
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
bulkGroupIds = chosen;
|
|
725
|
+
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] : []);
|
|
726
|
+
if (chosen === undefined || chosen === "skip") {
|
|
727
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
728
|
+
return;
|
|
664
729
|
}
|
|
730
|
+
bulkGroupIds = chosen;
|
|
665
731
|
}
|
|
666
732
|
for (const [index, candidate] of selectedCandidates.entries()) {
|
|
667
733
|
const review = await prepareCandidateForReview(candidate);
|
|
@@ -696,20 +762,16 @@ export async function runSetup(libraryDir, roots) {
|
|
|
696
762
|
approvedCandidates.push({ review, groupIds: bulkGroupIds, approvedSensitivePaths });
|
|
697
763
|
continue;
|
|
698
764
|
}
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
pendingCounts.set(id, (pendingCounts.get(id) ?? 0) + 1);
|
|
703
|
-
const groupIds = await autocompleteMultiselect({
|
|
704
|
-
message: `Assign ${review.candidate.name} to one or more groups`,
|
|
705
|
-
options: groupOptions(catalog.groups, pendingCounts),
|
|
706
|
-
required: true,
|
|
707
|
-
placeholder: "Filter groups...",
|
|
708
|
-
});
|
|
709
|
-
if (isCancel(groupIds)) {
|
|
765
|
+
const left = count - index - 1;
|
|
766
|
+
const groupIds = await pickGroups(`Assign ${review.candidate.name} to one or more groups (${index + 1}/${count} \u00b7 ${left} left)`, [], true);
|
|
767
|
+
if (groupIds === undefined) {
|
|
710
768
|
cancel("Setup cancelled; nothing was saved.");
|
|
711
769
|
return;
|
|
712
770
|
}
|
|
771
|
+
if (groupIds === "skip") {
|
|
772
|
+
log.info(safeDisplay(`Skipped ${review.candidate.name}; it will not be saved.`));
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
713
775
|
approvedCandidates.push({ review, groupIds, approvedSensitivePaths });
|
|
714
776
|
}
|
|
715
777
|
if (approvedCandidates.length === 0) {
|
|
@@ -718,9 +780,19 @@ export async function runSetup(libraryDir, roots) {
|
|
|
718
780
|
}
|
|
719
781
|
const summary = approvedCandidates.map(({ review, groupIds }) => {
|
|
720
782
|
const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
|
|
721
|
-
return `${review.candidate.name} → ${names}`;
|
|
783
|
+
return `${review.candidate.name} → ${names || "no group"}`;
|
|
722
784
|
}).join("\n");
|
|
723
|
-
|
|
785
|
+
// Deletions and renames of saved groups happen at the same final Yes, so show them here too.
|
|
786
|
+
const groupChanges = [
|
|
787
|
+
...catalog.groups.filter((group) => !groupsAtStart.some((start) => start.id === group.id)).map((group) => `created ${group.name}`),
|
|
788
|
+
...groupsAtStart.flatMap((start) => {
|
|
789
|
+
const now = catalog.groups.find((group) => group.id === start.id);
|
|
790
|
+
if (!now)
|
|
791
|
+
return [`deleted ${start.name}${start.saved > 0 ? ` (its ${start.saved} saved resource(s) stay in the library)` : ""}`];
|
|
792
|
+
return now.name === start.name ? [] : [`renamed ${start.name} → ${now.name}`];
|
|
793
|
+
}),
|
|
794
|
+
];
|
|
795
|
+
note(`${summary}${groupChanges.length > 0 ? `\n\nGroup changes: ${groupChanges.join("; ")}` : ""}\n\nSecret detection is heuristic and cannot guarantee completeness.`, "Resources ready to copy");
|
|
724
796
|
const libraryLabel = displayPath(libraryDir, homeDir);
|
|
725
797
|
const save = await confirm({
|
|
726
798
|
message: `Save ${approvedCandidates.length} copied resource(s) in ${libraryLabel}? Original files will not be changed.`,
|
|
@@ -743,13 +815,26 @@ export async function runSetup(libraryDir, roots) {
|
|
|
743
815
|
pendingItemNames.add(candidateName);
|
|
744
816
|
}
|
|
745
817
|
await saveCatalog(libraryDir, catalog);
|
|
746
|
-
|
|
747
|
-
|
|
818
|
+
let savedCount = 0;
|
|
819
|
+
try {
|
|
820
|
+
for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
|
|
821
|
+
await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
|
|
822
|
+
savedCount += 1;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
log.error(safeDisplay(`Group changes were saved; ${savedCount} of ${approvedCandidates.length} resource(s) were saved before this error.`));
|
|
827
|
+
throw error;
|
|
748
828
|
}
|
|
749
829
|
const usedGroupIds = new Set(approvedCandidates.flatMap(({ groupIds }) => groupIds));
|
|
750
830
|
const usedGroups = catalog.groups.filter((group) => usedGroupIds.has(group.id)).map((group) => group.name);
|
|
831
|
+
if (usedGroups.length === 0) {
|
|
832
|
+
log.info(safeDisplay(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryLabel}, not in any group.`));
|
|
833
|
+
outro("Next: add them to a group with copi manage, then copi apply <group> <project> --dry-run");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
751
836
|
log.info(safeDisplay(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryLabel} (group: ${usedGroups.join(", ")}).`));
|
|
752
|
-
outro(`Next: copi apply ${shellQuote(usedGroups[0]
|
|
837
|
+
outro(`Next: copi apply ${shellQuote(usedGroups[0])} <project> --dry-run (edit or rename: copi manage)`);
|
|
753
838
|
}
|
|
754
839
|
export async function runGroupManager(libraryDir) {
|
|
755
840
|
if (await handleNameConflicts(libraryDir))
|
|
@@ -771,6 +856,7 @@ export async function runGroupManager(libraryDir) {
|
|
|
771
856
|
...(catalog.groups.length > 0 ? [
|
|
772
857
|
{ value: "rename", label: "Rename group" },
|
|
773
858
|
{ value: "assign", label: "Edit group membership" },
|
|
859
|
+
{ value: "delete", label: "Delete group" },
|
|
774
860
|
] : []),
|
|
775
861
|
{ value: "done", label: "Finish" },
|
|
776
862
|
],
|
|
@@ -796,6 +882,22 @@ export async function runGroupManager(libraryDir) {
|
|
|
796
882
|
cancel("Group changes were not saved.");
|
|
797
883
|
return;
|
|
798
884
|
}
|
|
885
|
+
if (action === "delete") {
|
|
886
|
+
const group = catalog.groups.find((entry) => entry.id === groupId);
|
|
887
|
+
if (!group)
|
|
888
|
+
throw new Error("Selected group is no longer available.");
|
|
889
|
+
const sure = await confirm({
|
|
890
|
+
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.` : ""}`,
|
|
891
|
+
initialValue: false,
|
|
892
|
+
});
|
|
893
|
+
if (isCancel(sure)) {
|
|
894
|
+
cancel("Group changes were not saved.");
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (sure)
|
|
898
|
+
deleteGroup(catalog, groupId);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
799
901
|
if (action === "rename") {
|
|
800
902
|
const group = catalog.groups.find((entry) => entry.id === groupId);
|
|
801
903
|
if (!group)
|