@vladpazych/dexter 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 (68) hide show
  1. package/bin/dexter +6 -0
  2. package/package.json +43 -0
  3. package/src/claude/index.ts +6 -0
  4. package/src/cli.ts +39 -0
  5. package/src/env/define.ts +190 -0
  6. package/src/env/index.ts +10 -0
  7. package/src/env/loader.ts +61 -0
  8. package/src/env/print.ts +98 -0
  9. package/src/env/validate.ts +46 -0
  10. package/src/index.ts +16 -0
  11. package/src/meta/adapters/fs.ts +22 -0
  12. package/src/meta/adapters/git.ts +29 -0
  13. package/src/meta/adapters/glob.ts +14 -0
  14. package/src/meta/adapters/index.ts +24 -0
  15. package/src/meta/adapters/process.ts +40 -0
  16. package/src/meta/cli.ts +340 -0
  17. package/src/meta/domain/bisect.ts +126 -0
  18. package/src/meta/domain/blame.ts +136 -0
  19. package/src/meta/domain/commit.ts +135 -0
  20. package/src/meta/domain/commits.ts +23 -0
  21. package/src/meta/domain/constraints/registry.ts +49 -0
  22. package/src/meta/domain/constraints/types.ts +30 -0
  23. package/src/meta/domain/diff.ts +34 -0
  24. package/src/meta/domain/eval.ts +57 -0
  25. package/src/meta/domain/format.ts +34 -0
  26. package/src/meta/domain/lint.ts +88 -0
  27. package/src/meta/domain/pickaxe.ts +99 -0
  28. package/src/meta/domain/quality.ts +145 -0
  29. package/src/meta/domain/rules.ts +21 -0
  30. package/src/meta/domain/scope-context.ts +63 -0
  31. package/src/meta/domain/service.ts +68 -0
  32. package/src/meta/domain/setup.ts +34 -0
  33. package/src/meta/domain/test.ts +72 -0
  34. package/src/meta/domain/transcripts.ts +88 -0
  35. package/src/meta/domain/typecheck.ts +41 -0
  36. package/src/meta/domain/workspace.ts +78 -0
  37. package/src/meta/errors.ts +19 -0
  38. package/src/meta/hooks/on-post-read.ts +61 -0
  39. package/src/meta/hooks/on-post-write.ts +65 -0
  40. package/src/meta/hooks/on-pre-bash.ts +69 -0
  41. package/src/meta/hooks/stubs.ts +51 -0
  42. package/src/meta/index.ts +36 -0
  43. package/src/meta/lib/actor.ts +53 -0
  44. package/src/meta/lib/eslint.ts +58 -0
  45. package/src/meta/lib/format.ts +55 -0
  46. package/src/meta/lib/paths.ts +36 -0
  47. package/src/meta/lib/present.ts +231 -0
  48. package/src/meta/lib/spec-links.ts +83 -0
  49. package/src/meta/lib/stdin.ts +56 -0
  50. package/src/meta/ports.ts +50 -0
  51. package/src/meta/types.ts +113 -0
  52. package/src/output/build.ts +56 -0
  53. package/src/output/index.ts +24 -0
  54. package/src/output/output.test.ts +374 -0
  55. package/src/output/render-cli.ts +55 -0
  56. package/src/output/render-json.ts +80 -0
  57. package/src/output/render-md.ts +43 -0
  58. package/src/output/render-xml.ts +55 -0
  59. package/src/output/render.ts +23 -0
  60. package/src/output/types.ts +44 -0
  61. package/src/pipe/format.ts +167 -0
  62. package/src/pipe/index.ts +4 -0
  63. package/src/pipe/parse.ts +131 -0
  64. package/src/pipe/spawn.ts +205 -0
  65. package/src/pipe/types.ts +27 -0
  66. package/src/terminal/colors.ts +95 -0
  67. package/src/terminal/index.ts +16 -0
  68. package/src/version.ts +1 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Log formatting for terminal and file output.
3
+ */
4
+
5
+ import { bold, dim, gray, red, reset, yellow } from "../terminal/colors.ts"
6
+ import type { LogLevel, PipedEntry } from "./types.ts"
7
+
8
+ /**
9
+ * Get terminal width from multiple sources.
10
+ * Priority: COLUMNS env > stdout.columns > 120
11
+ */
12
+ function getTerminalWidth(): number {
13
+ // COLUMNS env var (set by shell or parent)
14
+ const envColumns = process.env.COLUMNS
15
+ if (envColumns) {
16
+ const parsed = parseInt(envColumns, 10)
17
+ if (!Number.isNaN(parsed) && parsed > 0) return parsed
18
+ }
19
+
20
+ // stdout.columns (works when TTY)
21
+ if (process.stdout.columns && process.stdout.columns > 0) {
22
+ return process.stdout.columns
23
+ }
24
+
25
+ // Default fallback
26
+ return 120
27
+ }
28
+
29
+ const LEVEL_LABELS: Record<LogLevel, string> = {
30
+ trace: "T",
31
+ debug: "D",
32
+ info: "I",
33
+ warn: "W",
34
+ error: "E",
35
+ fatal: "F",
36
+ }
37
+
38
+ const LEVEL_COLORS: Record<LogLevel, string> = {
39
+ trace: gray,
40
+ debug: dim,
41
+ info: "",
42
+ warn: yellow,
43
+ error: red,
44
+ fatal: bold + red,
45
+ }
46
+
47
+ /** Fields to exclude from key=value output */
48
+ const SUPPRESSED = new Set(["level", "time", "pid", "hostname", "name", "msg", "message", "v", "timestamp"])
49
+
50
+ /**
51
+ * Format fields as key=value pairs.
52
+ * @param fields - Log fields
53
+ * @param maxValueLen - Max length per value (truncate with ...)
54
+ */
55
+ function formatFields(fields: Record<string, unknown>, maxValueLen = 20): string {
56
+ const pairs: string[] = []
57
+
58
+ for (const [key, value] of Object.entries(fields)) {
59
+ if (SUPPRESSED.has(key) || value === undefined) continue
60
+
61
+ let strValue = typeof value === "string" ? value : JSON.stringify(value)
62
+ if (strValue.length > maxValueLen) {
63
+ strValue = `${strValue.slice(0, maxValueLen - 1)}…`
64
+ }
65
+ pairs.push(`${key}=${strValue}`)
66
+ }
67
+
68
+ return pairs.join(" ")
69
+ }
70
+
71
+ /**
72
+ * Format file:line prefix with fixed-width line number.
73
+ */
74
+ function formatPrefix(logFile: string, lineNumber: number): string {
75
+ const line = lineNumber.toString().padEnd(5, " ")
76
+ return `${logFile}:${line}`
77
+ }
78
+
79
+ export type FormatTerminalOptions = {
80
+ /** Terminal width for truncation */
81
+ width?: number
82
+ /** Max width per field value */
83
+ maxValueLen?: number
84
+ }
85
+
86
+ /**
87
+ * Strip ANSI escape codes for length calculation.
88
+ */
89
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI pattern
90
+ const ANSI_REGEX = /\x1b\[[0-9;]*m/g
91
+ function stripAnsiLocal(str: string): string {
92
+ return str.replace(ANSI_REGEX, "")
93
+ }
94
+
95
+ /**
96
+ * Format log entry for terminal output.
97
+ *
98
+ * Format: "L file:line message kv..."
99
+ * - Level character is colored
100
+ * - Message preserves original ANSI codes (e.g., vite colors)
101
+ * - Truncated to terminal width
102
+ * - file:line is clickable in most terminals
103
+ */
104
+ export function formatTerminal(entry: PipedEntry, options: FormatTerminalOptions = {}): string {
105
+ const { width = getTerminalWidth(), maxValueLen = 20 } = options
106
+
107
+ const label = LEVEL_LABELS[entry.level]
108
+ const levelColor = LEVEL_COLORS[entry.level]
109
+ const prefix = formatPrefix(entry.logFile, entry.lineNumber)
110
+ const kv = formatFields(entry.fields, maxValueLen)
111
+
112
+ let message = entry.message
113
+ let kvPart = kv ? ` ${kv}` : ""
114
+
115
+ // Calculate visible length (without ANSI codes)
116
+ const messagePlain = stripAnsiLocal(message)
117
+
118
+ // Fixed parts: "L " + prefix + " "
119
+ const fixedWidth = 2 + prefix.length + 1
120
+ const available = width - fixedWidth
121
+
122
+ if (available > 0) {
123
+ const total = messagePlain.length + kvPart.length
124
+
125
+ if (total > available) {
126
+ // Truncate kv first, then message
127
+ if (kvPart.length > 0 && messagePlain.length < available - 3) {
128
+ const kvMax = available - messagePlain.length - 4
129
+ kvPart = kvMax > 0 ? ` ${kv.slice(0, kvMax)}…` : ""
130
+ } else {
131
+ kvPart = ""
132
+ if (messagePlain.length > available - 1) {
133
+ // Truncate message (approximate - may cut mid-ANSI sequence)
134
+ const truncLen = available - 2
135
+ if (truncLen > 0 && truncLen < messagePlain.length) {
136
+ // Simple truncation for messages with ANSI - preserve some content
137
+ message = `${message.slice(0, truncLen + (message.length - messagePlain.length))}${reset}…`
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ // Color only the level character, preserve message colors
145
+ // Dim the file:line prefix for reduced visual noise
146
+ const coloredLabel = levelColor ? `${levelColor}${label}${reset}` : label
147
+ const dimmedPrefix = `${dim}${prefix}${reset}`
148
+ return `${coloredLabel} ${dimmedPrefix} ${message}${kvPart}`
149
+ }
150
+
151
+ /**
152
+ * Format log entry for file output.
153
+ *
154
+ * Format: "L HH:MM:SS message kv..."
155
+ * - No colors (ANSI stripped)
156
+ * - No truncation (full content)
157
+ * - No file:line (the file IS the source)
158
+ */
159
+ export function formatFile(entry: PipedEntry): string {
160
+ const label = LEVEL_LABELS[entry.level]
161
+ const time = new Date(entry.timestamp).toISOString().slice(11, 19)
162
+ const kv = formatFields(entry.fields, 1000) // No truncation
163
+ const message = stripAnsiLocal(entry.message) // Strip ANSI for file
164
+
165
+ const kvPart = kv ? ` ${kv}` : ""
166
+ return `${label} ${time} ${message}${kvPart}`
167
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./format.ts"
2
+ export * from "./parse.ts"
3
+ export * from "./spawn.ts"
4
+ export * from "./types.ts"
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Log line parsing.
3
+ *
4
+ * Handles both structured JSON (pino, etc.) and raw text output.
5
+ */
6
+
7
+ import { stripAnsi } from "../terminal/colors.ts"
8
+ import type { LogEntry, LogLevel } from "./types.ts"
9
+
10
+ /** Pino numeric level to LogLevel */
11
+ const PINO_LEVELS: Record<number, LogLevel> = {
12
+ 10: "trace",
13
+ 20: "debug",
14
+ 30: "info",
15
+ 40: "warn",
16
+ 50: "error",
17
+ 60: "fatal",
18
+ }
19
+
20
+ /**
21
+ * Parse a JSON log line (pino format).
22
+ * Returns null if not valid JSON or missing required fields.
23
+ */
24
+ export function parseJson(line: string): LogEntry | null {
25
+ try {
26
+ const data = JSON.parse(line) as Record<string, unknown>
27
+
28
+ // Pino uses numeric levels
29
+ if (typeof data.level === "number") {
30
+ return {
31
+ level: PINO_LEVELS[data.level] ?? "info",
32
+ message: (data.msg as string) ?? "",
33
+ fields: data,
34
+ timestamp: (data.time as number) ?? Date.now(),
35
+ raw: line,
36
+ }
37
+ }
38
+
39
+ // String level (other loggers)
40
+ if (typeof data.level === "string") {
41
+ return {
42
+ level: data.level as LogLevel,
43
+ message: (data.msg as string) ?? (data.message as string) ?? "",
44
+ fields: data,
45
+ timestamp: (data.time as number) ?? (data.timestamp as number) ?? Date.now(),
46
+ raw: line,
47
+ }
48
+ }
49
+
50
+ return null
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Detect log level from raw text content.
58
+ */
59
+ function detectLevel(text: string): LogLevel {
60
+ const lower = text.toLowerCase()
61
+
62
+ // Error patterns
63
+ if (
64
+ lower.includes("error") ||
65
+ lower.includes("failed") ||
66
+ lower.includes("exception") ||
67
+ lower.includes("eaddrinuse") ||
68
+ lower.includes("enoent") ||
69
+ lower.includes("eacces") ||
70
+ text.startsWith("throw ") ||
71
+ text.includes("Emitted 'error'")
72
+ ) {
73
+ return "error"
74
+ }
75
+
76
+ // Stack trace and error object patterns (continuation of error)
77
+ if (
78
+ text.trimStart().startsWith("at ") ||
79
+ text.includes("node:") ||
80
+ (text.startsWith(" ") && text.includes("(")) ||
81
+ /^\s*\^$/.test(text) ||
82
+ /code: '/.test(text) ||
83
+ /errno:/.test(text) ||
84
+ /syscall:/.test(text) ||
85
+ /address:/.test(text) ||
86
+ /^\s*port:/.test(text) ||
87
+ /^\s*\}$/.test(text)
88
+ ) {
89
+ return "error"
90
+ }
91
+
92
+ // Warning patterns
93
+ if (lower.includes("warn") || lower.includes("deprecat")) {
94
+ return "warn"
95
+ }
96
+
97
+ if (lower.includes("debug")) {
98
+ return "debug"
99
+ }
100
+
101
+ return "info"
102
+ }
103
+
104
+ /**
105
+ * Parse a raw text line (non-JSON output).
106
+ * Returns null for empty lines.
107
+ * Preserves original ANSI codes in message for terminal passthrough.
108
+ */
109
+ export function parseRaw(line: string): LogEntry | null {
110
+ const trimmed = line.trim()
111
+ if (!trimmed) return null
112
+
113
+ // Strip ANSI only for level detection, preserve original in message
114
+ const cleaned = stripAnsi(trimmed)
115
+ if (!cleaned) return null
116
+
117
+ return {
118
+ level: detectLevel(cleaned),
119
+ message: trimmed, // Preserve original ANSI codes
120
+ fields: {},
121
+ timestamp: Date.now(),
122
+ raw: line,
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Parse any log line (JSON or raw).
128
+ */
129
+ export function parse(line: string): LogEntry | null {
130
+ return parseJson(line) ?? parseRaw(line)
131
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Process spawning with output capture and routing.
3
+ *
4
+ * Spawns a child process, captures stdout/stderr, parses each line,
5
+ * and routes to file and terminal with appropriate formatting.
6
+ */
7
+
8
+ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process"
9
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
10
+ import { dirname, join, resolve } from "node:path"
11
+ import { createInterface } from "node:readline"
12
+ import { setColorEnabled } from "../terminal/colors.ts"
13
+ import { formatFile, formatTerminal } from "./format.ts"
14
+ import { parse } from "./parse.ts"
15
+ import type { PipedEntry } from "./types.ts"
16
+
17
+ /**
18
+ * Find monorepo root by looking for package.json with workspaces.
19
+ * Falls back to start directory.
20
+ */
21
+ function findMonorepoRoot(startDir: string): string {
22
+ let dir = startDir
23
+ while (dir !== dirname(dir)) {
24
+ const pkgPath = join(dir, "package.json")
25
+ if (existsSync(pkgPath)) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
28
+ if (pkg.workspaces) {
29
+ return dir
30
+ }
31
+ } catch {
32
+ // Invalid JSON, continue searching
33
+ }
34
+ }
35
+ dir = dirname(dir)
36
+ }
37
+ return startDir
38
+ }
39
+
40
+ export type PipeOptions = {
41
+ /** Short name for display and log file (e.g., "api", "web") */
42
+ name: string
43
+ /** Command to run */
44
+ cmd: string
45
+ /** Command arguments */
46
+ args: string[]
47
+ /** Working directory */
48
+ cwd?: string
49
+ /**
50
+ * Log file path. Defaults to `.logs/{name}.log` in monorepo root.
51
+ * If relative, resolved from cwd.
52
+ */
53
+ logFile?: string
54
+ /** Terminal width for truncation (auto-detected if not specified) */
55
+ width?: number
56
+ /** Additional environment variables */
57
+ env?: Record<string, string>
58
+ /** Callback for each parsed log entry */
59
+ onLog?: (entry: PipedEntry) => void
60
+ /** Callback on process exit */
61
+ onExit?: (code: number | null) => void
62
+ }
63
+
64
+ export type PipeHandle = {
65
+ /** The spawned child process */
66
+ child: ChildProcess
67
+ /** Send SIGTERM to the process */
68
+ stop: () => void
69
+ /** Promise that resolves when process exits */
70
+ wait: () => Promise<number | null>
71
+ }
72
+
73
+ /**
74
+ * Spawn a process with output piping.
75
+ *
76
+ * - Captures stdout/stderr line by line
77
+ * - Parses each line (JSON or raw)
78
+ * - Writes full content to log file
79
+ * - Writes truncated colored output to terminal
80
+ */
81
+ export function pipe(options: PipeOptions): PipeHandle {
82
+ // Enable colors for dexter output (override NO_COLOR from parent shell)
83
+ setColorEnabled(true)
84
+
85
+ const cwd = options.cwd ?? process.cwd()
86
+ const monorepoRoot = findMonorepoRoot(cwd)
87
+
88
+ // Default log file: {name}.log in monorepo root
89
+ const logFile = options.logFile ?? join(monorepoRoot, `${options.name}.log`)
90
+ const logFilePath = logFile.startsWith("/") ? logFile : resolve(cwd, logFile)
91
+
92
+ // Display name is just the short name (e.g., "api", "web")
93
+ const displayName = options.name
94
+
95
+ // Ensure directory exists and truncate file
96
+ try {
97
+ mkdirSync(dirname(logFilePath), { recursive: true })
98
+ } catch {
99
+ // dir exists
100
+ }
101
+ writeFileSync(logFilePath, "")
102
+
103
+ let lineNumber = 0
104
+
105
+ // Remove NO_COLOR to avoid conflict with our color handling
106
+ const { NO_COLOR: _, ...cleanEnv } = process.env
107
+
108
+ // Get terminal width for output truncation
109
+ // Priority: explicit option > stdout.columns > COLUMNS env > default
110
+ let terminalWidth = options.width
111
+ if (!terminalWidth) {
112
+ if (process.stdout.columns && process.stdout.columns > 0) {
113
+ terminalWidth = process.stdout.columns
114
+ } else if (process.env.COLUMNS) {
115
+ terminalWidth = parseInt(process.env.COLUMNS, 10) || 120
116
+ } else {
117
+ terminalWidth = 120
118
+ }
119
+ }
120
+
121
+ const child = nodeSpawn(options.cmd, options.args, {
122
+ cwd,
123
+ stdio: ["inherit", "pipe", "pipe"],
124
+ env: {
125
+ ...cleanEnv,
126
+ // Pass terminal width to child for proper truncation
127
+ COLUMNS: String(terminalWidth),
128
+ // Enable colors in child processes (vite, etc.)
129
+ FORCE_COLOR: "1",
130
+ // Enable colors in dexter's output
131
+ DEXTER_COLOR: "1",
132
+ ...options.env,
133
+ // Signal to apps: output JSON for parsing, don't write own files
134
+ LOG_FORMAT: "json",
135
+ DEV_RUNNER: "1",
136
+ },
137
+ })
138
+
139
+ const handleLine = (line: string) => {
140
+ const parsed = parse(line)
141
+ if (!parsed) return
142
+
143
+ lineNumber++
144
+
145
+ const entry: PipedEntry = {
146
+ ...parsed,
147
+ source: options.name,
148
+ logFile: displayName,
149
+ lineNumber,
150
+ }
151
+
152
+ // Write to file (full content, source of truth)
153
+ const fileLine = formatFile(entry)
154
+ appendFileSync(logFilePath, `${fileLine}\n`)
155
+
156
+ // Write to terminal (truncated, colored)
157
+ const terminalLine = formatTerminal(entry, { width: terminalWidth })
158
+ process.stdout.write(`${terminalLine}\n`)
159
+
160
+ // Callback
161
+ options.onLog?.(entry)
162
+ }
163
+
164
+ // Capture stdout
165
+ if (child.stdout) {
166
+ const rl = createInterface({ input: child.stdout })
167
+ rl.on("line", handleLine)
168
+ }
169
+
170
+ // Capture stderr
171
+ if (child.stderr) {
172
+ const rl = createInterface({ input: child.stderr })
173
+ rl.on("line", handleLine)
174
+ }
175
+
176
+ // Exit handling
177
+ const exitPromise = new Promise<number | null>((resolve) => {
178
+ child.on("exit", (code) => {
179
+ options.onExit?.(code)
180
+ resolve(code)
181
+ })
182
+ })
183
+
184
+ // Forward signals
185
+ const forwardSignal = (signal: NodeJS.Signals) => {
186
+ process.on(signal, () => child.kill(signal))
187
+ }
188
+ forwardSignal("SIGINT")
189
+ forwardSignal("SIGTERM")
190
+
191
+ return {
192
+ child,
193
+ stop: () => child.kill("SIGTERM"),
194
+ wait: () => exitPromise,
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Spawn and wait for exit (convenience wrapper).
200
+ */
201
+ export async function pipeAndWait(options: PipeOptions): Promise<number> {
202
+ const handle = pipe(options)
203
+ const code = await handle.wait()
204
+ return code ?? 0
205
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Pipe module types.
3
+ */
4
+
5
+ export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"
6
+
7
+ export type LogEntry = {
8
+ /** Log level */
9
+ level: LogLevel
10
+ /** Main message */
11
+ message: string
12
+ /** Structured fields (from JSON logs) */
13
+ fields: Record<string, unknown>
14
+ /** Unix timestamp ms */
15
+ timestamp: number
16
+ /** Original raw line */
17
+ raw: string
18
+ }
19
+
20
+ export type PipedEntry = LogEntry & {
21
+ /** Source identifier (e.g., "dimas-server") */
22
+ source: string
23
+ /** Log file path */
24
+ logFile: string
25
+ /** Line number in log file */
26
+ lineNumber: number
27
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ANSI terminal colors with NO_COLOR/FORCE_COLOR support.
3
+ *
4
+ * Priority:
5
+ * 1. DEXTER_COLOR=1 → enabled (dexter override)
6
+ * 2. NO_COLOR set → disabled (accessibility)
7
+ * 3. FORCE_COLOR=1 → enabled
8
+ * 4. TTY detection → enabled if TTY
9
+ * 5. Default → enabled (dev-friendly default)
10
+ *
11
+ * https://no-color.org/
12
+ */
13
+
14
+ let _colorEnabled: boolean | null = null
15
+
16
+ function isColorEnabled(): boolean {
17
+ if (_colorEnabled !== null) return _colorEnabled
18
+
19
+ // Dexter-specific override (highest priority)
20
+ if (process.env.DEXTER_COLOR === "1") {
21
+ _colorEnabled = true
22
+ return true
23
+ }
24
+
25
+ // Explicit disable
26
+ if (process.env.NO_COLOR && process.env.NO_COLOR !== "0") {
27
+ _colorEnabled = false
28
+ return false
29
+ }
30
+
31
+ // Explicit enable
32
+ if (process.env.FORCE_COLOR === "1") {
33
+ _colorEnabled = true
34
+ return true
35
+ }
36
+
37
+ // TTY detection
38
+ if (process.stdout.isTTY === true) {
39
+ _colorEnabled = true
40
+ return true
41
+ }
42
+ if (process.stdout.isTTY === false) {
43
+ _colorEnabled = false
44
+ return false
45
+ }
46
+
47
+ // Default: enable for better dev experience
48
+ _colorEnabled = true
49
+ return true
50
+ }
51
+
52
+ /** Enable or disable colors programmatically */
53
+ export function setColorEnabled(enabled: boolean): void {
54
+ _colorEnabled = enabled
55
+ }
56
+
57
+ /** Raw ANSI codes */
58
+ export const reset = "\x1b[0m"
59
+ export const bold = "\x1b[1m"
60
+ export const dim = "\x1b[2m"
61
+ export const red = "\x1b[31m"
62
+ export const green = "\x1b[32m"
63
+ export const yellow = "\x1b[33m"
64
+ export const blue = "\x1b[34m"
65
+ export const magenta = "\x1b[35m"
66
+ export const cyan = "\x1b[36m"
67
+ export const gray = "\x1b[90m"
68
+
69
+ /**
70
+ * Wrap text with color code and reset (respects color enabled).
71
+ */
72
+ export const wrap = (colorCode: string) => (text: string) => (isColorEnabled() ? `${colorCode}${text}${reset}` : text)
73
+
74
+ export const c = {
75
+ reset,
76
+ bold,
77
+ dim,
78
+ red: wrap(red),
79
+ green: wrap(green),
80
+ yellow: wrap(yellow),
81
+ blue: wrap(blue),
82
+ magenta: wrap(magenta),
83
+ cyan: wrap(cyan),
84
+ gray: wrap(gray),
85
+ dimmed: wrap(dim),
86
+ bolded: wrap(bold),
87
+ }
88
+
89
+ /**
90
+ * Strip ANSI escape codes from string.
91
+ * Uses Bun's native Zig implementation (6-57x faster than regex).
92
+ */
93
+ export function stripAnsi(str: string): string {
94
+ return Bun.stripANSI(str)
95
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ blue,
3
+ bold,
4
+ c,
5
+ cyan,
6
+ dim,
7
+ gray,
8
+ green,
9
+ magenta,
10
+ red,
11
+ reset,
12
+ setColorEnabled,
13
+ stripAnsi,
14
+ wrap,
15
+ yellow,
16
+ } from "./colors.ts"
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const version = "0.1.0"