city-cli 0.9.2__tar.gz → 0.9.4__tar.gz

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.
Files changed (70) hide show
  1. {city_cli-0.9.2 → city_cli-0.9.4}/PKG-INFO +10 -10
  2. {city_cli-0.9.2 → city_cli-0.9.4}/README.md +8 -8
  3. city_cli-0.9.4/VERSION +1 -0
  4. {city_cli-0.9.2 → city_cli-0.9.4}/pyproject.toml +2 -2
  5. city_cli-0.9.4/src/city_deploy/card_schema.py +128 -0
  6. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/cli.py +56 -81
  7. city_cli-0.9.4/src/city_deploy/controller.py +62 -0
  8. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/local.py +9 -7
  9. city_cli-0.9.2/VERSION +0 -1
  10. {city_cli-0.9.2 → city_cli-0.9.4}/.gitignore +0 -0
  11. {city_cli-0.9.2 → city_cli-0.9.4}/src/city/__init__.py +0 -0
  12. {city_cli-0.9.2 → city_cli-0.9.4}/src/city/testkit/__init__.py +0 -0
  13. {city_cli-0.9.2 → city_cli-0.9.4}/src/city/testkit/keys.py +0 -0
  14. {city_cli-0.9.2 → city_cli-0.9.4}/src/city/testkit/py.typed +0 -0
  15. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/__init__.py +0 -0
  16. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/__main__.py +0 -0
  17. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/agent.py +0 -0
  18. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/minimarket.py +0 -0
  19. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/remote.py +0 -0
  20. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/report.py +0 -0
  21. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/runner.py +0 -0
  22. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/stages.py +0 -0
  23. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/world.py +0 -0
  24. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/__init__.py +0 -0
  25. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/__main__.py +0 -0
  26. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/api.py +0 -0
  27. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/clock.py +0 -0
  28. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/conform.py +0 -0
  29. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/errors.py +0 -0
  30. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/quota.py +0 -0
  31. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/receipt.py +0 -0
  32. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/runtime.py +0 -0
  33. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/secrets.py +0 -0
  34. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/serve.py +0 -0
  35. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/service.py +0 -0
  36. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/skeleton.py +0 -0
  37. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/state.py +0 -0
  38. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/Dockerfile +0 -0
  39. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/SKILL.md +0 -0
  40. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/brain/brain.py +0 -0
  41. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/card.yaml +0 -0
  42. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/city.yaml +0 -0
  43. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/Dockerfile +0 -0
  44. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/SKILL.md +0 -0
  45. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/brain/brain.py +0 -0
  46. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/card.yaml +0 -0
  47. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/city.yaml +0 -0
  48. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/Dockerfile +0 -0
  49. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/SKILL.md +0 -0
  50. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/brain/brain.py +0 -0
  51. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/card.yaml +0 -0
  52. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/city.yaml +0 -0
  53. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/Dockerfile +0 -0
  54. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/SKILL.md +0 -0
  55. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/brain/brain.py +0 -0
  56. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/card.yaml +0 -0
  57. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/city.yaml +0 -0
  58. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/__init__.py +0 -0
  59. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/__main__.py +0 -0
  60. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/app.py +0 -0
  61. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/auth.py +0 -0
  62. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/cache.py +0 -0
  63. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/catalog.py +0 -0
  64. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/events.py +0 -0
  65. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/model.py +0 -0
  66. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/ports.py +0 -0
  67. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/query.py +0 -0
  68. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/service.py +0 -0
  69. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/visibility.py +0 -0
  70. {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: city-cli
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Milano Agentic City CLI (city): init, dev, conform, and deploy.
5
5
  Project-URL: Homepage, https://github.com/projectnandaitaly/agentic-city
6
6
  Project-URL: Repository, https://github.com/projectnandaitaly/agentic-city
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Topic :: Software Development :: Libraries
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.12
20
- Requires-Dist: city-sdk>=0.9.2
20
+ Requires-Dist: city-sdk>=0.9.4
21
21
  Requires-Dist: cryptography>=43
22
22
  Requires-Dist: fastapi>=0.115
23
23
  Requires-Dist: httpx>=0.27
@@ -56,8 +56,8 @@ This provides the `city` command. It depends on [`city-sdk`](https://pypi.org/pr
56
56
  export CITY_DEPLOY_URL=https://deploy.projectnandaitaly.ai
57
57
 
58
58
  city login --team t07
59
- city keys init
60
- city init --role cup --name demo
59
+ city init agent demo
60
+ # edit card.yaml role if needed (cup|facility|mmg|b2b)
61
61
  city card validate
62
62
  city deploy --promote
63
63
  city status agent:t07/demo
@@ -80,7 +80,7 @@ city --json --api-url "$CITY_DEPLOY_URL" status agent:t07/demo
80
80
  If `CITY_DEPLOY_URL` / `--api-url` is unset, `city` uses an **in-process** Deploy service (useful for `init`, `dev`, and offline checks).
81
81
 
82
82
  ```bash
83
- city init --role cup --name demo
83
+ city init agent demo
84
84
  city dev
85
85
  city conform --profile fast
86
86
  ```
@@ -89,13 +89,13 @@ city conform --profile fast
89
89
 
90
90
  | Command | Purpose |
91
91
  |---|---|
92
- | `city login` | Device-flow team session; stores token under `.city/` |
93
- | `city keys init` | Local controller key + register public key |
94
- | `city init` | Scaffold `cup` / `facility` / `mmg` / `b2b` template |
95
- | `city card validate` | Basic card.yaml checks |
92
+ | `city login` | Device-flow team session; stores token and ensures Team Controller Key under `.city/` |
93
+ | `city keys init` | Escape hatch: regenerate controller key + register public key |
94
+ | `city init agent <name>` | Scaffold project (`--template cup\|facility\|mmg\|b2b`); agent type is `card.yaml` `role` |
95
+ | `city card validate` | Validate `card.yaml` against the MHM card schema (base roles only) |
96
96
  | `city dev` | One local need through the harness |
97
97
  | `city conform` | Conformance suite (`--profile full\|fast`, or `--url`) |
98
- | `city deploy` | Upload + pipeline (`--promote`, `--runtime`) |
98
+ | `city deploy` | Upload + pipeline; rejects non-base `role` before build (`--promote`, `--runtime`) |
99
99
  | `city promote` / `rollback` / `status` / `retire` | Lifecycle |
100
100
  | `city secrets set` / `city scale` / `city llm key rotate` | Ops |
101
101
  | `city compliance` / `city verify` | Receipts and offline verify |
@@ -27,8 +27,8 @@ This provides the `city` command. It depends on [`city-sdk`](https://pypi.org/pr
27
27
  export CITY_DEPLOY_URL=https://deploy.projectnandaitaly.ai
28
28
 
29
29
  city login --team t07
30
- city keys init
31
- city init --role cup --name demo
30
+ city init agent demo
31
+ # edit card.yaml role if needed (cup|facility|mmg|b2b)
32
32
  city card validate
33
33
  city deploy --promote
34
34
  city status agent:t07/demo
@@ -51,7 +51,7 @@ city --json --api-url "$CITY_DEPLOY_URL" status agent:t07/demo
51
51
  If `CITY_DEPLOY_URL` / `--api-url` is unset, `city` uses an **in-process** Deploy service (useful for `init`, `dev`, and offline checks).
52
52
 
53
53
  ```bash
54
- city init --role cup --name demo
54
+ city init agent demo
55
55
  city dev
56
56
  city conform --profile fast
57
57
  ```
@@ -60,13 +60,13 @@ city conform --profile fast
60
60
 
61
61
  | Command | Purpose |
62
62
  |---|---|
63
- | `city login` | Device-flow team session; stores token under `.city/` |
64
- | `city keys init` | Local controller key + register public key |
65
- | `city init` | Scaffold `cup` / `facility` / `mmg` / `b2b` template |
66
- | `city card validate` | Basic card.yaml checks |
63
+ | `city login` | Device-flow team session; stores token and ensures Team Controller Key under `.city/` |
64
+ | `city keys init` | Escape hatch: regenerate controller key + register public key |
65
+ | `city init agent <name>` | Scaffold project (`--template cup\|facility\|mmg\|b2b`); agent type is `card.yaml` `role` |
66
+ | `city card validate` | Validate `card.yaml` against the MHM card schema (base roles only) |
67
67
  | `city dev` | One local need through the harness |
68
68
  | `city conform` | Conformance suite (`--profile full\|fast`, or `--url`) |
69
- | `city deploy` | Upload + pipeline (`--promote`, `--runtime`) |
69
+ | `city deploy` | Upload + pipeline; rejects non-base `role` before build (`--promote`, `--runtime`) |
70
70
  | `city promote` / `rollback` / `status` / `retire` | Lifecycle |
71
71
  | `city secrets set` / `city scale` / `city llm key rotate` | Ops |
72
72
  | `city compliance` / `city verify` | Receipts and offline verify |
city_cli-0.9.4/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.9.4
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "city-cli"
3
- version = "0.9.2"
3
+ version = "0.9.4"
4
4
  description = "Milano Agentic City CLI (city): init, dev, conform, and deploy."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Typing :: Typed",
18
18
  ]
19
19
  dependencies = [
20
- "city-sdk>=0.9.2",
20
+ "city-sdk>=0.9.4",
21
21
  "cryptography>=43",
22
22
  "fastapi>=0.115",
23
23
  "httpx>=0.27",
@@ -0,0 +1,128 @@
1
+ """Load and validate MHM agent cards from project ``card.yaml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from functools import cache
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml # type: ignore[import-untyped]
12
+ from cap.core.errors import CapError
13
+ from jsonschema import Draft202012Validator
14
+ from referencing import Registry, Resource
15
+ from referencing.jsonschema import DRAFT202012
16
+
17
+ _PLACEHOLDER_X = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
18
+
19
+
20
+ def specs_root() -> Path:
21
+ here = Path(__file__).resolve()
22
+ for parent in here.parents:
23
+ candidate = parent / "specs" / "profiles" / "mhm" / "1.0" / "card.json"
24
+ if candidate.is_file():
25
+ return parent / "specs"
26
+ raise CapError("SCHEMA_INVALID", "specs")
27
+
28
+
29
+ def _schema_files() -> list[Path]:
30
+ root = specs_root()
31
+ files: list[Path] = []
32
+ for base in (root / "cap", root / "profiles", root / "events"):
33
+ if base.is_dir():
34
+ files.extend(sorted(base.rglob("*.json")))
35
+ return files
36
+
37
+
38
+ @cache
39
+ def _registry() -> Registry:
40
+ resources: list[tuple[str, Resource[object]]] = []
41
+ for path in _schema_files():
42
+ contents = json.loads(path.read_text(encoding="utf-8"))
43
+ schema_id = contents.get("$id")
44
+ if isinstance(schema_id, str):
45
+ resources.append(
46
+ (schema_id, Resource.from_contents(contents, default_specification=DRAFT202012))
47
+ )
48
+ return Registry().with_resources(resources)
49
+
50
+
51
+ @cache
52
+ def _mhm_validator() -> Draft202012Validator:
53
+ schema = json.loads(
54
+ (specs_root() / "profiles" / "mhm" / "1.0" / "card.json").read_text(encoding="utf-8")
55
+ )
56
+ return Draft202012Validator(
57
+ schema,
58
+ registry=_registry(),
59
+ format_checker=Draft202012Validator.FORMAT_CHECKER,
60
+ )
61
+
62
+
63
+ def load_card_yaml(text: str) -> dict[str, Any]:
64
+ loaded = yaml.safe_load(text)
65
+ if not isinstance(loaded, dict):
66
+ raise CapError("SCHEMA_INVALID", "card")
67
+ return loaded
68
+
69
+
70
+ def card_from_yaml(text: str, public: Mapping[str, Any] | None = None) -> dict[str, Any]:
71
+ """Build a deployable card dict from ``card.yaml``, injecting agent keys."""
72
+ raw = load_card_yaml(text)
73
+ agent_id = raw.get("agent_id")
74
+ team = raw.get("team")
75
+ name = raw.get("name") or "agent"
76
+ version = raw.get("version")
77
+ role = raw.get("role")
78
+ service_types = raw.get("service_types")
79
+ no_clinical = raw.get("no_clinical_advice")
80
+ if not isinstance(agent_id, str) or not isinstance(team, str) or not isinstance(version, str):
81
+ raise CapError("SCHEMA_INVALID", "card")
82
+ if not isinstance(role, str):
83
+ raise CapError("SCHEMA_INVALID", "role")
84
+ if not isinstance(service_types, list) or not service_types:
85
+ raise CapError("SCHEMA_INVALID", "service_types")
86
+ if no_clinical is not True:
87
+ raise CapError("SCHEMA_INVALID", "no_clinical_advice")
88
+ if public is None:
89
+ key = {
90
+ "kid": f"{agent_id}#k1",
91
+ "kty": "OKP",
92
+ "crv": "Ed25519",
93
+ "x": _PLACEHOLDER_X,
94
+ }
95
+ else:
96
+ key = {
97
+ "kid": f"{agent_id}#k1",
98
+ "kty": public["kty"],
99
+ "crv": public["crv"],
100
+ "x": public["x"],
101
+ }
102
+ card: dict[str, Any] = {
103
+ "agent_id": agent_id,
104
+ "team": team,
105
+ "name": name,
106
+ "version": version,
107
+ "cap_versions": raw.get("cap_versions") or ["1.0"],
108
+ "profiles": raw.get("profiles") or ["mhm/1.0"],
109
+ "keys": [key],
110
+ "endpoints": raw.get("endpoints") or {"mailbox": agent_id},
111
+ "role": role,
112
+ "service_types": service_types,
113
+ "no_clinical_advice": True,
114
+ }
115
+ for optional in ("municipi", "languages", "accessibility"):
116
+ if optional in raw:
117
+ card[optional] = raw[optional]
118
+ return card
119
+
120
+
121
+ def validate_mhm_card(card: Mapping[str, Any]) -> None:
122
+ role = card.get("role")
123
+ if role not in {"cup", "facility", "mmg", "b2b"}:
124
+ raise CapError("SCHEMA_INVALID", "role")
125
+ errors = sorted(_mhm_validator().iter_errors(dict(card)), key=lambda err: str(list(err.path)))
126
+ if errors:
127
+ path = ".".join(str(part) for part in errors[0].absolute_path) or "card"
128
+ raise CapError("SCHEMA_INVALID", path)
@@ -6,7 +6,6 @@ import asyncio
6
6
  import json
7
7
  import os
8
8
  import sys
9
- from collections.abc import Mapping
10
9
  from datetime import UTC, datetime, timedelta
11
10
  from pathlib import Path
12
11
  from typing import Any
@@ -16,23 +15,25 @@ import typer
16
15
  from cap.core.envelope import verify
17
16
  from cap.core.errors import CapError
18
17
  from cap.core.grant import issue
19
- from cap.core.keys import PrivateKey, PublicKey
18
+ from cap.core.keys import PublicKey
20
19
  from cap.core.timeutil import format_time
21
- from cryptography.hazmat.primitives import serialization
22
- from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
23
20
  from httpx import ASGITransport
24
21
 
25
22
  from city_deploy.api import create_app
23
+ from city_deploy.card_schema import card_from_yaml, validate_mhm_card
26
24
  from city_deploy.conform import select_profile
25
+ from city_deploy.controller import ensure_controller_key, load_controller
27
26
  from city_deploy.local import flat_field, init_project, run_local_need
28
27
  from city_deploy.service import DeployService
29
28
 
30
29
  app = typer.Typer(no_args_is_help=True, add_completion=False)
30
+ init_app = typer.Typer(no_args_is_help=True, add_completion=False)
31
31
  keys_app = typer.Typer(no_args_is_help=True, add_completion=False)
32
32
  card_app = typer.Typer(no_args_is_help=True, add_completion=False)
33
33
  secrets_app = typer.Typer(no_args_is_help=True, add_completion=False)
34
34
  llm_app = typer.Typer(no_args_is_help=True, add_completion=False)
35
35
  llm_key_app = typer.Typer(no_args_is_help=True, add_completion=False)
36
+ app.add_typer(init_app, name="init")
36
37
  app.add_typer(keys_app, name="keys")
37
38
  app.add_typer(card_app, name="card")
38
39
  app.add_typer(secrets_app, name="secrets")
@@ -77,6 +78,16 @@ def login(
77
78
  token_path = _token_path(root)
78
79
  token_path.parent.mkdir(parents=True, exist_ok=True)
79
80
  token_path.write_text(str(polled["token"]), encoding="utf-8")
81
+ authed = _client(ctx, root)
82
+ try:
83
+ ensure_controller_key(
84
+ root,
85
+ lambda public: authed.post("/keys/register", {"jwk": public}),
86
+ overwrite=False,
87
+ )
88
+ except CapError as exc:
89
+ typer.echo(exc.body()["code"])
90
+ raise typer.Exit(code=1) from exc
80
91
  _emit(ctx, {"team_id": polled["team_id"]}, f"logged in as {polled['team_id']}")
81
92
 
82
93
 
@@ -85,22 +96,16 @@ def keys_init(
85
96
  ctx: typer.Context,
86
97
  root: Path = typer.Option(Path("."), "--root"),
87
98
  ) -> None:
88
- raw = Ed25519PrivateKey.generate()
89
- private = PrivateKey(raw)
90
- public = private.public_key().to_jwk()
91
- city = root / ".city"
92
- city.mkdir(parents=True, exist_ok=True)
93
- pem = raw.private_bytes(
94
- encoding=serialization.Encoding.PEM,
95
- format=serialization.PrivateFormat.PKCS8,
96
- encryption_algorithm=serialization.NoEncryption(),
97
- )
98
- key_path = city / "controller.pem"
99
- key_path.write_bytes(pem)
100
- key_path.chmod(0o600)
101
- (city / "controller.jwk").write_text(json.dumps(public), encoding="utf-8")
102
- response = _client(ctx, root).post("/keys/register", {"jwk": public})
103
- _raise_for_status(response)
99
+ client = _client(ctx, root)
100
+ try:
101
+ public = ensure_controller_key(
102
+ root,
103
+ lambda jwk: client.post("/keys/register", {"jwk": jwk}),
104
+ overwrite=True,
105
+ )
106
+ except CapError as exc:
107
+ typer.echo(exc.body()["code"])
108
+ raise typer.Exit(code=1) from exc
104
109
  _emit(ctx, public, "controller public key registered")
105
110
 
106
111
 
@@ -130,7 +135,7 @@ def keys_rotate(
130
135
  "not_before": _stamp(ctx, hours=-1),
131
136
  "not_after": _stamp(ctx, hours=1),
132
137
  }
133
- signed = issue(_load_controller(root), grant, kid=f"team:{team}#controller")
138
+ signed = issue(load_controller(root), grant, kid=f"team:{team}#controller")
134
139
  response = client.post(
135
140
  f"/agents/{agent}/keys/rotate",
136
141
  {"grant": signed.grant, "jws": signed.jws},
@@ -139,19 +144,19 @@ def keys_rotate(
139
144
  _emit(ctx, response.json(), "agent key rotated")
140
145
 
141
146
 
142
- @app.command("init")
143
- def init(
147
+ @init_app.command("agent")
148
+ def init_agent(
144
149
  ctx: typer.Context,
145
- role: str = typer.Option(..., "--role"),
146
- name: str = typer.Option(..., "--name"),
150
+ name: str = typer.Argument(...),
151
+ template: str = typer.Option("cup", "--template"),
147
152
  root: Path = typer.Option(Path("."), "--root"),
148
153
  ) -> None:
149
154
  try:
150
- payload = init_project(root, role, name)
155
+ payload = init_project(root, name, template=template)
151
156
  except CapError as exc:
152
157
  typer.echo(exc.body()["code"])
153
158
  raise typer.Exit(code=1) from exc
154
- _emit(ctx, payload, f"initialized {role} {name}")
159
+ _emit(ctx, payload, f"initialized agent {name}")
155
160
 
156
161
 
157
162
  @card_app.command("validate")
@@ -160,20 +165,19 @@ def card_validate(
160
165
  root: Path = typer.Option(Path("."), "--root"),
161
166
  ) -> None:
162
167
  text = (root / "card.yaml").read_text(encoding="utf-8")
163
- agent_id = flat_field(text, "agent_id")
164
- team = flat_field(text, "team")
165
- version = flat_field(text, "version")
166
- ok = (
167
- agent_id is not None
168
- and agent_id.startswith("agent:")
169
- and team is not None
170
- and version is not None
171
- and "keys:" in text
172
- )
173
- payload = {"valid": ok, "agent_id": agent_id, "team": team}
174
- if not ok:
168
+ try:
169
+ card = card_from_yaml(text)
170
+ validate_mhm_card(card)
171
+ except CapError as exc:
172
+ payload = {"valid": False, "code": exc.body()["code"]}
175
173
  _emit(ctx, payload, "card invalid")
176
- raise typer.Exit(code=1)
174
+ raise typer.Exit(code=1) from exc
175
+ payload = {
176
+ "valid": True,
177
+ "agent_id": card["agent_id"],
178
+ "team": card["team"],
179
+ "role": card["role"],
180
+ }
177
181
  _emit(ctx, payload, "card valid")
178
182
 
179
183
 
@@ -238,16 +242,21 @@ def deploy(
238
242
  runtime: str = typer.Option("memory", "--runtime"),
239
243
  ) -> None:
240
244
  text = (root / "card.yaml").read_text(encoding="utf-8")
241
- agent_id = flat_field(text, "agent_id")
242
- version = flat_field(text, "version")
243
- team_field = flat_field(text, "team")
244
- if agent_id is None or version is None or team_field is None:
245
- raise typer.Exit(code=1)
245
+ try:
246
+ preview = card_from_yaml(text)
247
+ validate_mhm_card(preview)
248
+ except CapError as exc:
249
+ typer.echo(exc.body()["code"])
250
+ raise typer.Exit(code=1) from exc
251
+ agent_id = str(preview["agent_id"])
252
+ version = str(preview["version"])
253
+ team_field = str(preview["team"])
246
254
  client = _client(ctx, root)
247
255
  minted = client.post(f"/agents/{agent_id}/keys", {})
248
256
  _raise_for_status(minted)
249
257
  public = minted.json()
250
- card = _card_from_yaml(text, public)
258
+ card = card_from_yaml(text, public)
259
+ validate_mhm_card(card)
251
260
  team = team_field.removeprefix("team:")
252
261
  slug = agent_id.split("/", 1)[1]
253
262
  grant = {
@@ -259,7 +268,7 @@ def deploy(
259
268
  "not_before": _stamp(ctx, hours=-1),
260
269
  "not_after": _stamp(ctx, hours=1),
261
270
  }
262
- signed = issue(_load_controller(root), grant, kid=f"team:{team}#controller")
271
+ signed = issue(load_controller(root), grant, kid=f"team:{team}#controller")
263
272
  created = client.post(
264
273
  "/deployments",
265
274
  {
@@ -484,14 +493,6 @@ def _token_path(root: Path) -> Path:
484
493
  return root / ".city" / "token"
485
494
 
486
495
 
487
- def _load_controller(root: Path) -> PrivateKey:
488
- data = (root / ".city" / "controller.pem").read_bytes()
489
- loaded = serialization.load_pem_private_key(data, password=None)
490
- if not isinstance(loaded, Ed25519PrivateKey):
491
- raise CapError("KEY_UNKNOWN", "controller")
492
- return PrivateKey(loaded)
493
-
494
-
495
496
  def _team_from_root(root: Path) -> str:
496
497
  text = (root / "card.yaml").read_text(encoding="utf-8")
497
498
  team = flat_field(text, "team")
@@ -522,32 +523,6 @@ def _service(ctx: typer.Context) -> DeployService:
522
523
  return deploy
523
524
 
524
525
 
525
- def _card_from_yaml(text: str, public: Mapping[str, Any]) -> dict[str, Any]:
526
- agent_id = flat_field(text, "agent_id")
527
- team = flat_field(text, "team")
528
- name = flat_field(text, "name") or "agent"
529
- version = flat_field(text, "version")
530
- if agent_id is None or team is None or version is None:
531
- raise CapError("SCHEMA_INVALID", "card")
532
- return {
533
- "agent_id": agent_id,
534
- "team": team,
535
- "name": name,
536
- "version": version,
537
- "cap_versions": ["1.0"],
538
- "profiles": ["mhm/1.0"],
539
- "keys": [
540
- {
541
- "kid": agent_id + "#k1",
542
- "kty": public["kty"],
543
- "crv": public["crv"],
544
- "x": public["x"],
545
- }
546
- ],
547
- "endpoints": {"mailbox": agent_id},
548
- }
549
-
550
-
551
526
  def _raise_for_status(response: httpx.Response) -> None:
552
527
  if response.status_code >= 400:
553
528
  typer.echo(response.text)
@@ -0,0 +1,62 @@
1
+ """Team Controller Key helpers for the city CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from cap.core.errors import CapError
12
+ from cap.core.keys import PrivateKey
13
+ from cryptography.hazmat.primitives import serialization
14
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
15
+
16
+
17
+ def controller_pem_path(root: Path) -> Path:
18
+ return root / ".city" / "controller.pem"
19
+
20
+
21
+ def load_controller(root: Path) -> PrivateKey:
22
+ data = controller_pem_path(root).read_bytes()
23
+ loaded = serialization.load_pem_private_key(data, password=None)
24
+ if not isinstance(loaded, Ed25519PrivateKey):
25
+ raise CapError("KEY_UNKNOWN", "controller")
26
+ return PrivateKey(loaded)
27
+
28
+
29
+ def ensure_controller_key(
30
+ root: Path,
31
+ register: Callable[[dict[str, Any]], httpx.Response],
32
+ *,
33
+ overwrite: bool,
34
+ ) -> dict[str, Any]:
35
+ """Ensure a local controller key exists and register its public JWK.
36
+
37
+ When ``overwrite`` is false and a PEM already exists, reuse it (login path).
38
+ When ``overwrite`` is true, always generate a new key (``city keys init``).
39
+ """
40
+ city = root / ".city"
41
+ city.mkdir(parents=True, exist_ok=True)
42
+ key_path = controller_pem_path(root)
43
+ jwk_path = city / "controller.jwk"
44
+ if key_path.is_file() and not overwrite:
45
+ public = load_controller(root).public_key().to_jwk()
46
+ jwk_path.write_text(json.dumps(public), encoding="utf-8")
47
+ else:
48
+ raw = Ed25519PrivateKey.generate()
49
+ private = PrivateKey(raw)
50
+ public = private.public_key().to_jwk()
51
+ pem = raw.private_bytes(
52
+ encoding=serialization.Encoding.PEM,
53
+ format=serialization.PrivateFormat.PKCS8,
54
+ encryption_algorithm=serialization.NoEncryption(),
55
+ )
56
+ key_path.write_bytes(pem)
57
+ key_path.chmod(0o600)
58
+ jwk_path.write_text(json.dumps(public), encoding="utf-8")
59
+ response = register(public)
60
+ if response.status_code >= 400:
61
+ raise CapError("KEY_UNKNOWN", "register")
62
+ return public
@@ -12,7 +12,7 @@ from city_sdk.layers import preserve_order
12
12
  from city_sdk.market import Market, Need, Peer
13
13
  from city_sdk.ports import MemoryAttest, MemoryIndex, MemoryLedger, MemoryRelay
14
14
 
15
- _ROLES = frozenset({"cup", "facility", "mmg", "b2b"})
15
+ _TEMPLATES = frozenset({"cup", "facility", "mmg", "b2b"})
16
16
  _SLUGS = {
17
17
  "cup": "cup-rapido",
18
18
  "facility": "osp-x",
@@ -32,12 +32,14 @@ def templates_root() -> Path:
32
32
  raise CapError("SCHEMA_INVALID", "templates")
33
33
 
34
34
 
35
- def init_project(root: Path, role: str, name: str) -> dict[str, str]:
36
- if role not in _ROLES or re.fullmatch(r"[a-z0-9-]+", name) is None:
37
- raise CapError("SCHEMA_INVALID", "role")
38
- source = templates_root() / role
35
+ def init_project(root: Path, name: str, *, template: str = "cup") -> dict[str, str]:
36
+ if template not in _TEMPLATES:
37
+ raise CapError("SCHEMA_INVALID", "template")
38
+ if re.fullmatch(r"[a-z0-9-]+", name) is None:
39
+ raise CapError("SCHEMA_INVALID", "name")
40
+ source = templates_root() / template
39
41
  root.mkdir(parents=True, exist_ok=True)
40
- slug = _SLUGS[role]
42
+ slug = _SLUGS[template]
41
43
  for path in source.rglob("*"):
42
44
  if not path.is_file() or "__pycache__" in path.parts:
43
45
  continue
@@ -47,7 +49,7 @@ def init_project(root: Path, role: str, name: str) -> dict[str, str]:
47
49
  if path.suffix in {".yaml", ".yml", ".md"} or path.name == "Dockerfile":
48
50
  text = text.replace(slug, name)
49
51
  target.write_text(text, encoding="utf-8")
50
- return {"role": role, "name": name, "path": str(root)}
52
+ return {"template": template, "name": name, "path": str(root)}
51
53
 
52
54
 
53
55
  def flat_field(text: str, key: str) -> str | None:
city_cli-0.9.2/VERSION DELETED
@@ -1 +0,0 @@
1
- 0.9.2
File without changes
File without changes
File without changes