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