@sayknow-cli/utils 0.7.11 → 0.7.13

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 (49) hide show
  1. package/dist/types/broken-pipe.d.ts +4 -3
  2. package/dist/types/canonical-log-dir.d.ts +20 -0
  3. package/dist/types/cli-metadata.d.ts +33 -0
  4. package/dist/types/cli.d.ts +12 -0
  5. package/dist/types/crash-fingerprint.d.ts +82 -0
  6. package/dist/types/crash-journal.d.ts +84 -0
  7. package/dist/types/crash-redaction.d.ts +20 -0
  8. package/dist/types/dirs.d.ts +127 -36
  9. package/dist/types/env-file.d.ts +59 -0
  10. package/dist/types/env.d.ts +19 -1
  11. package/dist/types/error-classification.d.ts +12 -0
  12. package/dist/types/fetch-retry.d.ts +6 -3
  13. package/dist/types/header-value.d.ts +10 -0
  14. package/dist/types/index.d.ts +6 -0
  15. package/dist/types/logger.d.ts +22 -8
  16. package/dist/types/native-process.d.ts +12 -0
  17. package/dist/types/procmgr.d.ts +1 -35
  18. package/dist/types/safe-error.d.ts +20 -0
  19. package/dist/types/sanitize-text.d.ts +10 -0
  20. package/dist/types/shell-config.d.ts +58 -0
  21. package/dist/types/stream.d.ts +2 -1
  22. package/dist/types/which.d.ts +1 -1
  23. package/package.json +2 -2
  24. package/src/broken-pipe.ts +16 -4
  25. package/src/canonical-log-dir.ts +59 -0
  26. package/src/cli-metadata.ts +74 -0
  27. package/src/cli.ts +30 -2
  28. package/src/crash-fingerprint.ts +294 -0
  29. package/src/crash-journal.ts +292 -0
  30. package/src/crash-redaction.ts +558 -0
  31. package/src/dirs.ts +583 -165
  32. package/src/env-file.ts +326 -20
  33. package/src/env.ts +321 -35
  34. package/src/error-classification.ts +31 -0
  35. package/src/fetch-retry.ts +23 -10
  36. package/src/frontmatter.ts +28 -2
  37. package/src/glob.ts +13 -10
  38. package/src/header-value.ts +15 -0
  39. package/src/index.ts +6 -0
  40. package/src/logger.ts +252 -97
  41. package/src/native-process.ts +19 -0
  42. package/src/postmortem.ts +1 -55
  43. package/src/procmgr.ts +16 -212
  44. package/src/ptree.ts +3 -2
  45. package/src/safe-error.ts +40 -0
  46. package/src/sanitize-text.ts +14 -0
  47. package/src/shell-config.ts +329 -0
  48. package/src/stream.ts +32 -1
  49. package/src/which.ts +6 -5
@@ -22,15 +22,18 @@ export interface FetchWithRetryOptions extends RequestInit {
22
22
  /**
23
23
  * Per-delay cap. Server-provided `Retry-After` hints exceeding this return
24
24
  * the current response immediately — caller deals with the `!response.ok`.
25
- * Default `60_000`.
25
+ * Scheduled values are also capped at the platform timer ceiling. Default
26
+ * `60_000`.
26
27
  */
27
28
  maxDelayMs?: number;
28
29
  /**
29
30
  * Fallback delay schedule when no server hint is present. Number, array
30
31
  * (indexed by attempt, clamped to last), or function. Default exponential
31
- * `500ms * 2 ** attempt` capped at `maxDelayMs`.
32
+ * `500ms * 2 ** attempt` capped at `maxDelayMs` and the platform timer
33
+ * ceiling. Values that remain negative or non-finite after capping retry
34
+ * immediately.
32
35
  */
33
- defaultDelayMs?: number | readonly number[] | ((attempt: number) => number);
36
+ defaultDelayMs?: number | readonly number[] | ((attempt: number, error?: unknown) => number);
34
37
  /**
35
38
  * Optional per-attempt overlay merged into the base `RequestInit` each try.
36
39
  * Headers from the overlay shallow-merge over the base. Useful for auth
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Sanitize a value destined for an HTTP header.
3
+ *
4
+ * OS-derived components (kernel release, hostname, os.version) can contain
5
+ * non-ASCII characters — e.g. Android kernel releases such as
6
+ * `4.4.302-Minimal™-EAS-QTI_Haptic-R26` — which `Headers`/`fetch` reject
7
+ * before the request is ever sent. Strip everything outside printable
8
+ * ASCII so header construction can never throw on runtime-derived values.
9
+ */
10
+ export declare function sanitizeHeaderComponent(value: string): string;
@@ -2,13 +2,18 @@ export { createAbortableStream, once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
3
  export * from "./broken-pipe";
4
4
  export * from "./color";
5
+ export * from "./crash-fingerprint";
6
+ export * from "./crash-journal";
7
+ export * from "./crash-redaction";
5
8
  export * from "./dirs";
6
9
  export * from "./env";
10
+ export * from "./error-classification";
7
11
  export * from "./fetch-retry";
8
12
  export * from "./format";
9
13
  export * from "./frontmatter";
10
14
  export * from "./fs-error";
11
15
  export * from "./glob";
16
+ export * from "./header-value";
12
17
  export * from "./hook-fetch";
13
18
  export * from "./json";
14
19
  export * as logger from "./logger";
@@ -20,6 +25,7 @@ export * as procmgr from "./procmgr";
20
25
  export * as prompt from "./prompt";
21
26
  export * as ptree from "./ptree";
22
27
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
28
+ export * from "./safe-error";
23
29
  export * from "./safe-stderr";
24
30
  export * from "./sanitize-text";
25
31
  export * from "./snowflake";
@@ -1,3 +1,14 @@
1
+ type LogPathPlatform = NodeJS.Platform | "posix";
2
+ /** A filesystem root. On POSIX a backslash is a file name, not a root or a separator. */
3
+ export declare function isFsRoot(target: string, platform?: LogPathPlatform): boolean;
4
+ /**
5
+ * Drop trailing separators without changing the directory name.
6
+ * POSIX removes only `/`. Windows also removes `\`.
7
+ * A root keeps one separator so `C:\` does not become the drive-relative name `C:`.
8
+ */
9
+ export declare function lexicalLogPath(target: string, platform?: LogPathPlatform): string;
10
+ /** True when "." / ".." collapse to a filesystem root. Does not follow symlinks. */
11
+ export declare function resolvesAsFsRoot(target: string, platform?: LogPathPlatform): boolean;
1
12
  /**
2
13
  * Replace the active log transports. Pass `console: true, file: false` for
3
14
  * long-running services (the auth broker, etc.) that want their structured
@@ -39,16 +50,18 @@ export declare function debug(message: string, context?: Record<string, unknown>
39
50
  export declare function printTimings(): void;
40
51
  /**
41
52
  * Begin recording startup timings under a new root span.
42
- * Idempotent: a second call while already recording is a no-op so that side-effect
43
- * starters (see module-timer.ts) and explicit starters (main.ts) can coexist.
53
+ * Idempotent: a second call while already recording is a no-op so that the
54
+ * early starter (cli.ts, the first CLI statement) and the explicit starter
55
+ * (main.ts) can coexist.
56
+ *
57
+ * The root is anchored at the process-start origin rather than at the call:
58
+ * `performance.now()` counts milliseconds since `performance.timeOrigin`
59
+ * (process start) on Bun, so `start: 0` makes `Total` the true
60
+ * time-since-process-start. Runtime bootstrap plus static module linking and
61
+ * evaluation then land in the root's self time instead of being invisible
62
+ * before the first statement of cli.ts.
44
63
  */
45
64
  export declare function startTiming(): void;
46
- /**
47
- * Record an externally-measured span as a leaf child of the active span (or root
48
- * when no span is active). Used by the module-load timing plugin to splice load
49
- * events into the tree retroactively.
50
- */
51
- export declare function recordModuleLoadSpan(path: string, start: number, durationMs: number): void;
52
65
  /**
53
66
  * End timing window and clear buffers.
54
67
  */
@@ -64,3 +77,4 @@ export declare function endTiming(): void;
64
77
  */
65
78
  export declare function time(op: string): void;
66
79
  export declare function time<T, A extends unknown[]>(op: string, fn: (...args: A) => T, ...args: A): T;
80
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Lazy synchronous binding for @sayknow-cli/natives process control.
3
+ *
4
+ * The natives package entry loads the compiled addon at import time, so a
5
+ * static import anywhere in the `@sayknow-cli/utils` root barrel graph would
6
+ * materialize the addon for every barrel consumer. The W5b S1/idle
7
+ * module-trace gate requires that merely importing the barrel never loads
8
+ * @sayknow-cli/natives; process-control callers bind at first real use instead.
9
+ */
10
+ type NativeProcessBindings = Pick<typeof import("@sayknow-cli/natives"), "Process" | "ProcessStatus">;
11
+ export declare function nativeProcessBindings(): NativeProcessBindings;
12
+ export {};
@@ -1,39 +1,5 @@
1
1
  import type { Subprocess } from "bun";
2
- export interface ShellConfig {
3
- shell: string;
4
- args: string[];
5
- env: Record<string, string>;
6
- prefix: string | undefined;
7
- }
8
- /**
9
- * Strip disabled macOS malloc-stack-logging vars from `process.env` in place.
10
- *
11
- * macOS leaves `MallocStackLogging=0` (or similar) inherited by debug-attached
12
- * shells. Bun's libc init then prints `MallocStackLogging: can't turn off
13
- * malloc stack logging because it was not enabled.` to stderr for every
14
- * subprocess. Scrubbing once at startup means every child we spawn — bash,
15
- * bun subagents, plugin installs, ptree commands — inherits a clean env.
16
- */
17
- export declare function scrubProcessEnv(): void;
18
- /**
19
- * Resolve a basic shell (bash or sh) as fallback.
20
- */
21
- export declare function resolveBasicShell(): string | undefined;
22
- /**
23
- * Get shell configuration based on platform.
24
- * Resolution order:
25
- * 1. User-specified shellPath in settings.json
26
- * 2. On Windows: Git Bash in known locations, then bash on PATH
27
- * 3. On Unix: $SHELL if bash/zsh, then fallback paths
28
- * 4. Fallback: sh
29
- */
30
- export declare function getShellConfig(customShellPath?: string): ShellConfig;
31
- /**
32
- * Clear the memoized shell configuration so the next {@link getShellConfig}
33
- * call re-resolves the shell and re-reads the environment (shell selection and
34
- * the bash CI/login flags). Primarily for tests that vary those inputs.
35
- */
36
- export declare function resetShellConfigCache(): void;
2
+ export { getShellConfig, resetShellConfigCache, resolveBasicShell, type ShellConfig, scrubProcessEnv, } from "./shell-config";
37
3
  /**
38
4
  * Check if a process is running.
39
5
  */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Throw-proof error description.
3
+ *
4
+ * Hostile or exotic thrown values can make `String(value)` throw (a `toString`
5
+ * that throws, a revoked proxy, a null-prototype object) and a `message` getter
6
+ * can throw too. Logging or recording a failure must never become a second
7
+ * failure, so every failure path that stringifies a caught value uses this.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { safeErrorDescription } from "@sayknow-cli/utils";
12
+ *
13
+ * try {
14
+ * await risky();
15
+ * } catch (error) {
16
+ * logger.warn("risky failed", { error: safeErrorDescription(error) });
17
+ * }
18
+ * ```
19
+ */
20
+ export declare function safeErrorDescription(value: unknown): string;
@@ -12,3 +12,13 @@
12
12
  * string after the control probe.
13
13
  */
14
14
  export declare function sanitizeText(text: string): string;
15
+ /**
16
+ * Sanitize untrusted text that must occupy exactly one rendered row.
17
+ *
18
+ * {@link sanitizeText} deliberately preserves `\n`, and width-based truncation
19
+ * treats it as zero-width, so a value carrying line breaks can still inject
20
+ * extra rows and evade a single-line width budget. Flatten every CR/LF run to a
21
+ * single space before the usual control/ANSI strip, then remove directional
22
+ * format controls that can visually reorder an otherwise safe row.
23
+ */
24
+ export declare function sanitizeDisplayLine(text: string): string;
@@ -0,0 +1,58 @@
1
+ export interface ShellConfig {
2
+ shell: string;
3
+ args: string[];
4
+ env: Record<string, string>;
5
+ prefix: string | undefined;
6
+ }
7
+ /**
8
+ * Strip disabled macOS malloc-stack-logging vars from `process.env` in place.
9
+ *
10
+ * macOS leaves `MallocStackLogging=0` (or similar) inherited by debug-attached
11
+ * shells. Bun's libc init then prints `MallocStackLogging: can't turn off
12
+ * malloc stack logging because it was not enabled.` to stderr for every
13
+ * subprocess. Scrubbing once at startup means every child we spawn — bash,
14
+ * bun subagents, plugin installs, ptree commands — inherits a clean env.
15
+ */
16
+ export declare function scrubProcessEnv(): void;
17
+ /**
18
+ * Names of hook-capable variables whose live value came from the project `.env`.
19
+ *
20
+ * Provenance comes from `$inheritedEnv`, the same launch snapshot that guards
21
+ * credentials: a value the launching shell exported with a different value than
22
+ * the project declaration is kept. Bun does not reveal which source supplied a
23
+ * value, so an inherited value the project repeats verbatim, or a key the project
24
+ * declares dynamically, is ambiguous and fails closed (dropped).
25
+ */
26
+ export declare function projectDeclaredSpawnHookNames(env?: Record<string, string | undefined>): string[];
27
+ /**
28
+ * Remove project-declared hook variables from a spawn environment in place.
29
+ * If removing a home selector leaves `HOME` unset, provide the trusted account
30
+ * home when one exists rather than allowing a Windows profile fallback.
31
+ */
32
+ export declare function stripProjectSpawnHooks(env: Record<string, string>): Record<string, string>;
33
+ /**
34
+ * The process environment without project-declared hook variables, for spawning
35
+ * the bash tool's shell processes (isolated worker chain and PTY sessions).
36
+ */
37
+ export declare function projectSafeSpawnEnv(): Record<string, string>;
38
+ /** Provide the trusted HOME override needed when PTY hook filtering removes a home selector. */
39
+ export declare function projectSafeHomeOverride(explicit?: Record<string, string>): Record<string, string>;
40
+ /**
41
+ * Resolve a basic shell (bash or sh) as fallback.
42
+ */
43
+ export declare function resolveBasicShell(): string | undefined;
44
+ /**
45
+ * Get shell configuration based on platform.
46
+ * Resolution order:
47
+ * 1. User-specified shellPath in settings.json
48
+ * 2. On Windows: Git Bash in known locations, then bash on PATH
49
+ * 3. On Unix: $SHELL if bash/zsh, then fallback paths
50
+ * 4. Fallback: sh
51
+ */
52
+ export declare function getShellConfig(customShellPath?: string): ShellConfig;
53
+ /**
54
+ * Clear the memoized shell configuration so the next {@link getShellConfig}
55
+ * call re-resolves the shell and re-reads the environment (shell selection and
56
+ * the bash CI/login flags). Primarily for tests that vary those inputs.
57
+ */
58
+ export declare function resetShellConfigCache(): void;
@@ -1,5 +1,6 @@
1
1
  export declare function readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array>;
2
- export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T>;
2
+ export type JsonlLineObserver = (raw: string) => void;
3
+ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onLine?: JsonlLineObserver): AsyncGenerator<T>;
3
4
  /**
4
5
  * Stream parsed JSON objects from SSE `data:` lines.
5
6
  *
@@ -26,7 +26,7 @@ export interface WhichOptions extends Bun.WhichOptions {
26
26
  */
27
27
  cache?: WhichCachePolicy;
28
28
  }
29
- declare function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null;
29
+ declare function darwinWhich(command: string, options?: Bun.WhichOptions): string | null;
30
30
  export declare const whichFresh: typeof darwinWhich;
31
31
  /**
32
32
  * Locate binary on PATH (with flexible caching).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/utils",
4
- "version": "0.7.11",
4
+ "version": "0.7.13",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@sayknow-cli/natives": "0.7.11",
34
+ "@sayknow-cli/natives": "0.7.13",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
@@ -9,6 +9,16 @@
9
9
  import * as fs from "node:fs";
10
10
 
11
11
  const KNOWN_SINK_PEER_CLOSED_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED"]);
12
+ /**
13
+ * Errno codes that mean the process's own stdout sink has gone away because
14
+ * the peer (terminal emulator, pager, pipe reader, or pty) was torn down. On
15
+ * macOS a torn-down pty slave produces `EIO`; a closed fd produces `EBADF`;
16
+ * a closed pipe produces `EPIPE`. All three are the same benign condition:
17
+ * the terminal disconnected.
18
+ *
19
+ * Mirrors `CLOSED_STDERR_ERROR_CODES` in `safe-stderr.ts`; keep in sync.
20
+ */
21
+ const PROCESS_STDOUT_PEER_CLOSED_CODES = new Set(["EIO", "EPIPE", "EBADF"]);
12
22
 
13
23
  type ErrorProperty = "code" | "fd" | "syscall";
14
24
 
@@ -70,9 +80,10 @@ export function isBrokenPipeError(error: unknown): boolean {
70
80
  }
71
81
 
72
82
  /**
73
- * A classifier for process-level stdout `EPIPE` errors. Its direct-write
74
- * evidence is private to each factory instance, so only the owner that
75
- * intercepted `process.stdout.write` can mark an error for this classifier.
83
+ * A classifier for process-level stdout peer-closed errors (`EPIPE`, `EIO`,
84
+ * `EBADF`). Its direct-write evidence is private to each factory instance, so
85
+ * only the owner that intercepted `process.stdout.write` can mark an error
86
+ * for this classifier.
76
87
  */
77
88
  export interface ProcessStdoutEpipeClassifier {
78
89
  markDirectProcessStdoutWriteError(error: unknown): void;
@@ -109,7 +120,8 @@ export function createProcessStdoutEpipeClassifier(): ProcessStdoutEpipeClassifi
109
120
  if (!isObjectLike(error)) return false;
110
121
 
111
122
  const code = readErrorProperty(error, "code");
112
- if (!code.available || code.value !== "EPIPE") return false;
123
+ if (!code.available || typeof code.value !== "string" || !PROCESS_STDOUT_PEER_CLOSED_CODES.has(code.value))
124
+ return false;
113
125
 
114
126
  if (directProcessStdoutWriteErrors.has(error)) return true;
115
127
 
@@ -0,0 +1,59 @@
1
+ import * as path from "node:path";
2
+
3
+ interface ProjectEnvSnapshotLike {
4
+ values: Record<string, string>;
5
+ dynamic: Set<string>;
6
+ }
7
+
8
+ interface CanonicalLogDirInput {
9
+ home: string;
10
+ env: Record<string, string | undefined>;
11
+ projectEnv: ProjectEnvSnapshotLike;
12
+ xdgEligible: boolean;
13
+ pathExists?: (target: string) => boolean;
14
+ }
15
+
16
+ const APP_NAME = "skc";
17
+ const DEFAULT_CONFIG_DIR_NAME = ".skc";
18
+
19
+ function canonicalEnvKey(name: string): string {
20
+ return process.platform === "win32" ? name.toUpperCase() : name;
21
+ }
22
+
23
+ function sanitizeConfigDirName(value: string | undefined): string | undefined {
24
+ const trimmed = value?.trim();
25
+ if (!trimmed || path.normalize(trimmed).split(/[\\/]/).includes("..")) return undefined;
26
+ return trimmed;
27
+ }
28
+
29
+ /** Resolve a caller environment value only when its dotenv provenance is trusted. */
30
+ function trustedValue(name: string, input: CanonicalLogDirInput): string | undefined {
31
+ const value = input.env[name];
32
+ if (!value) return undefined;
33
+ const key = canonicalEnvKey(name);
34
+ if (input.projectEnv.dynamic.has(key) || input.projectEnv.values[key] === value) return undefined;
35
+ return value;
36
+ }
37
+
38
+ /**
39
+ * Resolve the canonical user log directory without mutating the environment.
40
+ *
41
+ * The caller supplies filesystem existence checks so this leaf stays free of
42
+ * filesystem and resolver side effects. Both the production directory resolver
43
+ * and the test preload use this exact path selection logic.
44
+ */
45
+ export function resolveCanonicalLogsDir(input: CanonicalLogDirInput): string {
46
+ const configDirName =
47
+ sanitizeConfigDirName(trustedValue("SKC_CONFIG_DIR", input)) ??
48
+ sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", input)) ??
49
+ DEFAULT_CONFIG_DIR_NAME;
50
+ const xdgStateHome =
51
+ input.xdgEligible && (process.platform === "linux" || process.platform === "darwin")
52
+ ? trustedValue("XDG_STATE_HOME", input)?.trim()
53
+ : undefined;
54
+ if (xdgStateHome) {
55
+ const xdgRoot = path.join(xdgStateHome, APP_NAME);
56
+ if (input.pathExists?.(xdgRoot) === true) return path.join(xdgRoot, "logs");
57
+ }
58
+ return path.join(input.home, configDirName, "logs");
59
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure CLI metadata leaf.
3
+ *
4
+ * The actual `skc` entry (`packages/coding-agent/src/cli.ts`) must stay inert on
5
+ * import: no directory resolution, logger construction, NSS lookup, native load
6
+ * or environment probing may run before argv routing. `dirs.ts` performs all of
7
+ * those at module scope, so the handful of constants the entry really needs live
8
+ * here and `dirs.ts` re-exports them for its existing callers.
9
+ *
10
+ * Only build-time package metadata may be imported here.
11
+ */
12
+
13
+ import { engines, version } from "../package.json" with { type: "json" };
14
+
15
+ /** App name (e.g. "skc") */
16
+ export const APP_NAME: string = "skc";
17
+
18
+ /** Version (e.g. "1.0.0") */
19
+ export const VERSION: string = version;
20
+
21
+ /** Minimum Bun version */
22
+ export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, "");
23
+
24
+ /**
25
+ * Build the diagnostic shown when the Bun runtime executing `skc` is older
26
+ * than {@link MIN_BUN_VERSION}. This is the most common Windows native-install
27
+ * failure (issue #525): `bun install -g sayknow-cli` probes a recent Bun while
28
+ * the `skc` launcher resolves an older Bun still on PATH. The message names the
29
+ * exact detected runtime path and gives a platform-specific upgrade + PATH fix
30
+ * instead of a bare `bun upgrade`.
31
+ *
32
+ * Pure and platform-parameterized so it can be unit-tested cross-platform.
33
+ */
34
+ export function formatBunRuntimeError(opts: {
35
+ currentVersion: string;
36
+ minVersion: string;
37
+ execPath?: string;
38
+ platform?: NodeJS.Platform;
39
+ }): string {
40
+ const platform = opts.platform ?? process.platform;
41
+ const lines = [
42
+ `error: ${APP_NAME} requires Bun >= ${opts.minVersion}, but the running Bun is v${opts.currentVersion}.`,
43
+ ];
44
+ if (opts.execPath) {
45
+ lines.push(` detected Bun runtime: ${opts.execPath}`);
46
+ }
47
+ if (platform === "win32") {
48
+ lines.push(
49
+ "",
50
+ "The 'skc' launcher is using an older Bun than the one used to install it.",
51
+ "Upgrade Bun, then restart your terminal so PATH and the runtime refresh:",
52
+ "",
53
+ ' powershell -c "irm bun.sh/install.ps1|iex"',
54
+ "",
55
+ "After restarting the terminal, verify both versions match:",
56
+ " bun --version",
57
+ " skc --version",
58
+ "",
59
+ "If 'skc' still loads the old runtime, make sure %USERPROFILE%\\.bun\\bin is",
60
+ "first on PATH and remove any stale Bun installs shadowing it.",
61
+ );
62
+ } else {
63
+ lines.push(
64
+ "",
65
+ "Upgrade Bun, then restart your terminal:",
66
+ " bun upgrade",
67
+ "",
68
+ "Then verify:",
69
+ " bun --version",
70
+ " skc --version",
71
+ );
72
+ }
73
+ return `${lines.join("\n")}\n`;
74
+ }
package/src/cli.ts CHANGED
@@ -23,6 +23,8 @@ export interface FlagDescriptor<K extends "string" | "boolean" | "integer" = "st
23
23
  multiple?: boolean;
24
24
  options?: readonly string[];
25
25
  required?: boolean;
26
+ /** The string value may be omitted; parsers decide whether to consume it. */
27
+ optionalValue?: boolean;
26
28
  }
27
29
 
28
30
  export interface ArgDescriptor {
@@ -40,6 +42,8 @@ interface FlagInput {
40
42
  multiple?: boolean;
41
43
  options?: readonly string[];
42
44
  required?: boolean;
45
+ /** The string value may be omitted; parsers decide whether to consume it. */
46
+ optionalValue?: boolean;
43
47
  }
44
48
 
45
49
  interface ArgInput {
@@ -352,7 +356,14 @@ function renderCommandBody(lines: string[], Cmd: CommandCtor): void {
352
356
  for (const [name, desc] of flagEntries) {
353
357
  const charPart = desc.char ? `-${desc.char}, ` : " ";
354
358
  const namePart = `--${name}`;
355
- const typePart = desc.kind === "boolean" ? "" : desc.kind === "integer" ? "=<int>" : "=<value>";
359
+ const typePart =
360
+ desc.kind === "boolean"
361
+ ? ""
362
+ : desc.kind === "integer"
363
+ ? "=<int>"
364
+ : desc.optionalValue
365
+ ? "[=<value>]"
366
+ : "=<value>";
356
367
  formatted.push([` ${charPart}${namePart}${typePart}`, desc.description ?? ""]);
357
368
  }
358
369
  const maxLeft = Math.max(...formatted.map(([l]) => l.length));
@@ -383,6 +394,15 @@ export interface CommandEntry {
383
394
  name: string;
384
395
  load: () => Promise<CommandCtor>;
385
396
  aliases?: string[];
397
+ /** Owns parsing, help, loading and failures for this family when supplied. */
398
+ dispatch?: (argv: string[], context: CommandEntryContext) => Promise<void>;
399
+ }
400
+
401
+ export interface CommandEntryContext {
402
+ bin: string;
403
+ version: string;
404
+ /** Canonical registered entry name, including when invoked through an alias. */
405
+ command: string;
386
406
  }
387
407
 
388
408
  export interface RunOptions {
@@ -428,9 +448,17 @@ export async function run(opts: RunOptions): Promise<void> {
428
448
  return;
429
449
  }
430
450
 
451
+ const dispatchedEntry = findEntry(opts.commands, commandId);
452
+ if (dispatchedEntry?.dispatch) {
453
+ await dispatchedEntry.dispatch(commandArgv, { bin, version, command: dispatchedEntry.name });
454
+ return;
455
+ }
456
+
431
457
  // Per-command help. Commands with nested subcommands can opt into receiving
432
458
  // help flags themselves so `cmd subcommand --help` can render subcommand help.
433
- if (commandArgv.includes("--help") || commandArgv.includes("-h")) {
459
+ const delimiterIndex = commandArgv.indexOf("--");
460
+ const helpArgs = delimiterIndex === -1 ? commandArgv : commandArgv.slice(0, delimiterIndex);
461
+ if (helpArgs.includes("--help") || helpArgs.includes("-h")) {
434
462
  const entry = findEntry(opts.commands, commandId);
435
463
  if (!entry) {
436
464
  process.stderr.write(`Unknown command: ${commandId}\n`);