agnostic-prompt-aps 1.1.5__py3-none-any.whl → 1.1.7__py3-none-any.whl

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.
aps_cli/cli.py CHANGED
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from dataclasses import dataclass
4
5
  from pathlib import Path
5
- from typing import Optional
6
+ from typing import Literal, Optional
6
7
 
7
8
  import questionary
8
9
  import typer
@@ -11,34 +12,239 @@ from rich.table import Table
11
12
 
12
13
  from . import __version__
13
14
  from .core import (
14
- SKILL_ID,
15
+ AdapterDetection,
16
+ Platform,
17
+ compute_skill_destinations,
15
18
  copy_dir,
16
19
  copy_template_tree,
17
20
  default_personal_skill_path,
18
21
  default_project_skill_path,
22
+ detect_adapters,
19
23
  ensure_dir,
20
24
  find_repo_root,
21
- infer_platform_id,
25
+ format_detection_label,
26
+ is_claude_platform,
22
27
  is_tty,
28
+ list_files_recursive,
23
29
  load_platforms,
30
+ pick_workspace_root,
24
31
  remove_dir,
25
32
  resolve_payload_skill_dir,
33
+ sort_platforms_for_ui,
34
+ SKILL_ID,
26
35
  )
27
36
 
28
37
  app = typer.Typer(add_completion=False)
29
38
  console = Console()
30
39
 
31
40
 
32
- def _norm_platform(platform: Optional[str]) -> str:
33
- if not platform:
34
- return ""
35
- if platform.lower() == "none":
36
- return "none"
37
- return platform
41
+ @app.callback(invoke_without_command=True)
42
+ def _root(
43
+ ctx: typer.Context,
44
+ version: bool = typer.Option(
45
+ False,
46
+ "--version",
47
+ help="Print CLI version",
48
+ is_eager=True,
49
+ ),
50
+ ) -> None:
51
+ """Root command for the APS CLI."""
52
+
53
+ if version:
54
+ # Match Node: `aps --version` prints version and exits.
55
+ typer.echo(__version__)
56
+ raise typer.Exit()
57
+
58
+ # Match Node: invoking `aps` with no subcommand prints help and exits with code 2.
59
+ if ctx.invoked_subcommand is None:
60
+ typer.echo(ctx.get_help(), err=True)
61
+ raise typer.Exit(code=2)
62
+
63
+ InstallScope = Literal["repo", "personal"]
64
+
65
+
66
+ def _normalize_platform_args(platforms: Optional[list[str]]) -> Optional[list[str]]:
67
+ """Normalize platform arguments, handling 'none' and comma-separated values."""
68
+ if not platforms:
69
+ return None
70
+
71
+ # Flatten comma-separated values
72
+ raw: list[str] = []
73
+ for v in platforms:
74
+ raw.extend(s.strip() for s in v.split(",") if s.strip())
75
+
76
+ if any(v.lower() == "none" for v in raw):
77
+ return []
78
+
79
+ # Deduplicate while preserving order
80
+ seen: set[str] = set()
81
+ out: list[str] = []
82
+ for v in raw:
83
+ if v not in seen:
84
+ seen.add(v)
85
+ out.append(v)
86
+ return out
87
+
88
+
89
+ def _fmt_path(p: Path) -> str:
90
+ """Format path for display, replacing home with ~."""
91
+ home = str(Path.home())
92
+ s = str(p)
93
+ if s.startswith(home):
94
+ return "~" + s[len(home) :]
95
+ return s
96
+
97
+
98
+ def _select_all_choice_label() -> str:
99
+ return "Select all adapters"
100
+
101
+
102
+ def _platform_display_name(platform: Platform) -> str:
103
+ """Get display name for platform in UI."""
104
+ return f"{platform.display_name} ({platform.platform_id})"
105
+
106
+
107
+ def _detection_for(
108
+ platform_id: str, detections: dict[str, AdapterDetection]
109
+ ) -> Optional[AdapterDetection]:
110
+ """Get detection result for a platform ID."""
111
+ return detections.get(platform_id)
112
+
113
+
114
+ @dataclass
115
+ class PlannedTemplateFile:
116
+ rel_path: str
117
+ dst_path: Path
118
+ exists: bool
119
+ will_write: bool
120
+
38
121
 
122
+ @dataclass
123
+ class PlannedPlatformTemplates:
124
+ platform_id: str
125
+ templates_dir: Path
126
+ template_root: Path
127
+ files: list[PlannedTemplateFile]
39
128
 
40
- def _is_claude_platform(platform_id: str) -> bool:
41
- return platform_id == "claude-code"
129
+
130
+ @dataclass
131
+ class PlannedSkillInstall:
132
+ dst: Path
133
+ exists: bool
134
+
135
+
136
+ @dataclass
137
+ class InitPlan:
138
+ scope: InstallScope
139
+ workspace_root: Optional[Path]
140
+ selected_platforms: list[str]
141
+ payload_skill_dir: Path
142
+ skills: list[PlannedSkillInstall]
143
+ templates: list[PlannedPlatformTemplates]
144
+
145
+
146
+ def _plan_platform_templates(
147
+ payload_skill_dir: Path,
148
+ scope: InstallScope,
149
+ workspace_root: Optional[Path],
150
+ selected_platforms: list[str],
151
+ force: bool,
152
+ ) -> list[PlannedPlatformTemplates]:
153
+ """Plan template files to be copied for selected platforms."""
154
+ template_root = Path.home() if scope == "personal" else workspace_root
155
+ if not template_root:
156
+ return []
157
+
158
+ plans: list[PlannedPlatformTemplates] = []
159
+
160
+ for platform_id in selected_platforms:
161
+ templates_dir = payload_skill_dir / "platforms" / platform_id / "templates"
162
+ if not templates_dir.is_dir():
163
+ continue
164
+
165
+ all_files = list_files_recursive(templates_dir)
166
+
167
+ def filter_fn(rel_path: str) -> bool:
168
+ # Skip .github/** for personal installs
169
+ if scope == "personal" and rel_path.startswith(".github"):
170
+ return False
171
+ return True
172
+
173
+ files: list[PlannedTemplateFile] = []
174
+ for src in all_files:
175
+ rel_path = str(src.relative_to(templates_dir)).replace("\\", "/")
176
+ if not filter_fn(rel_path):
177
+ continue
178
+
179
+ dst_path = template_root / rel_path
180
+ exists = dst_path.exists()
181
+ files.append(
182
+ PlannedTemplateFile(
183
+ rel_path=rel_path,
184
+ dst_path=dst_path,
185
+ exists=exists,
186
+ will_write=not exists or force,
187
+ )
188
+ )
189
+
190
+ plans.append(
191
+ PlannedPlatformTemplates(
192
+ platform_id=platform_id,
193
+ templates_dir=templates_dir,
194
+ template_root=template_root,
195
+ files=files,
196
+ )
197
+ )
198
+
199
+ return plans
200
+
201
+
202
+ def _render_plan(plan: InitPlan, force: bool) -> str:
203
+ """Render plan as human-readable text."""
204
+ lines: list[str] = []
205
+
206
+ lines.append("Selected adapters:")
207
+ if not plan.selected_platforms:
208
+ lines.append(" (none)")
209
+ else:
210
+ for p in plan.selected_platforms:
211
+ lines.append(f" - {p}")
212
+ lines.append("")
213
+
214
+ lines.append("Skill install destinations:")
215
+ for s in plan.skills:
216
+ status = (
217
+ "overwrite" if s.exists and force else "overwrite (needs confirmation)" if s.exists else "create"
218
+ )
219
+ lines.append(f" - {_fmt_path(s.dst)} [{status}]")
220
+ lines.append("")
221
+
222
+ if not plan.templates:
223
+ lines.append("Platform templates: (none)")
224
+ return "\n".join(lines)
225
+
226
+ lines.append("Platform templates:")
227
+ for t in plan.templates:
228
+ will_write = sum(1 for f in t.files if f.will_write)
229
+ skipped = len(t.files) - will_write
230
+ skip_msg = f", {skipped} skipped (exists)" if skipped > 0 else ""
231
+ lines.append(f" - {t.platform_id}: {will_write} file(s) to write{skip_msg}")
232
+
233
+ preview = [f for f in t.files if f.will_write][:30]
234
+ for f in preview:
235
+ lines.append(f" {f.rel_path}")
236
+ if will_write > 30:
237
+ lines.append(" ...")
238
+
239
+ return "\n".join(lines)
240
+
241
+
242
+ def _render_empty_platform_warning() -> str:
243
+ """Return warning message for empty platform selection."""
244
+ return (
245
+ "Note: No platform adapters selected. Only the APS skill will be installed.\n"
246
+ " Templates will not be copied. Use --platform <id> to include platform templates."
247
+ )
42
248
 
43
249
 
44
250
  @app.command()
@@ -48,170 +254,344 @@ def init(
48
254
  "--root",
49
255
  help="Workspace root to install project skill under (defaults to repo root or cwd)",
50
256
  ),
51
- repo: bool = typer.Option(False, "--repo", help="Force install as project skill (workspace/.github/skills or workspace/.claude/skills)"),
52
- personal: bool = typer.Option(False, "--personal", help="Force install as personal skill (~/.copilot/skills or ~/.claude/skills)"),
53
- platform: Optional[str] = typer.Option(None, "--platform", help='Platform adapter to apply (default: inferred; use "none" for skill only)'),
54
- yes: bool = typer.Option(False, "--yes", help="Non-interactive: accept inferred/default choices"),
55
- force: bool = typer.Option(False, "--force", help="Overwrite existing skill"),
56
- dry_run: bool = typer.Option(False, "--dry-run", help="Print the plan only, do not write files"),
257
+ repo: bool = typer.Option(
258
+ False,
259
+ "--repo",
260
+ help="Force install as project skill (workspace/.github/skills or workspace/.claude/skills)",
261
+ ),
262
+ personal: bool = typer.Option(
263
+ False,
264
+ "--personal",
265
+ help="Force install as personal skill (~/.copilot/skills or ~/.claude/skills)",
266
+ ),
267
+ platform: Optional[list[str]] = typer.Option(
268
+ None,
269
+ "--platform",
270
+ help='Platform adapter(s) to apply (e.g. vscode-copilot, claude-code). Use "none" to skip platform templates.',
271
+ ),
272
+ yes: bool = typer.Option(
273
+ False, "--yes", "-y", help="Non-interactive: accept inferred/default choices"
274
+ ),
275
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"),
276
+ dry_run: bool = typer.Option(
277
+ False, "--dry-run", help="Print the plan only, do not write files"
278
+ ),
57
279
  ):
58
280
  """Install APS into a repo (.github/skills/...) or as a personal skill (~/.copilot/skills/...)."""
59
281
 
60
- if repo and personal:
61
- raise typer.BadParameter("Use at most one of --repo or --personal")
62
-
63
282
  payload_skill_dir = resolve_payload_skill_dir()
64
-
65
283
  repo_root = find_repo_root(Path.cwd())
66
- workspace_root = Path(root).expanduser().resolve() if root else (repo_root or Path.cwd().resolve())
284
+ guessed_workspace_root = pick_workspace_root(root)
285
+
286
+ platforms = load_platforms(payload_skill_dir)
287
+ platforms = sort_platforms_for_ui(platforms)
288
+ platforms_by_id = {p.platform_id: p for p in platforms}
289
+ available_platform_ids = [p.platform_id for p in platforms]
290
+
291
+ detections = (
292
+ detect_adapters(guessed_workspace_root, platforms)
293
+ if guessed_workspace_root
294
+ else {}
295
+ )
67
296
 
68
- inferred_platform = infer_platform_id(workspace_root)
69
- platform_id = _norm_platform(platform) or (inferred_platform or "")
297
+ cli_platforms = _normalize_platform_args(platform)
298
+
299
+ # Determine platform selection
300
+ selected_platforms: list[str] = []
301
+
302
+ if cli_platforms is not None:
303
+ selected_platforms = cli_platforms
304
+ elif not yes and is_tty():
305
+ choices = [
306
+ questionary.Choice(title=_select_all_choice_label(), value="__all__")
307
+ ]
308
+ for platform_id in available_platform_ids:
309
+ det = _detection_for(platform_id, detections)
310
+ label = format_detection_label(det) if det else ""
311
+ p = platforms_by_id[platform_id]
312
+ checked = bool(det and det.detected)
313
+ choices.append(
314
+ questionary.Choice(
315
+ title=f"{_platform_display_name(p)}{label}",
316
+ value=platform_id,
317
+ checked=checked,
318
+ )
319
+ )
320
+
321
+ picked = questionary.checkbox(
322
+ "Select platform adapters to apply (press <space> to select, <a> to toggle all):",
323
+ choices=choices,
324
+ ).ask()
325
+
326
+ if picked is None:
327
+ raise typer.Abort()
328
+
329
+ has_all = "__all__" in picked
330
+ picked_platforms = [p for p in picked if p != "__all__"]
331
+
332
+ if has_all and not picked_platforms:
333
+ selected_platforms = list(available_platform_ids)
334
+ else:
335
+ selected_platforms = picked_platforms
336
+ else:
337
+ # Non-interactive defaults
338
+ if yes and detections:
339
+ selected_platforms = [
340
+ pid for pid, det in detections.items() if det.detected
341
+ ]
342
+ else:
343
+ selected_platforms = []
70
344
 
71
- install_scope = "repo" if repo else "personal" if personal else ("repo" if repo_root else "personal")
345
+ # Determine scope
346
+ install_scope: InstallScope = (
347
+ "personal" if personal else "repo" if repo else ("repo" if repo_root else "personal")
348
+ )
349
+ workspace_root = guessed_workspace_root
72
350
 
73
351
  if not yes and is_tty():
74
- # Platform selection (first, so platform choice can inform installation location)
75
- if not platform:
76
- platforms = load_platforms(payload_skill_dir)
77
- choices = [questionary.Choice(title="None (skill only)", value="none")]
78
- if inferred_platform:
79
- choices.insert(0, questionary.Choice(title=f"Auto-detected: {inferred_platform}", value=inferred_platform))
80
- for p in platforms:
81
- if p.platform_id == inferred_platform:
82
- continue
83
- choices.append(questionary.Choice(title=f"{p.display_name} ({p.platform_id})", value=p.platform_id))
84
-
85
- platform_id = questionary.select("Select a platform adapter to apply:", choices=choices, default=inferred_platform or "none").ask()
86
- assert isinstance(platform_id, str)
87
-
88
- # Scope
89
352
  if not (repo or personal):
90
- claude = _is_claude_platform(platform_id)
91
- install_scope = questionary.select(
353
+ # Compute likely destinations for display
354
+ personal_bases: set[str] = set()
355
+ wants_claude = any(is_claude_platform(p) for p in selected_platforms)
356
+ wants_non_claude = (
357
+ any(not is_claude_platform(p) for p in selected_platforms)
358
+ or not selected_platforms
359
+ )
360
+ if wants_non_claude:
361
+ base = str(default_personal_skill_path(claude=False)).replace(
362
+ SKILL_ID, ""
363
+ )
364
+ personal_bases.add(_fmt_path(Path(base)))
365
+ if wants_claude:
366
+ base = str(default_personal_skill_path(claude=True)).replace(
367
+ SKILL_ID, ""
368
+ )
369
+ personal_bases.add(_fmt_path(Path(base)))
370
+
371
+ scope_answer = questionary.select(
92
372
  "Where should APS be installed?",
93
373
  choices=[
94
374
  questionary.Choice(
95
- title=f"Project skill in this repo ({repo_root})" if repo_root else "Project skill (choose workspace)",
375
+ title=(
376
+ f"Project skill in this repo ({_fmt_path(repo_root)})"
377
+ if repo_root
378
+ else "Project skill (choose a workspace folder)"
379
+ ),
96
380
  value="repo",
97
381
  ),
98
382
  questionary.Choice(
99
- title=f"Personal skill ({default_personal_skill_path(claude=claude)})",
383
+ title=f"Personal skill for your user ({', '.join(sorted(personal_bases))})",
100
384
  value="personal",
101
385
  ),
102
386
  ],
103
387
  default="repo" if repo_root else "personal",
104
388
  ).ask()
105
- assert isinstance(install_scope, str)
389
+ assert scope_answer in ("repo", "personal")
390
+ install_scope = scope_answer
106
391
 
107
- # Workspace root (only necessary for repo scope)
108
- if install_scope == "repo" and (not root and not repo_root):
109
- root_answer = questionary.text("Workspace root path:", default=str(workspace_root)).ask()
110
- workspace_root = Path(str(root_answer)).expanduser().resolve()
392
+ if install_scope == "repo" and not workspace_root:
393
+ root_answer = questionary.text(
394
+ "Workspace root path (the folder that contains .github/):",
395
+ default=str(Path.cwd()),
396
+ ).ask()
397
+ workspace_root = Path(root_answer).expanduser().resolve()
111
398
 
112
- force = questionary.confirm("Overwrite existing files if they exist?", default=force).ask()
113
- dry_run = questionary.confirm("Dry run (print plan only)?", default=dry_run).ask()
399
+ if install_scope == "repo" and not workspace_root:
400
+ raise typer.BadParameter(
401
+ "Repo install selected but no workspace root found. Run in a git repo or pass --root <path>."
402
+ )
114
403
 
115
404
  # Compute destinations
116
- claude = _is_claude_platform(platform_id)
117
- skill_dest = (
118
- default_project_skill_path(workspace_root, claude=claude)
119
- if install_scope == "repo"
120
- else default_personal_skill_path(claude=claude)
405
+ skill_dests = compute_skill_destinations(
406
+ install_scope, workspace_root, selected_platforms
121
407
  )
408
+ skills = [
409
+ PlannedSkillInstall(dst=dst, exists=dst.exists()) for dst in skill_dests
410
+ ]
122
411
 
123
- # Determine template source if platform is set
124
- templates_dir = None
125
- template_root = None
126
- if platform_id and platform_id != "none":
127
- templates_dir = payload_skill_dir / "platforms" / platform_id / "templates"
128
- if templates_dir.is_dir():
129
- template_root = Path.home() if install_scope == "personal" else workspace_root
130
- else:
131
- templates_dir = None
132
-
133
- plan = {
134
- "install_scope": install_scope,
135
- "workspace_root": str(workspace_root),
136
- "platform_id": platform_id or None,
137
- "claude": bool(claude),
138
- "skill_source": str(payload_skill_dir),
139
- "skill_dest": str(skill_dest),
140
- "templates_source": str(templates_dir) if templates_dir else None,
141
- "templates_dest": str(template_root) if template_root else None,
142
- "force": bool(force),
143
- }
144
-
145
- if dry_run:
146
- console.print_json(json.dumps({"plan": plan}, indent=2))
147
- raise typer.Exit(code=0)
412
+ # Plan templates
413
+ templates = _plan_platform_templates(
414
+ payload_skill_dir, install_scope, workspace_root, selected_platforms, force
415
+ )
148
416
 
149
- # Install skill
150
- if skill_dest.exists():
151
- if not force:
152
- raise typer.BadParameter(f"Destination already exists: {skill_dest}. Re-run with --force.")
153
- remove_dir(skill_dest)
417
+ plan = InitPlan(
418
+ scope=install_scope,
419
+ workspace_root=workspace_root,
420
+ selected_platforms=selected_platforms,
421
+ payload_skill_dir=payload_skill_dir,
422
+ skills=skills,
423
+ templates=templates,
424
+ )
154
425
 
155
- ensure_dir(skill_dest.parent)
156
- copy_dir(payload_skill_dir, skill_dest)
426
+ if dry_run:
427
+ console.print("Dry run — planned actions:\n")
428
+ console.print(_render_plan(plan, force))
429
+ if not plan.selected_platforms:
430
+ console.print()
431
+ console.print(_render_empty_platform_warning())
432
+ return
157
433
 
158
- console.print("[green]APS installed.[/green]")
159
- console.print(f" Skill: {skill_dest}")
434
+ if not yes and is_tty():
435
+ console.print(_render_plan(plan, force))
436
+ console.print()
437
+
438
+ if not plan.selected_platforms:
439
+ console.print(_render_empty_platform_warning())
440
+ console.print()
441
+
442
+ if any(s.exists for s in skills) and not force:
443
+ console.print(
444
+ "Note: One or more skill destinations already exist. Confirming will overwrite them."
445
+ )
446
+
447
+ ok = questionary.confirm("Proceed with these changes?", default=False).ask()
448
+ if not ok:
449
+ console.print("Cancelled.")
450
+ return
451
+ else:
452
+ # Non-interactive: refuse to overwrite without --force
453
+ conflicts = [s for s in skills if s.exists]
454
+ if conflicts and not force:
455
+ first = conflicts[0]
456
+ raise typer.BadParameter(
457
+ f"Destination exists: {first.dst} (use --force to overwrite)"
458
+ )
459
+
460
+ # Execute skill copies
461
+ for s in skills:
462
+ if s.exists:
463
+ if force or (is_tty() and not yes):
464
+ remove_dir(s.dst)
465
+
466
+ ensure_dir(s.dst.parent)
467
+ copy_dir(payload_skill_dir, s.dst)
468
+ console.print(f"Installed APS skill -> {s.dst}")
469
+
470
+ # Copy templates
471
+ for t in templates:
160
472
 
161
- # Copy templates (if platform has templates)
162
- if templates_dir and template_root:
163
473
  def filter_fn(rel_path: str) -> bool:
164
- # Skip .github/** for personal installs (shouldn't put .github in home dir)
165
474
  if install_scope == "personal" and rel_path.startswith(".github"):
166
475
  return False
167
476
  return True
168
477
 
169
478
  copied = copy_template_tree(
170
- templates_dir,
171
- template_root,
479
+ t.templates_dir,
480
+ t.template_root,
172
481
  force=force,
173
482
  filter_fn=filter_fn,
174
483
  )
484
+
175
485
  if copied:
176
- console.print(f" Installed {len(copied)} template file(s):")
486
+ console.print(
487
+ f"Installed {len(copied)} template file(s) for {t.platform_id}:"
488
+ )
177
489
  for f in copied:
178
- console.print(f" - {f}")
490
+ console.print(f" - {f}")
491
+
492
+ console.print("\nNext steps:")
493
+ console.print("- Ensure your IDE has Agent Skills enabled as needed.")
494
+ for d in skill_dests:
495
+ console.print(f"- Skill location: {d}")
179
496
 
180
497
 
181
498
  @app.command()
182
- def doctor(json_out: bool = typer.Option(False, "--json", help="Machine-readable output")):
183
- """Check whether APS is installed and infer platform settings."""
184
- repo_root = find_repo_root(Path.cwd())
185
- workspace_root = repo_root or Path.cwd().resolve()
499
+ def doctor(
500
+ root: Optional[str] = typer.Option(
501
+ None,
502
+ "--root",
503
+ help="Workspace root path (defaults to git repo root if found)",
504
+ ),
505
+ json_out: bool = typer.Option(False, "--json", help="Output JSON format"),
506
+ ):
507
+ """Check APS installation status + basic platform detection."""
508
+ workspace_root = pick_workspace_root(root)
509
+
510
+ payload_skill_dir = resolve_payload_skill_dir()
511
+ platforms = sort_platforms_for_ui(load_platforms(payload_skill_dir))
512
+ detected_adapters = (
513
+ detect_adapters(workspace_root, platforms) if workspace_root else None
514
+ )
515
+
516
+ # Build installations list matching Node structure
517
+ installations: list[dict] = []
518
+
519
+ if workspace_root:
520
+ repo_skill = default_project_skill_path(workspace_root, claude=False)
521
+ repo_skill_claude = default_project_skill_path(workspace_root, claude=True)
522
+ installations.append(
523
+ {
524
+ "scope": "repo",
525
+ "path": str(repo_skill),
526
+ "installed": (repo_skill / "SKILL.md").exists(),
527
+ }
528
+ )
529
+ installations.append(
530
+ {
531
+ "scope": "repo (claude)",
532
+ "path": str(repo_skill_claude),
533
+ "installed": (repo_skill_claude / "SKILL.md").exists(),
534
+ }
535
+ )
186
536
 
187
- # Check both Copilot and Claude locations.
188
- project_skill = default_project_skill_path(workspace_root, claude=False)
189
- project_skill_claude = default_project_skill_path(workspace_root, claude=True)
190
537
  personal_skill = default_personal_skill_path(claude=False)
191
538
  personal_skill_claude = default_personal_skill_path(claude=True)
539
+ installations.append(
540
+ {
541
+ "scope": "personal",
542
+ "path": str(personal_skill),
543
+ "installed": (personal_skill / "SKILL.md").exists(),
544
+ }
545
+ )
546
+ installations.append(
547
+ {
548
+ "scope": "personal (claude)",
549
+ "path": str(personal_skill_claude),
550
+ "installed": (personal_skill_claude / "SKILL.md").exists(),
551
+ }
552
+ )
192
553
 
193
- out = {
194
- "cwd": str(Path.cwd()),
195
- "repo_root": str(repo_root) if repo_root else None,
196
- "inferred_platform": infer_platform_id(workspace_root),
197
- "project_skill": {"path": str(project_skill), "installed": (project_skill / "SKILL.md").exists()},
198
- "project_skill_claude": {"path": str(project_skill_claude), "installed": (project_skill_claude / "SKILL.md").exists()},
199
- "personal_skill": {"path": str(personal_skill), "installed": (personal_skill / "SKILL.md").exists()},
200
- "personal_skill_claude": {"path": str(personal_skill_claude), "installed": (personal_skill_claude / "SKILL.md").exists()},
554
+ # Format detected_adapters for JSON output
555
+ adapters_out = None
556
+ if detected_adapters:
557
+ adapters_out = {
558
+ pid: {
559
+ "platformId": det.platform_id,
560
+ "detected": det.detected,
561
+ "reasons": list(det.reasons),
562
+ }
563
+ for pid, det in detected_adapters.items()
564
+ }
565
+
566
+ result = {
567
+ "workspace_root": str(workspace_root) if workspace_root else None,
568
+ "detected_adapters": adapters_out,
569
+ "installations": installations,
201
570
  }
202
571
 
203
572
  if json_out:
204
- console.print_json(json.dumps(out, indent=2))
205
- raise typer.Exit(code=0)
573
+ # Match Node: print raw JSON to stdout (no Rich formatting).
574
+ typer.echo(json.dumps(result, indent=2))
575
+ return
576
+
577
+ console.print("APS Doctor")
578
+ console.print("----------")
579
+ console.print(f"Workspace root: {workspace_root or '(not detected)'}")
580
+
581
+ if detected_adapters:
582
+ detected = [d for d in detected_adapters.values() if d.detected]
583
+ if detected:
584
+ console.print(
585
+ f"Detected adapters: {', '.join(d.platform_id for d in detected)}"
586
+ )
587
+ else:
588
+ console.print("Detected adapters: (none)")
589
+ console.print("")
206
590
 
207
- console.print("[bold]APS doctor[/bold]")
208
- console.print(f" cwd: {out['cwd']}")
209
- console.print(f" repo_root: {out['repo_root'] or '(none)'}")
210
- console.print(f" inferred_platform: {out['inferred_platform'] or '(none)'}")
211
- console.print(f" project skill: {'OK' if out['project_skill']['installed'] else 'missing'} — {out['project_skill']['path']}")
212
- console.print(f" project skill (claude): {'OK' if out['project_skill_claude']['installed'] else 'missing'} — {out['project_skill_claude']['path']}")
213
- console.print(f" personal skill: {'OK' if out['personal_skill']['installed'] else 'missing'} — {out['personal_skill']['path']}")
214
- console.print(f" personal skill (claude): {'OK' if out['personal_skill_claude']['installed'] else 'missing'} — {out['personal_skill_claude']['path']}")
591
+ console.print("Installed skills:")
592
+ for inst in installations:
593
+ status = "✓" if inst["installed"] else "✗"
594
+ console.print(f"- {inst['scope']}: {inst['path']} {status}")
215
595
 
216
596
 
217
597
  @app.command()
@@ -219,6 +599,7 @@ def platforms():
219
599
  """List available platform adapters bundled with this APS release."""
220
600
  payload_skill_dir = resolve_payload_skill_dir()
221
601
  plats = load_platforms(payload_skill_dir)
602
+ plats = sort_platforms_for_ui(plats)
222
603
 
223
604
  table = Table(title="APS Platform Adapters")
224
605
  table.add_column("platform_id")
@@ -234,7 +615,7 @@ def platforms():
234
615
  @app.command()
235
616
  def version():
236
617
  """Print CLI version."""
237
- console.print(__version__)
618
+ typer.echo(__version__)
238
619
 
239
620
 
240
621
  def main():
@@ -243,4 +624,4 @@ def main():
243
624
 
244
625
 
245
626
  if __name__ == "__main__":
246
- main()
627
+ main()