@sayknow-cli/utils 0.3.6 → 0.3.8

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 (40) hide show
  1. package/dist/types/abortable.d.ts +27 -0
  2. package/dist/types/async.d.ts +6 -0
  3. package/dist/types/cli.d.ts +126 -0
  4. package/dist/types/color.d.ts +82 -0
  5. package/dist/types/dirs.d.ts +163 -0
  6. package/dist/types/env.d.ts +68 -0
  7. package/dist/types/fetch-retry.d.ts +80 -0
  8. package/dist/types/format.d.ts +37 -0
  9. package/dist/types/frontmatter.d.ts +25 -0
  10. package/dist/types/fs-error.d.ts +31 -0
  11. package/dist/types/glob.d.ts +28 -0
  12. package/dist/types/hook-fetch.d.ts +16 -0
  13. package/dist/types/index.d.ts +30 -0
  14. package/dist/types/json.d.ts +4 -0
  15. package/dist/types/logger.d.ts +66 -0
  16. package/dist/types/mermaid-ascii.d.ts +11 -0
  17. package/dist/types/mime.d.ts +29 -0
  18. package/dist/types/peek-file.d.ts +9 -0
  19. package/dist/types/postmortem.d.ts +29 -0
  20. package/dist/types/procmgr.d.ts +35 -0
  21. package/dist/types/prompt.d.ts +18 -0
  22. package/dist/types/ptree.d.ts +108 -0
  23. package/dist/types/ring.d.ts +93 -0
  24. package/dist/types/safe-stderr.d.ts +1 -0
  25. package/dist/types/sanitize-text.d.ts +14 -0
  26. package/dist/types/snowflake.d.ts +25 -0
  27. package/dist/types/spawn-env.d.ts +4 -0
  28. package/dist/types/stream.d.ts +68 -0
  29. package/dist/types/tab-spacing.d.ts +12 -0
  30. package/dist/types/temp.d.ts +14 -0
  31. package/dist/types/type-guards.d.ts +3 -0
  32. package/dist/types/which.d.ts +37 -0
  33. package/package.json +8 -7
  34. package/src/cli.ts +51 -13
  35. package/src/env.ts +5 -2
  36. package/src/format.ts +16 -5
  37. package/src/frontmatter.ts +14 -2
  38. package/src/glob.ts +29 -22
  39. package/src/postmortem.ts +4 -1
  40. package/src/tab-spacing.ts +16 -1
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Cache policy for which lookups.
3
+ */
4
+ export declare const enum WhichCachePolicy {
5
+ /**
6
+ * Use cached result if available.
7
+ */
8
+ Cached = 0,
9
+ /**
10
+ * Bypass cache and perform a new lookup.
11
+ */
12
+ Bypass = 1,
13
+ /**
14
+ * Always update cache.
15
+ */
16
+ Fresh = 2,
17
+ /**
18
+ * Read-only, serves from cache if present, but doesn't write.
19
+ */
20
+ ReadOnly = 3
21
+ }
22
+ export interface WhichOptions extends Bun.WhichOptions {
23
+ /**
24
+ * Cache policy for the lookup.
25
+ * Defaults to `WhichCachePolicy.Fresh`.
26
+ */
27
+ cache?: WhichCachePolicy;
28
+ }
29
+ export declare const whichFresh: typeof Bun.which;
30
+ /**
31
+ * Locate binary on PATH (with flexible caching).
32
+ *
33
+ * @param command - Binary name to resolve
34
+ * @param options - Bun.WhichOptions plus `cache` control
35
+ * @returns Filesystem path if found, else null
36
+ */
37
+ export declare function $which(command: string, options?: WhichOptions): string | null;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/utils",
4
- "version": "0.3.6",
4
+ "version": "0.3.8",
5
5
  "description": "Shared utilities for pi packages",
6
- "homepage": "https://github.com/jaybeyond/Sayknow_CLI",
6
+ "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
8
8
  "license": "MIT",
9
9
  "repository": {
@@ -21,7 +21,7 @@
21
21
  "streams"
22
22
  ],
23
23
  "main": "./src/index.ts",
24
- "types": "./src/index.ts",
24
+ "types": "./dist/types/index.d.ts",
25
25
  "scripts": {
26
26
  "check": "biome check . && bun run check:types",
27
27
  "check:types": "tsgo -p tsconfig.json --noEmit",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@sayknow-cli/natives": "0.3.6",
34
+ "@sayknow-cli/natives": "0.3.8",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
@@ -44,15 +44,16 @@
44
44
  "bun": ">=1.3.14"
45
45
  },
46
46
  "files": [
47
- "src"
47
+ "src",
48
+ "dist/types"
48
49
  ],
49
50
  "exports": {
50
51
  ".": {
51
- "types": "./src/index.ts",
52
+ "types": "./dist/types/index.d.ts",
52
53
  "import": "./src/index.ts"
53
54
  },
54
55
  "./*": {
55
- "types": "./src/*.ts",
56
+ "types": "./dist/types/*.d.ts",
56
57
  "import": "./src/*.ts"
57
58
  },
58
59
  "./*.js": "./src/*.ts"
package/src/cli.ts CHANGED
@@ -68,6 +68,18 @@ export const Args = {
68
68
  },
69
69
  };
70
70
 
71
+ /**
72
+ * Thrown when CLI argument/flag parsing or validation fails (unknown flag,
73
+ * bad option value, missing required arg, etc.). `run()` catches this to print
74
+ * the message and render usage instead of crashing as an uncaught exception.
75
+ */
76
+ export class CliParseError extends Error {
77
+ constructor(message: string) {
78
+ super(message);
79
+ this.name = "CliParseError";
80
+ }
81
+ }
82
+
71
83
  // ---------------------------------------------------------------------------
72
84
  // Parse result types — mirrors oclif's typed output from this.parse()
73
85
  // ---------------------------------------------------------------------------
@@ -174,12 +186,22 @@ export abstract class Command {
174
186
 
175
187
  // strict=false when command declares args (positionals must pass through)
176
188
  // or when the command itself opts out
177
- const { values: rawValues, positionals } = nodeParseArgs({
178
- args: this.argv,
179
- options,
180
- allowPositionals: true,
181
- strict,
182
- });
189
+ let rawValues: Record<string, string | boolean | Array<string | boolean> | undefined>;
190
+ let positionals: string[];
191
+ try {
192
+ const parsed = nodeParseArgs({
193
+ args: this.argv,
194
+ options,
195
+ allowPositionals: true,
196
+ strict,
197
+ });
198
+ rawValues = parsed.values;
199
+ positionals = parsed.positionals;
200
+ } catch (err) {
201
+ // node:util parseArgs throws on unknown flags / malformed input — surface
202
+ // it as a CliParseError so run() renders usage instead of crashing.
203
+ throw new CliParseError(err instanceof Error ? err.message : String(err));
204
+ }
183
205
 
184
206
  // Convert raw values to proper types and validate
185
207
  const flags: Record<string, unknown> = {};
@@ -191,7 +213,7 @@ export abstract class Command {
191
213
  } else {
192
214
  const n = Number.parseInt(raw as string, 10);
193
215
  if (Number.isNaN(n)) {
194
- throw new Error(`Expected integer for --${name}, got "${raw}"`);
216
+ throw new CliParseError(`Expected integer for --${name}, got "${raw}"`);
195
217
  }
196
218
  flags[name] = n;
197
219
  }
@@ -204,14 +226,16 @@ export abstract class Command {
204
226
  // Validate options constraint
205
227
  if (val !== undefined && desc.options && !Array.isArray(val)) {
206
228
  if (!desc.options.includes(val as string)) {
207
- throw new Error(`Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`);
229
+ throw new CliParseError(
230
+ `Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`,
231
+ );
208
232
  }
209
233
  }
210
234
  flags[name] = val;
211
235
  }
212
236
  // Validate required
213
237
  if (desc.required && flags[name] === undefined) {
214
- throw new Error(`Missing required flag: --${name}`);
238
+ throw new CliParseError(`Missing required flag: --${name}`);
215
239
  }
216
240
  }
217
241
 
@@ -230,13 +254,15 @@ export abstract class Command {
230
254
  }
231
255
  // Validate required
232
256
  if (desc.required && args[argName] === undefined) {
233
- throw new Error(`Missing required argument: ${argName}`);
257
+ throw new CliParseError(`Missing required argument: ${argName}`);
234
258
  }
235
259
  // Validate options constraint
236
260
  const argVal = args[argName];
237
261
  if (argVal !== undefined && desc.options && typeof argVal === "string") {
238
262
  if (!desc.options.includes(argVal)) {
239
- throw new Error(`Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`);
263
+ throw new CliParseError(
264
+ `Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`,
265
+ );
240
266
  }
241
267
  }
242
268
  }
@@ -407,7 +433,7 @@ export async function run(opts: RunOptions): Promise<void> {
407
433
  const instance = new Cmd(commandArgv, config);
408
434
  await instance.run();
409
435
  } else {
410
- const config = await loadAllCommands(opts);
436
+ const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
411
437
  renderCommandHelp(bin, entry.name, config.commands.get(entry.name) ?? Cmd);
412
438
  }
413
439
  return;
@@ -425,7 +451,19 @@ export async function run(opts: RunOptions): Promise<void> {
425
451
  const Cmd = await entry.load();
426
452
  const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
427
453
  const instance = new Cmd(commandArgv, config);
428
- await instance.run();
454
+ try {
455
+ await instance.run();
456
+ } catch (err) {
457
+ if (err instanceof CliParseError) {
458
+ // Invalid args/flags for a real command: print the problem + usage and
459
+ // exit with a usage error, instead of crashing as an uncaught exception.
460
+ process.stderr.write(`${err.message}\n\n`);
461
+ renderCommandHelp(bin, entry.name, Cmd);
462
+ process.exitCode = 2;
463
+ return;
464
+ }
465
+ throw err;
466
+ }
429
467
  }
430
468
 
431
469
  /** Resolve all command loaders for help/alias display. */
package/src/env.ts CHANGED
@@ -272,7 +272,10 @@ export function isCompiledBinary(): boolean {
272
272
 
273
273
  const TRUTHY: Dict<boolean> = { "1": true, Y: true, TRUE: true, YES: true, ON: true };
274
274
  export function $flag(name: string, def: boolean = false): boolean {
275
- const value = $env[name];
275
+ const value = $env[name]?.trim();
276
276
  if (!value) return def;
277
- return TRUTHY[value] === true;
277
+ // Boolean-like env values are documented as case-insensitive (`1`/`true`/`yes`/`on`),
278
+ // so normalize before the lookup — otherwise `FOO=true` (the common lowercase spelling)
279
+ // would silently read as false while only `FOO=TRUE`/`FOO=1` worked.
280
+ return TRUTHY[value.toUpperCase()] === true;
278
281
  }
package/src/format.ts CHANGED
@@ -33,9 +33,9 @@ export function formatDuration(ms: number): string {
33
33
  export function formatNumber(n: number): string {
34
34
  if (n < 1_000) return n.toString();
35
35
  if (n < 10_000) return `${trim1(n / 1_000)}K`;
36
- if (n < 1_000_000) return `${Math.round(n / 1_000)}K`;
36
+ if (n < 1_000_000) return `${roundBelow(n / 1_000, 1_000)}K`;
37
37
  if (n < 10_000_000) return `${trim1(n / 1_000_000)}M`;
38
- if (n < 1_000_000_000) return `${Math.round(n / 1_000_000)}M`;
38
+ if (n < 1_000_000_000) return `${roundBelow(n / 1_000_000, 1_000)}M`;
39
39
  if (n < 10_000_000_000) return `${trim1(n / 1_000_000_000)}B`;
40
40
  return `${Math.round(n / 1_000_000_000)}B`;
41
41
  }
@@ -46,15 +46,26 @@ function trim1(n: number): string {
46
46
  return s.endsWith(".0") ? s.slice(0, -2) : s;
47
47
  }
48
48
 
49
+ /** Round to an integer without crossing the next compact suffix boundary. */
50
+ function roundBelow(n: number, nextUnit: number): number {
51
+ return Math.min(Math.round(n), nextUnit - 1);
52
+ }
53
+
49
54
  /**
50
55
  * Format a byte count to a human-readable string.
51
56
  * Examples: "512B", "1.5KB", "2.3MB", "1.2GB"
52
57
  */
53
58
  export function formatBytes(bytes: number): string {
54
59
  if (bytes < 1024) return `${bytes}B`;
55
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
56
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
57
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
60
+ if (bytes < 1024 * 1024) return `${formatByteUnit(bytes, 1024)}KB`;
61
+ if (bytes < 1024 * 1024 * 1024) return `${formatByteUnit(bytes, 1024 * 1024)}MB`;
62
+ return `${formatByteUnit(bytes, 1024 * 1024 * 1024)}GB`;
63
+ }
64
+
65
+ /** Format bytes to 1 decimal without rounding up into the next byte unit. */
66
+ function formatByteUnit(bytes: number, unit: number): string {
67
+ const tenths = Math.min(Math.round((bytes / unit) * 10), 1024 * 10 - 1);
68
+ return (tenths / 10).toFixed(1);
58
69
  }
59
70
 
60
71
  /**
@@ -17,15 +17,27 @@ function stripLooseScalarTrailingCommas(metadata: string): string {
17
17
  .join("\n");
18
18
  }
19
19
 
20
+ function asFrontmatterRecord(parsed: unknown): Record<string, unknown> | null {
21
+ if (parsed === null) return null;
22
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
23
+ throw new Error("YAML frontmatter root must be an object");
24
+ }
25
+ const prototype = Object.getPrototypeOf(parsed);
26
+ if (prototype !== Object.prototype && prototype !== null) {
27
+ throw new Error("YAML frontmatter root must be an object");
28
+ }
29
+ return parsed as Record<string, unknown>;
30
+ }
31
+
20
32
  function parseYamlMetadata(metadata: string): Record<string, unknown> | null {
21
33
  const normalized = metadata.replaceAll("\t", " ");
22
34
  try {
23
- return YAML.parse(normalized) as Record<string, unknown> | null;
35
+ return asFrontmatterRecord(YAML.parse(normalized));
24
36
  } catch (strictError) {
25
37
  const loose = stripLooseScalarTrailingCommas(normalized);
26
38
  if (loose === normalized) throw strictError;
27
39
  try {
28
- return YAML.parse(loose) as Record<string, unknown> | null;
40
+ return asFrontmatterRecord(YAML.parse(loose));
29
41
  } catch {
30
42
  throw strictError;
31
43
  }
package/src/glob.ts CHANGED
@@ -70,20 +70,25 @@ function parseGitignorePatterns(content: string, gitignoreDir: string, baseDir:
70
70
  } else {
71
71
  patterns.push(pattern);
72
72
  }
73
+ } else if (pattern.includes("/") && !pattern.startsWith("**/")) {
74
+ // Separator in the middle: git anchors these to the .gitignore's
75
+ // directory, same as rooted patterns
76
+ const absolutePattern = path.join(gitignoreDir, pattern);
77
+ const relativeToBase = path.relative(baseDir, absolutePattern);
78
+ if (relativeToBase.startsWith("..")) {
79
+ // Pattern is outside the search directory, skip
80
+ continue;
81
+ }
82
+ pattern = relativeToBase.replace(/\\/g, "/");
83
+ patterns.push(pattern);
84
+ if (isDirectoryOnly) {
85
+ patterns.push(`${pattern}/**`);
86
+ }
73
87
  } else {
74
- // Unrooted pattern: match anywhere in the tree
75
- if (pattern.includes("/")) {
76
- // Contains slash: match from any directory level
77
- patterns.push(`**/${pattern}`);
78
- if (isDirectoryOnly) {
79
- patterns.push(`**/${pattern}/**`);
80
- }
81
- } else {
82
- // No slash: match file/dir name anywhere
83
- patterns.push(`**/${pattern}`);
84
- if (isDirectoryOnly) {
85
- patterns.push(`**/${pattern}/**`);
86
- }
88
+ // No middle separator: match file/dir name anywhere in the tree
89
+ patterns.push(`**/${pattern}`);
90
+ if (isDirectoryOnly) {
91
+ patterns.push(`**/${pattern}/**`);
87
92
  }
88
93
  }
89
94
  }
@@ -145,8 +150,13 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
145
150
  effectiveExclude = [...effectiveExclude, ...gitignorePatterns];
146
151
  }
147
152
 
153
+ const excludeGlobs = effectiveExclude.map(pattern => new Glob(pattern));
154
+
148
155
  const base = cwd ?? getProjectDir();
149
156
  const allResults: string[] = [];
157
+ // Overlapping patterns (e.g. `["**/*.ts", "src/*.ts"]`) can both match the same
158
+ // file; dedupe so a path is returned at most once regardless of pattern overlap.
159
+ const seen = new Set<string>();
150
160
 
151
161
  // Combine timeout and abort signals
152
162
  const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
@@ -171,17 +181,14 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
171
181
 
172
182
  // Check exclusion patterns
173
183
  const normalized = entry.replace(/\\/g, "/");
174
- let excluded = false;
175
- for (const excludePattern of effectiveExclude) {
176
- const excludeGlob = new Glob(excludePattern);
177
- if (excludeGlob.match(normalized)) {
178
- excluded = true;
179
- break;
180
- }
184
+ if (excludeGlobs.some(excludeGlob => excludeGlob.match(normalized))) {
185
+ continue;
181
186
  }
182
- if (!excluded) {
183
- allResults.push(normalized);
187
+ if (seen.has(normalized)) {
188
+ continue;
184
189
  }
190
+ seen.add(normalized);
191
+ allResults.push(normalized);
185
192
  }
186
193
  }
187
194
 
package/src/postmortem.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import inspector from "node:inspector";
9
9
  import { isMainThread } from "node:worker_threads";
10
- import { logger } from ".";
10
+ import * as logger from "./logger";
11
11
  import { safeStderrWrite } from "./safe-stderr";
12
12
 
13
13
  // Cleanup reasons, in order of priority/meaning.
@@ -39,6 +39,9 @@ function runCleanup(reason: Reason): Promise<void> {
39
39
  cleanupStage = "running";
40
40
  break;
41
41
  case "running":
42
+ if (reason === Reason.EXIT) {
43
+ return Promise.resolve();
44
+ }
42
45
  logger.error("Cleanup invoked recursively", { stack: new Error().stack });
43
46
  return Promise.resolve();
44
47
  case "complete":
@@ -13,6 +13,9 @@ export const DEFAULT_TAB_WIDTH = 3;
13
13
  const EDITORCONFIG_NAME = ".editorconfig";
14
14
 
15
15
  let defaultTabWidth = DEFAULT_TAB_WIDTH;
16
+ type TabWidthChangeListener = (width: number) => void;
17
+
18
+ const tabWidthChangeListeners = new Set<TabWidthChangeListener>();
16
19
 
17
20
  const editorConfigCache = new Map<string, ParsedEditorConfig>();
18
21
  const editorConfigChainCache = new Map<string, ChainEntry[]>();
@@ -278,13 +281,25 @@ function resolveEditorConfigTabWidth(match: EditorConfigMatch | undefined, fallb
278
281
 
279
282
  return undefined;
280
283
  }
284
+ export function onDefaultTabWidthChange(listener: TabWidthChangeListener): () => void {
285
+ tabWidthChangeListeners.add(listener);
286
+ return () => {
287
+ tabWidthChangeListeners.delete(listener);
288
+ };
289
+ }
281
290
 
282
291
  export function getDefaultTabWidth(): number {
283
292
  return defaultTabWidth;
284
293
  }
285
294
 
286
295
  export function setDefaultTabWidth(width: number): void {
287
- defaultTabWidth = clampTabWidth(width);
296
+ const next = clampTabWidth(width);
297
+ if (defaultTabWidth === next) return;
298
+ defaultTabWidth = next;
299
+ indentationCache.clear();
300
+ for (const listener of tabWidthChangeListeners) {
301
+ listener(next);
302
+ }
288
303
  }
289
304
 
290
305
  /**