Skip to content

Commit 89d2b73

Browse files
committed
feat(cli): add fdp device list/show/use
Removes the need to hand-edit ~/.fdp/config.toml when a default is wanted. `fdp catalog` remains as a deprecated alias.
1 parent 352eadb commit 89d2b73

2 files changed

Lines changed: 109 additions & 3 deletions

File tree

fdp/cli.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
from datetime import datetime, timezone
2222
from pathlib import Path
2323

24-
from . import auth
24+
from . import auth, config
2525
from .catalog import catalog
26-
from .devices import active_handles, resolve_for_capability
26+
from .devices import CAPABILITIES, active_handles, resolve_for_capability
2727
from .environment import (
2828
compose_device_config, resolve_bearer_token, setup_environment,
2929
)
@@ -154,6 +154,39 @@ def do_catalog(args) -> None:
154154
raise ValueError(f"Unknown catalog subcommand: {args.subcmd!r}")
155155

156156

157+
def do_device(args) -> None:
158+
if args.device_command == "list":
159+
for name in catalog.names():
160+
handle = catalog[name]
161+
caps = [cap for cap, (predicate, _) in CAPABILITIES.items()
162+
if predicate(handle)]
163+
print(f"{name}\t{handle.description}\t"
164+
f"[{', '.join(caps) if caps else 'none'}]")
165+
elif args.device_command == "show":
166+
import yaml
167+
print(yaml.safe_dump(catalog[args.name].schema.model_dump(),
168+
sort_keys=False))
169+
elif args.device_command == "use":
170+
if args.clear:
171+
config.set_default_device(None)
172+
print("Cleared the default device.")
173+
return
174+
if args.name is None:
175+
print("Error: `fdp device use` needs a device name "
176+
"(or --clear).", file=sys.stderr)
177+
sys.exit(1)
178+
if args.name not in catalog:
179+
print(f"Error: unknown device {args.name!r}. Registered: "
180+
f"{', '.join(catalog.names())}", file=sys.stderr)
181+
sys.exit(1)
182+
config.set_default_device(args.name)
183+
print(f"Default device set to '{args.name}' in "
184+
f"{config.config_path()}.")
185+
else:
186+
raise ValueError(
187+
f"Unknown device subcommand: {args.device_command!r}")
188+
189+
157190
def do_skills(args) -> None:
158191
skill_dirs = discover_skill_dirs()
159192
backend_arg = getattr(args, "backend", "claude")
@@ -312,13 +345,26 @@ def build_parser() -> argparse.ArgumentParser:
312345
help="The path whose contents will be listed")
313346
p_ls.set_defaults(func=do_ls)
314347

315-
p_cat = sub.add_parser("catalog", help="Inspect the tokamak catalog")
348+
p_cat = sub.add_parser("catalog",
349+
help="(deprecated) alias for `fdp device`")
316350
cat_sub = p_cat.add_subparsers(dest="subcmd", required=True)
317351
cat_sub.add_parser("list", help="List tokamak names and descriptions")
318352
show = cat_sub.add_parser("show", help="Print a tokamak's full catalog YAML")
319353
show.add_argument("name")
320354
p_cat.set_defaults(func=do_catalog, needs_env=False)
321355

356+
p_dev = sub.add_parser("device", help="Inspect and select devices")
357+
dev_sub = p_dev.add_subparsers(dest="device_command", required=True)
358+
dev_sub.add_parser("list", help="List devices and their capabilities")
359+
dev_show = dev_sub.add_parser("show", help="Print a device's catalog YAML")
360+
dev_show.add_argument("name")
361+
dev_use = dev_sub.add_parser(
362+
"use", help="Record a default device in ~/.fdp/config.toml")
363+
dev_use.add_argument("name", nargs="?", default=None)
364+
dev_use.add_argument("--clear", action="store_true",
365+
help="Remove the recorded default device.")
366+
p_dev.set_defaults(func=do_device, needs_env=False)
367+
322368
p_sk = sub.add_parser("skills",
323369
help="Manage AI assistant skills")
324370
sk_sub = p_sk.add_subparsers(dest="skills_command", required=True)

tests/test_device_command.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright 2024 General Atomics
2+
# Licensed under the Apache License, Version 2.0.
3+
4+
"""The `fdp device` subcommand group."""
5+
6+
import contextlib
7+
import io
8+
import unittest
9+
from unittest import mock
10+
11+
from test_capabilities import CatalogFixture, _D3D_YAML, _MAST_YAML
12+
13+
14+
class TestDeviceCommand(CatalogFixture):
15+
YAMLS = (("d3d", _D3D_YAML), ("mast", _MAST_YAML))
16+
17+
def _run(self, argv):
18+
from fdp import cli
19+
out = io.StringIO()
20+
with contextlib.redirect_stdout(out):
21+
cli.main(argv)
22+
return out.getvalue()
23+
24+
def test_list_shows_names_and_capabilities(self):
25+
text = self._run(["device", "list"])
26+
self.assertIn("d3d", text)
27+
self.assertIn("mast", text)
28+
self.assertIn("origin", text)
29+
self.assertIn("bearer", text)
30+
31+
def test_show_prints_yaml(self):
32+
text = self._run(["device", "show", "d3d"])
33+
self.assertIn("name: d3d", text)
34+
35+
def test_use_writes_config_and_takes_effect(self):
36+
from fdp.config import read_default_device
37+
from fdp.devices import explicit_device_name
38+
self._run(["device", "use", "mast"])
39+
self.assertEqual(read_default_device(), "mast")
40+
self.assertEqual(explicit_device_name(), "mast")
41+
42+
def test_use_clear_removes_the_setting(self):
43+
from fdp.config import read_default_device
44+
self._run(["device", "use", "mast"])
45+
self._run(["device", "use", "--clear"])
46+
self.assertIsNone(read_default_device())
47+
48+
def test_use_rejects_unknown_device(self):
49+
from fdp import cli
50+
stderr = io.StringIO()
51+
with self.assertRaises(SystemExit) as ctx, \
52+
contextlib.redirect_stderr(stderr):
53+
cli.main(["device", "use", "nosuchdevice"])
54+
self.assertEqual(ctx.exception.code, 1)
55+
self.assertIn("nosuchdevice", stderr.getvalue())
56+
self.assertNotIn("['", stderr.getvalue())
57+
58+
59+
if __name__ == "__main__":
60+
unittest.main()

0 commit comments

Comments
 (0)