specli 0.0.16 → 0.0.18

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 (69) hide show
  1. package/dist/cli/compile.js +9 -2
  2. package/package.json +2 -1
  3. package/src/ai/tools.test.ts +83 -0
  4. package/src/ai/tools.ts +211 -0
  5. package/src/cli/auth-requirements.test.ts +27 -0
  6. package/src/cli/auth-requirements.ts +91 -0
  7. package/src/cli/auth-schemes.test.ts +66 -0
  8. package/src/cli/auth-schemes.ts +187 -0
  9. package/src/cli/capabilities.test.ts +94 -0
  10. package/src/cli/capabilities.ts +88 -0
  11. package/src/cli/command-id.test.ts +32 -0
  12. package/src/cli/command-id.ts +16 -0
  13. package/src/cli/command-index.ts +19 -0
  14. package/src/cli/command-model.test.ts +44 -0
  15. package/src/cli/command-model.ts +128 -0
  16. package/src/cli/compile.ts +109 -0
  17. package/src/cli/crypto.ts +9 -0
  18. package/src/cli/derive-name.ts +101 -0
  19. package/src/cli/exec.ts +72 -0
  20. package/src/cli/main.ts +255 -0
  21. package/src/cli/naming.test.ts +86 -0
  22. package/src/cli/naming.ts +224 -0
  23. package/src/cli/operations.test.ts +57 -0
  24. package/src/cli/operations.ts +152 -0
  25. package/src/cli/params.test.ts +70 -0
  26. package/src/cli/params.ts +71 -0
  27. package/src/cli/pluralize.ts +41 -0
  28. package/src/cli/positional.test.ts +65 -0
  29. package/src/cli/positional.ts +75 -0
  30. package/src/cli/request-body.test.ts +35 -0
  31. package/src/cli/request-body.ts +94 -0
  32. package/src/cli/runtime/argv.ts +14 -0
  33. package/src/cli/runtime/auth/resolve.ts +59 -0
  34. package/src/cli/runtime/body-flags.test.ts +261 -0
  35. package/src/cli/runtime/body-flags.ts +176 -0
  36. package/src/cli/runtime/body.ts +24 -0
  37. package/src/cli/runtime/collect.ts +6 -0
  38. package/src/cli/runtime/compat.ts +89 -0
  39. package/src/cli/runtime/context.ts +62 -0
  40. package/src/cli/runtime/execute.ts +147 -0
  41. package/src/cli/runtime/generated.ts +242 -0
  42. package/src/cli/runtime/headers.ts +37 -0
  43. package/src/cli/runtime/index.ts +3 -0
  44. package/src/cli/runtime/profile/secrets.ts +83 -0
  45. package/src/cli/runtime/profile/store.ts +100 -0
  46. package/src/cli/runtime/request.test.ts +375 -0
  47. package/src/cli/runtime/request.ts +390 -0
  48. package/src/cli/runtime/server-url.ts +45 -0
  49. package/src/cli/runtime/template.ts +26 -0
  50. package/src/cli/runtime/validate/ajv.ts +13 -0
  51. package/src/cli/runtime/validate/coerce.test.ts +98 -0
  52. package/src/cli/runtime/validate/coerce.ts +71 -0
  53. package/src/cli/runtime/validate/error.ts +29 -0
  54. package/src/cli/runtime/validate/index.ts +4 -0
  55. package/src/cli/runtime/validate/schema.ts +54 -0
  56. package/src/cli/schema-shape.ts +36 -0
  57. package/src/cli/schema.ts +76 -0
  58. package/src/cli/server.test.ts +55 -0
  59. package/src/cli/server.ts +167 -0
  60. package/src/cli/spec-id.ts +12 -0
  61. package/src/cli/spec-loader.ts +58 -0
  62. package/src/cli/stable-json.ts +35 -0
  63. package/src/cli/strings.ts +21 -0
  64. package/src/cli/types.ts +59 -0
  65. package/src/cli.ts +94 -0
  66. package/src/compiled.ts +24 -0
  67. package/src/macros/env.ts +21 -0
  68. package/src/macros/spec.ts +17 -0
  69. package/src/macros/version.ts +14 -0
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AuthScheme } from "./auth-schemes.js";
3
+ import { deriveCapabilities } from "./capabilities.js";
4
+ import type { CommandModel } from "./command-model.js";
5
+ import type { ServerInfo } from "./server.js";
6
+ import type { NormalizedOperation, OpenApiDoc } from "./types.js";
7
+
8
+ describe("deriveCapabilities", () => {
9
+ test("reports requestBody + server vars", () => {
10
+ const doc: OpenApiDoc = {
11
+ openapi: "3.0.3",
12
+ security: [{ bearerAuth: [] }],
13
+ };
14
+
15
+ const servers: ServerInfo[] = [
16
+ {
17
+ url: "https://{region}.api.example.com",
18
+ variables: [],
19
+ variableNames: ["region"],
20
+ },
21
+ ];
22
+
23
+ const authSchemes: AuthScheme[] = [
24
+ { key: "bearerAuth", kind: "http-bearer" },
25
+ ];
26
+
27
+ const operations: NormalizedOperation[] = [
28
+ {
29
+ key: "POST /contacts",
30
+ method: "POST",
31
+ path: "/contacts",
32
+ tags: [],
33
+ parameters: [],
34
+ requestBody: {
35
+ required: true,
36
+ contentTypes: ["application/json"],
37
+ schemasByContentType: { "application/json": { type: "object" } },
38
+ },
39
+ },
40
+ ];
41
+
42
+ const commands: CommandModel = {
43
+ resources: [
44
+ {
45
+ resource: "contacts",
46
+ actions: [
47
+ {
48
+ id: "x",
49
+ key: "POST /contacts",
50
+ action: "create",
51
+ pathArgs: [],
52
+ method: "POST",
53
+ path: "/contacts",
54
+ tags: [],
55
+ style: "rest",
56
+ positionals: [],
57
+ flags: [],
58
+ params: [],
59
+ auth: { alternatives: [] },
60
+ requestBody: {
61
+ required: true,
62
+ content: [
63
+ {
64
+ contentType: "application/json",
65
+ required: true,
66
+ schemaType: "object",
67
+ },
68
+ ],
69
+ hasJson: true,
70
+ hasFormUrlEncoded: false,
71
+ hasMultipart: false,
72
+ bodyFlags: ["--data", "--file"],
73
+ preferredContentType: "application/json",
74
+ },
75
+ },
76
+ ],
77
+ },
78
+ ],
79
+ };
80
+
81
+ const caps = deriveCapabilities({
82
+ doc,
83
+ servers,
84
+ authSchemes,
85
+ operations,
86
+ commands,
87
+ });
88
+ expect(caps.servers.hasVariables).toBe(true);
89
+ expect(caps.operations.hasRequestBodies).toBe(true);
90
+ expect(caps.commands.hasRequestBodies).toBe(true);
91
+ expect(caps.auth.hasSecurityRequirements).toBe(true);
92
+ expect(caps.auth.kinds).toEqual(["http-bearer"]);
93
+ });
94
+ });
@@ -0,0 +1,88 @@
1
+ import type { AuthScheme, AuthSchemeKind } from "./auth-schemes.js";
2
+ import type { CommandModel } from "./command-model.js";
3
+ import type { ServerInfo } from "./server.js";
4
+ import type {
5
+ NormalizedOperation,
6
+ OpenApiDoc,
7
+ SecurityRequirement,
8
+ } from "./types.js";
9
+
10
+ export type Capabilities = {
11
+ servers: {
12
+ count: number;
13
+ hasVariables: boolean;
14
+ };
15
+ auth: {
16
+ count: number;
17
+ kinds: AuthSchemeKind[];
18
+ hasSecurityRequirements: boolean;
19
+ };
20
+ operations: {
21
+ count: number;
22
+ hasRequestBodies: boolean;
23
+ };
24
+ commands: {
25
+ countResources: number;
26
+ countActions: number;
27
+ hasRequestBodies: boolean;
28
+ };
29
+ };
30
+
31
+ function uniqueSorted<T>(items: T[], compare: (a: T, b: T) => number): T[] {
32
+ const out = [...items];
33
+ out.sort(compare);
34
+ return out.filter((v, i) => i === 0 || compare(out[i - 1] as T, v) !== 0);
35
+ }
36
+
37
+ function hasSecurity(requirements: SecurityRequirement[] | undefined): boolean {
38
+ if (!requirements?.length) return false;
39
+
40
+ // Treat any non-empty array as "auth exists", even if it contains `{}` to mean optional.
41
+ return true;
42
+ }
43
+
44
+ export function deriveCapabilities(input: {
45
+ doc: OpenApiDoc;
46
+ servers: ServerInfo[];
47
+ authSchemes: AuthScheme[];
48
+ operations: NormalizedOperation[];
49
+ commands?: CommandModel;
50
+ }): Capabilities {
51
+ const serverHasVars = input.servers.some((s) => s.variableNames.length > 0);
52
+
53
+ const authKinds = uniqueSorted(
54
+ input.authSchemes.map((s) => s.kind),
55
+ (a, b) => a.localeCompare(b),
56
+ );
57
+
58
+ const hasSecurityRequirements =
59
+ hasSecurity(input.doc.security) ||
60
+ input.operations.some((op) => hasSecurity(op.security));
61
+
62
+ const opHasBodies = input.operations.some((op) => Boolean(op.requestBody));
63
+
64
+ const cmdResources = input.commands?.resources ?? [];
65
+ const cmdActions = cmdResources.flatMap((r) => r.actions);
66
+ const cmdHasBodies = cmdActions.some((a) => Boolean(a.requestBody));
67
+
68
+ return {
69
+ servers: {
70
+ count: input.servers.length,
71
+ hasVariables: serverHasVars,
72
+ },
73
+ auth: {
74
+ count: input.authSchemes.length,
75
+ kinds: authKinds,
76
+ hasSecurityRequirements,
77
+ },
78
+ operations: {
79
+ count: input.operations.length,
80
+ hasRequestBodies: opHasBodies,
81
+ },
82
+ commands: {
83
+ countResources: cmdResources.length,
84
+ countActions: cmdActions.length,
85
+ hasRequestBodies: cmdHasBodies,
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { buildCommandId } from "./command-id.js";
4
+
5
+ describe("buildCommandId", () => {
6
+ test("includes spec/resource/action/op", () => {
7
+ expect(
8
+ buildCommandId({
9
+ specId: "contacts-api",
10
+ resource: "contacts",
11
+ action: "get",
12
+ operationKey: "GET /contacts/{id}",
13
+ }),
14
+ ).toBe("contacts-api:contacts:get:get-contacts-id");
15
+ });
16
+
17
+ test("disambiguates by operationKey", () => {
18
+ const a = buildCommandId({
19
+ specId: "x",
20
+ resource: "contacts",
21
+ action: "list",
22
+ operationKey: "GET /contacts",
23
+ });
24
+ const b = buildCommandId({
25
+ specId: "x",
26
+ resource: "contacts",
27
+ action: "list",
28
+ operationKey: "GET /contacts/search",
29
+ });
30
+ expect(a).not.toBe(b);
31
+ });
32
+ });
@@ -0,0 +1,16 @@
1
+ import { kebabCase } from "./strings.js";
2
+
3
+ export type CommandIdParts = {
4
+ specId: string;
5
+ resource: string;
6
+ action: string;
7
+ operationKey: string;
8
+ };
9
+
10
+ export function buildCommandId(parts: CommandIdParts): string {
11
+ // operationKey is the ultimate disambiguator, but we keep the id readable.
12
+ // Example:
13
+ // contacts-api:contacts:get:GET-/contacts/{id}
14
+ const op = kebabCase(parts.operationKey.replace(/\s+/g, "-"));
15
+ return `${parts.specId}:${kebabCase(parts.resource)}:${kebabCase(parts.action)}:${op}`;
16
+ }
@@ -0,0 +1,19 @@
1
+ import type { CommandAction, CommandModel } from "./command-model.js";
2
+
3
+ export type CommandsIndex = {
4
+ byId: Record<string, CommandAction>;
5
+ };
6
+
7
+ export function buildCommandsIndex(
8
+ commands: CommandModel | undefined,
9
+ ): CommandsIndex {
10
+ const byId: Record<string, CommandAction> = {};
11
+
12
+ for (const resource of commands?.resources ?? []) {
13
+ for (const action of resource.actions) {
14
+ byId[action.id] = action;
15
+ }
16
+ }
17
+
18
+ return { byId };
19
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { buildCommandModel } from "./command-model.js";
4
+ import type { PlannedOperation } from "./naming.js";
5
+
6
+ describe("buildCommandModel", () => {
7
+ test("groups operations by resource", () => {
8
+ const planned: PlannedOperation[] = [
9
+ {
10
+ key: "GET /contacts",
11
+ method: "GET",
12
+ path: "/contacts",
13
+ tags: ["Contacts"],
14
+ parameters: [],
15
+ resource: "contacts",
16
+ action: "list",
17
+ canonicalAction: "list",
18
+ pathArgs: [],
19
+ style: "rest",
20
+ },
21
+ {
22
+ key: "GET /contacts/{id}",
23
+ method: "GET",
24
+ path: "/contacts/{id}",
25
+ tags: ["Contacts"],
26
+ parameters: [],
27
+ resource: "contacts",
28
+ action: "get",
29
+ canonicalAction: "get",
30
+ pathArgs: ["id"],
31
+ style: "rest",
32
+ },
33
+ ];
34
+
35
+ const model = buildCommandModel(planned, { specId: "contacts-api" });
36
+ expect(model.resources).toHaveLength(1);
37
+ expect(model.resources[0]?.resource).toBe("contacts");
38
+ expect(model.resources[0]?.actions).toHaveLength(2);
39
+ expect(model.resources[0]?.actions.map((a) => a.action)).toEqual([
40
+ "get",
41
+ "list",
42
+ ]);
43
+ });
44
+ });
@@ -0,0 +1,128 @@
1
+ import type { AuthSummary } from "./auth-requirements.js";
2
+ import { summarizeAuth } from "./auth-requirements.js";
3
+ import type { AuthScheme } from "./auth-schemes.js";
4
+ import { buildCommandId } from "./command-id.js";
5
+ import type { PlannedOperation } from "./naming.js";
6
+ import type { ParamSpec } from "./params.js";
7
+ import { deriveParamSpecs } from "./params.js";
8
+ import { deriveFlags, derivePositionals } from "./positional.js";
9
+ import type { RequestBodyInfo } from "./request-body.js";
10
+ import { deriveRequestBodyInfo } from "./request-body.js";
11
+ import type { SecurityRequirement } from "./types.js";
12
+
13
+ export type CommandAction = {
14
+ id: string;
15
+ key: string;
16
+ action: string;
17
+ // Derived path arguments. These become positionals later.
18
+ pathArgs: string[];
19
+ method: string;
20
+ path: string;
21
+ operationId?: string;
22
+ tags: string[];
23
+ summary?: string;
24
+ description?: string;
25
+ deprecated?: boolean;
26
+ style: PlannedOperation["style"];
27
+
28
+ // Derived CLI shape (Phase 1 output; Phase 2 will wire these into commander)
29
+ positionals: Array<import("./positional.js").PositionalArg>;
30
+ flags: Array<
31
+ Pick<
32
+ import("./params.js").ParamSpec,
33
+ | "in"
34
+ | "name"
35
+ | "flag"
36
+ | "required"
37
+ | "description"
38
+ | "type"
39
+ | "format"
40
+ | "enum"
41
+ | "itemType"
42
+ | "itemFormat"
43
+ | "itemEnum"
44
+ >
45
+ >;
46
+
47
+ // Full raw params list (useful for debugging and future features)
48
+ params: ParamSpec[];
49
+
50
+ auth: AuthSummary;
51
+ requestBody?: RequestBodyInfo;
52
+ requestBodySchema?: import("./types.js").JsonSchema;
53
+ };
54
+
55
+ export type CommandResource = {
56
+ resource: string;
57
+ actions: CommandAction[];
58
+ };
59
+
60
+ export type CommandModel = {
61
+ resources: CommandResource[];
62
+ };
63
+
64
+ export type BuildCommandModelOptions = {
65
+ specId: string;
66
+ globalSecurity?: SecurityRequirement[];
67
+ authSchemes?: AuthScheme[];
68
+ };
69
+
70
+ export function buildCommandModel(
71
+ planned: PlannedOperation[],
72
+ options: BuildCommandModelOptions,
73
+ ): CommandModel {
74
+ const byResource = new Map<string, CommandAction[]>();
75
+
76
+ for (const op of planned) {
77
+ const list = byResource.get(op.resource) ?? [];
78
+ const params = deriveParamSpecs(op);
79
+ const positionals = derivePositionals({ pathArgs: op.pathArgs, params });
80
+ const flags = deriveFlags({ pathArgs: op.pathArgs, params });
81
+
82
+ list.push({
83
+ id: buildCommandId({
84
+ specId: options.specId,
85
+ resource: op.resource,
86
+ action: op.action,
87
+ operationKey: op.key,
88
+ }),
89
+ key: op.key,
90
+ action: op.action,
91
+ pathArgs: op.pathArgs,
92
+ method: op.method,
93
+ path: op.path,
94
+ operationId: op.operationId,
95
+ tags: op.tags,
96
+ summary: op.summary,
97
+ description: op.description,
98
+ deprecated: op.deprecated,
99
+ style: op.style,
100
+ params,
101
+ positionals,
102
+ flags: flags.flags,
103
+ auth: summarizeAuth(
104
+ op.security,
105
+ options.globalSecurity,
106
+ options.authSchemes ?? [],
107
+ ),
108
+ requestBody: deriveRequestBodyInfo(op),
109
+ requestBodySchema: deriveRequestBodyInfo(op)?.preferredSchema,
110
+ });
111
+ byResource.set(op.resource, list);
112
+ }
113
+
114
+ const resources: CommandResource[] = [];
115
+
116
+ for (const [resource, actions] of byResource.entries()) {
117
+ actions.sort((a, b) => {
118
+ if (a.action !== b.action) return a.action.localeCompare(b.action);
119
+ if (a.path !== b.path) return a.path.localeCompare(b.path);
120
+ return a.method.localeCompare(b.method);
121
+ });
122
+ resources.push({ resource, actions });
123
+ }
124
+
125
+ resources.sort((a, b) => a.resource.localeCompare(b.resource));
126
+
127
+ return { resources };
128
+ }
@@ -0,0 +1,109 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { deriveBinaryName } from "./derive-name.js";
4
+
5
+ // Resolve the path to compiled.ts relative to this file's location
6
+ // At runtime this file is at dist/cli/compile.js, so we go up two levels to package root
7
+ // then into src/compiled.ts (which must be included in the published package)
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const compiledEntrypoint = path.resolve(__dirname, "../../src/compiled.ts");
10
+
11
+ export type CompileOptions = {
12
+ name?: string;
13
+ outfile?: string;
14
+ target?: string;
15
+ minify?: boolean;
16
+ bytecode?: boolean;
17
+ dotenv?: boolean; // --no-dotenv sets this to false
18
+ bunfig?: boolean; // --no-bunfig sets this to false
19
+ define?: string[];
20
+ server?: string;
21
+ serverVar?: string[];
22
+ auth?: string;
23
+ };
24
+
25
+ function parseKeyValue(input: string): { key: string; value: string } {
26
+ const idx = input.indexOf("=");
27
+ if (idx === -1)
28
+ throw new Error(`Invalid --define '${input}', expected key=value`);
29
+ const key = input.slice(0, idx).trim();
30
+ const value = input.slice(idx + 1).trim();
31
+ if (!key) throw new Error(`Invalid --define '${input}', missing key`);
32
+ return { key, value };
33
+ }
34
+
35
+ export async function compileCommand(
36
+ spec: string,
37
+ options: CompileOptions,
38
+ ): Promise<void> {
39
+ // Derive name from spec if not provided
40
+ const name = options.name ?? (await deriveBinaryName(spec));
41
+ const outfile = options.outfile ?? `./out/${name}`;
42
+
43
+ const target = options.target
44
+ ? (options.target as Bun.Build.Target)
45
+ : (`bun-${process.platform}-${process.arch}` as Bun.Build.Target);
46
+
47
+ // Parse --define pairs
48
+ const define: Record<string, string> = {};
49
+ if (options.define) {
50
+ for (const pair of options.define) {
51
+ const { key, value } = parseKeyValue(pair);
52
+ define[key] = JSON.stringify(value);
53
+ }
54
+ }
55
+
56
+ // Build command args
57
+ const buildArgs = [
58
+ "build",
59
+ "--compile",
60
+ `--outfile=${outfile}`,
61
+ `--target=${target}`,
62
+ ];
63
+
64
+ if (options.minify) buildArgs.push("--minify");
65
+ if (options.bytecode) buildArgs.push("--bytecode");
66
+
67
+ for (const [k, v] of Object.entries(define)) {
68
+ buildArgs.push("--define", `${k}=${v}`);
69
+ }
70
+
71
+ if (options.dotenv === false) buildArgs.push("--no-compile-autoload-dotenv");
72
+ if (options.bunfig === false) buildArgs.push("--no-compile-autoload-bunfig");
73
+
74
+ buildArgs.push(compiledEntrypoint);
75
+
76
+ // Only set env vars that have actual values - avoid empty strings
77
+ // because the macros will embed them and they will override defaults.
78
+ const buildEnv: Record<string, string> = {
79
+ ...process.env,
80
+ SPECLI_SPEC: spec,
81
+ SPECLI_NAME: name,
82
+ };
83
+ if (options.server) buildEnv.SPECLI_SERVER = options.server;
84
+ if (options.serverVar?.length)
85
+ buildEnv.SPECLI_SERVER_VARS = options.serverVar.join(",");
86
+ if (options.auth) buildEnv.SPECLI_AUTH = options.auth;
87
+
88
+ const proc = Bun.spawn({
89
+ cmd: ["bun", ...buildArgs],
90
+ stdout: "pipe",
91
+ stderr: "pipe",
92
+ env: buildEnv,
93
+ });
94
+
95
+ const output = await new Response(proc.stdout).text();
96
+ const error = await new Response(proc.stderr).text();
97
+ const code = await proc.exited;
98
+
99
+ if (output) process.stdout.write(output);
100
+ if (error) process.stderr.write(error);
101
+ if (code !== 0) {
102
+ process.exitCode = code;
103
+ return;
104
+ }
105
+
106
+ process.stdout.write(`ok: built ${outfile}\n`);
107
+ process.stdout.write(`target: ${target}\n`);
108
+ process.stdout.write(`name: ${name}\n`);
109
+ }
@@ -0,0 +1,9 @@
1
+ export async function sha256Hex(text: string): Promise<string> {
2
+ const data = new TextEncoder().encode(text);
3
+ const hash = await crypto.subtle.digest("SHA-256", data);
4
+ const bytes = new Uint8Array(hash);
5
+
6
+ let out = "";
7
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
8
+ return out;
9
+ }
@@ -0,0 +1,101 @@
1
+ const RESERVED_NAMES = [
2
+ "exec",
3
+ "compile",
4
+ "profile",
5
+ "auth",
6
+ "help",
7
+ "version",
8
+ ];
9
+
10
+ /**
11
+ * Derives a clean binary name from an OpenAPI spec.
12
+ * Priority:
13
+ * 1. info.title (kebab-cased, sanitized)
14
+ * 2. Host from spec URL (if URL provided)
15
+ * 3. Fallback to "specli"
16
+ */
17
+ export async function deriveBinaryName(spec: string): Promise<string> {
18
+ try {
19
+ // Load spec to extract title
20
+ const text = await loadSpecText(spec);
21
+ const doc = parseSpec(text);
22
+
23
+ const title = doc?.info?.title;
24
+ if (title && typeof title === "string") {
25
+ const name = sanitizeName(title);
26
+ if (name) return name;
27
+ }
28
+ } catch {
29
+ // Fall through to URL-based derivation
30
+ }
31
+
32
+ // Try to derive from URL host
33
+ if (/^https?:\/\//i.test(spec)) {
34
+ try {
35
+ const url = new URL(spec);
36
+ const hostParts = url.hostname.split(".");
37
+ // Use first meaningful segment (skip www, api prefixes)
38
+ const meaningful = hostParts.find(
39
+ (p) => p !== "www" && p !== "api" && p.length > 2,
40
+ );
41
+ if (meaningful) {
42
+ const name = sanitizeName(meaningful);
43
+ if (name) return name;
44
+ }
45
+ } catch {
46
+ // Invalid URL, fall through
47
+ }
48
+ }
49
+
50
+ // Fallback
51
+ return "specli";
52
+ }
53
+
54
+ async function loadSpecText(spec: string): Promise<string> {
55
+ if (/^https?:\/\//i.test(spec)) {
56
+ const res = await fetch(spec);
57
+ if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
58
+ return res.text();
59
+ }
60
+ return Bun.file(spec).text();
61
+ }
62
+
63
+ function parseSpec(text: string): { info?: { title?: string } } | null {
64
+ try {
65
+ const trimmed = text.trimStart();
66
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
67
+ return JSON.parse(text);
68
+ }
69
+ // Use Bun's YAML parser
70
+ const { YAML } = globalThis.Bun ?? {};
71
+ if (YAML?.parse) {
72
+ return YAML.parse(text) as { info?: { title?: string } };
73
+ }
74
+ // Fallback: only JSON supported
75
+ return null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Convert title to valid binary name:
83
+ * - kebab-case
84
+ * - lowercase
85
+ * - remove invalid chars
86
+ * - max 32 chars
87
+ * - avoid reserved names
88
+ */
89
+ function sanitizeName(input: string): string {
90
+ let name = input
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with dash
93
+ .replace(/^-+|-+$/g, "") // Trim leading/trailing dashes
94
+ .slice(0, 32); // Limit length
95
+
96
+ if (RESERVED_NAMES.includes(name)) {
97
+ name = `${name}-cli`;
98
+ }
99
+
100
+ return name;
101
+ }