trickle-cli 0.1.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.
Files changed (125) hide show
  1. package/dist/api-client.d.ts +208 -0
  2. package/dist/api-client.js +237 -0
  3. package/dist/commands/annotate.d.ts +6 -0
  4. package/dist/commands/annotate.js +433 -0
  5. package/dist/commands/audit.d.ts +7 -0
  6. package/dist/commands/audit.js +82 -0
  7. package/dist/commands/auto.d.ts +8 -0
  8. package/dist/commands/auto.js +268 -0
  9. package/dist/commands/capture.d.ts +14 -0
  10. package/dist/commands/capture.js +271 -0
  11. package/dist/commands/check.d.ts +6 -0
  12. package/dist/commands/check.js +408 -0
  13. package/dist/commands/codegen.d.ts +21 -0
  14. package/dist/commands/codegen.js +129 -0
  15. package/dist/commands/coverage.d.ts +13 -0
  16. package/dist/commands/coverage.js +126 -0
  17. package/dist/commands/dashboard.d.ts +1 -0
  18. package/dist/commands/dashboard.js +83 -0
  19. package/dist/commands/dev.d.ts +14 -0
  20. package/dist/commands/dev.js +319 -0
  21. package/dist/commands/diff.d.ts +7 -0
  22. package/dist/commands/diff.js +79 -0
  23. package/dist/commands/docs.d.ts +13 -0
  24. package/dist/commands/docs.js +383 -0
  25. package/dist/commands/errors.d.ts +7 -0
  26. package/dist/commands/errors.js +180 -0
  27. package/dist/commands/export.d.ts +18 -0
  28. package/dist/commands/export.js +238 -0
  29. package/dist/commands/functions.d.ts +6 -0
  30. package/dist/commands/functions.js +71 -0
  31. package/dist/commands/infer.d.ts +14 -0
  32. package/dist/commands/infer.js +275 -0
  33. package/dist/commands/init.d.ts +5 -0
  34. package/dist/commands/init.js +395 -0
  35. package/dist/commands/mock.d.ts +5 -0
  36. package/dist/commands/mock.js +232 -0
  37. package/dist/commands/openapi.d.ts +8 -0
  38. package/dist/commands/openapi.js +82 -0
  39. package/dist/commands/overview.d.ts +11 -0
  40. package/dist/commands/overview.js +266 -0
  41. package/dist/commands/pack.d.ts +11 -0
  42. package/dist/commands/pack.js +133 -0
  43. package/dist/commands/proxy.d.ts +13 -0
  44. package/dist/commands/proxy.js +312 -0
  45. package/dist/commands/replay.d.ts +14 -0
  46. package/dist/commands/replay.js +289 -0
  47. package/dist/commands/run.d.ts +17 -0
  48. package/dist/commands/run.js +997 -0
  49. package/dist/commands/sample.d.ts +13 -0
  50. package/dist/commands/sample.js +260 -0
  51. package/dist/commands/search.d.ts +5 -0
  52. package/dist/commands/search.js +80 -0
  53. package/dist/commands/stubs.d.ts +6 -0
  54. package/dist/commands/stubs.js +187 -0
  55. package/dist/commands/tail.d.ts +4 -0
  56. package/dist/commands/tail.js +76 -0
  57. package/dist/commands/test-gen.d.ts +13 -0
  58. package/dist/commands/test-gen.js +237 -0
  59. package/dist/commands/trace.d.ts +14 -0
  60. package/dist/commands/trace.js +417 -0
  61. package/dist/commands/types.d.ts +7 -0
  62. package/dist/commands/types.js +128 -0
  63. package/dist/commands/unpack.d.ts +11 -0
  64. package/dist/commands/unpack.js +166 -0
  65. package/dist/commands/validate.d.ts +13 -0
  66. package/dist/commands/validate.js +310 -0
  67. package/dist/commands/watch.d.ts +9 -0
  68. package/dist/commands/watch.js +267 -0
  69. package/dist/config.d.ts +1 -0
  70. package/dist/config.js +66 -0
  71. package/dist/formatters/diff-formatter.d.ts +5 -0
  72. package/dist/formatters/diff-formatter.js +43 -0
  73. package/dist/formatters/type-formatter.d.ts +22 -0
  74. package/dist/formatters/type-formatter.js +135 -0
  75. package/dist/index.d.ts +2 -0
  76. package/dist/index.js +419 -0
  77. package/dist/local-codegen.d.ts +22 -0
  78. package/dist/local-codegen.js +762 -0
  79. package/dist/ui/badges.d.ts +16 -0
  80. package/dist/ui/badges.js +71 -0
  81. package/dist/ui/helpers.d.ts +13 -0
  82. package/dist/ui/helpers.js +85 -0
  83. package/package.json +23 -0
  84. package/src/api-client.ts +407 -0
  85. package/src/commands/annotate.ts +450 -0
  86. package/src/commands/audit.ts +103 -0
  87. package/src/commands/auto.ts +268 -0
  88. package/src/commands/capture.ts +257 -0
  89. package/src/commands/check.ts +437 -0
  90. package/src/commands/codegen.ts +128 -0
  91. package/src/commands/coverage.ts +170 -0
  92. package/src/commands/dashboard.ts +46 -0
  93. package/src/commands/dev.ts +323 -0
  94. package/src/commands/diff.ts +99 -0
  95. package/src/commands/docs.ts +392 -0
  96. package/src/commands/errors.ts +205 -0
  97. package/src/commands/export.ts +287 -0
  98. package/src/commands/functions.ts +81 -0
  99. package/src/commands/infer.ts +260 -0
  100. package/src/commands/init.ts +419 -0
  101. package/src/commands/mock.ts +220 -0
  102. package/src/commands/openapi.ts +53 -0
  103. package/src/commands/overview.ts +310 -0
  104. package/src/commands/pack.ts +139 -0
  105. package/src/commands/proxy.ts +314 -0
  106. package/src/commands/replay.ts +356 -0
  107. package/src/commands/run.ts +1190 -0
  108. package/src/commands/sample.ts +259 -0
  109. package/src/commands/search.ts +107 -0
  110. package/src/commands/stubs.ts +211 -0
  111. package/src/commands/tail.ts +94 -0
  112. package/src/commands/test-gen.ts +236 -0
  113. package/src/commands/trace.ts +440 -0
  114. package/src/commands/types.ts +161 -0
  115. package/src/commands/unpack.ts +179 -0
  116. package/src/commands/validate.ts +368 -0
  117. package/src/commands/watch.ts +277 -0
  118. package/src/config.ts +38 -0
  119. package/src/formatters/diff-formatter.ts +51 -0
  120. package/src/formatters/type-formatter.ts +161 -0
  121. package/src/index.ts +454 -0
  122. package/src/local-codegen.ts +859 -0
  123. package/src/ui/badges.ts +66 -0
  124. package/src/ui/helpers.ts +80 -0
  125. package/tsconfig.json +8 -0
@@ -0,0 +1,277 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+ import { listFunctions, fetchCodegen, FunctionRow } from "../api-client";
5
+ import { getBackendUrl } from "../config";
6
+
7
+ export interface WatchOptions {
8
+ dir?: string;
9
+ env?: string;
10
+ interval?: string;
11
+ }
12
+
13
+ interface DetectedFormat {
14
+ format: string;
15
+ fileName: string;
16
+ label: string;
17
+ }
18
+
19
+ /**
20
+ * Detect which codegen formats are relevant based on package.json dependencies.
21
+ */
22
+ function detectFormats(projectDir: string): DetectedFormat[] {
23
+ const pkgPath = path.join(projectDir, "package.json");
24
+ if (!fs.existsSync(pkgPath)) {
25
+ return [
26
+ { format: "", fileName: "types.d.ts", label: "TypeScript types" },
27
+ { format: "guards", fileName: "guards.ts", label: "Type guards" },
28
+ ];
29
+ }
30
+
31
+ let pkg: Record<string, unknown>;
32
+ try {
33
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
34
+ } catch {
35
+ return [
36
+ { format: "", fileName: "types.d.ts", label: "TypeScript types" },
37
+ { format: "guards", fileName: "guards.ts", label: "Type guards" },
38
+ ];
39
+ }
40
+
41
+ const deps: Record<string, string> = {
42
+ ...(pkg.dependencies as Record<string, string> || {}),
43
+ ...(pkg.devDependencies as Record<string, string> || {}),
44
+ };
45
+
46
+ const formats: DetectedFormat[] = [];
47
+
48
+ formats.push({ format: "", fileName: "types.d.ts", label: "TypeScript types" });
49
+
50
+ if (deps["axios"]) {
51
+ formats.push({ format: "axios", fileName: "axios-client.ts", label: "Axios client" });
52
+ } else {
53
+ formats.push({ format: "client", fileName: "api-client.ts", label: "Fetch API client" });
54
+ }
55
+
56
+ if (deps["@tanstack/react-query"] || deps["react-query"]) {
57
+ formats.push({ format: "react-query", fileName: "hooks.ts", label: "React Query hooks" });
58
+ }
59
+ if (deps["swr"]) {
60
+ formats.push({ format: "swr", fileName: "swr-hooks.ts", label: "SWR hooks" });
61
+ }
62
+ if (deps["zod"]) {
63
+ formats.push({ format: "zod", fileName: "schemas.ts", label: "Zod schemas" });
64
+ }
65
+ if (deps["@trpc/server"] || deps["@trpc/client"]) {
66
+ formats.push({ format: "trpc", fileName: "trpc-router.ts", label: "tRPC router" });
67
+ }
68
+ if (deps["class-validator"] || deps["@nestjs/common"]) {
69
+ formats.push({ format: "class-validator", fileName: "dtos.ts", label: "class-validator DTOs" });
70
+ }
71
+ if (deps["express"] || deps["@types/express"]) {
72
+ formats.push({ format: "handlers", fileName: "handlers.d.ts", label: "Express handler types" });
73
+ }
74
+ if (deps["msw"]) {
75
+ formats.push({ format: "msw", fileName: "msw-handlers.ts", label: "MSW mock handlers" });
76
+ }
77
+
78
+ formats.push({ format: "guards", fileName: "guards.ts", label: "Type guards" });
79
+
80
+ return formats;
81
+ }
82
+
83
+ /**
84
+ * Build a fingerprint from the functions list to detect changes.
85
+ * Uses function names + type hashes + last_seen timestamps.
86
+ */
87
+ function buildFingerprint(functions: FunctionRow[]): string {
88
+ const parts = functions
89
+ .map((f) => `${f.function_name}:${f.last_seen_at}`)
90
+ .sort();
91
+ return parts.join("|");
92
+ }
93
+
94
+ /**
95
+ * Generate all relevant type files to the output directory.
96
+ */
97
+ async function regenerate(
98
+ formats: DetectedFormat[],
99
+ outDir: string,
100
+ env?: string,
101
+ ): Promise<{ generated: number; files: string[] }> {
102
+ if (!fs.existsSync(outDir)) {
103
+ fs.mkdirSync(outDir, { recursive: true });
104
+ }
105
+
106
+ const files: string[] = [];
107
+ let generated = 0;
108
+
109
+ for (const f of formats) {
110
+ try {
111
+ const result = await fetchCodegen({
112
+ env,
113
+ format: f.format || undefined,
114
+ });
115
+
116
+ const content = result.types;
117
+ if (!content || content.includes("No functions found") || content.includes("No API routes found")) {
118
+ continue;
119
+ }
120
+
121
+ const filePath = path.join(outDir, f.fileName);
122
+ fs.writeFileSync(filePath, content, "utf-8");
123
+ generated++;
124
+ files.push(f.fileName);
125
+ } catch {
126
+ // Skip failed formats silently
127
+ }
128
+ }
129
+
130
+ return { generated, files };
131
+ }
132
+
133
+ function timestamp(): string {
134
+ const now = new Date();
135
+ return now.toLocaleTimeString("en-US", { hour12: false });
136
+ }
137
+
138
+ /**
139
+ * `trickle watch` — Watch for new type observations and auto-regenerate type files.
140
+ */
141
+ export async function watchCommand(opts: WatchOptions): Promise<void> {
142
+ const backendUrl = getBackendUrl();
143
+ const projectDir = process.cwd();
144
+ const outDir = path.resolve(opts.dir || ".trickle");
145
+ const intervalMs = parseInterval(opts.interval || "3s");
146
+
147
+ // Check backend connectivity
148
+ try {
149
+ const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
150
+ if (!res.ok) throw new Error("not ok");
151
+ } catch {
152
+ console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}\n`));
153
+ process.exit(1);
154
+ }
155
+
156
+ // Detect formats from project deps
157
+ const formats = detectFormats(projectDir);
158
+
159
+ console.log("");
160
+ console.log(chalk.bold(" trickle watch"));
161
+ console.log(chalk.gray(" " + "─".repeat(50)));
162
+ console.log(chalk.gray(` Backend: ${backendUrl}`));
163
+ console.log(chalk.gray(` Output: ${outDir}`));
164
+ console.log(chalk.gray(` Interval: ${opts.interval || "3s"}`));
165
+ if (opts.env) {
166
+ console.log(chalk.gray(` Env: ${opts.env}`));
167
+ }
168
+ console.log(chalk.gray(` Formats: ${formats.map((f) => f.fileName).join(", ")}`));
169
+ console.log(chalk.gray(" " + "─".repeat(50)));
170
+ console.log("");
171
+
172
+ // Initial generation
173
+ console.log(chalk.gray(` [${timestamp()}]`) + " Performing initial type generation...");
174
+ let lastFingerprint = "";
175
+
176
+ try {
177
+ const { functions } = await listFunctions({ env: opts.env, limit: 1000 });
178
+ if (functions.length > 0) {
179
+ lastFingerprint = buildFingerprint(functions);
180
+ const { generated, files } = await regenerate(formats, outDir, opts.env);
181
+ if (generated > 0) {
182
+ console.log(
183
+ chalk.green(` [${timestamp()}]`) +
184
+ ` Generated ${generated} files: ${chalk.white(files.join(", "))}`,
185
+ );
186
+ } else {
187
+ console.log(chalk.gray(` [${timestamp()}]`) + " No types to generate yet.");
188
+ }
189
+ } else {
190
+ console.log(chalk.gray(` [${timestamp()}]`) + " No observed functions yet. Waiting...");
191
+ }
192
+ } catch {
193
+ console.log(chalk.yellow(` [${timestamp()}]`) + " Could not fetch initial types. Will retry...");
194
+ }
195
+
196
+ console.log("");
197
+ console.log(chalk.gray(" Watching for type changes... (Ctrl+C to stop)"));
198
+ console.log("");
199
+
200
+ // Poll loop
201
+ const poll = async () => {
202
+ try {
203
+ const { functions } = await listFunctions({ env: opts.env, limit: 1000 });
204
+ const fingerprint = buildFingerprint(functions);
205
+
206
+ if (fingerprint !== lastFingerprint && fingerprint !== "") {
207
+ // Types changed — find what's new
208
+ const newFunctions = functions.filter((f) => {
209
+ // A function is "new" if it wasn't in the last fingerprint
210
+ return !lastFingerprint.includes(`${f.function_name}:`);
211
+ });
212
+ const updatedFunctions = functions.filter((f) => {
213
+ // A function is "updated" if its timestamp changed
214
+ const oldEntry = `${f.function_name}:${f.last_seen_at}`;
215
+ return lastFingerprint.includes(`${f.function_name}:`) && !lastFingerprint.includes(oldEntry);
216
+ });
217
+
218
+ lastFingerprint = fingerprint;
219
+
220
+ // Show what changed
221
+ for (const f of newFunctions) {
222
+ console.log(
223
+ chalk.cyan(` [${timestamp()}]`) +
224
+ chalk.gray(" New: ") +
225
+ chalk.white(f.function_name),
226
+ );
227
+ }
228
+ for (const f of updatedFunctions) {
229
+ console.log(
230
+ chalk.blue(` [${timestamp()}]`) +
231
+ chalk.gray(" Updated: ") +
232
+ chalk.white(f.function_name),
233
+ );
234
+ }
235
+
236
+ // Regenerate
237
+ const { generated, files } = await regenerate(formats, outDir, opts.env);
238
+ if (generated > 0) {
239
+ console.log(
240
+ chalk.green(` [${timestamp()}]`) +
241
+ ` Regenerated ${generated} files: ${chalk.white(files.join(", "))}`,
242
+ );
243
+ }
244
+ }
245
+ } catch {
246
+ // Silently skip poll failures
247
+ }
248
+ };
249
+
250
+ // Set up interval
251
+ const timer = setInterval(poll, intervalMs);
252
+
253
+ // Handle graceful shutdown
254
+ const cleanup = () => {
255
+ clearInterval(timer);
256
+ console.log(chalk.gray(`\n [${timestamp()}]`) + " Watch stopped.\n");
257
+ process.exit(0);
258
+ };
259
+
260
+ process.on("SIGINT", cleanup);
261
+ process.on("SIGTERM", cleanup);
262
+ }
263
+
264
+ function parseInterval(input: string): number {
265
+ const match = input.match(/^(\d+)(s|ms|m)?$/);
266
+ if (!match) return 3000;
267
+
268
+ const value = parseInt(match[1], 10);
269
+ const unit = match[2] || "s";
270
+
271
+ switch (unit) {
272
+ case "ms": return value;
273
+ case "s": return value * 1000;
274
+ case "m": return value * 60 * 1000;
275
+ default: return value * 1000;
276
+ }
277
+ }
package/src/config.ts ADDED
@@ -0,0 +1,38 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+
5
+ const DEFAULT_BACKEND_URL = "http://localhost:4888";
6
+
7
+ interface TrickleConfig {
8
+ backendUrl?: string;
9
+ }
10
+
11
+ function loadConfigFile(): TrickleConfig | null {
12
+ const configPath = path.join(os.homedir(), ".trickle", "config.json");
13
+ try {
14
+ if (fs.existsSync(configPath)) {
15
+ const raw = fs.readFileSync(configPath, "utf-8");
16
+ return JSON.parse(raw) as TrickleConfig;
17
+ }
18
+ } catch {
19
+ // Ignore invalid config file
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function getBackendUrl(): string {
25
+ // 1. Environment variable takes priority
26
+ if (process.env.TRICKLE_BACKEND_URL) {
27
+ return process.env.TRICKLE_BACKEND_URL.replace(/\/+$/, "");
28
+ }
29
+
30
+ // 2. Config file
31
+ const config = loadConfigFile();
32
+ if (config?.backendUrl) {
33
+ return config.backendUrl.replace(/\/+$/, "");
34
+ }
35
+
36
+ // 3. Default
37
+ return DEFAULT_BACKEND_URL;
38
+ }
@@ -0,0 +1,51 @@
1
+ import chalk from "chalk";
2
+ import { TypeDiff } from "../api-client";
3
+ import { formatType } from "./type-formatter";
4
+
5
+ /**
6
+ * Format an array of TypeDiff entries as colorized diff output.
7
+ */
8
+ export function formatDiffs(diffs: TypeDiff[]): string {
9
+ if (diffs.length === 0) {
10
+ return chalk.gray(" No differences found.");
11
+ }
12
+
13
+ const lines: string[] = [];
14
+
15
+ for (const diff of diffs) {
16
+ const pathStr = chalk.gray(diff.path);
17
+
18
+ switch (diff.kind) {
19
+ case "added":
20
+ lines.push(
21
+ chalk.green(" + added ") +
22
+ pathStr +
23
+ chalk.gray(": ") +
24
+ formatType(diff.type, 0)
25
+ );
26
+ break;
27
+
28
+ case "removed":
29
+ lines.push(
30
+ chalk.red(" - removed ") +
31
+ pathStr +
32
+ chalk.gray(": ") +
33
+ formatType(diff.type, 0)
34
+ );
35
+ break;
36
+
37
+ case "changed":
38
+ lines.push(
39
+ chalk.yellow(" ~ changed ") +
40
+ pathStr +
41
+ chalk.gray(": ") +
42
+ formatType(diff.from, 0) +
43
+ chalk.gray(" -> ") +
44
+ formatType(diff.to, 0)
45
+ );
46
+ break;
47
+ }
48
+ }
49
+
50
+ return lines.join("\n");
51
+ }
@@ -0,0 +1,161 @@
1
+ import chalk from "chalk";
2
+
3
+ // TypeNode shape matching the backend
4
+ interface TypeNode {
5
+ kind: string;
6
+ name?: string;
7
+ element?: TypeNode;
8
+ properties?: Record<string, TypeNode>;
9
+ members?: TypeNode[];
10
+ params?: TypeNode[];
11
+ returnType?: TypeNode;
12
+ resolved?: TypeNode;
13
+ key?: TypeNode;
14
+ value?: TypeNode;
15
+ elements?: TypeNode[];
16
+ }
17
+
18
+ const INDENT_SIZE = 2;
19
+
20
+ function primitiveColor(name: string): string {
21
+ switch (name) {
22
+ case "string":
23
+ return chalk.green(name);
24
+ case "number":
25
+ case "bigint":
26
+ return chalk.yellow(name);
27
+ case "boolean":
28
+ return chalk.blue(name);
29
+ case "null":
30
+ case "undefined":
31
+ return chalk.gray(name);
32
+ case "symbol":
33
+ return chalk.magenta(name);
34
+ default:
35
+ return chalk.white(name);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Format a TypeNode as colorized pseudo-TypeScript.
41
+ */
42
+ export function formatType(node: TypeNode | unknown, indent: number = 0): string {
43
+ if (!node || typeof node !== "object") {
44
+ return chalk.gray("unknown");
45
+ }
46
+
47
+ const n = node as TypeNode;
48
+ const pad = " ".repeat(indent);
49
+ const innerPad = " ".repeat(indent + INDENT_SIZE);
50
+
51
+ switch (n.kind) {
52
+ case "primitive":
53
+ return primitiveColor(n.name || "unknown");
54
+
55
+ case "array":
56
+ if (n.element) {
57
+ const inner = formatType(n.element, indent);
58
+ // Wrap complex types in parens for array notation
59
+ if (n.element.kind === "union") {
60
+ return `(${inner})[]`;
61
+ }
62
+ return `${inner}[]`;
63
+ }
64
+ return chalk.gray("unknown[]");
65
+
66
+ case "object": {
67
+ if (!n.properties) return chalk.gray("{}");
68
+ const keys = Object.keys(n.properties);
69
+ if (keys.length === 0) return chalk.gray("{}");
70
+
71
+ // Inline for small objects (2 or fewer properties)
72
+ if (keys.length <= 2) {
73
+ const props = keys.map(
74
+ (key) => `${chalk.white(key)}: ${formatType(n.properties![key], 0)}`
75
+ );
76
+ return `{ ${props.join(", ")} }`;
77
+ }
78
+
79
+ // Multi-line for larger objects
80
+ const lines = keys.map(
81
+ (key) =>
82
+ `${innerPad}${chalk.white(key)}: ${formatType(n.properties![key], indent + INDENT_SIZE)}`
83
+ );
84
+ return `{\n${lines.join(",\n")}\n${pad}}`;
85
+ }
86
+
87
+ case "union": {
88
+ if (!n.members || n.members.length === 0) return chalk.gray("never");
89
+ return n.members.map((m) => formatType(m, indent)).join(chalk.gray(" | "));
90
+ }
91
+
92
+ case "function": {
93
+ const params = (n.params || [])
94
+ .map((p, i) => `${chalk.white(`arg${i}`)}: ${formatType(p, indent)}`)
95
+ .join(", ");
96
+ const ret = n.returnType ? formatType(n.returnType, indent) : chalk.gray("void");
97
+ return `(${params}) => ${ret}`;
98
+ }
99
+
100
+ case "promise": {
101
+ const resolved = n.resolved ? formatType(n.resolved, indent) : chalk.gray("unknown");
102
+ return `${chalk.cyan("Promise")}<${resolved}>`;
103
+ }
104
+
105
+ case "map": {
106
+ const key = n.key ? formatType(n.key, indent) : chalk.gray("unknown");
107
+ const value = n.value ? formatType(n.value, indent) : chalk.gray("unknown");
108
+ return `${chalk.cyan("Map")}<${key}, ${value}>`;
109
+ }
110
+
111
+ case "set": {
112
+ const element = n.element ? formatType(n.element, indent) : chalk.gray("unknown");
113
+ return `${chalk.cyan("Set")}<${element}>`;
114
+ }
115
+
116
+ case "tuple": {
117
+ if (!n.elements || n.elements.length === 0) return "[]";
118
+ const elems = n.elements.map((e) => formatType(e, indent)).join(", ");
119
+ return `[${elems}]`;
120
+ }
121
+
122
+ case "unknown":
123
+ return chalk.gray("unknown");
124
+
125
+ default:
126
+ return chalk.gray("unknown");
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Format a type node as a compact single-line string (no colors).
132
+ */
133
+ export function formatTypePlain(node: TypeNode | unknown): string {
134
+ if (!node || typeof node !== "object") return "unknown";
135
+
136
+ const n = node as TypeNode;
137
+
138
+ switch (n.kind) {
139
+ case "primitive":
140
+ return n.name || "unknown";
141
+ case "array":
142
+ return n.element ? `${formatTypePlain(n.element)}[]` : "unknown[]";
143
+ case "object": {
144
+ if (!n.properties) return "{}";
145
+ const keys = Object.keys(n.properties);
146
+ if (keys.length === 0) return "{}";
147
+ const props = keys.map((k) => `${k}: ${formatTypePlain(n.properties![k])}`);
148
+ return `{ ${props.join(", ")} }`;
149
+ }
150
+ case "union":
151
+ return (n.members || []).map(formatTypePlain).join(" | ");
152
+ case "function": {
153
+ const params = (n.params || []).map((p, i) => `arg${i}: ${formatTypePlain(p)}`).join(", ");
154
+ return `(${params}) => ${n.returnType ? formatTypePlain(n.returnType) : "void"}`;
155
+ }
156
+ case "promise":
157
+ return `Promise<${n.resolved ? formatTypePlain(n.resolved) : "unknown"}>`;
158
+ default:
159
+ return "unknown";
160
+ }
161
+ }