@narumitw/pi-sync 0.25.0 → 0.29.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/src/command.ts CHANGED
@@ -1,56 +1,139 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
2
  import type { CommandArgumentCompletion, CommandOptions } from "./types.js";
2
3
 
3
4
  const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
4
5
  { value: "--yes", label: "--yes", description: "Skip confirmation prompts" },
5
6
  { value: "-y", label: "-y", description: "Skip confirmation prompts" },
6
7
  ];
7
- const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
8
- { value: "help", label: "help", description: "Show command usage" },
9
- { value: "init", label: "init", description: "Create local config template" },
10
- { value: "config", label: "config", description: "Show resolved configuration" },
11
- { value: "status", label: "status", description: "Show sync status" },
12
- { value: "diff", label: "diff", description: "Show local/remote diff" },
13
- { value: "doctor", label: "doctor", description: "Check config, secrets, and lock state" },
14
- { value: "push", label: "push", description: "Upload local settings" },
15
- { value: "pull", label: "pull", description: "Apply remote settings" },
16
- { value: "sync", label: "sync", description: "Push or pull as needed" },
17
- { value: "history", label: "history", description: "Show recent remote snapshots" },
18
- { value: "rollback", label: "rollback", description: "Apply a previous snapshot" },
19
- { value: "unlock", label: "unlock", description: "Remove a stale local lock" },
20
- ];
8
+ export const SYNC_COMMANDS = [
9
+ { name: "help", description: "Show command usage" },
10
+ { name: "use", description: "Switch the current sync target", usageSuffix: " <target>" },
11
+ { name: "init", description: "Create local config template" },
12
+ { name: "config", description: "Show resolved configuration" },
13
+ { name: "files", description: "Choose synced files" },
14
+ { name: "status", description: "Show sync status" },
15
+ { name: "diff", description: "Show local/remote diff" },
16
+ { name: "doctor", description: "Check config, secrets, and lock state" },
17
+ { name: "push", description: "Upload local settings" },
18
+ { name: "pull", description: "Apply remote settings" },
19
+ { name: "sync", description: "Push or pull as needed" },
20
+ { name: "history", description: "Show recent remote snapshots" },
21
+ { name: "rollback", description: "Apply a previous snapshot", usageSuffix: " <snapshot>" },
22
+ { name: "unlock", description: "Remove a stale local lock", usageSuffix: " --stale" },
23
+ ] as const;
24
+
25
+ export type SyncCommandName = (typeof SYNC_COMMANDS)[number]["name"];
26
+
27
+ const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = SYNC_COMMANDS.map(
28
+ ({ name, description }) => ({ value: name, label: name, description }),
29
+ );
30
+ let targetCompletionNames: string[] = [];
31
+
32
+ export function setSyncTargetCompletions(names: readonly string[]) {
33
+ targetCompletionNames = [...new Set(names)].sort((left, right) => left.localeCompare(right));
34
+ }
35
+ const TARGET_FLAG_COMPLETION = {
36
+ value: "--target",
37
+ label: "--target",
38
+ description: "Address a target without switching",
39
+ } as const;
21
40
  const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]> = {
41
+ config: [TARGET_FLAG_COMPLETION],
42
+ files: [TARGET_FLAG_COMPLETION],
43
+ status: [TARGET_FLAG_COMPLETION],
44
+ diff: [TARGET_FLAG_COMPLETION],
45
+ doctor: [TARGET_FLAG_COMPLETION],
22
46
  push: [
23
47
  ...YES_FLAG_COMPLETIONS,
24
48
  { value: "--force", label: "--force", description: "Overwrite visible remote changes" },
49
+ TARGET_FLAG_COMPLETION,
25
50
  ],
26
51
  pull: [
27
52
  ...YES_FLAG_COMPLETIONS,
28
53
  { value: "--force", label: "--force", description: "Overwrite local changes" },
54
+ TARGET_FLAG_COMPLETION,
29
55
  ],
30
56
  sync: [
31
57
  ...YES_FLAG_COMPLETIONS,
32
58
  { value: "--force", label: "--force", description: "Resolve conflicts by forcing action" },
59
+ TARGET_FLAG_COMPLETION,
33
60
  ],
34
- rollback: YES_FLAG_COMPLETIONS,
61
+ history: [TARGET_FLAG_COMPLETION],
62
+ rollback: [...YES_FLAG_COMPLETIONS, TARGET_FLAG_COMPLETION],
35
63
  unlock: [{ value: "--stale", label: "--stale", description: "Remove only a stale lock" }],
36
64
  };
37
65
 
38
66
  export function splitArgs(input: string) {
39
- return input.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((arg) => arg.replace(/^['"]|['"]$/g, "")) ?? [];
67
+ return (
68
+ input.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((arg) => arg.replace(/^['"]|['"]$/g, "")) ??
69
+ []
70
+ );
40
71
  }
41
72
 
42
73
  export function parseOptions(args: string[]): CommandOptions {
74
+ const values: string[] = [];
75
+ let yes = false;
76
+ let force = false;
77
+ let stale = false;
78
+ let target: string | undefined;
79
+ for (let index = 0; index < args.length; index += 1) {
80
+ const arg = args[index];
81
+ if (arg === "--yes" || arg === "-y") yes = true;
82
+ else if (arg === "--force") force = true;
83
+ else if (arg === "--stale") stale = true;
84
+ else if (arg === "--target") {
85
+ const name = args[index + 1];
86
+ if (!name || name.startsWith("-")) throw new Error("--target requires a target name.");
87
+ if (target !== undefined) throw new Error("--target may be provided only once.");
88
+ target = name;
89
+ index += 1;
90
+ } else if (arg.startsWith("-")) {
91
+ throw new Error(`Unknown sync option: ${arg}`);
92
+ } else values.push(arg);
93
+ }
43
94
  return {
44
- yes: args.includes("--yes") || args.includes("-y"),
45
- force: args.includes("--force"),
46
- stale: args.includes("--stale"),
95
+ yes,
96
+ force,
97
+ stale,
47
98
  silent: false,
48
99
  reload: true,
49
100
  auto: false,
50
- args: args.filter((arg) => !arg.startsWith("-")),
101
+ ...(target === undefined ? {} : { target }),
102
+ args: values,
51
103
  };
52
104
  }
53
105
 
106
+ export function validateCommandOptions(command: string, options: CommandOptions) {
107
+ const targetAllowed = new Set([
108
+ "config",
109
+ "files",
110
+ "status",
111
+ "diff",
112
+ "doctor",
113
+ "push",
114
+ "pull",
115
+ "sync",
116
+ "history",
117
+ "rollback",
118
+ ]);
119
+ if (options.target && !targetAllowed.has(command)) {
120
+ throw new Error(`--target is not supported by /sync ${command}.`);
121
+ }
122
+ if ((options.yes || options.force) && !["push", "pull", "sync", "rollback"].includes(command)) {
123
+ throw new Error(`Confirmation/force options are not supported by /sync ${command}.`);
124
+ }
125
+ if (options.stale && command !== "unlock") {
126
+ throw new Error(`--stale is not supported by /sync ${command}.`);
127
+ }
128
+ const expectedValues = command === "rollback" || command === "use" ? 1 : 0;
129
+ if (options.args.length !== expectedValues) {
130
+ if (command === "rollback")
131
+ throw new Error("Usage: /sync rollback <snapshot-id> [--yes] [--target <name>]");
132
+ if (command === "use") throw new Error("Usage: /sync use <target>");
133
+ throw new Error(`Unexpected argument for /sync ${command}: ${options.args.join(" ")}`);
134
+ }
135
+ }
136
+
54
137
  export function completeSyncArguments(argumentPrefix: string): CommandArgumentCompletion[] | null {
55
138
  const prefix = argumentPrefix.trimStart();
56
139
  if (prefix === "") return [...SYNC_COMMAND_COMPLETIONS];
@@ -65,10 +148,20 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
65
148
  return matches.length > 0 ? [...matches] : null;
66
149
  }
67
150
 
151
+ const args = tokens.slice(1);
152
+ if (command === "use") {
153
+ if (args.length > 1 || (trailingSpace && args.length > 0)) return null;
154
+ return completeTargetValue(prefix, trailingSpace ? "" : (args[0] ?? ""));
155
+ }
156
+ const targetFlagIndex = args.lastIndexOf("--target");
157
+ if (targetFlagIndex >= 0 && targetFlagIndex === args.length - (trailingSpace ? 1 : 2)) {
158
+ const currentTarget = trailingSpace ? "" : (args.at(-1) ?? "");
159
+ if (!currentTarget.startsWith("-")) return completeTargetValue(prefix, currentTarget);
160
+ }
161
+
68
162
  const flagCompletions = SYNC_FLAG_COMPLETIONS[command];
69
163
  if (!flagCompletions) return null;
70
164
 
71
- const args = tokens.slice(1);
72
165
  const completedArgs = trailingSpace ? args : args.slice(0, -1);
73
166
  const completedValues = completedArgs.filter((arg) => !arg.startsWith("-"));
74
167
  if (command === "rollback" ? completedValues.length > 1 : completedValues.length > 0) {
@@ -88,10 +181,55 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
88
181
  : null;
89
182
  }
90
183
 
184
+ function completeTargetValue(prefix: string, current: string) {
185
+ const currentRaw = current ? (prefix.match(/\S+$/u)?.[0] ?? "") : "";
186
+ const completionPrefix = currentRaw ? prefix.slice(0, prefix.length - currentRaw.length) : prefix;
187
+ const matches = targetCompletionNames.filter((name) => name.startsWith(current));
188
+ return matches.length > 0
189
+ ? matches.map((name) => ({
190
+ value: `${completionPrefix}${name}`,
191
+ label: name,
192
+ description: "Sync target",
193
+ }))
194
+ : null;
195
+ }
196
+
197
+ export function syncMenuOptions() {
198
+ return SYNC_COMMANDS.map(({ name, description }) => `${name} — ${description}`);
199
+ }
200
+
201
+ export function syncCommandFromMenuOption(option: string): SyncCommandName | undefined {
202
+ return SYNC_COMMANDS.find(({ name, description }) => option === `${name} — ${description}`)?.name;
203
+ }
204
+
205
+ export async function resolveSyncCommand(input: string, ctx: ExtensionCommandContext) {
206
+ const [subcommand, ...rest] = splitArgs(input);
207
+ if (subcommand) return { subcommand, rest };
208
+ if (!ctx.hasUI) {
209
+ ctx.ui.notify(usage(), "info");
210
+ return undefined;
211
+ }
212
+
213
+ const selectedOption = await ctx.ui.select("pi-sync", syncMenuOptions());
214
+ const selected = selectedOption ? syncCommandFromMenuOption(selectedOption) : undefined;
215
+ if (!selected) return undefined;
216
+ if (selected !== "rollback") return { subcommand: selected, rest: [] };
217
+
218
+ const target = (await ctx.ui.input("Rollback snapshot", "snapshot id"))?.trim();
219
+ if (!target) {
220
+ ctx.ui.notify("Rollback cancelled.", "info");
221
+ return undefined;
222
+ }
223
+ return { subcommand: selected, rest: [target] };
224
+ }
225
+
91
226
  export function usage() {
227
+ const commands = SYNC_COMMANDS.map(
228
+ (command) => `${command.name}${"usageSuffix" in command ? command.usageSuffix : ""}`,
229
+ ).join(", ");
92
230
  return [
93
- "Usage: /pisync <command>",
94
- "Commands: init, config, status, diff, doctor, push, pull, sync, history, rollback <snapshot>, unlock --stale",
95
- "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN, PI_SYNC_SESSIONS/syncSessions, region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json when PI_CODING_AGENT_DIR is set).",
231
+ "Usage: /sync <command>",
232
+ `Commands: ${commands}`,
233
+ "Settings: use /sync init or edit profiles and targets in ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json). Existing PI_SYNC_* overrides still work but are deprecated for future major-version removal.",
96
234
  ].join("\n");
97
235
  }