@liustack/modlens 3.0.0 → 3.1.1

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.
@@ -0,0 +1,279 @@
1
+ # modlens skill launcher (Windows, PowerShell 5.1 compatible).
2
+ #
3
+ # The Windows twin of run.sh: identical resolution order, identical diagnostic
4
+ # fields, identical exit codes. One stable action for the agent ("run
5
+ # modlens"); this script picks a working way to run it here.
6
+ #
7
+ # Invoke it per-process so no global policy is touched:
8
+ # powershell -ExecutionPolicy Bypass -File run.ps1 -q "test"
9
+ #
10
+ # Resolution order (kept identical in run.sh):
11
+ # 1. A compatible modlens already on PATH -> run it directly.
12
+ # 2. npx present -> run the pinned npm version.
13
+ # 3. bunx present -> run the pinned version via Bun.
14
+ # 4. (phase B placeholder) a native artifact -> not published yet.
15
+ # 5. Nothing usable -> structured diagnosis, exit 78.
16
+ #
17
+ # It never writes PATH, never needs admin rights, never fetches a second script,
18
+ # and has no postinstall step.
19
+
20
+ $ErrorActionPreference = 'Stop'
21
+
22
+ # --- Version constants: stamped by scripts/release.mjs at release time. --------
23
+ # Do not edit $Pinned by hand; scripts/stamp.test.mjs asserts it equals the
24
+ # package.json version, and the release script rewrites it on every bump.
25
+ $Package = '@liustack/modlens'
26
+ $Bin = 'modlens'
27
+ $Pinned = '3.1.1'
28
+ # -------------------------------------------------------------------------------
29
+
30
+ $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
31
+
32
+ # Environment snapshot, filled by Collect and read by the emitters.
33
+ $script:Arch = ''
34
+ $script:CliPresent = $false
35
+ $script:CliPath = $null
36
+ $script:CliVer = $null
37
+ $script:CliCompat = $false
38
+ $script:NpxPresent = $false
39
+ $script:NpxPath = $null
40
+ $script:BunxPresent = $false
41
+ $script:BunxPath = $null
42
+ $script:NodePresent = $false
43
+ $script:NodeVer = $null
44
+ $script:NodeFloorOk = $false
45
+ $script:Selected = 'none'
46
+
47
+ # First "X.Y.Z" token printed by `$Bin --version`.
48
+ function Get-CliVersion {
49
+ try { $out = & $Bin --version 2>$null } catch { return '' }
50
+ if (-not $out) { return '' }
51
+ $line = [string]($out | Select-Object -First 1)
52
+ $m = [regex]::Match($line, '[0-9]+\.[0-9]+\.[0-9]+')
53
+ if ($m.Success) { return $m.Value } else { return '' }
54
+ }
55
+
56
+ # Compatible = same major version as $Pinned AND not older than $Pinned.
57
+ # Same major keeps a globally installed CLI usable without a forced re-download;
58
+ # not-older refuses a stale build that predates the version this skill needs.
59
+ function Test-Compatible {
60
+ param([string] $Ver)
61
+ $f = $Ver -split '\.'
62
+ $p = $Pinned -split '\.'
63
+ if ($f.Count -lt 3 -or $p.Count -lt 3) { return $false }
64
+ $fMaj = [int]$f[0]; $fMin = [int]$f[1]; $fPat = [int]$f[2]
65
+ $pMaj = [int]$p[0]; $pMin = [int]$p[1]; $pPat = [int]$p[2]
66
+ if ($fMaj -ne $pMaj) { return $false }
67
+ if ($fMin -gt $pMin) { return $true }
68
+ if ($fMin -lt $pMin) { return $false }
69
+ return ($fPat -ge $pPat)
70
+ }
71
+
72
+ # The npx path runs the CLI on this machine's node, so npx is only usable when
73
+ # node itself meets the CLI's floor. An old node with a working npx used to be
74
+ # selected anyway, a path known to fail at run time.
75
+ $NodeFloor = '22.13.0'
76
+ function Test-NodeMeetsFloor {
77
+ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false }
78
+ try { $nv = ((& node --version 2>$null) -replace '^v', '') } catch { return $false }
79
+ if (-not $nv) { return $false }
80
+ $n = $nv -split '\.'
81
+ $f = $NodeFloor -split '\.'
82
+ if ($n.Count -lt 2) { return $false }
83
+ $nMaj = [int]$n[0]; $nMin = [int]$n[1]
84
+ $fMaj = [int]$f[0]; $fMin = [int]$f[1]
85
+ if ($nMaj -gt $fMaj) { return $true }
86
+ if ($nMaj -lt $fMaj) { return $false }
87
+ return ($nMin -ge $fMin)
88
+ }
89
+
90
+ # Return exactly one word: the chosen launch path.
91
+ function Resolve-LaunchKind {
92
+ $cli = Get-Command $Bin -ErrorAction SilentlyContinue
93
+ if ($cli) {
94
+ $v = Get-CliVersion
95
+ if ($v -and (Test-Compatible $v)) { return 'path' }
96
+ }
97
+ if ((Get-Command npx -ErrorAction SilentlyContinue) -and (Test-NodeMeetsFloor)) { return 'npx' }
98
+ if (Get-Command bunx -ErrorAction SilentlyContinue) { return 'bunx' }
99
+ # Phase B goes here: check a versioned user cache, then download and verify a
100
+ # native artifact into it. Any such download must use curl.exe (written in
101
+ # full so PowerShell 5.1 does not resolve `curl` to its Invoke-WebRequest
102
+ # alias), never Invoke-WebRequest, which stamps Mark-of-the-Web; see
103
+ # design.md 8.3.
104
+ return 'none'
105
+ }
106
+
107
+ # Run the resolved CLI and return its output (used to chain the CLI's own
108
+ # doctor). Passes every argument through untouched.
109
+ function Invoke-Cli {
110
+ param([string[]] $CliArgs)
111
+ switch ($script:Selected) {
112
+ 'path' { & $Bin @CliArgs }
113
+ 'npx' { & npx --yes --package "$Package@$Pinned" $Bin @CliArgs }
114
+ 'bunx' { & bunx --bun "$Package@$Pinned" @CliArgs }
115
+ }
116
+ }
117
+
118
+ function Get-Arch {
119
+ switch ($env:PROCESSOR_ARCHITECTURE) {
120
+ 'AMD64' { return 'x64' }
121
+ 'ARM64' { return 'arm64' }
122
+ 'x86' { return 'x86' }
123
+ default { return $env:PROCESSOR_ARCHITECTURE }
124
+ }
125
+ }
126
+
127
+ # Probe the environment once into the $script:* snapshot.
128
+ function Collect {
129
+ $script:Arch = Get-Arch
130
+
131
+ $cli = Get-Command $Bin -ErrorAction SilentlyContinue
132
+ if ($cli) {
133
+ $script:CliPresent = $true
134
+ $script:CliPath = $cli.Source
135
+ $script:CliVer = Get-CliVersion
136
+ $script:CliCompat = [bool]($script:CliVer -and (Test-Compatible $script:CliVer))
137
+ }
138
+
139
+ $npx = Get-Command npx -ErrorAction SilentlyContinue
140
+ if ($npx) { $script:NpxPresent = $true; $script:NpxPath = $npx.Source }
141
+
142
+ $bunx = Get-Command bunx -ErrorAction SilentlyContinue
143
+ if ($bunx) { $script:BunxPresent = $true; $script:BunxPath = $bunx.Source }
144
+
145
+ $node = Get-Command node -ErrorAction SilentlyContinue
146
+ if ($node) {
147
+ $script:NodePresent = $true
148
+ try { $script:NodeVer = ((& node --version 2>$null) -replace '^v', '') } catch { $script:NodeVer = $null }
149
+ $script:NodeFloorOk = Test-NodeMeetsFloor
150
+ }
151
+
152
+ $script:Selected = Resolve-LaunchKind
153
+ }
154
+
155
+ # Assemble the structured diagnosis. $Chained, when a parsed object, becomes
156
+ # cliDoctor; otherwise cliDoctor is null.
157
+ function Build-DiagnosisJson {
158
+ param($Chained)
159
+ $checked = [ordered]@{
160
+ pathCli = [ordered]@{ present = $script:CliPresent; path = $script:CliPath; version = $script:CliVer; compatible = $script:CliCompat }
161
+ npx = [ordered]@{ present = $script:NpxPresent; path = $script:NpxPath; nodeMeetsFloor = $script:NodeFloorOk }
162
+ bunx = [ordered]@{ present = $script:BunxPresent; path = $script:BunxPath }
163
+ node = [ordered]@{ present = $script:NodePresent; version = $script:NodeVer }
164
+ }
165
+ $steps = @()
166
+ if ($script:Selected -eq 'none') {
167
+ $major = $Pinned.Split('.')[0]
168
+ $first = "Install Node 22.13+ from https://nodejs.org so npx can run $Package@$Pinned, then re-run this launcher."
169
+ if ($script:NpxPresent -and (-not $script:NodeFloorOk)) {
170
+ $first = "npx is present but node $(if ($script:NodeVer) { $script:NodeVer } else { 'missing' }) is below the $NodeFloor floor this CLI needs. Upgrade Node at https://nodejs.org, then re-run this launcher."
171
+ }
172
+ $steps = @(
173
+ $first,
174
+ "No JavaScript runtime? Install Bun from https://bun.sh to use bunx, or put a compatible $Bin (major $major, at or above $Pinned) on PATH."
175
+ )
176
+ }
177
+ $obj = [ordered]@{
178
+ tool = $Bin
179
+ package = $Package
180
+ pinnedVersion = $Pinned
181
+ os = 'windows'
182
+ arch = $script:Arch
183
+ checked = $checked
184
+ nativeArtifact = [ordered]@{ available = $false; note = $NativeNote }
185
+ selected = $script:Selected
186
+ nextSteps = @($steps)
187
+ cliDoctor = $Chained
188
+ }
189
+ return ($obj | ConvertTo-Json -Depth 20)
190
+ }
191
+
192
+ # Human-readable diagnosis for `doctor` without --json.
193
+ function Write-DiagnosisText {
194
+ Write-Output "$Bin launcher diagnosis"
195
+ Write-Output ''
196
+ Write-Output (" os / arch: windows / {0}" -f $script:Arch)
197
+ Write-Output (" pinned version: {0} ({1})" -f $Pinned, $Package)
198
+ if ($script:CliPresent) {
199
+ $verdict = if ($script:CliCompat) { 'compatible' } else { 'incompatible' }
200
+ Write-Output (" {0} on PATH: {1} (version {2}, {3})" -f $Bin, $script:CliPath, $script:CliVer, $verdict)
201
+ }
202
+ else {
203
+ Write-Output (" {0} on PATH: no" -f $Bin)
204
+ }
205
+ $npxDesc = 'no'
206
+ if ($script:NpxPresent) {
207
+ if ($script:NodeFloorOk) { $npxDesc = $script:NpxPath }
208
+ else { $npxDesc = "$($script:NpxPath) (unusable: node $(if ($script:NodeVer) { $script:NodeVer } else { 'missing' }) is below $NodeFloor)" }
209
+ }
210
+ Write-Output (" npx: {0}" -f $npxDesc)
211
+ Write-Output (" bunx: {0}" -f $(if ($script:BunxPresent) { $script:BunxPath } else { 'no' }))
212
+ Write-Output (" node: {0}" -f $(if ($script:NodePresent) { $script:NodeVer } else { 'no' }))
213
+ Write-Output (" selected path: {0}" -f $script:Selected)
214
+ if ($script:Selected -eq 'none') {
215
+ Write-Output ''
216
+ Write-Output ("No runtime can launch {0} here. {1}" -f $Bin, $NativeNote)
217
+ Write-Output 'Next steps:'
218
+ Write-Output ' - Install Node 22.13+ from https://nodejs.org, then re-run this launcher.'
219
+ Write-Output (" - Or install Bun from https://bun.sh, or put a compatible {0} on PATH." -f $Bin)
220
+ }
221
+ }
222
+
223
+ # `doctor [--json] [extra...]`: launcher selection diagnosis. When a CLI is
224
+ # resolvable, chain the CLI's own doctor so one call reports both layers. Extra
225
+ # flags pass through to the chained CLI doctor.
226
+ function Invoke-Doctor {
227
+ param([string[]] $DocArgs)
228
+ Collect
229
+ $json = $false
230
+ foreach ($a in $DocArgs) { if ($a -eq '--json') { $json = $true } }
231
+ if ($json) {
232
+ $chained = $null
233
+ if ($script:Selected -ne 'none') {
234
+ try {
235
+ $raw = (Invoke-Cli -CliArgs (@('doctor') + $DocArgs) 2>$null | Out-String).Trim()
236
+ if ($raw.StartsWith('{')) { $chained = ($raw | ConvertFrom-Json) }
237
+ }
238
+ catch { $chained = $null }
239
+ }
240
+ Write-Output (Build-DiagnosisJson $chained)
241
+ }
242
+ else {
243
+ Write-DiagnosisText
244
+ if ($script:Selected -ne 'none') {
245
+ Write-Output ''
246
+ Write-Output "--- $Bin doctor ---"
247
+ Invoke-Cli -CliArgs (@('doctor') + $DocArgs)
248
+ }
249
+ }
250
+ }
251
+
252
+ # Default action: forward every argument to the resolved CLI and exit with its
253
+ # code. No usable runtime -> structured diagnosis on stderr, exit 78 (EX_CONFIG)
254
+ # so the agent never mistakes the diagnosis for a result.
255
+ function Invoke-Run {
256
+ param([string[]] $CliArgs)
257
+ $sel = Resolve-LaunchKind
258
+ switch ($sel) {
259
+ 'path' { & $Bin @CliArgs; exit $LASTEXITCODE }
260
+ 'npx' { & npx --yes --package "$Package@$Pinned" $Bin @CliArgs; exit $LASTEXITCODE }
261
+ 'bunx' { & bunx --bun "$Package@$Pinned" @CliArgs; exit $LASTEXITCODE }
262
+ 'none' {
263
+ Collect
264
+ [Console]::Error.WriteLine((Build-DiagnosisJson $null))
265
+ exit 78
266
+ }
267
+ }
268
+ }
269
+
270
+ $Command = ''
271
+ if ($args.Count -ge 1) { $Command = [string]$args[0] }
272
+ $Rest = @()
273
+ if ($args.Count -gt 1) { $Rest = $args[1..($args.Count - 1)] }
274
+
275
+ switch ($Command) {
276
+ 'doctor' { Invoke-Doctor -DocArgs $Rest }
277
+ 'where' { Collect; Write-Output $script:Selected }
278
+ default { Invoke-Run -CliArgs $args }
279
+ }
@@ -0,0 +1,315 @@
1
+ #!/bin/sh
2
+ # modlens skill launcher (macOS / Linux).
3
+ #
4
+ # One stable action for the agent ("run modlens"); this script picks a working
5
+ # way to run it in the current environment. Written to POSIX sh so it runs under
6
+ # dash, busybox ash, and bash alike. Invoke it with `bash run.sh ...` (or plain
7
+ # `sh run.sh ...`) so a lost execute bit after a file copy never matters.
8
+ #
9
+ # Resolution order (kept identical in run.ps1):
10
+ # 1. A compatible modlens already on PATH -> run it directly.
11
+ # 2. npx present -> run the pinned npm version.
12
+ # 3. bunx present -> run the pinned version via Bun.
13
+ # 4. (phase B placeholder) a native artifact -> not published yet.
14
+ # 5. Nothing usable -> structured diagnosis, exit 78.
15
+ #
16
+ # It never writes PATH, never needs admin rights, never fetches a second script,
17
+ # and has no postinstall step.
18
+ set -eu
19
+
20
+ # --- Version constants: stamped by scripts/release.mjs at release time. --------
21
+ # Do not edit PINNED by hand; scripts/stamp.test.mjs asserts it equals the
22
+ # package.json version, and the release script rewrites it on every bump.
23
+ PKG="@liustack/modlens"
24
+ BIN="modlens"
25
+ PINNED="3.1.1"
26
+ # -------------------------------------------------------------------------------
27
+
28
+ NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
29
+
30
+ # Split "X.Y.Z" (extra suffix ignored) into the globals _MAJ, _MIN, _PAT.
31
+ # Any non-numeric component becomes 0 so integer tests below never abort.
32
+ parse_semver() {
33
+ _raw="$1"
34
+ _MAJ="${_raw%%.*}"
35
+ _rest="${_raw#*.}"
36
+ if [ "$_rest" = "$_raw" ]; then
37
+ _MIN=0
38
+ _PAT=0
39
+ else
40
+ _MIN="${_rest%%.*}"
41
+ _rest2="${_rest#*.}"
42
+ if [ "$_rest2" = "$_rest" ]; then _PAT=0; else _PAT="${_rest2%%.*}"; fi
43
+ fi
44
+ case "$_MAJ" in '' | *[!0-9]*) _MAJ=0 ;; esac
45
+ case "$_MIN" in '' | *[!0-9]*) _MIN=0 ;; esac
46
+ case "$_PAT" in '' | *[!0-9]*) _PAT=0 ;; esac
47
+ }
48
+
49
+ # Compatible = same major version as PINNED AND not older than PINNED.
50
+ # Same major keeps a globally installed CLI usable without a forced re-download;
51
+ # not-older refuses a stale build that predates the version this skill needs.
52
+ compatible() {
53
+ parse_semver "$1"
54
+ _f_maj=$_MAJ
55
+ _f_min=$_MIN
56
+ _f_pat=$_PAT
57
+ parse_semver "$PINNED"
58
+ [ "$_f_maj" = "$_MAJ" ] || return 1
59
+ if [ "$_f_min" -gt "$_MIN" ]; then return 0; fi
60
+ if [ "$_f_min" -lt "$_MIN" ]; then return 1; fi
61
+ [ "$_f_pat" -ge "$_PAT" ]
62
+ }
63
+
64
+ # First "X.Y.Z" token printed by `$BIN --version`.
65
+ cli_version() {
66
+ "$BIN" --version 2>/dev/null | head -n 1 |
67
+ sed -n 's/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p'
68
+ }
69
+
70
+ # The npx path runs the CLI on this machine's node, so npx is only usable when
71
+ # node itself meets the CLI's floor. An old node with a working npx used to be
72
+ # selected anyway, a path known to fail at run time.
73
+ NODE_FLOOR="22.13.0"
74
+ node_meets_floor() {
75
+ command -v node >/dev/null 2>&1 || return 1
76
+ _nv="$(node --version 2>/dev/null | sed 's/^v//')"
77
+ [ -n "$_nv" ] || return 1
78
+ parse_semver "$NODE_FLOOR"
79
+ _floor_maj="$_MAJ"
80
+ _floor_min="$_MIN"
81
+ parse_semver "$_nv"
82
+ if [ "$_MAJ" -gt "$_floor_maj" ]; then return 0; fi
83
+ if [ "$_MAJ" -lt "$_floor_maj" ]; then return 1; fi
84
+ [ "$_MIN" -ge "$_floor_min" ]
85
+ }
86
+
87
+ # Echo exactly one word: the chosen launch path.
88
+ resolve() {
89
+ if command -v "$BIN" >/dev/null 2>&1; then
90
+ _v="$(cli_version)"
91
+ if [ -n "$_v" ] && compatible "$_v"; then
92
+ echo "path"
93
+ return
94
+ fi
95
+ fi
96
+ if command -v npx >/dev/null 2>&1 && node_meets_floor; then
97
+ echo "npx"
98
+ return
99
+ fi
100
+ if command -v bunx >/dev/null 2>&1; then
101
+ echo "bunx"
102
+ return
103
+ fi
104
+ # Phase B goes here: check a versioned user cache, then download and verify a
105
+ # native artifact into it. Any such download must use curl (never a piped
106
+ # second script), which does not stamp quarantine / Mark-of-the-Web the way a
107
+ # browser does, matching design.md 8.3.
108
+ echo "none"
109
+ }
110
+
111
+ # Run the resolved CLI without exec, so its output can be captured (used to
112
+ # chain the CLI's own doctor). Passes every argument through untouched.
113
+ run_cli() {
114
+ case "$G_SEL" in
115
+ path) "$BIN" "$@" ;;
116
+ npx) npx --yes --package "$PKG@$PINNED" "$BIN" "$@" ;;
117
+ bunx) bunx --bun "$PKG@$PINNED" "$@" ;;
118
+ esac
119
+ }
120
+
121
+ detect_os() { uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]'; }
122
+
123
+ detect_arch() {
124
+ _a="$(uname -m 2>/dev/null)"
125
+ case "$_a" in
126
+ x86_64 | amd64) echo "x64" ;;
127
+ aarch64 | arm64) echo "arm64" ;;
128
+ *) echo "$_a" ;;
129
+ esac
130
+ }
131
+
132
+ # Escape a value for a JSON string literal (backslash and double quote).
133
+ json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
134
+
135
+ # Render "null" for an empty value, else an escaped JSON string.
136
+ jstr() {
137
+ if [ -z "$1" ]; then printf 'null'; else printf '"%s"' "$(json_escape "$1")"; fi
138
+ }
139
+
140
+ # 1 -> true, anything else -> false.
141
+ jbool() { if [ "$1" = "1" ]; then printf 'true'; else printf 'false'; fi; }
142
+
143
+ # Probe the environment once into G_* globals shared by the emitters.
144
+ collect() {
145
+ G_OS="$(detect_os)"
146
+ G_ARCH="$(detect_arch)"
147
+
148
+ G_CLI_PRESENT=0
149
+ G_CLI_PATH=""
150
+ G_CLI_VER=""
151
+ G_CLI_COMPAT=0
152
+ if command -v "$BIN" >/dev/null 2>&1; then
153
+ G_CLI_PRESENT=1
154
+ G_CLI_PATH="$(command -v "$BIN")"
155
+ G_CLI_VER="$(cli_version)"
156
+ if [ -n "$G_CLI_VER" ] && compatible "$G_CLI_VER"; then G_CLI_COMPAT=1; fi
157
+ fi
158
+
159
+ G_NPX_PRESENT=0
160
+ G_NPX_PATH=""
161
+ if command -v npx >/dev/null 2>&1; then
162
+ G_NPX_PRESENT=1
163
+ G_NPX_PATH="$(command -v npx)"
164
+ fi
165
+
166
+ G_BUNX_PRESENT=0
167
+ G_BUNX_PATH=""
168
+ if command -v bunx >/dev/null 2>&1; then
169
+ G_BUNX_PRESENT=1
170
+ G_BUNX_PATH="$(command -v bunx)"
171
+ fi
172
+
173
+ G_NODE_PRESENT=0
174
+ G_NODE_VER=""
175
+ if command -v node >/dev/null 2>&1; then
176
+ G_NODE_PRESENT=1
177
+ G_NODE_VER="$(node --version 2>/dev/null | sed 's/^v//')"
178
+ fi
179
+
180
+ G_NODE_FLOOR_OK=0
181
+ if node_meets_floor; then G_NODE_FLOOR_OK=1; fi
182
+
183
+ G_SEL="$(resolve)"
184
+ }
185
+
186
+ # Build the nextSteps JSON array body (without the brackets) into G_NEXTSTEPS.
187
+ compute_next_steps() {
188
+ if [ "$G_SEL" = "none" ]; then
189
+ if [ "$G_NPX_PRESENT" = 1 ] && [ "$G_NODE_FLOOR_OK" = 0 ]; then
190
+ _s1="npx is present but node ${G_NODE_VER:-missing} is below the $NODE_FLOOR floor this CLI needs. Upgrade Node at https://nodejs.org, then re-run this launcher."
191
+ else
192
+ _s1="Install Node 22.13+ from https://nodejs.org so npx can run $PKG@$PINNED, then re-run this launcher."
193
+ fi
194
+ _s2="No JavaScript runtime? Install Bun from https://bun.sh to use bunx, or put a compatible $BIN (major ${PINNED%%.*}, at or above $PINNED) on PATH."
195
+ G_NEXTSTEPS="$(printf '"%s", "%s"' "$(json_escape "$_s1")" "$(json_escape "$_s2")")"
196
+ else
197
+ G_NEXTSTEPS=""
198
+ fi
199
+ }
200
+
201
+ # Emit the structured diagnosis. $1, when a JSON object, is embedded as cliDoctor.
202
+ emit_json() {
203
+ _chained="${1:-}"
204
+ compute_next_steps
205
+ printf '{\n'
206
+ printf ' "tool": %s,\n' "$(jstr "$BIN")"
207
+ printf ' "package": %s,\n' "$(jstr "$PKG")"
208
+ printf ' "pinnedVersion": %s,\n' "$(jstr "$PINNED")"
209
+ printf ' "os": %s,\n' "$(jstr "$G_OS")"
210
+ printf ' "arch": %s,\n' "$(jstr "$G_ARCH")"
211
+ printf ' "checked": {\n'
212
+ printf ' "pathCli": { "present": %s, "path": %s, "version": %s, "compatible": %s },\n' \
213
+ "$(jbool "$G_CLI_PRESENT")" "$(jstr "$G_CLI_PATH")" "$(jstr "$G_CLI_VER")" "$(jbool "$G_CLI_COMPAT")"
214
+ printf ' "npx": { "present": %s, "path": %s, "nodeMeetsFloor": %s },\n' "$(jbool "$G_NPX_PRESENT")" "$(jstr "$G_NPX_PATH")" "$(jbool "$G_NODE_FLOOR_OK")"
215
+ printf ' "bunx": { "present": %s, "path": %s },\n' "$(jbool "$G_BUNX_PRESENT")" "$(jstr "$G_BUNX_PATH")"
216
+ printf ' "node": { "present": %s, "version": %s }\n' "$(jbool "$G_NODE_PRESENT")" "$(jstr "$G_NODE_VER")"
217
+ printf ' },\n'
218
+ printf ' "nativeArtifact": { "available": false, "note": %s },\n' "$(jstr "$NATIVE_NOTE")"
219
+ printf ' "selected": %s,\n' "$(jstr "$G_SEL")"
220
+ printf ' "nextSteps": [%s],\n' "$G_NEXTSTEPS"
221
+ # First character of the captured output, via POSIX parameter expansion
222
+ # (cut -c1 would take the first char of every line, not of the whole string).
223
+ _first="${_chained%"${_chained#?}"}"
224
+ if [ -n "$_chained" ] && [ "$_first" = "{" ]; then
225
+ printf ' "cliDoctor": %s\n' "$_chained"
226
+ else
227
+ printf ' "cliDoctor": null\n'
228
+ fi
229
+ printf '}\n'
230
+ }
231
+
232
+ # Human-readable diagnosis for `doctor` without --json.
233
+ emit_text() {
234
+ printf '%s launcher diagnosis\n\n' "$BIN"
235
+ printf ' os / arch: %s / %s\n' "$G_OS" "$G_ARCH"
236
+ printf ' pinned version: %s (%s)\n' "$PINNED" "$PKG"
237
+ if [ "$G_CLI_PRESENT" = 1 ]; then
238
+ printf ' %s on PATH: %s (version %s, %s)\n' "$BIN" "$G_CLI_PATH" \
239
+ "${G_CLI_VER:-unknown}" "$([ "$G_CLI_COMPAT" = 1 ] && echo compatible || echo incompatible)"
240
+ else
241
+ printf ' %s on PATH: no\n' "$BIN"
242
+ fi
243
+ _npx_desc="no"
244
+ if [ "$G_NPX_PRESENT" = 1 ]; then
245
+ if [ "$G_NODE_FLOOR_OK" = 1 ]; then
246
+ _npx_desc="$G_NPX_PATH"
247
+ else
248
+ _npx_desc="$G_NPX_PATH (unusable: node ${G_NODE_VER:-missing} is below $NODE_FLOOR)"
249
+ fi
250
+ fi
251
+ printf ' npx: %s\n' "$_npx_desc"
252
+ printf ' bunx: %s\n' "$([ "$G_BUNX_PRESENT" = 1 ] && echo "$G_BUNX_PATH" || echo no)"
253
+ printf ' node: %s\n' "$([ "$G_NODE_PRESENT" = 1 ] && echo "${G_NODE_VER:-yes}" || echo no)"
254
+ printf ' selected path: %s\n' "$G_SEL"
255
+ if [ "$G_SEL" = "none" ]; then
256
+ printf '\nNo runtime can launch %s here. %s\n' "$BIN" "$NATIVE_NOTE"
257
+ printf 'Next steps:\n'
258
+ printf ' - Install Node 22.13+ from https://nodejs.org, then re-run this launcher.\n'
259
+ printf ' - Or install Bun from https://bun.sh, or put a compatible %s on PATH.\n' "$BIN"
260
+ fi
261
+ }
262
+
263
+ # `doctor [--json] [extra...]`: launcher selection diagnosis. When a CLI is
264
+ # resolvable, chain the CLI's own doctor (engine/config diagnosis) so one call
265
+ # reports both layers. Extra flags pass through to the chained CLI doctor.
266
+ doctor() {
267
+ collect
268
+ _json=0
269
+ for _a in "$@"; do
270
+ if [ "$_a" = "--json" ]; then _json=1; fi
271
+ done
272
+ if [ "$_json" = 1 ]; then
273
+ _chained=""
274
+ if [ "$G_SEL" != "none" ]; then
275
+ _chained="$(run_cli doctor "$@" 2>/dev/null)" || _chained=""
276
+ fi
277
+ emit_json "$_chained"
278
+ else
279
+ emit_text
280
+ if [ "$G_SEL" != "none" ]; then
281
+ printf '\n--- %s doctor ---\n' "$BIN"
282
+ run_cli doctor "$@" || true
283
+ fi
284
+ fi
285
+ }
286
+
287
+ # Default action: forward every argument to the resolved CLI, inheriting stdio
288
+ # and exit code. No usable runtime -> structured diagnosis on stderr, exit 78
289
+ # (EX_CONFIG) so the agent never mistakes the diagnosis for a result.
290
+ run() {
291
+ _sel="$(resolve)"
292
+ case "$_sel" in
293
+ path) exec "$BIN" "$@" ;;
294
+ npx) exec npx --yes --package "$PKG@$PINNED" "$BIN" "$@" ;;
295
+ bunx) exec bunx --bun "$PKG@$PINNED" "$@" ;;
296
+ none)
297
+ collect
298
+ emit_json "" >&2
299
+ exit 78
300
+ ;;
301
+ esac
302
+ }
303
+
304
+ case "${1:-}" in
305
+ doctor)
306
+ shift
307
+ doctor "$@"
308
+ ;;
309
+ where)
310
+ resolve
311
+ ;;
312
+ *)
313
+ run "$@"
314
+ ;;
315
+ esac