@nanobpm/nano-workforce 0.151.1 → 0.152.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.
@@ -92,6 +92,28 @@ jobs:
92
92
  # blocker, and a self-proving canary asserts the oracle's red path genuinely fires so the gate can
93
93
  # never rot into a vacuous green while the corpus is parked. Hermetic (in-process wasm engine, no
94
94
  # sockets), so it runs on every PR/push as its own job.
95
+ # install.sh onboarding script (issue #576): POSIX-sh cleanliness + a hermetic
96
+ # dry-run smoke test asserting the emitted command sequence. The script is
97
+ # served from `main` for `curl … | sh`, so a broken change is live the instant
98
+ # it merges — this gate keeps that surface honest.
99
+ install-script:
100
+ name: install.sh (shellcheck + dry-run smoke)
101
+ runs-on: ubuntu-latest
102
+ steps:
103
+ - name: Checkout
104
+ uses: actions/checkout@v4
105
+
106
+ - name: Setup Node.js
107
+ uses: actions/setup-node@v4
108
+ with:
109
+ node-version: "24"
110
+
111
+ - name: shellcheck (POSIX sh)
112
+ run: shellcheck -s sh install.sh test/install-smoke.sh
113
+
114
+ - name: Dry-run smoke test
115
+ run: sh test/install-smoke.sh
116
+
95
117
  derivation-parity:
96
118
  name: derivation parity (nwf corpus regression guard)
97
119
  runs-on: ubuntu-latest
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.152.0](https://github.com/nanobpm/nano-workforce/compare/v0.151.1...v0.152.0) (2026-08-28)
2
+
3
+ ### Features
4
+
5
+ * **onboarding:** one-command install script — hire your coding harnesses and bring up a Nano workforce ([#597](https://github.com/nanobpm/nano-workforce/issues/597)) ([73b840b](https://github.com/nanobpm/nano-workforce/commit/73b840bbb3c602e83d4cac6990e69d0b77ed0344)), closes [jwulf/c8ctl-plugin-nano#117](https://github.com/jwulf/c8ctl-plugin-nano/issues/117) [#576](https://github.com/nanobpm/nano-workforce/issues/576)
6
+
1
7
  ## [0.151.1](https://github.com/nanobpm/nano-workforce/compare/v0.151.0...v0.151.1) (2026-08-28)
2
8
 
3
9
  ### Bug Fixes
package/README.md CHANGED
@@ -79,6 +79,47 @@ You can also run Nano Workforce directly, outside Studio, against a running Nano
79
79
  engine. This is the second-class path — no left-rail integration or update UI — but
80
80
  it's the same app and the same behaviour.
81
81
 
82
+ ### Install with one command
83
+
84
+ If you already have one or more coding-agent CLIs installed (`kimi`, `qwen`,
85
+ `copilot`, `claude`, `pi`), one command takes you from there to a **running Nano
86
+ engine with a supervised workforce of hired agents**:
87
+
88
+ ```sh
89
+ curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
90
+ ```
91
+
92
+ It installs `@camunda8/cli` and the `c8ctl-plugin-nano` plugin, installs shell
93
+ completion, detects which of your harnesses are installed, lets you multi-select
94
+ which to hire (with a model and an instance count each), hires each with
95
+ `--rank senior --protocol acp --permission yolo`, composes a declarative
96
+ **workforce manifest** (`c8 nano workforce add`), and brings it up
97
+ (`c8 nano start` then `c8 nano workforce start`).
98
+
99
+ > ⚠️ The hired agents run **unattended** with `--permission yolo` — full tool
100
+ > access (shell, file writes, network) as your user on this host. The script shows
101
+ > a confirmation summary before it hires anything; pass `--yes` to skip it.
102
+
103
+ **What it does *not* do:** it stops at "engine up, workforce up, agents polling".
104
+ It does **not** install/deploy/run the Nano Workforce **app** itself — for that,
105
+ use Nano Studio (above) or the manual steps below.
106
+
107
+ Prompts read from `/dev/tty`, so the `curl … | sh` pipe works interactively. It is
108
+ also fully scriptable and safe to re-run (it converges rather than duplicating):
109
+
110
+ ```sh
111
+ # Non-interactive (CI / re-provisioning); repeatable --harness <name>[:<model>][:<instances>]
112
+ curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
113
+
114
+ # Preview every command without changing anything
115
+ curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:1 --dry-run
116
+ ```
117
+
118
+ `qwen` must always be given an explicit model (it stalls without one). With no
119
+ controlling terminal and no `--harness`, the script exits with usage rather than
120
+ hanging. Because it is served from `main`, a merged change to `install.sh` is live
121
+ immediately.
122
+
82
123
  ### Prerequisites
83
124
 
84
125
  - A running **Nano gateway/engine** (default `http://localhost:8080`) — what the app
package/install.sh ADDED
@@ -0,0 +1,782 @@
1
+ #!/bin/sh
2
+ # Nano Workforce — one-command onboarding installer.
3
+ #
4
+ # curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
5
+ #
6
+ # Takes a machine from "has some coding-agent CLIs installed" to "a running Nano
7
+ # engine with a supervised workforce of hired agents". It installs @camunda8/cli
8
+ # and the c8ctl-plugin-nano plugin, lets you pick which of your installed coding
9
+ # harnesses to hire (and with which model / how many instances), composes a
10
+ # declarative workforce manifest from those choices, and brings the engine and
11
+ # the workforce up.
12
+ #
13
+ # Scope: this script stops at "engine up, workforce up, agents polling". It does
14
+ # NOT install/deploy/run the Nano Workforce app itself (that is a follow-up).
15
+ #
16
+ # Constraints (see nanobpm/nano-workforce#576):
17
+ # - POSIX sh only (piped to `sh`, which is dash on many distros). No bashisms:
18
+ # no arrays, no `[[`, no `local`, no `read -a`, no `set -o pipefail`.
19
+ # - stdin is the curl pipe, NOT the keyboard: every prompt reads from /dev/tty.
20
+ # - never sudo on the user's behalf.
21
+ # - shellcheck -s sh clean (enforced in CI).
22
+
23
+ set -eu
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Curated data tables — refresh these against each harness's current docs.
27
+ # Kept together near the top so they are cheap to update.
28
+ #
29
+ # ACP invocation per harness (the base command line the worker spawns). Every
30
+ # hire uses --protocol acp; two harnesses ride an adapter binary:
31
+ #
32
+ # copilot copilot --acp (native; plugin appends --acp)
33
+ # kimi kimi acp (native subcommand)
34
+ # qwen qwen --experimental-acp (native, hidden flag)
35
+ # claude claude-code-acp (adapter: @zed-industries/claude-code-acp)
36
+ # pi pi-acp (adapter: pi-acp)
37
+ #
38
+ # The chosen model is baked into the --command as `--model <id>` so it actually
39
+ # reaches the harness (c8 nano hire --model only sets AGENT_MODEL in the env,
40
+ # which nothing forwards to the CLI automatically). --model is still passed to
41
+ # `hire` for bookkeeping (it shows in `hire --list` / `supervisor status`).
42
+ # ---------------------------------------------------------------------------
43
+
44
+ ALL_HARNESSES='kimi qwen copilot claude pi'
45
+
46
+ # Curated, known-good model ids for the non-queryable harnesses (copilot, claude,
47
+ # qwen). pi/kimi are queried live and fall back to these. Verify at refresh time.
48
+ MODELS_copilot='gpt-5.4 claude-sonnet-4.6 claude-opus-4.8'
49
+ MODELS_claude='sonnet opus haiku'
50
+ MODELS_qwen='qwen3-coder-plus qwen3-coder-flash qwen-max'
51
+ MODELS_pi='pi-fast pi-balanced pi-max'
52
+ MODELS_kimi='kimi-k2 kimi-k2-turbo'
53
+
54
+ MIN_NODE='22.18.0' # @camunda8/cli engines floor
55
+ PLUGIN='c8ctl-plugin-nano'
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Output helpers
59
+ # ---------------------------------------------------------------------------
60
+ if [ -t 2 ]; then
61
+ C_RESET=$(printf '\033[0m'); C_BOLD=$(printf '\033[1m')
62
+ C_RED=$(printf '\033[31m'); C_YEL=$(printf '\033[33m'); C_GRN=$(printf '\033[32m')
63
+ else
64
+ C_RESET=''; C_BOLD=''; C_RED=''; C_YEL=''; C_GRN=''
65
+ fi
66
+
67
+ info() { printf '%s\n' "${C_BOLD}==>${C_RESET} $*" >&2; }
68
+ note() { printf '%s\n' " $*" >&2; }
69
+ warn() { printf '%s\n' "${C_YEL}warning:${C_RESET} $*" >&2; }
70
+ err() { printf '%s\n' "${C_RED}error:${C_RESET} $*" >&2; }
71
+ ok() { printf '%s\n' "${C_GRN}ok:${C_RESET} $*" >&2; }
72
+
73
+ die() { err "$*"; exit 1; }
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Global state
77
+ # ---------------------------------------------------------------------------
78
+ DRY_RUN=0
79
+ ASSUME_YES=0
80
+ SHELL_OVERRIDE=''
81
+ INSTALL_ADAPTERS=0 # auto-install missing adapters without prompting
82
+ CLI_HARNESS_SPECS='' # newline-separated name[:model][:instances] from --harness
83
+ SELECTIONS='' # newline-separated "name<US>model<US>instances" (US = non-whitespace 0x1f, so an empty model field does not collapse under read)
84
+ FAILURES='' # newline-separated failure notes (partial-success report)
85
+ SEP=$(printf '\037') # US (unit separator): non-whitespace, so `read` never collapses adjacent/empty fields
86
+
87
+ # Test hooks (undocumented; used by the CI dry-run smoke test to stay hermetic).
88
+ # NANO_INSTALL_HARNESSES_OVERRIDE — space list of "detected" harnesses.
89
+ # NANO_INSTALL_ADAPTERS_PRESENT — space list of adapter bins to treat present.
90
+ HARNESS_OVERRIDE="${NANO_INSTALL_HARNESSES_OVERRIDE:-}"
91
+ ADAPTERS_PRESENT="${NANO_INSTALL_ADAPTERS_PRESENT:-}"
92
+ # Whether each override was *set at all* (even to ""). A set-but-empty value means
93
+ # "none detected/present" and must NOT fall back to command -v, or the smoke test
94
+ # stops being hermetic on a runner image that happens to ship a harness/adapter binary.
95
+ if [ "${NANO_INSTALL_HARNESSES_OVERRIDE+set}" = set ]; then HARNESS_OVERRIDE_SET=1; else HARNESS_OVERRIDE_SET=0; fi
96
+ if [ "${NANO_INSTALL_ADAPTERS_PRESENT+set}" = set ]; then ADAPTERS_PRESENT_SET=1; else ADAPTERS_PRESENT_SET=0; fi
97
+
98
+ CLI='' # resolved c8ctl / c8 binary
99
+ TTY='' # /dev/tty if usable, else empty
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Command runner — honours --dry-run for mutating commands.
103
+ # ---------------------------------------------------------------------------
104
+ run() {
105
+ if [ "$DRY_RUN" -eq 1 ]; then
106
+ printf '%s\n' "+ $*" >&2
107
+ return 0
108
+ fi
109
+ "$@"
110
+ }
111
+
112
+ # Like run() but for a command whose printed form differs from argv (a string we
113
+ # assembled). $1 is the human/dry-run string; the rest is the argv to execute.
114
+ run_as() {
115
+ _show=$1; shift
116
+ if [ "$DRY_RUN" -eq 1 ]; then
117
+ printf '%s\n' "+ $_show" >&2
118
+ return 0
119
+ fi
120
+ "$@"
121
+ }
122
+
123
+ record_failure() { FAILURES="${FAILURES}$1
124
+ "; }
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # TTY-backed prompting (stdin is the curl pipe, so read from /dev/tty)
128
+ # ---------------------------------------------------------------------------
129
+ init_tty() {
130
+ if [ -e /dev/tty ] && (: >/dev/tty) 2>/dev/null && (: </dev/tty) 2>/dev/null; then
131
+ TTY=/dev/tty
132
+ else
133
+ TTY=''
134
+ fi
135
+ }
136
+
137
+ # ask "<prompt>" -> sets ANS (empty string on EOF)
138
+ ask() {
139
+ ANS=''
140
+ [ -n "$TTY" ] || return 0
141
+ printf '%s' "$1" >"$TTY"
142
+ IFS= read -r ANS <"$TTY" || ANS=''
143
+ }
144
+
145
+ # confirm "<prompt>" -> returns 0 for yes
146
+ confirm() {
147
+ ask "$1 [y/N] "
148
+ case "$ANS" in
149
+ y|Y|yes|YES|Yes) return 0 ;;
150
+ *) return 1 ;;
151
+ esac
152
+ }
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Version comparison — is $1 >= $2 (dotted numeric, e.g. 22.18.0)?
156
+ # ---------------------------------------------------------------------------
157
+ ver_field() { # ver index(1..3) -> numeric field, missing = 0
158
+ _v=$1; _i=$2
159
+ _v=${_v%%[!0-9.]*}
160
+ case "$_i" in
161
+ 1) printf '%s' "${_v%%.*}" ;;
162
+ 2) _r=${_v#*.}; [ "$_r" = "$_v" ] && { printf '0'; return; }; printf '%s' "${_r%%.*}" ;;
163
+ 3) _r=${_v#*.}; [ "$_r" = "$_v" ] && { printf '0'; return; }
164
+ _p=${_r#*.}; [ "$_p" = "$_r" ] && { printf '0'; return; }
165
+ printf '%s' "${_p%%.*}" ;;
166
+ esac
167
+ }
168
+
169
+ ver_ge() { # $1 >= $2 ?
170
+ _i=1
171
+ while [ "$_i" -le 3 ]; do
172
+ _a=$(ver_field "$1" "$_i"); _b=$(ver_field "$2" "$_i")
173
+ [ -z "$_a" ] && _a=0; [ -z "$_b" ] && _b=0
174
+ if [ "$_a" -gt "$_b" ]; then return 0; fi
175
+ if [ "$_a" -lt "$_b" ]; then return 1; fi
176
+ _i=$((_i + 1))
177
+ done
178
+ return 0
179
+ }
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Usage
183
+ # ---------------------------------------------------------------------------
184
+ usage() {
185
+ cat >&2 <<'EOF'
186
+ Nano Workforce installer — hire your coding harnesses and bring up a workforce.
187
+
188
+ Usage:
189
+ curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
190
+ sh install.sh [options]
191
+
192
+ Options:
193
+ --harness <name>[:<model>][:<instances>]
194
+ Non-interactive selection; repeatable. <name> is one of
195
+ kimi|qwen|copilot|claude|pi. Omit :model for the harness
196
+ default (NOT allowed for qwen). Omit :instances for 5 on the
197
+ first selection, 1 thereafter. To set instances WITHOUT a
198
+ model, leave the model field empty: name::instances (e.g.
199
+ copilot::2 — 'copilot:2' means model "2", not 2 instances).
200
+ -y, --yes Skip the confirmation summary.
201
+ --install-adapters Auto-install a selected harness's missing ACP adapter
202
+ (claude/pi) instead of prompting/skipping.
203
+ --shell <bash|zsh|fish>
204
+ Override shell-completion detection.
205
+ --dry-run Print every command that would run; change nothing.
206
+ -h, --help Show this help.
207
+
208
+ Non-interactive example:
209
+ curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
210
+
211
+ With no controlling terminal (/dev/tty) and no --harness, the script exits
212
+ non-zero rather than hanging on a prompt.
213
+ EOF
214
+ }
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Argument parsing
218
+ # ---------------------------------------------------------------------------
219
+ parse_args() {
220
+ while [ $# -gt 0 ]; do
221
+ case "$1" in
222
+ --harness)
223
+ [ $# -ge 2 ] || die "--harness requires a value (name[:model][:instances])"
224
+ CLI_HARNESS_SPECS="${CLI_HARNESS_SPECS}$2
225
+ "
226
+ shift 2 ;;
227
+ --harness=*)
228
+ CLI_HARNESS_SPECS="${CLI_HARNESS_SPECS}${1#--harness=}
229
+ "
230
+ shift ;;
231
+ --yes|-y) ASSUME_YES=1; shift ;;
232
+ --install-adapters) INSTALL_ADAPTERS=1; shift ;;
233
+ --dry-run) DRY_RUN=1; shift ;;
234
+ --shell)
235
+ [ $# -ge 2 ] || die "--shell requires a value (bash|zsh|fish)"
236
+ SHELL_OVERRIDE=$2; shift 2 ;;
237
+ --shell=*) SHELL_OVERRIDE=${1#--shell=}; shift ;;
238
+ -h|--help) usage; exit 0 ;;
239
+ *) err "unknown option: $1"; usage; exit 2 ;;
240
+ esac
241
+ done
242
+ }
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Step 1 — Preflight
246
+ # ---------------------------------------------------------------------------
247
+ preflight() {
248
+ info "Preflight"
249
+ command -v node >/dev/null 2>&1 || die "node not found. Install Node >= ${MIN_NODE} (https://nodejs.org or use nvm), then re-run."
250
+ command -v npm >/dev/null 2>&1 || die "npm not found. Install Node/npm >= ${MIN_NODE} (https://nodejs.org), then re-run."
251
+ _nv=$(node -v 2>/dev/null | sed 's/^v//')
252
+ if ! ver_ge "$_nv" "$MIN_NODE"; then
253
+ die "node $_nv is too old — @camunda8/cli needs >= ${MIN_NODE}. Upgrade via https://nodejs.org or nvm, then re-run."
254
+ fi
255
+ ok "node $_nv (>= ${MIN_NODE}), npm present"
256
+
257
+ # Non-fatal: agents need GitHub access to do useful work.
258
+ if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
259
+ if command -v gh >/dev/null 2>&1; then
260
+ if ! gh auth status >/dev/null 2>&1; then
261
+ warn "gh is installed but not authenticated, and no GITHUB_TOKEN/GH_TOKEN is set."
262
+ note "Agents need GitHub access — run 'gh auth login' or export GITHUB_TOKEN before they start pulling work."
263
+ fi
264
+ else
265
+ warn "no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
266
+ note "Agents need GitHub access — install gh (https://cli.github.com) and 'gh auth login', or export GITHUB_TOKEN."
267
+ fi
268
+ fi
269
+ }
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Step 2 — Install the CLI + plugin, version-gate on `nano workforce`
273
+ # ---------------------------------------------------------------------------
274
+ resolve_cli() {
275
+ if command -v c8ctl >/dev/null 2>&1; then CLI=c8ctl
276
+ elif command -v c8 >/dev/null 2>&1; then CLI=c8
277
+ else CLI=c8ctl; fi
278
+ }
279
+
280
+ install_cli() {
281
+ info "Installing @camunda8/cli"
282
+ if ! run npm i -g @camunda8/cli; then
283
+ err "'npm i -g @camunda8/cli' failed."
284
+ note "If this was an EACCES permissions error, do NOT sudo blindly. Options:"
285
+ note " - set a user npm prefix: npm config set prefix ~/.npm-global (then add its bin to PATH)"
286
+ note " - use nvm so global installs land in your user dir"
287
+ note " - run 'sudo npm i -g @camunda8/cli' yourself if you understand the implications"
288
+ exit 1
289
+ fi
290
+ resolve_cli
291
+ if [ "$DRY_RUN" -eq 0 ]; then
292
+ command -v "$CLI" >/dev/null 2>&1 || die "@camunda8/cli installed but '$CLI' is not on PATH — add npm's global bin dir to PATH and re-run."
293
+ fi
294
+ ok "@camunda8/cli installed ($CLI)"
295
+
296
+ # Shell completion — non-fatal.
297
+ info "Installing shell completion"
298
+ if [ -n "$SHELL_OVERRIDE" ]; then
299
+ if run "$CLI" completion install --shell "$SHELL_OVERRIDE"; then ok "completion installed (--shell $SHELL_OVERRIDE)"; else warn "completion install failed (non-fatal) — continuing."; fi
300
+ else
301
+ if run "$CLI" completion install; then ok "completion installed"; else warn "completion install failed (non-fatal) — continuing."; fi
302
+ fi
303
+
304
+ # Load the nano plugin — idempotent (load, else upgrade, else already present).
305
+ info "Loading the $PLUGIN plugin"
306
+ if run "$CLI" load plugin "$PLUGIN"; then
307
+ ok "plugin loaded"
308
+ elif run "$CLI" upgrade plugin "$PLUGIN"; then
309
+ ok "plugin upgraded"
310
+ elif [ "$DRY_RUN" -eq 1 ] || "$CLI" nano >/dev/null 2>&1; then
311
+ note "plugin already loaded — continuing."
312
+ else
313
+ die "could not load the $PLUGIN plugin. Try: $CLI load plugin $PLUGIN"
314
+ fi
315
+
316
+ version_gate
317
+ }
318
+
319
+ # Version-gate: `c8 nano workforce` must exist. The command supports `list --json`
320
+ # (documented "for the install script / CI"); on a plugin new enough to have the
321
+ # workforce subcommand that emits a JSON object, older plugins print top-level
322
+ # usage text instead. Detect the JSON.
323
+ version_gate() {
324
+ info "Checking plugin version (needs 'nano workforce')"
325
+ if [ "$DRY_RUN" -eq 1 ]; then
326
+ note "dry-run: skipping live version-gate probe."
327
+ return 0
328
+ fi
329
+ _out=$("$CLI" nano workforce list --json 2>/dev/null || true)
330
+ # Require the first non-whitespace character to be '{' — a real JSON object.
331
+ # An older plugin prints usage/help text, which never starts with '{'. We strip
332
+ # all whitespace and inspect the first character so leading blank lines or
333
+ # indentation don't fool the gate, and so help text that merely mentions
334
+ # "workers"/"version"/"name" can't produce a false positive.
335
+ if [ "$(printf '%s' "$_out" | tr -d '[:space:]' | cut -c1)" = '{' ]; then
336
+ ok "'nano workforce' available"
337
+ else
338
+ die "the installed $PLUGIN is too old: it has no 'nano workforce' command (needs jwulf/c8ctl-plugin-nano#117). Upgrade with: $CLI upgrade plugin $PLUGIN"
339
+ fi
340
+ }
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Step 3 — Detect installed harnesses
344
+ # ---------------------------------------------------------------------------
345
+ harness_detected() { # $1 harness -> 0 if present
346
+ if [ "$HARNESS_OVERRIDE_SET" -eq 1 ]; then
347
+ case " $HARNESS_OVERRIDE " in *" $1 "*) return 0 ;; *) return 1 ;; esac
348
+ fi
349
+ command -v "$1" >/dev/null 2>&1
350
+ }
351
+
352
+ DETECTED=''
353
+ detect_harnesses() {
354
+ info "Detecting installed coding harnesses"
355
+ DETECTED=''
356
+ for _h in $ALL_HARNESSES; do
357
+ if harness_detected "$_h"; then
358
+ DETECTED="${DETECTED}${_h} "
359
+ note "found: $_h"
360
+ else
361
+ note "not installed (skipping): $_h"
362
+ fi
363
+ done
364
+ DETECTED=$(printf '%s' "$DETECTED" | sed 's/ *$//')
365
+ [ -n "$DETECTED" ] || die "none of ${ALL_HARNESSES} are installed — install a coding-agent CLI first, then re-run."
366
+ }
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # ACP command / adapter tables
370
+ # ---------------------------------------------------------------------------
371
+ acp_base() { # $1 harness -> base ACP invocation
372
+ case "$1" in
373
+ copilot) printf 'copilot --acp' ;;
374
+ kimi) printf 'kimi acp' ;;
375
+ qwen) printf 'qwen --experimental-acp' ;;
376
+ claude) printf 'claude-code-acp' ;;
377
+ pi) printf 'pi-acp' ;;
378
+ *) printf '%s' "$1" ;;
379
+ esac
380
+ }
381
+
382
+ adapter_bin() { # $1 harness -> adapter binary name (empty if native)
383
+ case "$1" in
384
+ claude) printf 'claude-code-acp' ;;
385
+ pi) printf 'pi-acp' ;;
386
+ *) printf '' ;;
387
+ esac
388
+ }
389
+
390
+ adapter_pkg() { # $1 harness -> npm package for its adapter (empty if native)
391
+ case "$1" in
392
+ claude) printf '@zed-industries/claude-code-acp' ;;
393
+ pi) printf 'pi-acp' ;;
394
+ *) printf '' ;;
395
+ esac
396
+ }
397
+
398
+ adapter_present() { # $1 adapter bin -> 0 if present
399
+ if [ "$ADAPTERS_PRESENT_SET" -eq 1 ]; then
400
+ case " $ADAPTERS_PRESENT " in *" $1 "*) return 0 ;; *) return 1 ;; esac
401
+ fi
402
+ command -v "$1" >/dev/null 2>&1
403
+ }
404
+
405
+ # Assemble the full ACP command line for a harness+model (model baked in).
406
+ build_command() { # $1 harness $2 model
407
+ _cmd=$(acp_base "$1")
408
+ if [ -n "$2" ]; then _cmd="$_cmd --model $2"; fi
409
+ printf '%s' "$_cmd"
410
+ }
411
+
412
+ curated_models() { # $1 harness -> space list
413
+ case "$1" in
414
+ copilot) printf '%s' "$MODELS_copilot" ;;
415
+ claude) printf '%s' "$MODELS_claude" ;;
416
+ qwen) printf '%s' "$MODELS_qwen" ;;
417
+ pi) printf '%s' "$MODELS_pi" ;;
418
+ kimi) printf '%s' "$MODELS_kimi" ;;
419
+ *) printf '' ;;
420
+ esac
421
+ }
422
+
423
+ # Live model query for the queryable harnesses. Prints models (best-effort), one
424
+ # per line, or nothing if the query fails / is unavailable.
425
+ live_models() { # $1 harness -> newline list on stdout
426
+ case "$1" in
427
+ pi)
428
+ pi --list-models 2>/dev/null | awk 'NR>1 && $2 != "" {print $2}' ;;
429
+ kimi)
430
+ kimi provider list --json 2>/dev/null \
431
+ | tr ',' '\n' \
432
+ | sed -n 's/.*"\([A-Za-z0-9._]*-[A-Za-z0-9._-]*\)".*/\1/p' ;;
433
+ *) : ;;
434
+ esac
435
+ }
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # Step 4/5 — interactive selection + model + instances
439
+ # ---------------------------------------------------------------------------
440
+ add_selection() { # $1 harness $2 model $3 instances
441
+ if [ -n "$2" ] && ! printf '%s' "$2" | grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
442
+ die "refusing model id '$2' for $1: only [A-Za-z0-9._/@+-] are allowed (':' is reserved as the --harness field separator, so it must not appear in a model id; guards against shell-metacharacter injection into the hire --command)."
443
+ fi
444
+ SELECTIONS="${SELECTIONS}$1${SEP}$2${SEP}$3
445
+ "
446
+ }
447
+
448
+ # Prompt for a model for a harness. Sets ANS to the chosen model (may be empty
449
+ # for "harness default"; never empty for qwen).
450
+ choose_model() { # $1 harness
451
+ _h=$1
452
+ # Build the candidate list: live query for pi/kimi, else curated. Model ids
453
+ # contain no spaces, so a space-separated list + `for` keeps this in the
454
+ # current shell (no subshell to lose _map/_count to).
455
+ _live=$(live_models "$_h" 2>/dev/null | sed '/^$/d' | head -n 20 | tr '\n' ' ' || true)
456
+ if [ -n "$(printf '%s' "$_live" | tr -d ' ')" ]; then
457
+ _cands=$_live
458
+ note "live models for $_h:"
459
+ else
460
+ _cands=$(curated_models "$_h")
461
+ [ -n "$_cands" ] && note "known-good models for $_h:"
462
+ fi
463
+
464
+ _n=0
465
+ _map=''
466
+ for _m in $_cands; do
467
+ [ -n "$_m" ] || continue
468
+ _n=$((_n + 1))
469
+ printf ' %s) %s\n' "$_n" "$_m" >"$TTY"
470
+ _map="${_map}${_n}=${_m} "
471
+ done
472
+ _count=$_n
473
+
474
+ if [ "$_h" = qwen ]; then
475
+ note "qwen requires an explicit model (it stalls with none)."
476
+ _extra=" c) enter your own"
477
+ else
478
+ _extra=" c) enter your own
479
+ d) use the harness default (no model)"
480
+ fi
481
+ printf '%s\n' "$_extra" >"$TTY"
482
+
483
+ while :; do
484
+ if [ "$_h" = qwen ]; then
485
+ ask "Model for $_h (1-${_count} or c): "
486
+ else
487
+ ask "Model for $_h (1-${_count}, c, or d): "
488
+ fi
489
+ case "$ANS" in
490
+ c|C)
491
+ ask "Enter model id for $_h: "
492
+ _m=$(printf '%s' "$ANS" | awk '{print $1}')
493
+ if [ -n "$_m" ]; then ANS=$_m; return 0; fi
494
+ warn "empty model id" ;;
495
+ d|D)
496
+ if [ "$_h" = qwen ]; then warn "qwen needs an explicit model"; continue; fi
497
+ ANS=''; return 0 ;;
498
+ ''|*[!0-9]*)
499
+ warn "please choose one of the listed options" ;;
500
+ *)
501
+ _sel=$(printf '%s' "$_map" | tr ' ' '\n' | grep "^${ANS}=" | head -n 1 | cut -d= -f2-)
502
+ if [ -n "$_sel" ]; then ANS=$_sel; return 0; fi
503
+ warn "no such option: $ANS" ;;
504
+ esac
505
+ done
506
+ }
507
+
508
+ # Ensure a harness's ACP adapter is present; may install (with consent) or skip.
509
+ # Returns 0 to keep the harness, 1 to skip it.
510
+ ensure_adapter() { # $1 harness; $2 "interactive" to allow the install prompt
511
+ _h=$1
512
+ _interactive=${2:-}
513
+ _bin=$(adapter_bin "$_h")
514
+ [ -n "$_bin" ] || return 0 # native, nothing to do
515
+ if adapter_present "$_bin"; then return 0; fi
516
+ _pkg=$(adapter_pkg "$_h")
517
+ if [ "$INSTALL_ADAPTERS" -eq 1 ]; then
518
+ info "Installing $_h ACP adapter ($_pkg)"
519
+ if run npm i -g "$_pkg"; then return 0; fi
520
+ warn "adapter install failed for $_h — skipping this harness."
521
+ record_failure "$_h: adapter install ($_pkg) failed"
522
+ return 1
523
+ fi
524
+ # Only prompt in the interactive selection path. A flag-driven (--harness) run
525
+ # must stay fully scriptable even from a terminal — use --install-adapters as
526
+ # the explicit non-interactive install mechanism.
527
+ if [ "$_interactive" = interactive ] && [ -n "$TTY" ] && confirm "$_h needs the ACP adapter '$_bin' ($_pkg). Install it globally now?"; then
528
+ info "Installing $_h ACP adapter ($_pkg)"
529
+ if run npm i -g "$_pkg"; then return 0; fi
530
+ warn "adapter install failed for $_h — skipping this harness."
531
+ record_failure "$_h: adapter install ($_pkg) failed"
532
+ return 1
533
+ fi
534
+ warn "skipping $_h — ACP adapter '$_bin' not installed (install with: npm i -g $_pkg)."
535
+ record_failure "$_h: skipped (missing ACP adapter $_bin)"
536
+ return 1
537
+ }
538
+
539
+ interactive_select() {
540
+ info "Select harnesses to hire"
541
+ _i=0
542
+ _idxmap=''
543
+ for _h in $DETECTED; do
544
+ _i=$((_i + 1))
545
+ printf ' %s) %s\n' "$_i" "$_h" >"$TTY"
546
+ _idxmap="${_idxmap}${_i}=${_h}
547
+ "
548
+ done
549
+ _total=$_i
550
+
551
+ while :; do
552
+ ask "Which to hire? (e.g. '1 3' or 'all'): "
553
+ _pick=$ANS
554
+ [ -n "$_pick" ] || { warn "select at least one, or Ctrl-C to abort"; continue; }
555
+ _chosen=''
556
+ if [ "$_pick" = all ] || [ "$_pick" = ALL ]; then
557
+ _chosen=$DETECTED
558
+ else
559
+ _bad=0
560
+ for _tok in $_pick; do
561
+ case "$_tok" in
562
+ ''|*[!0-9]*) warn "not a number: $_tok"; _bad=1; break ;;
563
+ esac
564
+ _m=$(printf '%s' "$_idxmap" | grep "^${_tok}=" | head -n 1 | cut -d= -f2-)
565
+ if [ -z "$_m" ]; then warn "no such option: $_tok"; _bad=1; break; fi
566
+ case " $_chosen " in *" $_m "*) : ;; *) _chosen="$_chosen $_m" ;; esac
567
+ done
568
+ [ "$_bad" -eq 0 ] || continue
569
+ fi
570
+ _chosen=$(printf '%s' "$_chosen" | sed 's/^ *//;s/ *$//')
571
+ [ -n "$_chosen" ] && break
572
+ done
573
+
574
+ _first=1
575
+ for _h in $_chosen; do
576
+ ensure_adapter "$_h" interactive || continue
577
+ choose_model "$_h"; _model=$ANS
578
+ if [ "$_first" -eq 1 ]; then _def=5; else _def=1; fi
579
+ while :; do
580
+ ask "How many '$_h' workers? [default $_def]: "
581
+ if [ -z "$ANS" ]; then _inst=$_def; break; fi
582
+ case "$ANS" in
583
+ ''|*[!0-9]*) warn "enter a whole number" ;;
584
+ *) if [ "$ANS" -ge 1 ]; then _inst=$ANS; break; else warn "must be >= 1"; fi ;;
585
+ esac
586
+ done
587
+ add_selection "$_h" "$_model" "$_inst"
588
+ _first=0
589
+ done
590
+ }
591
+
592
+ # Non-interactive selection from --harness specs.
593
+ noninteractive_select() {
594
+ info "Composing workforce from --harness flags"
595
+ _first=1
596
+ # Specs are name[:model][:instances] with no spaces, so iterate with `for`
597
+ # in the current shell (add_selection must mutate SELECTIONS here).
598
+ for _spec in $CLI_HARNESS_SPECS; do
599
+ [ -n "$_spec" ] || continue
600
+ _name=$(printf '%s' "$_spec" | cut -d: -f1)
601
+ _model=$(printf '%s' "$_spec" | cut -s -d: -f2)
602
+ _inst=$(printf '%s' "$_spec" | cut -s -d: -f3)
603
+ case " $ALL_HARNESSES " in
604
+ *" $_name "*) : ;;
605
+ *) die "unknown harness in --harness: '$_name' (expected one of ${ALL_HARNESSES})" ;;
606
+ esac
607
+ if ! harness_detected "$_name"; then
608
+ warn "--harness $_name: not installed on this host — skipping."
609
+ record_failure "$_name: not installed"
610
+ continue
611
+ fi
612
+ if [ "$_name" = qwen ] && [ -z "$_model" ]; then
613
+ die "--harness qwen requires an explicit model (qwen stalls with none): use qwen:<model>[:<instances>]"
614
+ fi
615
+ ensure_adapter "$_name" || continue
616
+ if [ -z "$_inst" ]; then
617
+ if [ "$_first" -eq 1 ]; then _inst=5; else _inst=1; fi
618
+ fi
619
+ case "$_inst" in
620
+ ''|*[!0-9]*) die "--harness $_name: instances must be a whole number, got '$_inst'" ;;
621
+ esac
622
+ [ "$_inst" -ge 1 ] || die "--harness $_name: instances must be >= 1"
623
+ add_selection "$_name" "$_model" "$_inst"
624
+ _first=0
625
+ done
626
+ }
627
+
628
+ # ---------------------------------------------------------------------------
629
+ # Step 7 — confirm
630
+ # ---------------------------------------------------------------------------
631
+ print_summary() {
632
+ info "Workforce to compose"
633
+ printf '%s\n' "$SELECTIONS" | sed '/^$/d' | while IFS="$SEP" read -r _h _model _inst; do
634
+ _shown=${_model:-'(harness default)'}
635
+ note "$_h ×$_inst model: $_shown"
636
+ done
637
+ }
638
+
639
+ confirm_or_die() {
640
+ [ -n "$SELECTIONS" ] || die "no harnesses selected — nothing to do."
641
+ print_summary
642
+ warn "These agents run UNATTENDED with --permission yolo: full tool access"
643
+ warn "(shell, file writes, network) as $(id -un 2>/dev/null || printf 'the current user') on this host."
644
+ if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ]; then
645
+ [ "$DRY_RUN" -eq 1 ] && note "dry-run: not asking for confirmation."
646
+ return 0
647
+ fi
648
+ if [ -z "$TTY" ]; then
649
+ die "refusing to proceed without confirmation and no /dev/tty — re-run with --yes."
650
+ fi
651
+ confirm "Proceed and bring up this unattended workforce?" || die "aborted by user."
652
+ }
653
+
654
+ # ---------------------------------------------------------------------------
655
+ # Step 6/7 — hire, compose, bring up
656
+ # ---------------------------------------------------------------------------
657
+ hire_all() {
658
+ info "Hiring agents"
659
+ while IFS="$SEP" read -r _h _model _inst; do
660
+ [ -n "$_h" ] || continue
661
+ _cmd=$(build_command "$_h" "$_model")
662
+ _show="$CLI nano hire --name $_h --rank senior --command '$_cmd' --model '$_model' --capabilities '' --protocol acp --permission yolo"
663
+ if run_as "$_show" "$CLI" nano hire \
664
+ --name "$_h" \
665
+ --rank senior \
666
+ --command "$_cmd" \
667
+ --model "$_model" \
668
+ --capabilities '' \
669
+ --protocol acp \
670
+ --permission yolo; then
671
+ ok "hired $_h"
672
+ else
673
+ warn "hire failed for $_h — continuing with the rest."
674
+ record_failure "$_h: hire failed"
675
+ fi
676
+ done <<EOF
677
+ $(printf '%s\n' "$SELECTIONS" | sed '/^$/d')
678
+ EOF
679
+ }
680
+
681
+ compose_workforce() {
682
+ info "Composing the workforce manifest"
683
+ while IFS="$SEP" read -r _h _model _inst; do
684
+ [ -n "$_h" ] || continue
685
+ if run "$CLI" nano workforce add "$_h" --instances "$_inst" --auto; then
686
+ ok "workforce add $_h ×$_inst"
687
+ else
688
+ warn "workforce add failed for $_h."
689
+ record_failure "$_h: workforce add failed"
690
+ fi
691
+ done <<EOF
692
+ $(printf '%s\n' "$SELECTIONS" | sed '/^$/d')
693
+ EOF
694
+ }
695
+
696
+ bring_up() {
697
+ info "Starting the engine"
698
+ if ! run "$CLI" nano start; then
699
+ err "'$CLI nano start' failed."
700
+ record_failure "engine: nano start failed"
701
+ return 1
702
+ fi
703
+ info "Starting the workforce"
704
+ if ! run "$CLI" nano workforce start; then
705
+ err "'$CLI nano workforce start' failed."
706
+ record_failure "workforce: nano workforce start failed"
707
+ return 1
708
+ fi
709
+ }
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Step 8 — output + hints
713
+ # ---------------------------------------------------------------------------
714
+ print_status_and_hints() {
715
+ info "Workforce status"
716
+ run "$CLI" nano workforce status || true
717
+
718
+ info "What now"
719
+ cat >&2 <<EOF
720
+ $CLI nano status engine health
721
+ $CLI nano workforce list the composed fleet (desired vs actual)
722
+ $CLI nano workforce add <profile> --instances N --auto grow the fleet, then 'workforce start'
723
+ $CLI nano workforce remove <profile> shrink it, then 'workforce start'
724
+ $CLI nano supervisor status per-worker state
725
+ $CLI nano supervisor logs <worker> --follow tail one worker's logs
726
+ $CLI nano workforce stop / $CLI nano stop shut the fleet / engine down
727
+ $CLI nano hire --list the hired profiles
728
+
729
+ Note: the Nano Workforce APP itself is not installed by this script yet — this
730
+ brings up the engine + a workforce of hired agents. See the README's "Run from
731
+ the CLI" section for installing/running the app.
732
+ EOF
733
+ }
734
+
735
+ # ---------------------------------------------------------------------------
736
+ # Partial-success report
737
+ # ---------------------------------------------------------------------------
738
+ report_and_exit() {
739
+ if [ -n "$FAILURES" ]; then
740
+ warn "Completed with some steps skipped or failed:"
741
+ printf '%s\n' "$FAILURES" | sed '/^$/d' | while IFS= read -r _f; do note "- $_f"; done
742
+ exit 1
743
+ fi
744
+ ok "Done — engine up, workforce of hired agents up."
745
+ exit 0
746
+ }
747
+
748
+ # ---------------------------------------------------------------------------
749
+ # main
750
+ # ---------------------------------------------------------------------------
751
+ main() {
752
+ parse_args "$@"
753
+ init_tty
754
+
755
+ # Decide interactive vs non-interactive up front.
756
+ _have_specs=0
757
+ [ -n "$(printf '%s' "$CLI_HARNESS_SPECS" | sed '/^$/d')" ] && _have_specs=1
758
+ if [ "$_have_specs" -eq 0 ] && [ -z "$TTY" ]; then
759
+ err "no controlling terminal (/dev/tty) and no --harness given — cannot prompt."
760
+ usage
761
+ exit 2
762
+ fi
763
+
764
+ preflight
765
+ install_cli
766
+ detect_harnesses
767
+
768
+ if [ "$_have_specs" -eq 1 ]; then
769
+ noninteractive_select
770
+ else
771
+ interactive_select
772
+ fi
773
+
774
+ confirm_or_die
775
+ hire_all
776
+ compose_workforce
777
+ bring_up || true
778
+ print_status_and_hints
779
+ report_and_exit
780
+ }
781
+
782
+ main "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.151.1",
3
+ "version": "0.152.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -0,0 +1,101 @@
1
+ #!/bin/sh
2
+ # Hermetic smoke test for install.sh — asserts the command sequence it EMITS
3
+ # under --dry-run for a few selections, plus the guard-rail exit codes. No nano
4
+ # CLI or coding harness/adapter needs to be installed: --dry-run means the
5
+ # mutating commands are printed (never run), harness detection is stubbed via
6
+ # NANO_INSTALL_HARNESSES_OVERRIDE, and adapter presence via
7
+ # NANO_INSTALL_ADAPTERS_PRESENT. Only `node` and `npm` are required (the
8
+ # preflight probe still runs), so run it where both are available.
9
+ #
10
+ # Run from the repo root: sh test/install-smoke.sh
11
+ set -eu
12
+
13
+ ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
14
+ SCRIPT="$ROOT/install.sh"
15
+ FAILED=0
16
+
17
+ pass() { printf 'PASS: %s\n' "$1"; }
18
+ fail() { printf 'FAIL: %s\n' "$1" >&2; FAILED=1; }
19
+
20
+ # assert_contains "<label>" "<needle>" "<haystack>"
21
+ assert_contains() {
22
+ case "$3" in
23
+ *"$2"*) pass "$1" ;;
24
+ *) fail "$1 — expected to find: $2"; printf '%s\n' "$3" | sed 's/^/ | /' >&2 ;;
25
+ esac
26
+ }
27
+ assert_not_contains() {
28
+ case "$3" in
29
+ *"$2"*) fail "$1 — did NOT expect: $2" ;;
30
+ *) pass "$1" ;;
31
+ esac
32
+ }
33
+
34
+ # --- Scenario 1: copilot + qwen, models + instances, dry-run ----------------
35
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot qwen" \
36
+ sh "$SCRIPT" --harness copilot:gpt-5.4:5 --harness qwen:qwen3-coder-plus:2 --yes --dry-run 2>&1)
37
+ assert_contains "s1: copilot model baked into --command" \
38
+ "--command 'copilot --acp --model gpt-5.4'" "$OUT"
39
+ assert_contains "s1: copilot --model bookkeeping" "--model 'gpt-5.4'" "$OUT"
40
+ assert_contains "s1: copilot hired senior/acp/yolo" \
41
+ "--rank senior --command 'copilot --acp --model gpt-5.4' --model 'gpt-5.4' --capabilities '' --protocol acp --permission yolo" "$OUT"
42
+ assert_contains "s1: qwen model baked (mandatory)" \
43
+ "--command 'qwen --experimental-acp --model qwen3-coder-plus'" "$OUT"
44
+ assert_contains "s1: workforce add copilot x5 auto" \
45
+ "nano workforce add copilot --instances 5 --auto" "$OUT"
46
+ assert_contains "s1: workforce add qwen x2 auto" \
47
+ "nano workforce add qwen --instances 2 --auto" "$OUT"
48
+ assert_contains "s1: engine start then workforce start" "nano start" "$OUT"
49
+ assert_contains "s1: workforce start" "nano workforce start" "$OUT"
50
+ assert_contains "s1: yolo warning shown" "UNATTENDED with --permission yolo" "$OUT"
51
+
52
+ # --- Scenario 2: qwen with NO model must fail (mandatory-model rule) ---------
53
+ if NANO_INSTALL_HARNESSES_OVERRIDE="qwen" \
54
+ sh "$SCRIPT" --harness qwen --yes --dry-run >/dev/null 2>&1; then
55
+ fail "s2: qwen without a model should exit non-zero"
56
+ else
57
+ pass "s2: qwen without a model exits non-zero"
58
+ fi
59
+
60
+ # --- Scenario 3: missing ACP adapter, non-interactive decline -> skip + rc1 --
61
+ set +e
62
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="claude copilot" NANO_INSTALL_ADAPTERS_PRESENT="" \
63
+ sh "$SCRIPT" --harness claude:opus:1 --harness copilot:gpt-5.4:2 --yes --dry-run 2>&1)
64
+ RC=$?
65
+ set -e
66
+ assert_contains "s3: claude skipped (missing adapter)" \
67
+ "skipping claude — ACP adapter 'claude-code-acp' not installed" "$OUT"
68
+ assert_not_contains "s3: claude NOT hired" "nano hire --name claude" "$OUT"
69
+ assert_contains "s3: copilot still hired" "nano hire --name copilot" "$OUT"
70
+ if [ "$RC" -ne 0 ]; then pass "s3: partial success exits non-zero"; else fail "s3: expected non-zero exit after a skip"; fi
71
+
72
+ # --- Scenario 4: no TTY + no --harness must not hang; exits non-zero --------
73
+ # install.sh reads prompts from /dev/tty, and `</dev/null` does not detach the
74
+ # controlling terminal — so from an interactive shell this scenario would open
75
+ # the real tty and hang. Only exercise it when there is no controlling TTY (CI),
76
+ # and skip it (still PASS) when run interactively.
77
+ if ( exec </dev/tty ) >/dev/null 2>&1; then
78
+ pass "s4: skipped (controlling TTY present; would hang interactively)"
79
+ else
80
+ set +e
81
+ sh "$SCRIPT" --dry-run </dev/null >/dev/null 2>&1
82
+ RC=$?
83
+ set -e
84
+ if [ "$RC" -ne 0 ]; then pass "s4: no TTY and no --harness exits non-zero"; else fail "s4: expected non-zero exit with no TTY and no flags"; fi
85
+ fi
86
+
87
+ # --- Scenario 5: default instances (5 then 1) and default model ------------
88
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot claude" NANO_INSTALL_ADAPTERS_PRESENT="claude-code-acp" \
89
+ sh "$SCRIPT" --harness copilot --harness claude --yes --dry-run 2>&1)
90
+ assert_contains "s5: first selection defaults to 5 instances" \
91
+ "nano workforce add copilot --instances 5 --auto" "$OUT"
92
+ assert_contains "s5: later selection defaults to 1 instance" \
93
+ "nano workforce add claude --instances 1 --auto" "$OUT"
94
+ assert_contains "s5: default model => bare adapter command, empty --model" \
95
+ "--command 'claude-code-acp' --model ''" "$OUT"
96
+
97
+ if [ "$FAILED" -ne 0 ]; then
98
+ printf '\nSMOKE TEST FAILED\n' >&2
99
+ exit 1
100
+ fi
101
+ printf '\nAll install.sh smoke assertions passed.\n'