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,34 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "Auth API",
5
+ "version": "1.0.0"
6
+ },
7
+ "servers": [{ "url": "https://api.example.com" }],
8
+ "components": {
9
+ "securitySchemes": {
10
+ "bearerAuth": {
11
+ "type": "http",
12
+ "scheme": "bearer",
13
+ "bearerFormat": "JWT"
14
+ },
15
+ "apiKeyAuth": {
16
+ "type": "apiKey",
17
+ "in": "header",
18
+ "name": "X-API-Key"
19
+ }
20
+ }
21
+ },
22
+ "paths": {
23
+ "/ping": {
24
+ "get": {
25
+ "operationId": "Ping.Get",
26
+ "responses": {
27
+ "200": {
28
+ "description": "OK"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "Body API",
5
+ "version": "1.0.0"
6
+ },
7
+ "servers": [{ "url": "https://api.example.com" }],
8
+ "paths": {
9
+ "/contacts": {
10
+ "post": {
11
+ "operationId": "Contacts.Create",
12
+ "tags": ["Contacts"],
13
+ "requestBody": {
14
+ "required": true,
15
+ "content": {
16
+ "application/json": {
17
+ "schema": {
18
+ "type": "object",
19
+ "properties": {
20
+ "name": { "type": "string" }
21
+ },
22
+ "required": ["name"]
23
+ }
24
+ },
25
+ "application/x-www-form-urlencoded": {
26
+ "schema": {
27
+ "type": "object",
28
+ "properties": {
29
+ "name": { "type": "string" }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ },
35
+ "responses": {
36
+ "200": { "description": "OK" }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "Collision API",
5
+ "version": "1.0.0"
6
+ },
7
+ "paths": {
8
+ "/contacts": {
9
+ "get": {
10
+ "operationId": "Contacts.List",
11
+ "responses": { "200": { "description": "OK" } }
12
+ }
13
+ },
14
+ "/contacts/search": {
15
+ "get": {
16
+ "operationId": "Contacts.List",
17
+ "responses": { "200": { "description": "OK" } }
18
+ }
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "OAuth API",
5
+ "version": "1.0.0"
6
+ },
7
+ "servers": [{ "url": "https://api.example.com" }],
8
+ "security": [
9
+ {
10
+ "oauth": ["read:ping"]
11
+ },
12
+ {}
13
+ ],
14
+ "components": {
15
+ "securitySchemes": {
16
+ "oauth": {
17
+ "type": "oauth2",
18
+ "flows": {
19
+ "authorizationCode": {
20
+ "authorizationUrl": "https://example.com/oauth/authorize",
21
+ "tokenUrl": "https://example.com/oauth/token",
22
+ "scopes": {
23
+ "read:ping": "read ping"
24
+ }
25
+ },
26
+ "clientCredentials": {
27
+ "tokenUrl": "https://example.com/oauth/token",
28
+ "scopes": {
29
+ "read:ping": "read ping",
30
+ "write:ping": "write ping"
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ },
37
+ "paths": {
38
+ "/ping": {
39
+ "get": {
40
+ "operationId": "Ping.Get",
41
+ "security": [
42
+ {
43
+ "oauth": ["read:ping"]
44
+ }
45
+ ],
46
+ "responses": {
47
+ "200": {
48
+ "description": "OK"
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "Servers API",
5
+ "version": "1.0.0"
6
+ },
7
+ "servers": [
8
+ {
9
+ "url": "https://{region}.api.example.com/{basePath}",
10
+ "description": "Regional server",
11
+ "variables": {
12
+ "region": {
13
+ "default": "us",
14
+ "enum": ["us", "eu"],
15
+ "description": "Region code"
16
+ },
17
+ "basePath": {
18
+ "default": "v1"
19
+ }
20
+ }
21
+ }
22
+ ],
23
+ "paths": {
24
+ "/ping": {
25
+ "get": {
26
+ "operationId": "Ping.Get",
27
+ "responses": {
28
+ "200": {
29
+ "description": "OK"
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,87 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "Contacts API",
5
+ "version": "1.0.0"
6
+ },
7
+ "servers": [
8
+ {
9
+ "url": "https://api.example.com"
10
+ }
11
+ ],
12
+ "paths": {
13
+ "/contacts": {
14
+ "get": {
15
+ "operationId": "Contacts.List",
16
+ "tags": ["Contacts"],
17
+ "summary": "List contacts",
18
+ "parameters": [
19
+ {
20
+ "in": "query",
21
+ "name": "limit",
22
+ "schema": { "type": "integer", "minimum": 1, "maximum": 100 },
23
+ "description": "Max items to return"
24
+ },
25
+ {
26
+ "in": "query",
27
+ "name": "tag",
28
+ "schema": { "type": "array", "items": { "type": "string" } },
29
+ "description": "Filter by tags"
30
+ }
31
+ ],
32
+ "responses": {
33
+ "200": {
34
+ "description": "OK",
35
+ "content": {
36
+ "application/json": {
37
+ "schema": {
38
+ "type": "array",
39
+ "items": {
40
+ "type": "object",
41
+ "properties": {
42
+ "id": { "type": "string" },
43
+ "name": { "type": "string" }
44
+ },
45
+ "required": ["id", "name"]
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ },
54
+ "/contacts/{id}": {
55
+ "get": {
56
+ "operationId": "Contacts.Get",
57
+ "tags": ["Contacts"],
58
+ "summary": "Get contact",
59
+ "parameters": [
60
+ {
61
+ "in": "path",
62
+ "name": "id",
63
+ "required": true,
64
+ "schema": { "type": "string" }
65
+ }
66
+ ],
67
+ "responses": {
68
+ "200": {
69
+ "description": "OK",
70
+ "content": {
71
+ "application/json": {
72
+ "schema": {
73
+ "type": "object",
74
+ "properties": {
75
+ "id": { "type": "string" },
76
+ "name": { "type": "string" }
77
+ },
78
+ "required": ["id", "name"]
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { main } from "./src/cli/main.ts";
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "specli",
3
+ "module": "index.ts",
4
+ "type": "module",
5
+ "version": "0.0.1",
6
+ "bin": {
7
+ "opencli": "./cli.ts"
8
+ },
9
+ "scripts": {
10
+ "lint": "biome ci",
11
+ "lint:check": "biome check --write --unsafe",
12
+ "lint:format": "biome format --write",
13
+ "typecheck": "tsgo"
14
+ },
15
+ "devDependencies": {
16
+ "@biomejs/biome": "^2.3.11",
17
+ "@types/bun": "^1.3.6",
18
+ "@typescript/native-preview": "^7.0.0-dev.20260120.1"
19
+ },
20
+ "dependencies": {
21
+ "@apidevtools/swagger-parser": "^12.1.0",
22
+ "ajv": "^8.17.1",
23
+ "ajv-formats": "^3.0.1",
24
+ "commander": "^14.0.2",
25
+ "openapi-types": "^12.1.3"
26
+ }
27
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bun
2
+
3
+ const specs = [
4
+ {
5
+ name: "vercel",
6
+ url: "https://api.vercel.com/copper/_openapi.json",
7
+ },
8
+ {
9
+ name: "digitalocean",
10
+ url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",
11
+ },
12
+ // {
13
+ // name: "stripe",
14
+ // url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml",
15
+ // },
16
+ // NOTE: OpenAI spec is not reliably fetchable via raw.githubusercontent.com
17
+ // in some environments (intermittent 404). Keep it out of the default smoke
18
+ // list to avoid flaky CI.
19
+ // {
20
+ // name: "openai",
21
+ // url: "https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml",
22
+ // },
23
+ ] as const;
24
+
25
+ type Result = {
26
+ spec: string;
27
+ ok: boolean;
28
+ ms: number;
29
+ error?: string;
30
+ };
31
+
32
+ const results: Result[] = [];
33
+
34
+ for (const spec of specs) {
35
+ const start = performance.now();
36
+ try {
37
+ console.log(spec.url);
38
+ await Bun.$`bun ./cli.ts exec ${spec.url} __schema --json --min > /dev/null`;
39
+ results.push({
40
+ spec: spec.name,
41
+ ok: true,
42
+ ms: Math.round(performance.now() - start),
43
+ });
44
+ } catch (err) {
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ results.push({
47
+ spec: spec.name,
48
+ ok: false,
49
+ ms: Math.round(performance.now() - start),
50
+ error: message,
51
+ });
52
+ }
53
+ }
54
+
55
+ const allOk = results.every((r) => r.ok);
56
+ for (const r of results) {
57
+ if (r.ok) {
58
+ process.stdout.write(`ok ${r.spec} (${r.ms}ms)\n`);
59
+ } else {
60
+ process.stdout.write(`fail ${r.spec} (${r.ms}ms)\n${r.error}\n`);
61
+ }
62
+ }
63
+
64
+ process.exitCode = allOk ? 0 : 1;
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { summarizeAuth } from "./auth-requirements.ts";
4
+ import type { AuthScheme } from "./auth-schemes.ts";
5
+
6
+ describe("summarizeAuth", () => {
7
+ test("uses operation-level security when present", () => {
8
+ const schemes: AuthScheme[] = [{ key: "oauth", kind: "oauth2" }];
9
+
10
+ const summary = summarizeAuth(
11
+ [{ oauth: ["read:ping"] }],
12
+ [{ oauth: ["read:other"] }],
13
+ schemes,
14
+ );
15
+
16
+ expect(summary.alternatives).toEqual([
17
+ [{ key: "oauth", scopes: ["read:ping"] }],
18
+ ]);
19
+ });
20
+
21
+ test("empty operation security disables auth", () => {
22
+ const schemes: AuthScheme[] = [{ key: "oauth", kind: "oauth2" }];
23
+
24
+ const summary = summarizeAuth([], [{ oauth: ["read:other"] }], schemes);
25
+ expect(summary.alternatives).toEqual([]);
26
+ });
27
+ });
@@ -0,0 +1,91 @@
1
+ import type { AuthScheme } from "./auth-schemes.ts";
2
+ import type { SecurityRequirement } from "./types.ts";
3
+
4
+ export type AuthRequirement = {
5
+ key: string;
6
+ scopes: string[];
7
+ };
8
+
9
+ export type AuthSummary = {
10
+ // Alternatives: any one of these sets is sufficient.
11
+ alternatives: AuthRequirement[][];
12
+ };
13
+
14
+ function isSecurityRequirement(value: unknown): value is SecurityRequirement {
15
+ if (!value || typeof value !== "object") return false;
16
+ if (Array.isArray(value)) return false;
17
+
18
+ for (const [k, v] of Object.entries(value)) {
19
+ if (typeof k !== "string") return false;
20
+ if (!Array.isArray(v)) return false;
21
+ if (!v.every((s) => typeof s === "string")) return false;
22
+ }
23
+
24
+ return true;
25
+ }
26
+
27
+ function normalizeSecurity(value: unknown): {
28
+ requirements: SecurityRequirement[];
29
+ source: "none" | "empty" | "non-empty";
30
+ } {
31
+ if (value == null) return { requirements: [], source: "none" };
32
+ if (!Array.isArray(value)) return { requirements: [], source: "none" };
33
+
34
+ const reqs = value.filter(isSecurityRequirement);
35
+ if (reqs.length === 0) return { requirements: [], source: "empty" };
36
+ return { requirements: reqs, source: "non-empty" };
37
+ }
38
+
39
+ export function summarizeAuth(
40
+ operationSecurity: unknown,
41
+ globalSecurity: unknown,
42
+ knownSchemes: AuthScheme[],
43
+ ): AuthSummary {
44
+ // Per spec:
45
+ // - operation security overrides root
46
+ // - empty array [] means "no auth"
47
+ const op = normalizeSecurity(operationSecurity);
48
+ if (op.source === "non-empty") {
49
+ return { alternatives: toAlternatives(op.requirements, knownSchemes) };
50
+ }
51
+ if (op.source === "empty") {
52
+ return { alternatives: [] };
53
+ }
54
+
55
+ const global = normalizeSecurity(globalSecurity);
56
+ if (global.source === "non-empty") {
57
+ return { alternatives: toAlternatives(global.requirements, knownSchemes) };
58
+ }
59
+
60
+ return { alternatives: [] };
61
+ }
62
+
63
+ function toAlternatives(
64
+ requirements: SecurityRequirement[],
65
+ knownSchemes: AuthScheme[],
66
+ ): AuthRequirement[][] {
67
+ const known = new Set(knownSchemes.map((s) => s.key));
68
+
69
+ return requirements.map((req) => {
70
+ const out: AuthRequirement[] = [];
71
+ for (const [key, scopes] of Object.entries(req)) {
72
+ out.push({
73
+ key,
74
+ scopes: Array.isArray(scopes) ? scopes : [],
75
+ });
76
+ }
77
+
78
+ // Stable order.
79
+ out.sort((a, b) => a.key.localeCompare(b.key));
80
+
81
+ // Prefer known schemes first.
82
+ out.sort((a, b) => {
83
+ const ak = known.has(a.key) ? 0 : 1;
84
+ const bk = known.has(b.key) ? 0 : 1;
85
+ if (ak !== bk) return ak - bk;
86
+ return a.key.localeCompare(b.key);
87
+ });
88
+
89
+ return out;
90
+ });
91
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { listAuthSchemes } from "./auth-schemes.ts";
4
+ import type { OpenApiDoc } from "./types.ts";
5
+
6
+ describe("listAuthSchemes", () => {
7
+ test("parses bearer + apiKey", () => {
8
+ const doc: OpenApiDoc = {
9
+ openapi: "3.0.3",
10
+ components: {
11
+ securitySchemes: {
12
+ bearerAuth: {
13
+ type: "http",
14
+ scheme: "bearer",
15
+ bearerFormat: "JWT",
16
+ },
17
+ apiKeyAuth: {
18
+ type: "apiKey",
19
+ in: "header",
20
+ name: "X-API-Key",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ const schemes = listAuthSchemes(doc);
27
+ expect(schemes).toHaveLength(2);
28
+
29
+ const bearer = schemes.find((s) => s.key === "bearerAuth");
30
+ expect(bearer?.kind).toBe("http-bearer");
31
+
32
+ const apiKey = schemes.find((s) => s.key === "apiKeyAuth");
33
+ expect(apiKey?.kind).toBe("api-key");
34
+ expect(apiKey?.in).toBe("header");
35
+ expect(apiKey?.name).toBe("X-API-Key");
36
+ });
37
+
38
+ test("parses oauth2 flows", () => {
39
+ const doc = {
40
+ openapi: "3.0.3",
41
+ components: {
42
+ securitySchemes: {
43
+ oauth: {
44
+ type: "oauth2",
45
+ flows: {
46
+ clientCredentials: {
47
+ tokenUrl: "https://example.com/oauth/token",
48
+ scopes: {
49
+ "read:ping": "read ping",
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ } as const;
57
+
58
+ const schemes = listAuthSchemes(doc);
59
+ const oauth = schemes.find((s) => s.key === "oauth");
60
+ expect(oauth?.kind).toBe("oauth2");
61
+ expect(oauth?.oauthFlows?.clientCredentials?.tokenUrl).toBe(
62
+ "https://example.com/oauth/token",
63
+ );
64
+ expect(oauth?.oauthFlows?.clientCredentials?.scopes).toEqual(["read:ping"]);
65
+ });
66
+ });