specli 0.0.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.
Files changed (76) hide show
  1. package/CLAUDE.md +111 -0
  2. package/PLAN.md +274 -0
  3. package/README.md +474 -0
  4. package/biome.jsonc +1 -0
  5. package/bun.lock +98 -0
  6. package/cli.ts +74 -0
  7. package/fixtures/openapi-array-items.json +22 -0
  8. package/fixtures/openapi-auth.json +34 -0
  9. package/fixtures/openapi-body.json +41 -0
  10. package/fixtures/openapi-collision.json +21 -0
  11. package/fixtures/openapi-oauth.json +54 -0
  12. package/fixtures/openapi-servers.json +35 -0
  13. package/fixtures/openapi.json +87 -0
  14. package/index.ts +1 -0
  15. package/package.json +27 -0
  16. package/scripts/smoke-specs.ts +64 -0
  17. package/src/cli/auth-requirements.test.ts +27 -0
  18. package/src/cli/auth-requirements.ts +91 -0
  19. package/src/cli/auth-schemes.test.ts +66 -0
  20. package/src/cli/auth-schemes.ts +187 -0
  21. package/src/cli/capabilities.test.ts +94 -0
  22. package/src/cli/capabilities.ts +88 -0
  23. package/src/cli/command-id.test.ts +32 -0
  24. package/src/cli/command-id.ts +16 -0
  25. package/src/cli/command-index.ts +19 -0
  26. package/src/cli/command-model.test.ts +44 -0
  27. package/src/cli/command-model.ts +128 -0
  28. package/src/cli/compile.ts +119 -0
  29. package/src/cli/crypto.ts +9 -0
  30. package/src/cli/derive-name.ts +101 -0
  31. package/src/cli/exec.ts +72 -0
  32. package/src/cli/main.ts +336 -0
  33. package/src/cli/naming.test.ts +86 -0
  34. package/src/cli/naming.ts +224 -0
  35. package/src/cli/operations.test.ts +57 -0
  36. package/src/cli/operations.ts +152 -0
  37. package/src/cli/params.test.ts +70 -0
  38. package/src/cli/params.ts +71 -0
  39. package/src/cli/pluralize.ts +41 -0
  40. package/src/cli/positional.test.ts +65 -0
  41. package/src/cli/positional.ts +75 -0
  42. package/src/cli/request-body.test.ts +35 -0
  43. package/src/cli/request-body.ts +94 -0
  44. package/src/cli/runtime/argv.ts +14 -0
  45. package/src/cli/runtime/auth/resolve.ts +31 -0
  46. package/src/cli/runtime/body.ts +24 -0
  47. package/src/cli/runtime/collect.ts +6 -0
  48. package/src/cli/runtime/context.ts +62 -0
  49. package/src/cli/runtime/execute.ts +138 -0
  50. package/src/cli/runtime/generated.ts +200 -0
  51. package/src/cli/runtime/headers.ts +37 -0
  52. package/src/cli/runtime/index.ts +3 -0
  53. package/src/cli/runtime/profile/secrets.ts +42 -0
  54. package/src/cli/runtime/profile/store.ts +98 -0
  55. package/src/cli/runtime/request.test.ts +153 -0
  56. package/src/cli/runtime/request.ts +487 -0
  57. package/src/cli/runtime/server-url.ts +44 -0
  58. package/src/cli/runtime/template.ts +26 -0
  59. package/src/cli/runtime/validate/ajv.ts +13 -0
  60. package/src/cli/runtime/validate/coerce.ts +71 -0
  61. package/src/cli/runtime/validate/error.ts +29 -0
  62. package/src/cli/runtime/validate/index.ts +4 -0
  63. package/src/cli/runtime/validate/schema.ts +54 -0
  64. package/src/cli/schema-shape.ts +36 -0
  65. package/src/cli/schema.ts +76 -0
  66. package/src/cli/server.test.ts +35 -0
  67. package/src/cli/server.ts +88 -0
  68. package/src/cli/spec-id.ts +12 -0
  69. package/src/cli/spec-loader.ts +58 -0
  70. package/src/cli/stable-json.ts +35 -0
  71. package/src/cli/strings.ts +21 -0
  72. package/src/cli/types.ts +59 -0
  73. package/src/compiled.ts +23 -0
  74. package/src/macros/env.ts +25 -0
  75. package/src/macros/spec.ts +17 -0
  76. package/tsconfig.json +29 -0
@@ -0,0 +1,200 @@
1
+ import { Command } from "commander";
2
+
3
+ import type { AuthScheme } from "../auth-schemes.ts";
4
+ import type { CommandModel } from "../command-model.ts";
5
+ import type { ServerInfo } from "../server.ts";
6
+
7
+ import { collectRepeatable } from "./collect.ts";
8
+ import { executeAction } from "./execute.ts";
9
+ import { coerceArrayInput, coerceValue } from "./validate/index.ts";
10
+
11
+ export type GeneratedCliContext = {
12
+ servers: ServerInfo[];
13
+ authSchemes: AuthScheme[];
14
+ commands: CommandModel;
15
+ specId: string;
16
+ };
17
+
18
+ export function addGeneratedCommands(
19
+ program: Command,
20
+ context: GeneratedCliContext,
21
+ ): void {
22
+ for (const resource of context.commands.resources) {
23
+ const resourceCmd = program
24
+ .command(resource.resource)
25
+ .description(`Operations for ${resource.resource}`);
26
+
27
+ for (const action of resource.actions) {
28
+ const cmd = resourceCmd.command(action.action);
29
+ cmd.description(
30
+ action.summary ??
31
+ action.description ??
32
+ `${action.method} ${action.path}`,
33
+ );
34
+
35
+ for (const pos of action.positionals) {
36
+ cmd.argument(`<${pos.name}>`, pos.description);
37
+ }
38
+
39
+ for (const flag of action.flags) {
40
+ const opt = flag.flag;
41
+ const desc = flag.description ?? `${flag.in} parameter`;
42
+
43
+ if (flag.type === "boolean") {
44
+ cmd.option(opt, desc);
45
+ continue;
46
+ }
47
+
48
+ const isArray = flag.type === "array";
49
+ const itemType = flag.itemType ?? "string";
50
+ const parser = (raw: string) => coerceValue(raw, itemType);
51
+
52
+ if (isArray) {
53
+ const key = `${opt} <value>`;
54
+ cmd.option(
55
+ key,
56
+ desc,
57
+ (value: string, prev: unknown[] | undefined) => {
58
+ const next: unknown[] = [...(prev ?? [])];
59
+
60
+ // Allow `--tags a,b` and `--tags '["a","b"]'` to expand.
61
+ const items = coerceArrayInput(value, itemType);
62
+ for (const item of items) {
63
+ next.push(item);
64
+ }
65
+
66
+ return next;
67
+ },
68
+ );
69
+ continue;
70
+ }
71
+
72
+ const key = `${opt} <value>`;
73
+ if (flag.required) cmd.requiredOption(key, desc, parser);
74
+ else cmd.option(key, desc, parser);
75
+ }
76
+
77
+ const reservedFlags = new Set(action.flags.map((f) => f.flag));
78
+
79
+ // Common curl-replacement options.
80
+ // Some APIs have parameters like `accept`, `timeout`, etc. We always add
81
+ // namespaced variants (`--oc-*`) and only add the short versions when they
82
+ // do not conflict with operation flags.
83
+ cmd
84
+ .option(
85
+ "--oc-header <header>",
86
+ "Extra header (repeatable)",
87
+ collectRepeatable,
88
+ )
89
+ .option("--oc-accept <type>", "Override Accept header")
90
+ .option("--oc-status", "Include status in --json output")
91
+ .option("--oc-headers", "Include headers in --json output")
92
+ .option("--oc-dry-run", "Print request without sending")
93
+ .option("--oc-curl", "Print curl command without sending")
94
+ .option("--oc-timeout <ms>", "Request timeout in milliseconds");
95
+
96
+ if (!reservedFlags.has("--header")) {
97
+ cmd.option(
98
+ "--header <header>",
99
+ "Extra header (repeatable)",
100
+ collectRepeatable,
101
+ );
102
+ }
103
+ if (!reservedFlags.has("--accept")) {
104
+ cmd.option("--accept <type>", "Override Accept header");
105
+ }
106
+ if (!reservedFlags.has("--status")) {
107
+ cmd.option("--status", "Include status in --json output");
108
+ }
109
+ if (!reservedFlags.has("--headers")) {
110
+ cmd.option("--headers", "Include headers in --json output");
111
+ }
112
+ if (!reservedFlags.has("--dry-run")) {
113
+ cmd.option("--dry-run", "Print request without sending");
114
+ }
115
+ if (!reservedFlags.has("--curl")) {
116
+ cmd.option("--curl", "Print curl command without sending");
117
+ }
118
+ if (!reservedFlags.has("--timeout")) {
119
+ cmd.option("--timeout <ms>", "Request timeout in milliseconds");
120
+ }
121
+
122
+ if (action.requestBody) {
123
+ cmd
124
+ .option("--oc-data <data>", "Inline request body")
125
+ .option("--oc-file <path>", "Request body from file")
126
+ .option(
127
+ "--oc-content-type <type>",
128
+ "Override Content-Type (defaults from OpenAPI)",
129
+ );
130
+
131
+ if (!reservedFlags.has("--data")) {
132
+ cmd.option("--data <data>", "Inline request body");
133
+ }
134
+ if (!reservedFlags.has("--file")) {
135
+ cmd.option("--file <path>", "Request body from file");
136
+ }
137
+ if (!reservedFlags.has("--content-type")) {
138
+ cmd.option(
139
+ "--content-type <type>",
140
+ "Override Content-Type (defaults from OpenAPI)",
141
+ );
142
+ }
143
+
144
+ // Expanded JSON body flags (only for simple object bodies).
145
+ const schema = action.requestBodySchema;
146
+ if (schema && schema.type === "object" && schema.properties) {
147
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
148
+ if (!name || typeof name !== "string") continue;
149
+ if (!propSchema || typeof propSchema !== "object") continue;
150
+ const t = (propSchema as { type?: unknown }).type;
151
+ if (
152
+ t !== "string" &&
153
+ t !== "number" &&
154
+ t !== "integer" &&
155
+ t !== "boolean"
156
+ ) {
157
+ continue;
158
+ }
159
+
160
+ const flagName = `--body-${name}`;
161
+ if (t === "boolean") {
162
+ cmd.option(flagName, `Body field '${name}'`);
163
+ } else {
164
+ cmd.option(`${flagName} <value>`, `Body field '${name}'`);
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ // Commander passes positional args and then the Command instance as last arg.
171
+ cmd.action(async (...args) => {
172
+ const command = args[args.length - 1];
173
+ const positionalValues = args.slice(0, -1).map((v) => String(v));
174
+
175
+ if (!(command instanceof Command)) {
176
+ throw new Error("Unexpected commander action signature");
177
+ }
178
+
179
+ const globals = command.optsWithGlobals();
180
+ const local = command.opts();
181
+
182
+ const bodyFlags: Record<string, unknown> = {};
183
+ for (const key of Object.keys(local)) {
184
+ if (!key.startsWith("body")) continue;
185
+ bodyFlags[key] = local[key];
186
+ }
187
+
188
+ await executeAction({
189
+ action,
190
+ positionalValues,
191
+ flagValues: { ...local, __body: bodyFlags },
192
+ globals,
193
+ servers: context.servers,
194
+ authSchemes: context.authSchemes,
195
+ specId: context.specId,
196
+ });
197
+ });
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,37 @@
1
+ export function parseHeaderInput(input: string): {
2
+ name: string;
3
+ value: string;
4
+ } {
5
+ const trimmed = input.trim();
6
+ if (!trimmed) throw new Error("Empty header");
7
+
8
+ // Support either "Name: Value" or "Name=Value".
9
+ const colon = trimmed.indexOf(":");
10
+ if (colon !== -1) {
11
+ const name = trimmed.slice(0, colon).trim();
12
+ const value = trimmed.slice(colon + 1).trim();
13
+ if (!name) throw new Error("Invalid header name");
14
+ return { name, value };
15
+ }
16
+
17
+ const eq = trimmed.indexOf("=");
18
+ if (eq !== -1) {
19
+ const name = trimmed.slice(0, eq).trim();
20
+ const value = trimmed.slice(eq + 1).trim();
21
+ if (!name) throw new Error("Invalid header name");
22
+ return { name, value };
23
+ }
24
+
25
+ throw new Error("Invalid header format. Use 'Name: Value' or 'Name=Value'.");
26
+ }
27
+
28
+ export function mergeHeaders(
29
+ base: Headers,
30
+ entries: Array<{ name: string; value: string }>,
31
+ ): Headers {
32
+ const h = new Headers(base);
33
+ for (const { name, value } of entries) {
34
+ h.set(name, value);
35
+ }
36
+ return h;
37
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./argv.ts";
2
+ export * from "./context.ts";
3
+ export * from "./generated.ts";
@@ -0,0 +1,42 @@
1
+ import { secrets } from "bun";
2
+
3
+ export type SecretKey = {
4
+ service: string;
5
+ name: string;
6
+ };
7
+
8
+ export function secretServiceForSpec(specId: string): string {
9
+ return `opencli:${specId}`;
10
+ }
11
+
12
+ export function tokenSecretKey(specId: string, profile: string): SecretKey {
13
+ return {
14
+ service: secretServiceForSpec(specId),
15
+ name: `profile:${profile}:token`,
16
+ };
17
+ }
18
+
19
+ export async function setToken(
20
+ specId: string,
21
+ profile: string,
22
+ token: string,
23
+ ): Promise<void> {
24
+ const key = tokenSecretKey(specId, profile);
25
+ await secrets.set({ service: key.service, name: key.name, value: token });
26
+ }
27
+
28
+ export async function getToken(
29
+ specId: string,
30
+ profile: string,
31
+ ): Promise<string | null> {
32
+ const key = tokenSecretKey(specId, profile);
33
+ return await secrets.get({ service: key.service, name: key.name });
34
+ }
35
+
36
+ export async function deleteToken(
37
+ specId: string,
38
+ profile: string,
39
+ ): Promise<boolean> {
40
+ const key = tokenSecretKey(specId, profile);
41
+ return await secrets.delete({ service: key.service, name: key.name });
42
+ }
@@ -0,0 +1,98 @@
1
+ import { YAML } from "bun";
2
+
3
+ export type Profile = {
4
+ name: string;
5
+ server?: string;
6
+ authScheme?: string;
7
+ // For apiKey schemes we also need the apiKey name/in from spec to inject,
8
+ // but that is discoverable from the spec at runtime.
9
+ };
10
+
11
+ export type ProfilesFile = {
12
+ profiles: Profile[];
13
+ defaultProfile?: string;
14
+ };
15
+
16
+ function configDir(): string {
17
+ // Keep it simple (v1). We can move to env-paths later.
18
+ const home = process.env.HOME;
19
+ if (!home) throw new Error("Missing HOME env var");
20
+ return `${home}/.config/opencli`;
21
+ }
22
+
23
+ function configPathJson(): string {
24
+ return `${configDir()}/profiles.json`;
25
+ }
26
+
27
+ function configPathYaml(): string {
28
+ return `${configDir()}/profiles.yaml`;
29
+ }
30
+
31
+ export async function readProfiles(): Promise<ProfilesFile> {
32
+ const jsonPath = configPathJson();
33
+ const yamlPath = configPathYaml();
34
+
35
+ const jsonFile = Bun.file(jsonPath);
36
+ const yamlFile = Bun.file(yamlPath);
37
+
38
+ const file = (await jsonFile.exists())
39
+ ? jsonFile
40
+ : (await yamlFile.exists())
41
+ ? yamlFile
42
+ : null;
43
+
44
+ if (!file) return { profiles: [] };
45
+
46
+ const text = await file.text();
47
+ let parsed: unknown;
48
+ try {
49
+ parsed = YAML.parse(text) as unknown;
50
+ } catch {
51
+ parsed = JSON.parse(text) as unknown;
52
+ }
53
+
54
+ const obj =
55
+ parsed && typeof parsed === "object"
56
+ ? (parsed as Record<string, unknown>)
57
+ : {};
58
+ const profiles = Array.isArray(obj.profiles)
59
+ ? (obj.profiles as Profile[])
60
+ : [];
61
+
62
+ return {
63
+ profiles: profiles.filter(Boolean),
64
+ defaultProfile:
65
+ typeof obj.defaultProfile === "string"
66
+ ? (obj.defaultProfile as string)
67
+ : undefined,
68
+ };
69
+ }
70
+
71
+ export async function writeProfiles(data: ProfilesFile): Promise<void> {
72
+ const dir = configDir();
73
+ await Bun.$`mkdir -p ${dir}`;
74
+ await Bun.write(configPathJson(), JSON.stringify(data, null, 2));
75
+ }
76
+
77
+ export function getProfile(
78
+ data: ProfilesFile,
79
+ name: string | undefined,
80
+ ): Profile | undefined {
81
+ const wanted = name ?? data.defaultProfile;
82
+ if (!wanted) return undefined;
83
+ return data.profiles.find((p) => p?.name === wanted);
84
+ }
85
+
86
+ export function upsertProfile(
87
+ data: ProfilesFile,
88
+ profile: Profile,
89
+ ): ProfilesFile {
90
+ const profiles = data.profiles.filter((p) => p.name !== profile.name);
91
+ profiles.push(profile);
92
+ profiles.sort((a, b) => a.name.localeCompare(b.name));
93
+ return { ...data, profiles };
94
+ }
95
+
96
+ export function removeProfile(data: ProfilesFile, name: string): ProfilesFile {
97
+ return { ...data, profiles: data.profiles.filter((p) => p.name !== name) };
98
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { tmpdir } from "node:os";
4
+
5
+ import type { CommandAction } from "../command-model.ts";
6
+
7
+ import { buildRequest } from "./request.ts";
8
+ import { createAjv, formatAjvErrors } from "./validate/index.ts";
9
+
10
+ function makeAction(partial?: Partial<CommandAction>): CommandAction {
11
+ return {
12
+ id: "test",
13
+ key: "POST /contacts",
14
+ action: "create",
15
+ pathArgs: [],
16
+ method: "POST",
17
+ path: "/contacts",
18
+ tags: [],
19
+ style: "rest",
20
+ positionals: [],
21
+ flags: [],
22
+ params: [],
23
+ auth: { alternatives: [] },
24
+ requestBody: {
25
+ required: true,
26
+ content: [
27
+ {
28
+ contentType: "application/json",
29
+ required: true,
30
+ schemaType: "object",
31
+ },
32
+ ],
33
+ hasJson: true,
34
+ hasFormUrlEncoded: false,
35
+ hasMultipart: false,
36
+ bodyFlags: ["--data", "--file"],
37
+ preferredContentType: "application/json",
38
+ preferredSchema: undefined,
39
+ },
40
+ requestBodySchema: {
41
+ type: "object",
42
+ properties: {
43
+ name: { type: "string" },
44
+ },
45
+ required: ["name"],
46
+ },
47
+ ...partial,
48
+ };
49
+ }
50
+
51
+ describe("buildRequest (requestBody)", () => {
52
+ test("builds body from expanded --body-* flags", async () => {
53
+ const prevHome = process.env.HOME;
54
+ const home = `${tmpdir()}/opencli-test-${crypto.randomUUID()}`;
55
+ process.env.HOME = home;
56
+
57
+ try {
58
+ const { request, curl } = await buildRequest({
59
+ specId: "spec",
60
+ action: makeAction(),
61
+ positionalValues: [],
62
+ flagValues: { __body: { bodyName: "A" } },
63
+ globals: {},
64
+ servers: [
65
+ { url: "https://api.example.com", variables: [], variableNames: [] },
66
+ ],
67
+ authSchemes: [],
68
+ });
69
+
70
+ expect(request.headers.get("Content-Type")).toBe("application/json");
71
+ expect(await request.clone().text()).toBe('{"name":"A"}');
72
+ expect(curl).toContain("--data");
73
+ expect(curl).toContain('{"name":"A"}');
74
+ } finally {
75
+ process.env.HOME = prevHome;
76
+ }
77
+ });
78
+
79
+ test("throws when requestBody is required but missing", async () => {
80
+ const prevHome = process.env.HOME;
81
+ const home = `${tmpdir()}/opencli-test-${crypto.randomUUID()}`;
82
+ process.env.HOME = home;
83
+
84
+ try {
85
+ await expect(() =>
86
+ buildRequest({
87
+ specId: "spec",
88
+ action: makeAction(),
89
+ positionalValues: [],
90
+ flagValues: {},
91
+ globals: {},
92
+ servers: [
93
+ {
94
+ url: "https://api.example.com",
95
+ variables: [],
96
+ variableNames: [],
97
+ },
98
+ ],
99
+ authSchemes: [],
100
+ }),
101
+ ).toThrow(
102
+ "Missing request body. Provide --data, --file, or --body-* flags.",
103
+ );
104
+ } finally {
105
+ process.env.HOME = prevHome;
106
+ }
107
+ });
108
+
109
+ test("throws friendly error for missing required expanded field", async () => {
110
+ const prevHome = process.env.HOME;
111
+ const home = `${tmpdir()}/opencli-test-${crypto.randomUUID()}`;
112
+ process.env.HOME = home;
113
+
114
+ try {
115
+ await expect(() =>
116
+ buildRequest({
117
+ specId: "spec",
118
+ action: makeAction(),
119
+ positionalValues: [],
120
+ flagValues: { __body: { bodyFoo: "bar" } },
121
+ globals: {},
122
+ servers: [
123
+ {
124
+ url: "https://api.example.com",
125
+ variables: [],
126
+ variableNames: [],
127
+ },
128
+ ],
129
+ authSchemes: [],
130
+ }),
131
+ ).toThrow(
132
+ "Missing required body field 'name'. Provide --body-name or use --data/--file.",
133
+ );
134
+ } finally {
135
+ process.env.HOME = prevHome;
136
+ }
137
+ });
138
+ });
139
+
140
+ describe("formatAjvErrors", () => {
141
+ test("pretty prints required errors", () => {
142
+ const ajv = createAjv();
143
+ const validate = ajv.compile({
144
+ type: "object",
145
+ properties: { name: { type: "string" } },
146
+ required: ["name"],
147
+ });
148
+
149
+ validate({});
150
+ const msg = formatAjvErrors(validate.errors);
151
+ expect(msg).toBe("/ missing required property 'name'");
152
+ });
153
+ });