convergent-cli 0.1.0__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.

Potentially problematic release.


This version of convergent-cli might be problematic. Click here for more details.

Files changed (83) hide show
  1. convergent_cli/__init__.py +1 -0
  2. convergent_cli/auth.py +227 -0
  3. convergent_cli/build_context.py +225 -0
  4. convergent_cli/calibration_finish.py +310 -0
  5. convergent_cli/calibration_frontier.py +499 -0
  6. convergent_cli/calibration_runner.py +1413 -0
  7. convergent_cli/calibration_send.py +44 -0
  8. convergent_cli/codex_app_server.py +127 -0
  9. convergent_cli/command_tree.py +102 -0
  10. convergent_cli/commands/__init__.py +58 -0
  11. convergent_cli/commands/agents.py +729 -0
  12. convergent_cli/commands/auth.py +117 -0
  13. convergent_cli/commands/calibration.py +3857 -0
  14. convergent_cli/commands/calibration_labels_load.py +627 -0
  15. convergent_cli/commands/capabilities.py +85 -0
  16. convergent_cli/commands/dataset_authoring.py +964 -0
  17. convergent_cli/commands/dataset_import.py +763 -0
  18. convergent_cli/commands/datasets.py +678 -0
  19. convergent_cli/commands/docs.py +120 -0
  20. convergent_cli/commands/doctor.py +124 -0
  21. convergent_cli/commands/environments.py +63 -0
  22. convergent_cli/commands/findings.py +206 -0
  23. convergent_cli/commands/graders.py +803 -0
  24. convergent_cli/commands/harness.py +366 -0
  25. convergent_cli/commands/issues.py +388 -0
  26. convergent_cli/commands/production.py +173 -0
  27. convergent_cli/commands/profile.py +86 -0
  28. convergent_cli/commands/rubrics.py +14 -0
  29. convergent_cli/commands/runs.py +1763 -0
  30. convergent_cli/commands/setup.py +968 -0
  31. convergent_cli/commands/task_qc.py +1041 -0
  32. convergent_cli/commands/task_qc_reports.py +393 -0
  33. convergent_cli/commands/verdicts.py +156 -0
  34. convergent_cli/commands/workspaces.py +170 -0
  35. convergent_cli/commands/worlds.py +292 -0
  36. convergent_cli/confirm.py +59 -0
  37. convergent_cli/control_client.py +141 -0
  38. convergent_cli/customer_client.py +1272 -0
  39. convergent_cli/docs_client.py +165 -0
  40. convergent_cli/errors.py +212 -0
  41. convergent_cli/harness_run.py +1169 -0
  42. convergent_cli/harness_tree.py +854 -0
  43. convergent_cli/installed_agent.py +1071 -0
  44. convergent_cli/json_files.py +23 -0
  45. convergent_cli/local_files.py +64 -0
  46. convergent_cli/main.py +106 -0
  47. convergent_cli/oauth.py +431 -0
  48. convergent_cli/output.py +15 -0
  49. convergent_cli/paging.py +62 -0
  50. convergent_cli/run_calibration.py +361 -0
  51. convergent_cli/run_usage.py +99 -0
  52. convergent_cli/sessions.py +275 -0
  53. convergent_cli/setup_agents.py +190 -0
  54. convergent_cli/setup_auth.py +463 -0
  55. convergent_cli/setup_instrument.py +244 -0
  56. convergent_cli/setup_mcp.py +525 -0
  57. convergent_cli/setup_wizard.py +125 -0
  58. convergent_cli/skill_files/convergent/SKILL.md +25 -0
  59. convergent_cli/skill_files/convergent-instrument/SKILL.md +15 -0
  60. convergent_cli/skill_files/convergent-verify/SKILL.md +15 -0
  61. convergent_cli/skills.py +489 -0
  62. convergent_cli/state.py +297 -0
  63. convergent_cli/transport.py +242 -0
  64. convergent_cli/verdict_feedback.py +161 -0
  65. convergent_cli/verifier_bench_preflight.py +32 -0
  66. convergent_cli/world_docker.py +145 -0
  67. convergent_cli/world_files.py +432 -0
  68. convergent_cli/world_live.py +660 -0
  69. convergent_cli/world_mcp.py +109 -0
  70. convergent_cli/world_probe_requirements.txt +29 -0
  71. convergent_cli/world_template/Dockerfile +10 -0
  72. convergent_cli/world_template/README.md +38 -0
  73. convergent_cli/world_template/convergent.toml +11 -0
  74. convergent_cli/world_template/examples/default/environment/catalog.json +3 -0
  75. convergent_cli/world_template/examples/default/instruction.md +1 -0
  76. convergent_cli/world_template/requirements.txt +1 -0
  77. convergent_cli/world_template/schemas/catalog.json +5 -0
  78. convergent_cli/world_template/server.py +25 -0
  79. convergent_cli-0.1.0.dist-info/METADATA +679 -0
  80. convergent_cli-0.1.0.dist-info/RECORD +83 -0
  81. convergent_cli-0.1.0.dist-info/WHEEL +4 -0
  82. convergent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  83. convergent_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1 @@
1
+ """The Convergent command line tool (`cv`)."""
convergent_cli/auth.py ADDED
@@ -0,0 +1,227 @@
1
+ """High-level auth commands for the public CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from shutil import rmtree
10
+
11
+ from convergent_cli.control_client import get_control_json_at
12
+ from convergent_cli.customer_client import forget_calibration_tokens
13
+ from convergent_cli.errors import (
14
+ AuthError,
15
+ ControlRefusedError,
16
+ NetworkError,
17
+ SessionRejectedError,
18
+ UsageError,
19
+ )
20
+ from convergent_cli.oauth import browser_sign_in, headless_sign_in, resolve_client_id
21
+ from convergent_cli.output import json_text
22
+ from convergent_cli.sessions import (
23
+ StoredSession,
24
+ access_token_for_profile,
25
+ remove_session,
26
+ save_session,
27
+ session_path,
28
+ )
29
+ from convergent_cli.state import (
30
+ Config,
31
+ active_profile_name,
32
+ config_path,
33
+ load_config,
34
+ save_config,
35
+ validate_origin,
36
+ validate_profile_name,
37
+ write_private_file,
38
+ )
39
+ from convergent_cli.transport import Transport, UrllibTransport
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class LoginResult:
44
+ profile_name: str
45
+ origin: str
46
+ session_file: Path
47
+ identity: Mapping[str, object]
48
+
49
+
50
+ def login(
51
+ *,
52
+ profile_name: str | None,
53
+ control_api_url: str | None,
54
+ client_id: str | None,
55
+ headless: bool,
56
+ transport: Transport | None = None,
57
+ announce: Callable[[str], None] = print,
58
+ read_redirect: Callable[[], str] = input,
59
+ ) -> LoginResult:
60
+ config = load_config()
61
+ name, origin, updated = _login_target(config, profile_name, control_api_url)
62
+ oauth_client = resolve_client_id(client_id, origin)
63
+ http = transport or UrllibTransport()
64
+ if headless:
65
+ session = headless_sign_in(
66
+ origin=origin,
67
+ client_id=oauth_client,
68
+ transport=http,
69
+ announce=announce,
70
+ read_redirect=read_redirect,
71
+ )
72
+ else:
73
+ session = browser_sign_in(
74
+ origin=origin,
75
+ client_id=oauth_client,
76
+ transport=http,
77
+ announce=announce,
78
+ )
79
+ session_file = _save_login_state(name, updated, session)
80
+ identity = _verify_login(name, origin, session.access_token, http)
81
+ return LoginResult(
82
+ profile_name=name, origin=origin, session_file=session_file, identity=identity
83
+ )
84
+
85
+
86
+ def logout(*, profile_name: str | None) -> tuple[str, bool]:
87
+ config = load_config()
88
+ name = validate_profile_name(profile_name) if profile_name else active_profile_name(config)
89
+ config.profile(name)
90
+ removed = remove_session(name)
91
+ cache = config_path().parent / "calibration-tokens"
92
+ if cache.exists():
93
+ rmtree(cache)
94
+ forget_calibration_tokens()
95
+ return name, removed
96
+
97
+
98
+ def whoami(
99
+ *,
100
+ profile_name: str | None,
101
+ transport: Transport | None = None,
102
+ now: datetime | None = None,
103
+ ) -> Mapping[str, object]:
104
+ http = transport or UrllibTransport()
105
+ config = load_config()
106
+ name = validate_profile_name(profile_name) if profile_name else active_profile_name(config)
107
+ profile = config.profile(name)
108
+ token = access_token_for_profile(
109
+ name,
110
+ profile.origin,
111
+ transport=http,
112
+ now=now or datetime.now(UTC),
113
+ )
114
+ identity = get_control_json_at(
115
+ profile.origin, "/v1/auth/me", access_token=token, transport=http
116
+ )
117
+ return _identity_payload(name, profile.origin, identity)
118
+
119
+
120
+ def whoami_json(payload: Mapping[str, object]) -> str:
121
+ return json_text(payload)
122
+
123
+
124
+ def _login_target(
125
+ config: Config,
126
+ profile_name: str | None,
127
+ control_api_url: str | None,
128
+ ) -> tuple[str, str, Config]:
129
+ name = validate_profile_name(profile_name) if profile_name else active_profile_name(config)
130
+ existing = config.profiles.get(name)
131
+ if existing is None and control_api_url is None:
132
+ raise UsageError(
133
+ f"profile {name!r} does not exist yet, so cv has no origin for it "
134
+ "and will not guess one. Pass --control-api-url ORIGIN."
135
+ )
136
+ if control_api_url:
137
+ origin = validate_origin(control_api_url)
138
+ elif existing is not None:
139
+ origin = existing.origin
140
+ else:
141
+ raise AssertionError("unreachable login target state")
142
+ return name, origin, config.with_profile_origin(name, origin)
143
+
144
+
145
+ def _verify_login(
146
+ profile_name: str, origin: str, access_token: str, http: Transport
147
+ ) -> Mapping[str, object]:
148
+ try:
149
+ identity = get_control_json_at(
150
+ origin, "/v1/auth/me", access_token=access_token, transport=http
151
+ )
152
+ except SessionRejectedError as error:
153
+ raise SessionRejectedError(
154
+ f"the new session was rejected by {origin}; retry `cv login --profile {profile_name}`"
155
+ ) from error
156
+ except ControlRefusedError as error:
157
+ raise ControlRefusedError(
158
+ f"signed in, but your account has no access to an organization at {origin}; "
159
+ "ask an administrator to add you"
160
+ ) from error
161
+ except NetworkError as error:
162
+ raise NetworkError(
163
+ f"stored the session for profile {profile_name!r} but could not verify it: {error}"
164
+ ) from error
165
+ return _identity_payload(profile_name, origin, identity)
166
+
167
+
168
+ def _identity_payload(
169
+ profile_name: str,
170
+ origin: str,
171
+ identity: Mapping[str, object],
172
+ ) -> Mapping[str, object]:
173
+ user_id = _required_string(identity, "user_id")
174
+ email = _required_string(identity, "email")
175
+ organization_id = _required_string(identity, "organization_id")
176
+ is_staff = identity.get("is_staff")
177
+ if not isinstance(is_staff, bool):
178
+ raise AuthError("/v1/auth/me returned no usable is_staff value")
179
+ permissions = identity.get("permissions")
180
+ if not isinstance(permissions, list) or not all(
181
+ isinstance(permission, str) for permission in permissions
182
+ ):
183
+ raise AuthError("/v1/auth/me returned no usable permissions list")
184
+ return {
185
+ "profile": profile_name,
186
+ "origin": origin,
187
+ "user_id": user_id,
188
+ "email": email,
189
+ "organization_id": organization_id,
190
+ "is_staff": is_staff,
191
+ "permissions": permissions,
192
+ }
193
+
194
+
195
+ def _save_login_state(profile_name: str, config: Config, session: StoredSession) -> Path:
196
+ config_file = config_path()
197
+ session_file = session_path(profile_name)
198
+ previous_config = _file_snapshot(config_file)
199
+ previous_session = _file_snapshot(session_file)
200
+ try:
201
+ saved_session = save_session(profile_name, session)
202
+ save_config(config)
203
+ return saved_session
204
+ except BaseException:
205
+ _restore_file(config_file, previous_config)
206
+ _restore_file(session_file, previous_session)
207
+ raise
208
+
209
+
210
+ def _file_snapshot(path: Path) -> str | None:
211
+ if not path.exists():
212
+ return None
213
+ return path.read_text(encoding="utf-8")
214
+
215
+
216
+ def _restore_file(path: Path, contents: str | None) -> None:
217
+ if contents is None:
218
+ path.unlink(missing_ok=True)
219
+ return
220
+ write_private_file(path, contents)
221
+
222
+
223
+ def _required_string(identity: Mapping[str, object], key: str) -> str:
224
+ value = identity.get(key)
225
+ if not isinstance(value, str) or not value:
226
+ raise AuthError(f"/v1/auth/me returned no usable {key}")
227
+ return value
@@ -0,0 +1,225 @@
1
+ """Reproducible build-context archives for hosted builds, and their safe extraction.
2
+
3
+ ``pack_build_context`` packs one project directory with a root ``Dockerfile``.
4
+ It leaves out, at any depth, version-control and cache directories, local
5
+ credential directories and files, and ``.env`` files (``SKIPPED_DIRECTORIES``
6
+ and ``SKIPPED_FILES``). With ``dockerignore`` it also leaves out what the root
7
+ ``.dockerignore`` excludes, by Docker's rules: ``*``, ``?``, ``[...]``, and
8
+ ``**`` patterns relative to the root, a pattern that matches a directory
9
+ excludes everything under it, ``!`` includes a path again, and the last
10
+ matching line wins. The root ``Dockerfile`` is always kept. The archive is
11
+ reproducible: entries in path order, no owner or time, modes of 0755 or 0644 by
12
+ the owner's execute bit, and a gzip header with no name or time.
13
+
14
+ ``extract_build_context`` unpacks an archive into a new directory, refusing
15
+ anything but files and directories, paths outside it, duplicate entries, and
16
+ archives over the build service's limits, which this package repeats because it
17
+ cannot import the server. Nothing reaches the target unless every entry is
18
+ written.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import gzip
24
+ import io
25
+ import os
26
+ import posixpath
27
+ import re
28
+ import stat
29
+ import tarfile
30
+ import tempfile
31
+ from pathlib import Path, PurePosixPath
32
+
33
+ from convergent_cli.errors import UsageError
34
+
35
+ MAX_UPLOAD_BYTES = 6 * 1024 * 1024
36
+ """The largest archive the server takes in one request; a larger one needs chunked upload."""
37
+ MAX_ARCHIVE_BYTES = 25 * 1024 * 1024
38
+ MAX_EXPANDED_BYTES = 100 * 1024 * 1024
39
+ MAX_ENTRIES = 10_000
40
+ SKIPPED_DIRECTORIES = frozenset(
41
+ {
42
+ ".git",
43
+ ".hg",
44
+ ".svn",
45
+ ".venv",
46
+ "venv",
47
+ "node_modules",
48
+ "__pycache__",
49
+ ".mypy_cache",
50
+ ".pytest_cache",
51
+ ".ruff_cache",
52
+ ".tox",
53
+ ".aws",
54
+ ".docker",
55
+ ".gnupg",
56
+ ".kube",
57
+ ".ssh",
58
+ }
59
+ )
60
+ SKIPPED_FILES = frozenset(
61
+ {
62
+ ".DS_Store",
63
+ ".env",
64
+ ".envrc",
65
+ ".git-credentials",
66
+ ".netrc",
67
+ ".npmrc",
68
+ ".pgpass",
69
+ ".pypirc",
70
+ }
71
+ )
72
+
73
+
74
+ def pack_build_context(root: Path, *, dockerignore: bool = False) -> tuple[bytes, list[str]]:
75
+ """``root`` as a reproducible tar.gz, and the paths it holds."""
76
+ if not (root / "Dockerfile").is_file():
77
+ raise UsageError(f"{root} has no Dockerfile at its root")
78
+ rules = _dockerignore_rules(root) if dockerignore else []
79
+ # Without a ``!`` line nothing under an excluded directory comes back, so skip walking it.
80
+ prune = bool(rules) and not any(keep for keep, _ in rules)
81
+ entries: list[Path] = []
82
+ for parent, dirs, names in os.walk(root):
83
+ relative_parent = Path(parent).relative_to(root).as_posix()
84
+ dirs[:] = [
85
+ name
86
+ for name in dirs
87
+ if name not in SKIPPED_DIRECTORIES
88
+ and not (prune and _ignored(rules, posixpath.normpath(f"{relative_parent}/{name}")))
89
+ ]
90
+ entries += [
91
+ Path(parent, name)
92
+ for name in (*dirs, *names)
93
+ if name not in SKIPPED_FILES and not name.startswith(".env.")
94
+ ]
95
+ paths = sorted(path.relative_to(root).as_posix() for path in entries)
96
+ if rules:
97
+ paths = dockerignore_kept(root, paths)
98
+ buffer = io.BytesIO()
99
+ with (
100
+ gzip.GzipFile(fileobj=buffer, mode="wb", mtime=0) as zipped,
101
+ tarfile.open(fileobj=zipped, mode="w", format=tarfile.PAX_FORMAT) as archive,
102
+ ):
103
+ for relative in paths:
104
+ path = root / relative
105
+ info = tarfile.TarInfo(relative)
106
+ mode = path.lstat().st_mode
107
+ if stat.S_ISDIR(mode):
108
+ info.type, info.mode = tarfile.DIRTYPE, 0o755
109
+ archive.addfile(info)
110
+ elif stat.S_ISREG(mode):
111
+ info.size, info.mode = path.stat().st_size, 0o755 if mode & 0o100 else 0o644
112
+ with path.open("rb") as content:
113
+ archive.addfile(info, content)
114
+ else:
115
+ raise UsageError(f"{relative} is a symlink or special file; copy its target")
116
+ data = buffer.getvalue()
117
+ if len(data) > MAX_UPLOAD_BYTES:
118
+ hint = "; leave out what the image does not need with .dockerignore" if dockerignore else ""
119
+ raise UsageError(f"the build context is over 6 MiB compressed ({len(data)} bytes){hint}")
120
+ return data, paths
121
+
122
+
123
+ def dockerignore_kept(root: Path, paths: list[str]) -> list[str]:
124
+ """``paths``, relative ``/`` paths under ``root``, less what its ``.dockerignore`` leaves out,
125
+ as ``docker build`` does: the root ``Dockerfile`` stays, and a kept path keeps the directories
126
+ above it."""
127
+ rules = _dockerignore_rules(root)
128
+ if not rules:
129
+ return paths
130
+ kept = {path for path in paths if path == "Dockerfile" or not _ignored(rules, path)}
131
+ kept |= {str(up) for path in kept for up in PurePosixPath(path).parents if str(up) != "."}
132
+ return [path for path in paths if path in kept]
133
+
134
+
135
+ def _dockerignore_rules(root: Path) -> list[tuple[bool, re.Pattern[str]]]:
136
+ """The root ``.dockerignore`` as ``(keep, pattern)`` rules, in file order."""
137
+ path = root / ".dockerignore"
138
+ if not path.is_file():
139
+ return []
140
+ rules = []
141
+ for raw in path.read_text(encoding="utf-8").splitlines():
142
+ line = raw.strip()
143
+ if not line or line.startswith("#"):
144
+ continue
145
+ keep = line.startswith("!")
146
+ pattern = posixpath.normpath(line[1:].strip() if keep else line).lstrip("/")
147
+ if pattern and pattern != ".":
148
+ rules.append((keep, re.compile(_pattern_regex(pattern))))
149
+ return rules
150
+
151
+
152
+ def _pattern_regex(pattern: str) -> str:
153
+ """A Docker ignore pattern as an anchored regular expression over ``/`` paths."""
154
+ out, i = ["^"], 0
155
+ while i < len(pattern):
156
+ char = pattern[i]
157
+ if pattern.startswith("**/", i):
158
+ out.append("(?:.*/)?")
159
+ i += 3
160
+ continue
161
+ if pattern.startswith("**", i):
162
+ out.append(".*")
163
+ i += 2
164
+ continue
165
+ if char == "*":
166
+ out.append("[^/]*")
167
+ elif char == "?":
168
+ out.append("[^/]")
169
+ elif char == "\\" and i + 1 < len(pattern):
170
+ i += 1
171
+ out.append(re.escape(pattern[i]))
172
+ elif char == "[" and (end := pattern.find("]", i + 2)) != -1:
173
+ body = pattern[i + 1 : end]
174
+ out.append("[" + ("^" + body[1:] if body[0] in "!^" else body) + "]")
175
+ i = end
176
+ else:
177
+ out.append(re.escape(char))
178
+ i += 1
179
+ out.append("$")
180
+ return "".join(out)
181
+
182
+
183
+ def _ignored(rules: list[tuple[bool, re.Pattern[str]]], path: str) -> bool:
184
+ """Whether the last rule matching ``path`` or a directory above it excludes it."""
185
+ candidates = [path, *(str(up) for up in PurePosixPath(path).parents if str(up) != ".")]
186
+ ignored = False
187
+ for keep, pattern in rules:
188
+ if any(pattern.match(candidate) for candidate in candidates):
189
+ ignored = not keep
190
+ return ignored
191
+
192
+
193
+ def extract_build_context(data: bytes, target: Path) -> int:
194
+ """Unpack ``data`` into ``target``, an absent or empty directory; return the file count."""
195
+ if len(data) > MAX_ARCHIVE_BYTES:
196
+ raise UsageError("the archive is over 25 MiB")
197
+ try:
198
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as source:
199
+ raw = source.read(MAX_EXPANDED_BYTES + 1)
200
+ if len(raw) > MAX_EXPANDED_BYTES:
201
+ raise UsageError("the archive expands to over 100 MiB")
202
+ with (
203
+ tarfile.open(fileobj=io.BytesIO(raw), mode="r:") as archive,
204
+ tempfile.TemporaryDirectory(prefix=".cv-pull-", dir=target.parent) as temporary,
205
+ ):
206
+ members = archive.getmembers()
207
+ names = [posixpath.normpath(member.name) for member in members]
208
+ if len(members) > MAX_ENTRIES or len(set(names)) != len(names):
209
+ raise UsageError("the archive has too many or duplicate entries")
210
+ if not all(
211
+ (member.isfile() and not member.issparse()) or member.isdir() for member in members
212
+ ):
213
+ raise UsageError("the archive holds a link or special file")
214
+ if any(name.startswith(("/", "../")) or name == ".." for name in names):
215
+ raise UsageError("the archive holds a path outside its directory")
216
+ staging = Path(temporary) / "out"
217
+ staging.mkdir()
218
+ archive.extractall(staging, filter="data")
219
+ staging.rename(target)
220
+ except (OSError, EOFError, tarfile.TarError) as exc:
221
+ raise UsageError(f"the archive could not be extracted: {exc}") from None
222
+ return sum(member.isfile() for member in members)
223
+
224
+
225
+ __all__ = ["extract_build_context", "pack_build_context"]