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.
- {city_cli-0.9.2 → city_cli-0.9.4}/PKG-INFO +10 -10
- {city_cli-0.9.2 → city_cli-0.9.4}/README.md +8 -8
- city_cli-0.9.4/VERSION +1 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/pyproject.toml +2 -2
- city_cli-0.9.4/src/city_deploy/card_schema.py +128 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/cli.py +56 -81
- city_cli-0.9.4/src/city_deploy/controller.py +62 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/local.py +9 -7
- city_cli-0.9.2/VERSION +0 -1
- {city_cli-0.9.2 → city_cli-0.9.4}/.gitignore +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city/__init__.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city/testkit/__init__.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city/testkit/keys.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city/testkit/py.typed +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/__init__.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/__main__.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/agent.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/minimarket.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/remote.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/report.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/runner.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/stages.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_conform/world.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/__init__.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/__main__.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/api.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/clock.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/conform.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/errors.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/quota.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/receipt.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/runtime.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/secrets.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/serve.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/service.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/skeleton.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/state.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/Dockerfile +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/SKILL.md +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/brain/brain.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/card.yaml +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/b2b/city.yaml +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/Dockerfile +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/SKILL.md +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/brain/brain.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/card.yaml +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/cup/city.yaml +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/Dockerfile +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/SKILL.md +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/brain/brain.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/card.yaml +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/facility/city.yaml +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/Dockerfile +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/SKILL.md +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/brain/brain.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/card.yaml +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_deploy/templates/mmg/city.yaml +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/__init__.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/__main__.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/app.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/auth.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/cache.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/catalog.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/events.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/model.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/ports.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/query.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/service.py +0 -0
- {city_cli-0.9.2 → city_cli-0.9.4}/src/city_index/visibility.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
60
|
-
|
|
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
|
|
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` |
|
|
94
|
-
| `city init
|
|
95
|
-
| `city card validate` |
|
|
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
|
|
31
|
-
|
|
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
|
|
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` |
|
|
65
|
-
| `city init
|
|
66
|
-
| `city card validate` |
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
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
|
-
@
|
|
143
|
-
def
|
|
147
|
+
@init_app.command("agent")
|
|
148
|
+
def init_agent(
|
|
144
149
|
ctx: typer.Context,
|
|
145
|
-
|
|
146
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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,
|
|
36
|
-
if
|
|
37
|
-
raise CapError("SCHEMA_INVALID", "
|
|
38
|
-
|
|
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[
|
|
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 {"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|