zinkee 0.1.10 → 0.1.12
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/.claude/settings.local.json +29 -1
- package/AGENTS.md +1 -0
- package/README.md +12 -0
- package/dist/index.js +748 -30
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli-examples.ts +18 -36
- package/src/cli-runner.ts +28 -2
- package/src/completion/engine.test.ts +138 -0
- package/src/completion/engine.ts +168 -0
- package/src/completion/install.test.ts +179 -0
- package/src/completion/install.ts +260 -0
- package/src/completion/runtime.ts +150 -0
- package/src/completion/scripts.ts +91 -0
- package/src/index.test.ts +52 -0
- package/src/program.ts +3 -1
- package/src/utils/argv-rewrite.test.ts +149 -0
- package/src/utils/argv-rewrite.ts +269 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { commandRegistry } from "../command-registry.js";
|
|
4
|
+
import { buildProgram } from "../program.js";
|
|
5
|
+
import {
|
|
6
|
+
buildOptionMetadata,
|
|
7
|
+
hoistGlobalOptions,
|
|
8
|
+
} from "./argv-rewrite.js";
|
|
9
|
+
|
|
10
|
+
function rewrite(userArgs: readonly string[]): string[] {
|
|
11
|
+
const optionMeta = buildOptionMetadata(buildProgram());
|
|
12
|
+
return hoistGlobalOptions(["node", "zinkee", ...userArgs], optionMeta, commandRegistry);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("hoistGlobalOptions", () => {
|
|
16
|
+
it("moves boolean global flags before the subcommand path", () => {
|
|
17
|
+
expect(rewrite(["schemas", "list", "--json"])).toEqual([
|
|
18
|
+
"node",
|
|
19
|
+
"zinkee",
|
|
20
|
+
"--json",
|
|
21
|
+
"schemas",
|
|
22
|
+
"list",
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("moves global value flags together with their value", () => {
|
|
27
|
+
expect(rewrite(["schemas", "list", "--profile", "staging"])).toEqual([
|
|
28
|
+
"node",
|
|
29
|
+
"zinkee",
|
|
30
|
+
"--profile",
|
|
31
|
+
"staging",
|
|
32
|
+
"schemas",
|
|
33
|
+
"list",
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("moves multiple globals preserving their relative order", () => {
|
|
38
|
+
expect(
|
|
39
|
+
rewrite(["schemas", "list", "--json", "--profile", "staging", "--read-only"]),
|
|
40
|
+
).toEqual([
|
|
41
|
+
"node",
|
|
42
|
+
"zinkee",
|
|
43
|
+
"--json",
|
|
44
|
+
"--profile",
|
|
45
|
+
"staging",
|
|
46
|
+
"--read-only",
|
|
47
|
+
"schemas",
|
|
48
|
+
"list",
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("hoists globals that are interleaved between subcommand path tokens", () => {
|
|
53
|
+
expect(rewrite(["schemas", "--json", "fields", "create"])).toEqual([
|
|
54
|
+
"node",
|
|
55
|
+
"zinkee",
|
|
56
|
+
"--json",
|
|
57
|
+
"schemas",
|
|
58
|
+
"fields",
|
|
59
|
+
"create",
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not touch global flags that already appear before the subcommand path", () => {
|
|
64
|
+
expect(rewrite(["--json", "schemas", "list"])).toEqual([
|
|
65
|
+
"node",
|
|
66
|
+
"zinkee",
|
|
67
|
+
"--json",
|
|
68
|
+
"schemas",
|
|
69
|
+
"list",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("preserves --api-key when it appears after profiles add (subcommand-local option)", () => {
|
|
74
|
+
expect(
|
|
75
|
+
rewrite(["profiles", "add", "myProfile", "--api-key", "abc", "--base-url", "https://x"]),
|
|
76
|
+
).toEqual([
|
|
77
|
+
"node",
|
|
78
|
+
"zinkee",
|
|
79
|
+
"profiles",
|
|
80
|
+
"add",
|
|
81
|
+
"myProfile",
|
|
82
|
+
"--api-key",
|
|
83
|
+
"abc",
|
|
84
|
+
"--base-url",
|
|
85
|
+
"https://x",
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("preserves --api-key/--base-url for profiles update while still hoisting other globals", () => {
|
|
90
|
+
expect(
|
|
91
|
+
rewrite(["profiles", "update", "myProfile", "--api-key", "abc", "--json"]),
|
|
92
|
+
).toEqual([
|
|
93
|
+
"node",
|
|
94
|
+
"zinkee",
|
|
95
|
+
"--json",
|
|
96
|
+
"profiles",
|
|
97
|
+
"update",
|
|
98
|
+
"myProfile",
|
|
99
|
+
"--api-key",
|
|
100
|
+
"abc",
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("supports the --foo=bar syntax for global value flags", () => {
|
|
105
|
+
expect(rewrite(["schemas", "list", "--profile=staging"])).toEqual([
|
|
106
|
+
"node",
|
|
107
|
+
"zinkee",
|
|
108
|
+
"--profile=staging",
|
|
109
|
+
"schemas",
|
|
110
|
+
"list",
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns argv unchanged when no subcommand path is found", () => {
|
|
115
|
+
expect(rewrite(["--json"])).toEqual(["node", "zinkee", "--json"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("respects -- and stops hoisting after it", () => {
|
|
119
|
+
expect(
|
|
120
|
+
rewrite(["schemas", "list", "--", "--json", "trailing"]),
|
|
121
|
+
).toEqual([
|
|
122
|
+
"node",
|
|
123
|
+
"zinkee",
|
|
124
|
+
"schemas",
|
|
125
|
+
"list",
|
|
126
|
+
"--",
|
|
127
|
+
"--json",
|
|
128
|
+
"trailing",
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("buildOptionMetadata", () => {
|
|
134
|
+
it("registers local options for leaf subcommands", () => {
|
|
135
|
+
const optionMeta = buildOptionMetadata(buildProgram());
|
|
136
|
+
const profilesAdd = optionMeta.localOptionsByCommand.get("profiles add");
|
|
137
|
+
|
|
138
|
+
expect(profilesAdd?.has("--api-key")).toBe(true);
|
|
139
|
+
expect(profilesAdd?.has("--base-url")).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("recognises global value vs boolean flags", () => {
|
|
143
|
+
const optionMeta = buildOptionMetadata(buildProgram());
|
|
144
|
+
|
|
145
|
+
expect(optionMeta.valueFlags.has("--profile")).toBe(true);
|
|
146
|
+
expect(optionMeta.booleanFlags.has("--json")).toBe(true);
|
|
147
|
+
expect(optionMeta.booleanFlags.has("--read-only")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
export const HOISTABLE_GLOBAL_FLAGS = {
|
|
4
|
+
"--profile": "value",
|
|
5
|
+
"--base-url": "value",
|
|
6
|
+
"--api-key": "value",
|
|
7
|
+
"--read-only": "boolean",
|
|
8
|
+
"--json": "boolean",
|
|
9
|
+
} as const satisfies Record<string, "value" | "boolean">;
|
|
10
|
+
|
|
11
|
+
type HoistableFlagName = keyof typeof HOISTABLE_GLOBAL_FLAGS;
|
|
12
|
+
|
|
13
|
+
function isHoistableFlag(flagName: string): flagName is HoistableFlagName {
|
|
14
|
+
return Object.hasOwn(HOISTABLE_GLOBAL_FLAGS, flagName);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OptionMetadata {
|
|
18
|
+
valueFlags: Set<string>;
|
|
19
|
+
booleanFlags: Set<string>;
|
|
20
|
+
localOptionsByCommand: Map<string, Set<string>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getCommandPath(command: Command): string {
|
|
24
|
+
const parts: string[] = [];
|
|
25
|
+
let cursor: Command | null = command;
|
|
26
|
+
while (cursor && cursor.parent) {
|
|
27
|
+
parts.unshift(cursor.name());
|
|
28
|
+
cursor = cursor.parent;
|
|
29
|
+
}
|
|
30
|
+
return parts.join(" ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildOptionMetadata(program: Command): OptionMetadata {
|
|
34
|
+
const valueFlags = new Set<string>();
|
|
35
|
+
const booleanFlags = new Set<string>();
|
|
36
|
+
const localOptionsByCommand = new Map<string, Set<string>>();
|
|
37
|
+
|
|
38
|
+
function visit(command: Command, isRoot: boolean): void {
|
|
39
|
+
const localFlags = new Set<string>();
|
|
40
|
+
|
|
41
|
+
for (const option of command.options) {
|
|
42
|
+
if (!option.long) continue;
|
|
43
|
+
|
|
44
|
+
if (option.required || option.optional) {
|
|
45
|
+
valueFlags.add(option.long);
|
|
46
|
+
} else {
|
|
47
|
+
booleanFlags.add(option.long);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isRoot) {
|
|
51
|
+
localFlags.add(option.long);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isRoot) {
|
|
56
|
+
localOptionsByCommand.set(getCommandPath(command), localFlags);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const sub of command.commands) {
|
|
60
|
+
visit(sub, false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
visit(program, true);
|
|
65
|
+
return { valueFlags, booleanFlags, localOptionsByCommand };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createDefaultOptionMetadata(): OptionMetadata {
|
|
69
|
+
return {
|
|
70
|
+
valueFlags: new Set([
|
|
71
|
+
"--profile",
|
|
72
|
+
"--base-url",
|
|
73
|
+
"--api-key",
|
|
74
|
+
"--install-completion",
|
|
75
|
+
"--show-completion",
|
|
76
|
+
]),
|
|
77
|
+
booleanFlags: new Set([
|
|
78
|
+
"--read-only",
|
|
79
|
+
"--json",
|
|
80
|
+
"--example",
|
|
81
|
+
"--help",
|
|
82
|
+
]),
|
|
83
|
+
localOptionsByCommand: new Map(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface FlagSplit {
|
|
88
|
+
flagName: string;
|
|
89
|
+
inlineValue: string | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isFlagToken(token: string): boolean {
|
|
93
|
+
return token.length > 1 && token.startsWith("-");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function splitFlagToken(token: string): FlagSplit {
|
|
97
|
+
if (token.startsWith("--") && token.includes("=")) {
|
|
98
|
+
const eqIdx = token.indexOf("=");
|
|
99
|
+
return {
|
|
100
|
+
flagName: token.slice(0, eqIdx),
|
|
101
|
+
inlineValue: token.slice(eqIdx + 1),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { flagName: token, inlineValue: undefined };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function flagConsumesValue(flagName: string, optionMeta: OptionMetadata): boolean {
|
|
108
|
+
if (optionMeta.valueFlags.has(flagName)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (optionMeta.booleanFlags.has(flagName)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface OperandPosition {
|
|
118
|
+
index: number;
|
|
119
|
+
token: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function collectOperandTokens(
|
|
123
|
+
userArgs: readonly string[],
|
|
124
|
+
optionMeta: OptionMetadata,
|
|
125
|
+
options: { skipFlag?: string } = {},
|
|
126
|
+
): OperandPosition[] {
|
|
127
|
+
const operands: OperandPosition[] = [];
|
|
128
|
+
let i = 0;
|
|
129
|
+
while (i < userArgs.length) {
|
|
130
|
+
const token = userArgs[i] as string;
|
|
131
|
+
|
|
132
|
+
if (token === "--") {
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isFlagToken(token)) {
|
|
137
|
+
const { flagName, inlineValue } = splitFlagToken(token);
|
|
138
|
+
|
|
139
|
+
if (options.skipFlag !== undefined && flagName === options.skipFlag) {
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (inlineValue !== undefined) {
|
|
145
|
+
i += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const consumes = flagConsumesValue(flagName, optionMeta);
|
|
150
|
+
const next = userArgs[i + 1];
|
|
151
|
+
if (consumes && next !== undefined && !isFlagToken(next)) {
|
|
152
|
+
i += 2;
|
|
153
|
+
} else {
|
|
154
|
+
i += 1;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
operands.push({ index: i, token });
|
|
160
|
+
i += 1;
|
|
161
|
+
}
|
|
162
|
+
return operands;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface CommandPathMatch {
|
|
166
|
+
commandName: string;
|
|
167
|
+
firstArgvIndex: number;
|
|
168
|
+
lastArgvIndex: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function findCommandPath(
|
|
172
|
+
operands: readonly OperandPosition[],
|
|
173
|
+
registry: Record<string, unknown>,
|
|
174
|
+
): CommandPathMatch | null {
|
|
175
|
+
if (operands.length === 0) return null;
|
|
176
|
+
|
|
177
|
+
const commandsByLength = Object.keys(registry).sort(
|
|
178
|
+
(left, right) => right.split(" ").length - left.split(" ").length,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
for (let start = 0; start < operands.length; start += 1) {
|
|
182
|
+
for (const commandName of commandsByLength) {
|
|
183
|
+
const parts = commandName.split(" ");
|
|
184
|
+
if (start + parts.length > operands.length) continue;
|
|
185
|
+
|
|
186
|
+
let matches = true;
|
|
187
|
+
for (let j = 0; j < parts.length; j += 1) {
|
|
188
|
+
if (operands[start + j]!.token !== parts[j]) {
|
|
189
|
+
matches = false;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (matches) {
|
|
195
|
+
return {
|
|
196
|
+
commandName,
|
|
197
|
+
firstArgvIndex: operands[start]!.index,
|
|
198
|
+
lastArgvIndex: operands[start + parts.length - 1]!.index,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function hoistGlobalOptions(
|
|
208
|
+
argv: readonly string[],
|
|
209
|
+
optionMeta: OptionMetadata,
|
|
210
|
+
registry: Record<string, unknown>,
|
|
211
|
+
): string[] {
|
|
212
|
+
if (argv.length < 3) return [...argv];
|
|
213
|
+
|
|
214
|
+
const userArgs = argv.slice(2);
|
|
215
|
+
const operands = collectOperandTokens(userArgs, optionMeta);
|
|
216
|
+
const cmdPath = findCommandPath(operands, registry);
|
|
217
|
+
|
|
218
|
+
if (!cmdPath) return [...argv];
|
|
219
|
+
|
|
220
|
+
const localOptions =
|
|
221
|
+
optionMeta.localOptionsByCommand.get(cmdPath.commandName) ?? new Set<string>();
|
|
222
|
+
|
|
223
|
+
const hoisted: string[] = [];
|
|
224
|
+
const remaining: string[] = [];
|
|
225
|
+
let i = 0;
|
|
226
|
+
|
|
227
|
+
while (i < userArgs.length) {
|
|
228
|
+
const token = userArgs[i] as string;
|
|
229
|
+
|
|
230
|
+
if (token === "--") {
|
|
231
|
+
remaining.push(...userArgs.slice(i));
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!isFlagToken(token)) {
|
|
236
|
+
remaining.push(token);
|
|
237
|
+
i += 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { flagName, inlineValue } = splitFlagToken(token);
|
|
242
|
+
|
|
243
|
+
if (!isHoistableFlag(flagName)) {
|
|
244
|
+
remaining.push(token);
|
|
245
|
+
i += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const isAfterCommandPath = i > cmdPath.lastArgvIndex;
|
|
250
|
+
const collidesWithLocal = localOptions.has(flagName);
|
|
251
|
+
|
|
252
|
+
const target = isAfterCommandPath && collidesWithLocal ? remaining : hoisted;
|
|
253
|
+
target.push(token);
|
|
254
|
+
|
|
255
|
+
const spec = HOISTABLE_GLOBAL_FLAGS[flagName];
|
|
256
|
+
if (spec === "value" && inlineValue === undefined) {
|
|
257
|
+
const next = userArgs[i + 1];
|
|
258
|
+
if (next !== undefined && !isFlagToken(next)) {
|
|
259
|
+
target.push(next);
|
|
260
|
+
i += 2;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
i += 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return [argv[0] as string, argv[1] as string, ...hoisted, ...remaining];
|
|
269
|
+
}
|