@@ -96,7 +96,37 @@ def _maybe_run_onboard(target: Path, wizard_flags: list[str]) -> int:
9696# ---- subcommands -----------------------------------------------------
9797
9898def cmd_install (adapter_name : str , target : Path , wizard_flags : list [str ]) -> int :
99- """Install one adapter into target. Existing `./install.sh <adapter>` UX."""
99+ """Install one adapter into target. Existing `./install.sh <adapter>` UX.
100+
101+ Refuses on pre-v0.9 projects (no install.json) when STRONG adapter
102+ signals are already on disk — without this guard, the install would
103+ create a fresh install.json containing only the newly-installed
104+ adapter, orphaning every pre-v0.9 install (they'd vanish from
105+ status/doctor/remove even though their files remain on disk). The
106+ same gate cmd_add uses. Weak signals (plain CLAUDE.md, AGENTS.md,
107+ run.py) are ignored to avoid false-refusing clean repos that happen
108+ to contain one of those common files.
109+ """
110+ detected = state_mod .legacy_unregistered_adapters (target )
111+ if detected :
112+ # Pre-v0.9 project: brain is present AND adapter signals exist.
113+ # Refuse so doctor can synthesize install.json first and
114+ # preserve the prior install(s). Brain-without-signals is NOT
115+ # gated — cloning agentic-stack itself, or copying the brain
116+ # template before first install, shouldn't deadlock the user.
117+ print (
118+ f"error: { target } /.agent/ exists but install.json does not.\n "
119+ f"this looks like a pre-v0.9 install. detected adapters: { detected } \n "
120+ f"\n "
121+ f"run this first to register them safely:\n "
122+ f" ./install.sh doctor\n "
123+ f"\n "
124+ f"proceeding would otherwise create a fresh install.json with only\n "
125+ f"the new adapter, leaving the existing ones invisible to\n "
126+ f"status/doctor/remove." ,
127+ file = sys .stderr ,
128+ )
129+ return 2
100130 manifest = _adapter_manifest (adapter_name )
101131 install_mod .install (
102132 manifest = manifest ,
@@ -107,7 +137,51 @@ def cmd_install(adapter_name: str, target: Path, wizard_flags: list[str]) -> int
107137 # Propagate the onboarding wizard's exit code: Ctrl-C, exception, or
108138 # explicit failure inside onboard.py should fail the install command,
109139 # matching the pre-v0.9.0 `exec python3 onboard.py` semantics.
110- return _maybe_run_onboard (target , wizard_flags )
140+ rc = _maybe_run_onboard (target , wizard_flags )
141+ if rc != 0 :
142+ return rc
143+ # Post-install: offer the manage TUI so users who installed one
144+ # adapter can immediately add others without re-running install.sh.
145+ # Skip if --yes (scripted) or non-TTY (CI safety).
146+ if "--yes" not in wizard_flags and sys .stdin .isatty () and sys .stdout .isatty ():
147+ _maybe_offer_manage (target )
148+ return 0
149+
150+
151+ def _maybe_offer_manage (target : Path ) -> None :
152+ """Offer the manage TUI after a single-adapter install.
153+
154+ Only invoked from cmd_install when the shell is interactive and the
155+ user didn't pass --yes. If every available adapter is already
156+ installed, skip the prompt — nothing useful to do in the TUI. Default
157+ is no, so just hitting enter dismisses without entering the TUI.
158+ """
159+ doc = state_mod .load (target ) or {}
160+ installed = set (doc .get ("adapters" ) or {})
161+ available = set ()
162+ root = _stack_root () / "adapters"
163+ if root .is_dir ():
164+ for p in root .iterdir ():
165+ if p .is_dir () and (p / "adapter.json" ).is_file ():
166+ available .add (p .name )
167+ not_installed = sorted (available - installed )
168+ if not not_installed :
169+ return
170+ sys .path .insert (0 , str (_stack_root ()))
171+ import onboard_widgets as widgets # noqa: E402
172+ print ()
173+ try :
174+ choice = widgets .ask_confirm (
175+ f"install or manage other adapters? ({ len (not_installed )} available)" ,
176+ default = False ,
177+ )
178+ except KeyboardInterrupt :
179+ # Install already succeeded; treat Ctrl-C at the offer as "no."
180+ print ()
181+ return
182+ if choice :
183+ from . import manage_tui
184+ manage_tui .run (target_root = target , stack_root = _stack_root ())
111185
112186
113187def cmd_add (adapter_name : str , target : Path ) -> int :
@@ -119,28 +193,21 @@ def cmd_add(adapter_name: str, target: Path) -> int:
119193 disappear from status/doctor/remove tracking even though their files
120194 are still on disk.
121195 """
122- if state_mod .load (target ) is None :
123- # Pre-v0.9 project. Detect adapters and refuse with a clear path forward.
124- from . import doctor as doctor_mod
125- signals_present = []
126- for name , signals in doctor_mod .DETECT_SIGNALS .items ():
127- if any ((target / f ).exists () for f , _ in signals ):
128- signals_present .append (name )
129- if signals_present :
130- print (
131- f"error: { target } /.agent/install.json not present, but these adapters\n "
132- f"appear to already be installed: { sorted (signals_present )} \n "
133- f"\n "
134- f"run this first to register them safely:\n "
135- f" ./install.sh doctor\n "
136- f"\n "
137- f"`add` would otherwise create a fresh install.json with only\n "
138- f"the new adapter, leaving the existing ones invisible to\n "
139- f"status/doctor/remove." ,
140- file = sys .stderr ,
141- )
142- return 2
143- # No prior install detected; safe to proceed (`add` on a clean repo).
196+ detected = state_mod .legacy_unregistered_adapters (target )
197+ if detected :
198+ print (
199+ f"error: { target } /.agent/ exists but install.json does not.\n "
200+ f"this looks like a pre-v0.9 install. detected adapters: { detected } \n "
201+ f"\n "
202+ f"run this first to register them safely:\n "
203+ f" ./install.sh doctor\n "
204+ f"\n "
205+ f"`add` would otherwise create a fresh install.json with only\n "
206+ f"the new adapter, leaving the existing ones invisible to\n "
207+ f"status/doctor/remove." ,
208+ file = sys .stderr ,
209+ )
210+ return 2
144211 manifest = _adapter_manifest (adapter_name )
145212 install_mod .install (
146213 manifest = manifest ,
@@ -180,43 +247,151 @@ def cmd_manage(target: Path) -> int:
180247 return manage_tui .run (target_root = target , stack_root = _stack_root ())
181248
182249
183- def cmd_bare (target : Path ) -> int :
250+ def cmd_bare (target : Path , wizard_flags : list [ str ] ) -> int :
184251 """`./install.sh` with no args.
185252
186- If install.json present: dispatch to add mode (offer adapters not yet
187- installed). If not: print usage and exit non-zero.
253+ Behavior:
254+ - install.json present → list what's still installable
255+ - no install.json + TTY → enter the onboarding wizard (multi-select
256+ harness step, then per-adapter install, then PREFERENCES.md flow)
257+ - no install.json + non-TTY → print usage and exit 2 (CI safety)
188258 """
189259 doc = state_mod .load (target )
190- if doc is None :
191- print ("usage: ./install.sh <adapter-name> [target-dir]" )
192- print (f"adapters: { _list_adapters ()} " )
260+ if doc is not None :
261+ installed = set (doc .get ("adapters" , {}).keys ())
262+ available = set ()
263+ root = _stack_root () / "adapters"
264+ if root .is_dir ():
265+ for p in root .iterdir ():
266+ if p .is_dir () and (p / "adapter.json" ).is_file ():
267+ available .add (p .name )
268+ not_installed = sorted (available - installed )
269+ if not not_installed :
270+ print (f"all available adapters already installed: { sorted (installed )} " )
271+ print ("run `./install.sh status` for a summary." )
272+ return 0
273+ print (f"already installed: { sorted (installed )} " )
274+ print (f"available to add: { not_installed } " )
193275 print ()
194- print ("on a project that's already installed, run:" )
195- print (" ./install.sh doctor # audit" )
196- print (" ./install.sh status # quick read-only view" )
197- print (" ./install.sh add <name> # install another adapter" )
198- print (" ./install.sh remove <name> # remove an adapter (with confirm)" )
199- print (" ./install.sh manage # interactive TUI for everything" )
276+ print (f"to add one: ./install.sh add <name>" )
277+ print (f"or interactively: ./install.sh manage" )
278+ return 0
279+
280+ # No install.json. Pre-v0.9 migration gate: if this is a legacy
281+ # install (brain AND adapter signals present) we must register
282+ # via doctor first. Brain-only (no signals) falls through to the
283+ # wizard — someone dropped the template here but never installed
284+ # anything, so there's nothing to migrate.
285+ detected = state_mod .legacy_unregistered_adapters (target )
286+ if detected :
287+ print (
288+ f"pre-v0.9 install detected at { target } ." ,
289+ file = sys .stderr ,
290+ )
291+ print (
292+ f".agent/ exists but install.json does not. detected adapters: "
293+ f"{ detected } " ,
294+ file = sys .stderr ,
295+ )
296+ print (file = sys .stderr )
297+ print (
298+ "run this first to register them safely:" , file = sys .stderr ,
299+ )
300+ print (" ./install.sh doctor" , file = sys .stderr )
301+ print (file = sys .stderr )
302+ print (
303+ "then re-run ./install.sh to add more adapters or use "
304+ "./install.sh manage." ,
305+ file = sys .stderr ,
306+ )
200307 return 2
201308
202- installed = set (doc .get ("adapters" , {}).keys ())
203- available = set ()
204- root = _stack_root () / "adapters"
205- if root .is_dir ():
206- for p in root .iterdir ():
207- if p .is_dir () and (p / "adapter.json" ).is_file ():
208- available .add (p .name )
209- not_installed = sorted (available - installed )
210- if not not_installed :
211- print (f"all available adapters already installed: { sorted (installed )} " )
212- print ("run `./install.sh status` for a summary." )
213- return 0
309+ # No install.json and not a legacy install — fresh project. Two paths.
310+ if sys .stdin .isatty () and sys .stdout .isatty ():
311+ return _run_install_wizard (target , wizard_flags )
214312
215- print (f"already installed: { sorted (installed )} " )
216- print (f"available to add: { not_installed } " )
313+ # Non-TTY (CI, scripted) → print usage, exit 2.
314+ print ("usage: ./install.sh <adapter-name> [target-dir]" )
315+ print (f"adapters: { _list_adapters ()} " )
217316 print ()
218- print (f"to add one: ./install.sh add <name>" )
219- return 0
317+ print ("on a project that's already installed, run:" )
318+ print (" ./install.sh doctor # audit" )
319+ print (" ./install.sh status # quick read-only view" )
320+ print (" ./install.sh add <name> # install another adapter" )
321+ print (" ./install.sh remove <name> # remove an adapter (with confirm)" )
322+ print (" ./install.sh manage # interactive TUI for everything" )
323+ return 2
324+
325+
326+ def _run_install_wizard (target : Path , wizard_flags : list [str ]) -> int :
327+ """Onboarding wizard: detect → multi-select → install each → PREFERENCES.md.
328+
329+ The original "give people options like Claude Code and all of that"
330+ flow. Reuses the manage TUI's multi-select widget for harness pick.
331+ Auto-checks adapters whose detection signals are present in `target`,
332+ so a user who already has CLAUDE.md / .cursor/ in their repo gets
333+ those pre-selected.
334+ """
335+ # Lazy imports — wizard path only.
336+ sys .path .insert (0 , str (_stack_root ()))
337+ import onboard_widgets as widgets # noqa: E402
338+ from onboard_ui import print_banner , intro , R , MUTED , GREEN # noqa: E402
339+ from . import doctor as doctor_mod
340+
341+ print_banner ()
342+ intro ("agentic-stack onboarding" )
343+
344+ # Discover all available adapters.
345+ available = sorted (n for n , _ in schema_mod .discover_all (_stack_root ()))
346+ if not available :
347+ print (f" { MUTED } no adapters available — repo seems empty{ R } " )
348+ return 1
349+
350+ # Auto-detect what's already on disk in the target. Only STRONG
351+ # signals count for default-check — a weak signal like a generic
352+ # CLAUDE.md, AGENTS.md, or run.py can belong to any project; pre-
353+ # checking one and hitting Enter at the multiselect would silently
354+ # overwrite the user's file (claude-code's CLAUDE.md is
355+ # merge_policy: overwrite). Weak-only matches still surface to the
356+ # user via the adapter list but stay unchecked until toggled.
357+ detected = set ()
358+ for name in available :
359+ signals = doctor_mod .DETECT_SIGNALS .get (name , [])
360+ if any (
361+ (target / f ).exists ()
362+ for f , strength in signals
363+ if strength == "strong"
364+ ):
365+ detected .add (name )
366+ defaults = [available .index (n ) for n in available if n in detected ]
367+
368+ if detected :
369+ print (f" { GREEN } detected{ R } : { sorted (detected )} — pre-checked below." )
370+ print ()
371+
372+ chosen = widgets .ask_multiselect (
373+ "which harnesses are you using?" ,
374+ available ,
375+ defaults = defaults ,
376+ )
377+ if not chosen :
378+ print (f" { MUTED } no adapters selected; brain not installed.{ R } " )
379+ print (f" { MUTED } you can run `./install.sh <adapter>` later.{ R } " )
380+ return 0
381+
382+ # Install each selected adapter via the manifest backend.
383+ for name in chosen :
384+ manifest_path = _stack_root () / "adapters" / name / "adapter.json"
385+ manifest = schema_mod .validate (manifest_path )
386+ install_mod .install (
387+ manifest = manifest ,
388+ target_root = target ,
389+ adapter_dir = _stack_root () / "adapters" / name ,
390+ stack_root = _stack_root (),
391+ )
392+
393+ # Continue to existing PREFERENCES.md flow.
394+ return _maybe_run_onboard (target , wizard_flags )
220395
221396
222397# ---- main ------------------------------------------------------------
@@ -246,7 +421,7 @@ def main(argv: list[str] | None = None) -> int:
246421
247422 if not rest :
248423 target = Path .cwd ()
249- return cmd_bare (target )
424+ return cmd_bare (target , wizard_flags )
250425
251426 first = rest [0 ]
252427
0 commit comments