freesolo-agent 0.1.0__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.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: freesolo-agent
3
+ Version: 0.1.0
4
+ Summary: Thin CLI for queuing Freesolo backend training jobs.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: freesolo>=0.2.4
8
+ Requires-Dist: httpx>=0.27.0
9
+
10
+ # Freesolo Agent CLI
11
+
12
+ Thin command-line client for queuing Freesolo backend training jobs.
13
+
14
+ The public package does not contain the Codex/GitHub/training worker. It only:
15
+
16
+ - validates JSON or interactive setup input
17
+ - verifies `FREESOLO_API_KEY`
18
+ - uploads a local dataset file or folder when `datasetPath` is provided
19
+ - enqueues `/api/training/jobs`
20
+ - polls `/api/training/jobs/{job_id}` until the backend worker finishes
21
+
22
+ The private worker implementation lives under `backend/src/training_agent`.
23
+
24
+ ## Run
25
+
26
+ Interactive setup starts when the CLI is launched from a terminal without JSON
27
+ input:
28
+
29
+ ```bash
30
+ uv run freesolo
31
+ ```
32
+
33
+ Automation can call the JSON interface:
34
+
35
+ ```bash
36
+ uv run freesolo '{"operation":"draft","orgId":"org_123","input":{"sourceRepoUrl":"https://github.com/acme/app.git","prompt":"Create a contract for the feature behavior..."}}'
37
+ ```
38
+
39
+ Supported operations are `draft`, `edit`, `optimize`, and `training`.
40
+
41
+ To provide a local dataset, pass `datasetPath` as either a file or folder path.
42
+ The CLI uploads it to the backend first, then the backend worker materializes it
43
+ as read-only context for the training agent:
44
+
45
+ ```bash
46
+ uv run freesolo '{"operation":"training","orgId":"org_123","input":{"targetRepoUrl":"https://github.com/acme/app-freesolo-training","datasetPath":"/mnt/data/my_dataset"}}'
47
+ ```
48
+
49
+ ## Environment
50
+
51
+ - `FREESOLO_API_KEY`: required.
52
+ - `FREESOLO_BASE_URL`: optional backend URL override. Defaults to production.
53
+ - `FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS`: optional polling interval.
54
+
55
+ Private GitHub app credentials, Codex settings, Tinker dependencies, and training
56
+ runtime configuration belong to the backend worker, not this CLI package.
@@ -0,0 +1,47 @@
1
+ # Freesolo Agent CLI
2
+
3
+ Thin command-line client for queuing Freesolo backend training jobs.
4
+
5
+ The public package does not contain the Codex/GitHub/training worker. It only:
6
+
7
+ - validates JSON or interactive setup input
8
+ - verifies `FREESOLO_API_KEY`
9
+ - uploads a local dataset file or folder when `datasetPath` is provided
10
+ - enqueues `/api/training/jobs`
11
+ - polls `/api/training/jobs/{job_id}` until the backend worker finishes
12
+
13
+ The private worker implementation lives under `backend/src/training_agent`.
14
+
15
+ ## Run
16
+
17
+ Interactive setup starts when the CLI is launched from a terminal without JSON
18
+ input:
19
+
20
+ ```bash
21
+ uv run freesolo
22
+ ```
23
+
24
+ Automation can call the JSON interface:
25
+
26
+ ```bash
27
+ uv run freesolo '{"operation":"draft","orgId":"org_123","input":{"sourceRepoUrl":"https://github.com/acme/app.git","prompt":"Create a contract for the feature behavior..."}}'
28
+ ```
29
+
30
+ Supported operations are `draft`, `edit`, `optimize`, and `training`.
31
+
32
+ To provide a local dataset, pass `datasetPath` as either a file or folder path.
33
+ The CLI uploads it to the backend first, then the backend worker materializes it
34
+ as read-only context for the training agent:
35
+
36
+ ```bash
37
+ uv run freesolo '{"operation":"training","orgId":"org_123","input":{"targetRepoUrl":"https://github.com/acme/app-freesolo-training","datasetPath":"/mnt/data/my_dataset"}}'
38
+ ```
39
+
40
+ ## Environment
41
+
42
+ - `FREESOLO_API_KEY`: required.
43
+ - `FREESOLO_BASE_URL`: optional backend URL override. Defaults to production.
44
+ - `FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS`: optional polling interval.
45
+
46
+ Private GitHub app credentials, Codex settings, Tinker dependencies, and training
47
+ runtime configuration belong to the backend worker, not this CLI package.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "freesolo-agent"
7
+ version = "0.1.0"
8
+ description = "Thin CLI for queuing Freesolo backend training jobs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "freesolo>=0.2.4",
13
+ "httpx>=0.27.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ freesolo = "commands.main:main"
18
+
19
+ [tool.setuptools]
20
+ py-modules = ["config"]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.mypy]
26
+ python_version = "3.11"
27
+ strict = true
28
+ warn_unused_ignores = true
29
+
30
+ [tool.ruff]
31
+ target-version = "py311"
32
+ line-length = 100
33
+
34
+ [tool.uv.sources]
35
+ freesolo = { path = "../../freesolo-sdk", editable = true }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Backend API clients used by the public CLI."""
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import httpx
6
+ from freesolo.utils.storage import FreesoloStorageClient
7
+
8
+
9
+ def require_freesolo_api_key() -> None:
10
+ value = os.getenv("FREESOLO_API_KEY")
11
+ if not value or not value.strip():
12
+ raise ValueError("FREESOLO_API_KEY is required to run the Freesolo Agent CLI")
13
+
14
+
15
+ def verify_freesolo_api_key() -> None:
16
+ try:
17
+ FreesoloStorageClient().verify_api_key()
18
+ except httpx.HTTPStatusError as error:
19
+ if error.response.status_code in {401, 403}:
20
+ raise ValueError(
21
+ "FREESOLO_API_KEY was rejected by Freesolo. Create a new API key "
22
+ "in the Freesolo app for the org that should own this run."
23
+ ) from error
24
+ raise
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+ from freesolo.utils.storage import FreesoloStorageClient
7
+
8
+ from core.types import TrainingInputPayload
9
+
10
+
11
+ def dataset_path_from_input(bot_input: TrainingInputPayload) -> str | None:
12
+ return bot_input.dataset_path
13
+
14
+
15
+ def sync_dataset_if_present(bot_input: TrainingInputPayload) -> str | None:
16
+ dataset_path = dataset_path_from_input(bot_input)
17
+ if dataset_path is None:
18
+ return None
19
+ try:
20
+ return FreesoloStorageClient().upload_dataset(dataset_path, name=Path(dataset_path).name)
21
+ except httpx.HTTPStatusError as error:
22
+ if error.response.status_code in {401, 403}:
23
+ raise ValueError(
24
+ "Freesolo API key was rejected while uploading the dataset. Use an active "
25
+ "key for the org that should own datasets and training runs."
26
+ ) from error
27
+ raise
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ from collections.abc import Mapping
7
+ from typing import Any, cast
8
+
9
+ import httpx
10
+ from freesolo.utils.storage import FreesoloStorageClient
11
+
12
+ from config import (
13
+ DEFAULT_JOB_POLL_INTERVAL_SECONDS,
14
+ JOB_HEARTBEAT_SECONDS,
15
+ TRAINING_JOBS_PATH,
16
+ )
17
+ from core.output import print_human
18
+ from core.types import (
19
+ DraftInput,
20
+ EditInput,
21
+ OptimizeInput,
22
+ TrainingInput,
23
+ TrainingInputPayload,
24
+ )
25
+
26
+
27
+ def job_poll_interval_seconds() -> float:
28
+ raw_value = os.getenv("FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS")
29
+ if raw_value is None or not raw_value.strip():
30
+ return DEFAULT_JOB_POLL_INTERVAL_SECONDS
31
+ try:
32
+ value = float(raw_value)
33
+ except ValueError as error:
34
+ raise ValueError("FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS must be a number") from error
35
+ if value <= 0:
36
+ raise ValueError("FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS must be positive")
37
+ return value
38
+
39
+
40
+ def environment_variables_from_input(bot_input: TrainingInputPayload) -> dict[str, str]:
41
+ return dict(bot_input.environment_variables)
42
+
43
+
44
+ def backend_training_payload(
45
+ operation: str,
46
+ org_id: str,
47
+ bot_input: TrainingInputPayload,
48
+ dataset_id: str | None = None,
49
+ ) -> dict[str, Any]:
50
+ payload: dict[str, Any] = {"action": operation, "orgId": org_id}
51
+ if dataset_id is not None:
52
+ payload["datasetId"] = dataset_id
53
+
54
+ environment_variables = environment_variables_from_input(bot_input)
55
+ if environment_variables:
56
+ payload["environmentVariables"] = environment_variables
57
+
58
+ if operation == "draft":
59
+ draft_input = cast(DraftInput, bot_input)
60
+ payload.update(
61
+ {
62
+ "sourceRepoUrl": draft_input.source_repo_url,
63
+ "sourceBranch": draft_input.source_branch,
64
+ "prompt": draft_input.prompt,
65
+ }
66
+ )
67
+ return payload
68
+ if operation == "edit":
69
+ edit_input = cast(EditInput, bot_input)
70
+ payload.update(
71
+ {
72
+ "targetRepoUrl": edit_input.target_repo_url,
73
+ "targetBranch": edit_input.target_branch,
74
+ "targetWorktreePath": edit_input.target_worktree_path,
75
+ "prompt": edit_input.prompt,
76
+ }
77
+ )
78
+ return payload
79
+ if operation == "optimize":
80
+ optimize_input = cast(OptimizeInput, bot_input)
81
+ payload.update(
82
+ {
83
+ "targetRepoUrl": optimize_input.target_repo_url,
84
+ "targetBranch": optimize_input.target_branch,
85
+ "targetWorktreePath": optimize_input.target_worktree_path,
86
+ }
87
+ )
88
+ return payload
89
+
90
+ training_input = cast(TrainingInput, bot_input)
91
+ payload.update(
92
+ {
93
+ "targetRepoUrl": training_input.target_repo_url,
94
+ "targetBranch": training_input.target_branch,
95
+ "targetWorktreePath": training_input.target_worktree_path,
96
+ }
97
+ )
98
+ return payload
99
+
100
+
101
+ def backend_headers(
102
+ storage_client: FreesoloStorageClient,
103
+ *,
104
+ track_usage: bool = False,
105
+ ) -> dict[str, str]:
106
+ headers = {
107
+ "Authorization": f"Bearer {storage_client.api_key}",
108
+ "Content-Type": "application/json",
109
+ }
110
+ if track_usage:
111
+ headers["X-Freesolo-Function"] = "freesolo_agent.run_backend_training_job"
112
+ return headers
113
+
114
+
115
+ def backend_json_response(response: httpx.Response, path: str) -> dict[str, Any]:
116
+ try:
117
+ payload = response.json() if response.content else {}
118
+ except ValueError:
119
+ payload = {}
120
+ if response.is_error:
121
+ raise RuntimeError(backend_error_message(payload, f"Freesolo {path} failed"))
122
+ if not isinstance(payload, dict):
123
+ raise RuntimeError(f"Freesolo {path} returned a non-object response")
124
+ return payload
125
+
126
+
127
+ def backend_error_message(payload: object, fallback: str) -> str:
128
+ if not isinstance(payload, dict):
129
+ return fallback
130
+ error = payload.get("error")
131
+ if isinstance(error, str) and error.strip():
132
+ return error
133
+ detail = payload.get("detail")
134
+ if isinstance(detail, str) and detail.strip():
135
+ return detail
136
+ return fallback
137
+
138
+
139
+ def job_from_response(payload: dict[str, Any]) -> dict[str, Any]:
140
+ job = payload.get("job")
141
+ if not isinstance(job, dict):
142
+ raise RuntimeError("Freesolo training job response did not include a job object")
143
+ return dict(job)
144
+
145
+
146
+ def job_id(job: Mapping[str, Any]) -> str:
147
+ value = job.get("jobId")
148
+ if not isinstance(value, str) or not value:
149
+ raise RuntimeError("Freesolo training job response missing jobId")
150
+ return value
151
+
152
+
153
+ def job_status(job: Mapping[str, Any]) -> str:
154
+ value = job.get("status")
155
+ if not isinstance(value, str) or not value:
156
+ raise RuntimeError("Freesolo training job response missing status")
157
+ return value
158
+
159
+
160
+ def job_result(job: Mapping[str, Any]) -> dict[str, Any]:
161
+ result = job.get("result")
162
+ if not isinstance(result, dict):
163
+ raise RuntimeError("Freesolo training job succeeded without a result object")
164
+ return dict(result)
165
+
166
+
167
+ async def run_backend_training_job(
168
+ operation: str,
169
+ org_id: str,
170
+ bot_input: TrainingInputPayload,
171
+ dataset_id: str | None = None,
172
+ ) -> tuple[dict[str, Any], str]:
173
+ storage_client = FreesoloStorageClient()
174
+ enqueue_headers = backend_headers(storage_client, track_usage=True)
175
+ poll_headers = backend_headers(storage_client)
176
+ poll_interval_seconds = job_poll_interval_seconds()
177
+ payload = backend_training_payload(operation, org_id, bot_input, dataset_id=dataset_id)
178
+
179
+ async with httpx.AsyncClient(timeout=storage_client.timeout_seconds) as client:
180
+ enqueue_response = await client.post(
181
+ f"{storage_client.base_url}{TRAINING_JOBS_PATH}",
182
+ headers=enqueue_headers,
183
+ json=payload,
184
+ )
185
+ job = job_from_response(backend_json_response(enqueue_response, TRAINING_JOBS_PATH))
186
+ current_job_id = job_id(job)
187
+ print_human(f"Queued backend {operation} job {current_job_id}.")
188
+ return await poll_backend_training_job(
189
+ client=client,
190
+ base_url=storage_client.base_url,
191
+ headers=poll_headers,
192
+ job_id=current_job_id,
193
+ initial_job=job,
194
+ poll_interval_seconds=poll_interval_seconds,
195
+ )
196
+
197
+
198
+ async def poll_backend_training_job(
199
+ *,
200
+ client: httpx.AsyncClient,
201
+ base_url: str,
202
+ headers: dict[str, str],
203
+ job_id: str,
204
+ initial_job: dict[str, Any],
205
+ poll_interval_seconds: float,
206
+ ) -> tuple[dict[str, Any], str]:
207
+ job = initial_job
208
+ last_status: str | None = None
209
+ last_heartbeat_at = 0.0
210
+ path = f"{TRAINING_JOBS_PATH}/{job_id}"
211
+ while True:
212
+ status = job_status(job)
213
+ now = time.monotonic()
214
+ if status != last_status:
215
+ print_human(f"Backend job {job_id} is {status}.")
216
+ last_status = status
217
+ last_heartbeat_at = now
218
+ elif now - last_heartbeat_at >= JOB_HEARTBEAT_SECONDS:
219
+ print_human(f"Backend job {job_id} is still {status}.")
220
+ last_heartbeat_at = now
221
+
222
+ if status == "succeeded":
223
+ return job_result(job), job_id
224
+ if status in {"failed", "cancelled"}:
225
+ error = job.get("error")
226
+ if not isinstance(error, str) or not error.strip():
227
+ error = f"Backend training job {job_id} {status}"
228
+ raise RuntimeError(error)
229
+
230
+ await asyncio.sleep(poll_interval_seconds)
231
+ poll_response = await client.get(f"{base_url}{path}", headers=headers)
232
+ job = job_from_response(backend_json_response(poll_response, path))
@@ -0,0 +1 @@
1
+ """Console command entrypoints."""
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ import traceback
7
+ from typing import cast
8
+
9
+ from client.auth import require_freesolo_api_key, verify_freesolo_api_key
10
+ from client.jobs import run_backend_training_job
11
+ from config import HELP_COMMANDS, INTERACTIVE_COMMANDS
12
+ from client.datasets import sync_dataset_if_present
13
+ from commands.setup import run_interactive
14
+ from core.output import print_human, write_json
15
+ from core.types import JsonDict
16
+ from core.validation import validate_request
17
+
18
+
19
+ def load_dotenv_if_available() -> None:
20
+ try:
21
+ from dotenv import load_dotenv
22
+ except ImportError:
23
+ return
24
+ load_dotenv()
25
+
26
+
27
+ def read_input() -> str:
28
+ if len(sys.argv) > 1:
29
+ return sys.argv[1]
30
+ return sys.stdin.read()
31
+
32
+
33
+ def usage() -> str:
34
+ return "\n".join(
35
+ [
36
+ "Usage:",
37
+ " freesolo",
38
+ " freesolo setup",
39
+ " freesolo '<json request>'",
40
+ "",
41
+ "No-argument TTY usage starts interactive setup. JSON usage enqueues backend",
42
+ "draft, edit, optimize, and training jobs, then polls until completion.",
43
+ "Pass top-level orgId for backend jobs.",
44
+ "Pass input.datasetPath as a local dataset file or folder path when you want",
45
+ "the backend worker to use uploaded data as a read-only source.",
46
+ "Pass environmentVariables or input.environmentVariables as KEY/value pairs",
47
+ "to make ephemeral env vars available only during the backend worker run.",
48
+ ]
49
+ )
50
+
51
+
52
+ async def run() -> int:
53
+ try:
54
+ load_dotenv_if_available()
55
+
56
+ if len(sys.argv) > 1 and sys.argv[1] in HELP_COMMANDS:
57
+ print(usage())
58
+ return 0
59
+
60
+ if (len(sys.argv) > 1 and sys.argv[1] in INTERACTIVE_COMMANDS) or (
61
+ len(sys.argv) == 1 and sys.stdin.isatty()
62
+ ):
63
+ return await run_interactive()
64
+
65
+ parsed_json = json.loads(read_input())
66
+ operation, bot_input, org_id = validate_request(parsed_json)
67
+ require_freesolo_api_key()
68
+ verify_freesolo_api_key()
69
+ dataset_id = sync_dataset_if_present(bot_input)
70
+ result_json, job_id = await run_backend_training_job(
71
+ operation,
72
+ org_id,
73
+ bot_input,
74
+ dataset_id=dataset_id,
75
+ )
76
+ if dataset_id:
77
+ result_json["datasetId"] = dataset_id
78
+ write_json(
79
+ sys.stdout,
80
+ cast(JsonDict, {"ok": True, "result": result_json, "jobId": job_id}),
81
+ )
82
+ return 0
83
+ except ValueError as error:
84
+ print_human(str(error))
85
+ write_json(sys.stderr, {"ok": False, "error": str(error)})
86
+ return 1
87
+ except Exception as error:
88
+ traceback.print_exc(file=sys.stderr)
89
+ write_json(sys.stderr, {"ok": False, "error": str(error)})
90
+ return 1
91
+
92
+
93
+ def main() -> None:
94
+ raise SystemExit(asyncio.run(run()))
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import os
5
+ import sys
6
+ import webbrowser
7
+ from typing import cast
8
+
9
+ from client.auth import verify_freesolo_api_key
10
+ from client.jobs import run_backend_training_job
11
+ from config import GITHUB_APP_INSTALL_URL, MAX_ENVIRONMENT_VARIABLES
12
+ from client.datasets import sync_dataset_if_present
13
+ from core.output import print_human, write_json
14
+ from core.types import DraftInput, JsonDict
15
+ from core.validation import (
16
+ required_string,
17
+ validate_dataset_path,
18
+ validate_environment_variables,
19
+ )
20
+
21
+
22
+ def prompt_required_line(label: str, *, default: str | None = None) -> str:
23
+ default_suffix = f" [{default}]" if default else ""
24
+ while True:
25
+ print(f"{label}{default_suffix}: ", file=sys.stderr, end="", flush=True)
26
+ value = sys.stdin.readline()
27
+ if value == "":
28
+ raise EOFError(f"Missing input for {label}")
29
+ trimmed = value.strip()
30
+ if trimmed:
31
+ return trimmed
32
+ if default:
33
+ return default
34
+ print_human(f"{label} is required.")
35
+
36
+
37
+ def prompt_optional_line(label: str) -> str | None:
38
+ print(f"{label} (optional, press Enter to skip): ", file=sys.stderr, end="", flush=True)
39
+ value = sys.stdin.readline()
40
+ if value == "":
41
+ return None
42
+ trimmed = value.strip()
43
+ return trimmed or None
44
+
45
+
46
+ def prompt_optional_dataset_path() -> str | None:
47
+ while True:
48
+ try:
49
+ return validate_dataset_path(prompt_optional_line("Dataset file or folder path"))
50
+ except ValueError as error:
51
+ print_human(str(error))
52
+
53
+
54
+ def prompt_environment_variables() -> dict[str, str]:
55
+ variables: dict[str, str] = {}
56
+ print_human("Environment variables for the backend training worker are optional.")
57
+ print_human("Enter variable names one at a time. Press Enter on the name prompt to finish.")
58
+ while True:
59
+ raw_name = prompt_optional_line("Environment variable name")
60
+ if raw_name is None:
61
+ return variables
62
+ name = raw_name.strip()
63
+ try:
64
+ validate_environment_variables({name: ""}, "environmentVariables")
65
+ except ValueError as error:
66
+ print_human(str(error))
67
+ continue
68
+ variables[name] = getpass.getpass(f"Value for {name}: ", stream=sys.stderr)
69
+ if len(variables) >= MAX_ENVIRONMENT_VARIABLES:
70
+ print_human(f"Reached the limit of {MAX_ENVIRONMENT_VARIABLES} variables.")
71
+ return variables
72
+
73
+
74
+ def prompt_secret(label: str) -> str:
75
+ while True:
76
+ try:
77
+ value = getpass.getpass(f"{label}: ", stream=sys.stderr).strip()
78
+ except EOFError as error:
79
+ raise EOFError(f"Missing input for {label}") from error
80
+ if value:
81
+ return value
82
+ print_human(f"{label} is required.")
83
+
84
+
85
+ def ensure_freesolo_api_key() -> None:
86
+ if os.getenv("FREESOLO_API_KEY"):
87
+ try:
88
+ verify_freesolo_api_key()
89
+ print_human("Using FREESOLO_API_KEY from environment.")
90
+ return
91
+ except ValueError as error:
92
+ print_human(str(error))
93
+ print_human("Enter a different Freesolo API key.")
94
+
95
+ while True:
96
+ os.environ["FREESOLO_API_KEY"] = prompt_secret("Freesolo API key")
97
+ try:
98
+ verify_freesolo_api_key()
99
+ print_human("Freesolo API key accepted.")
100
+ return
101
+ except ValueError as error:
102
+ print_human(str(error))
103
+
104
+
105
+ def prompt_multiline_description() -> str:
106
+ while True:
107
+ print_human("Describe what you are trying to do.")
108
+ print_human("Finish with a blank line.")
109
+ lines: list[str] = []
110
+ while True:
111
+ line = sys.stdin.readline()
112
+ if line == "":
113
+ if not lines:
114
+ raise EOFError("Missing prompt")
115
+ break
116
+ stripped = line.rstrip("\n")
117
+ if not stripped:
118
+ if lines:
119
+ break
120
+ print_human("Prompt is required.")
121
+ continue
122
+ lines.append(stripped)
123
+
124
+ prompt = "\n".join(lines)
125
+ try:
126
+ return required_string(prompt, "prompt", 20, 8_000)
127
+ except ValueError as error:
128
+ print_human(str(error))
129
+
130
+
131
+ def offer_github_app_install() -> None:
132
+ print_human("If the source repository is private, connect the Freesolo GitHub App:")
133
+ print_human(GITHUB_APP_INSTALL_URL)
134
+ opened = webbrowser.open(GITHUB_APP_INSTALL_URL, new=2)
135
+ if not opened:
136
+ print_human("Open the URL above in your browser.")
137
+ print(
138
+ "Press Enter once GitHub access is connected, or press Enter to continue for public repos: ",
139
+ file=sys.stderr,
140
+ end="",
141
+ flush=True,
142
+ )
143
+ sys.stdin.readline()
144
+
145
+
146
+ async def run_interactive() -> int:
147
+ ensure_freesolo_api_key()
148
+ org_id = required_string(
149
+ prompt_required_line("Freesolo org ID"),
150
+ "orgId",
151
+ 1,
152
+ 100,
153
+ )
154
+
155
+ source_repo_url = required_string(
156
+ prompt_required_line("Source repository URL"),
157
+ "sourceRepoUrl",
158
+ 1,
159
+ 300,
160
+ )
161
+ offer_github_app_install()
162
+ prompt = prompt_multiline_description()
163
+ source_branch = prompt_optional_line("Source branch")
164
+ dataset_path = prompt_optional_dataset_path()
165
+ environment_variables = prompt_environment_variables()
166
+ input_data = DraftInput(
167
+ source_repo_url=source_repo_url,
168
+ source_branch=source_branch,
169
+ prompt=prompt,
170
+ dataset_path=dataset_path,
171
+ environment_variables=environment_variables,
172
+ )
173
+ print_human("Setup inputs collected. Queuing backend draft job.")
174
+ dataset_id = sync_dataset_if_present(input_data)
175
+ result_json, job_id = await run_backend_training_job(
176
+ "draft",
177
+ org_id,
178
+ input_data,
179
+ dataset_id=dataset_id,
180
+ )
181
+ if dataset_id:
182
+ result_json["datasetId"] = dataset_id
183
+ target_repo_url = result_json.get("targetRepoUrl")
184
+ if isinstance(target_repo_url, str) and target_repo_url:
185
+ print_human(f"Drafted training contract in {target_repo_url}")
186
+ write_json(
187
+ sys.stdout,
188
+ cast(JsonDict, {"ok": True, "result": result_json, "jobId": job_id}),
189
+ )
190
+ return 0
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ HELP_COMMANDS = {"help", "-h", "--help"}
6
+ INTERACTIVE_COMMANDS = {"setup"}
7
+
8
+ TRAINING_JOBS_PATH = "/api/training/jobs"
9
+ DEFAULT_JOB_POLL_INTERVAL_SECONDS = 5.0
10
+ JOB_HEARTBEAT_SECONDS = 30.0
11
+
12
+ GITHUB_APP_SLUG = "freesolo-agent"
13
+ GITHUB_APP_INSTALL_URL = f"https://github.com/apps/{GITHUB_APP_SLUG}/installations/new"
14
+
15
+ MAX_ENVIRONMENT_VARIABLES = 100
16
+ MAX_ENVIRONMENT_VALUE_LENGTH = 100_000
17
+ ENVIRONMENT_VARIABLE_NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
@@ -0,0 +1 @@
1
+ """Shared public CLI helpers."""
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import TextIO
6
+
7
+ from core.types import JsonDict
8
+
9
+
10
+ def print_human(message: str = "") -> None:
11
+ print(message, file=sys.stderr, flush=True)
12
+
13
+
14
+ def write_json(stream: TextIO, payload: JsonDict) -> None:
15
+ stream.write(f"{json.dumps(payload, separators=(',', ':'))}\n")
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TypeAlias
5
+
6
+ JsonValue: TypeAlias = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
7
+ JsonDict: TypeAlias = dict[str, JsonValue]
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class DraftInput:
12
+ source_repo_url: str
13
+ prompt: str
14
+ source_branch: str | None = None
15
+ dataset_path: str | None = None
16
+ environment_variables: dict[str, str] = field(default_factory=dict)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class EditInput:
21
+ target_repo_url: str
22
+ prompt: str
23
+ target_branch: str | None = None
24
+ target_worktree_path: str | None = None
25
+ dataset_path: str | None = None
26
+ environment_variables: dict[str, str] = field(default_factory=dict)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class OptimizeInput:
31
+ target_repo_url: str
32
+ target_branch: str | None = None
33
+ target_worktree_path: str | None = None
34
+ dataset_path: str | None = None
35
+ environment_variables: dict[str, str] = field(default_factory=dict)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TrainingInput:
40
+ target_repo_url: str
41
+ target_branch: str | None = None
42
+ target_worktree_path: str | None = None
43
+ dataset_path: str | None = None
44
+ environment_variables: dict[str, str] = field(default_factory=dict)
45
+
46
+
47
+ TrainingInputPayload: TypeAlias = DraftInput | EditInput | OptimizeInput | TrainingInput
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from pathlib import Path
5
+
6
+ from config import (
7
+ ENVIRONMENT_VARIABLE_NAME_PATTERN,
8
+ MAX_ENVIRONMENT_VALUE_LENGTH,
9
+ MAX_ENVIRONMENT_VARIABLES,
10
+ )
11
+ from core.types import DraftInput, EditInput, OptimizeInput, TrainingInput
12
+
13
+
14
+ def as_mapping(value: object) -> Mapping[str, object]:
15
+ if not isinstance(value, dict):
16
+ raise ValueError("Input must be a JSON object")
17
+ return value
18
+
19
+
20
+ def optional_string(value: object, name: str, max_length: int) -> str | None:
21
+ if value is None or value == "":
22
+ return None
23
+ if not isinstance(value, str):
24
+ raise ValueError(f"{name} must be a string")
25
+ trimmed = value.strip()
26
+ if not trimmed:
27
+ return None
28
+ if len(trimmed) > max_length:
29
+ raise ValueError(f"{name} must be at most {max_length} characters")
30
+ return trimmed
31
+
32
+
33
+ def required_string(value: object, name: str, min_length: int, max_length: int) -> str:
34
+ if not isinstance(value, str):
35
+ raise ValueError(f"{name} must be a string")
36
+ trimmed = value.strip()
37
+ if len(trimmed) < min_length:
38
+ raise ValueError(f"{name} must be at least {min_length} characters")
39
+ if len(trimmed) > max_length:
40
+ raise ValueError(f"{name} must be at most {max_length} characters")
41
+ return trimmed
42
+
43
+
44
+ def validate_dataset_path(value: object) -> str | None:
45
+ raw_path = optional_string(value, "datasetPath", 2_000)
46
+ if raw_path is None:
47
+ return None
48
+
49
+ try:
50
+ resolved = Path(raw_path).expanduser().resolve(strict=True)
51
+ except OSError as error:
52
+ raise ValueError(f"datasetPath must exist: {raw_path}") from error
53
+ if not resolved.is_file() and not resolved.is_dir():
54
+ raise ValueError("datasetPath must be a file or folder")
55
+ return str(resolved)
56
+
57
+
58
+ def validate_environment_variables(value: object, name: str) -> dict[str, str]:
59
+ if value is None:
60
+ return {}
61
+ if not isinstance(value, dict):
62
+ raise ValueError(f"{name} must be an object of environment variable names to values")
63
+ if len(value) > MAX_ENVIRONMENT_VARIABLES:
64
+ raise ValueError(f"{name} must include at most {MAX_ENVIRONMENT_VARIABLES} variables")
65
+
66
+ variables: dict[str, str] = {}
67
+ for raw_key, raw_value in value.items():
68
+ if not isinstance(raw_key, str):
69
+ raise ValueError(f"{name} keys must be strings")
70
+ key = raw_key.strip()
71
+ if not ENVIRONMENT_VARIABLE_NAME_PATTERN.fullmatch(key):
72
+ raise ValueError(f"{name} key {raw_key!r} must be a valid environment variable name")
73
+ if not isinstance(raw_value, str):
74
+ raise ValueError(f"{name}.{key} must be a string")
75
+ if len(raw_value) > MAX_ENVIRONMENT_VALUE_LENGTH:
76
+ raise ValueError(f"{name}.{key} is too large")
77
+ variables[key] = raw_value
78
+ return variables
79
+
80
+
81
+ def merged_environment_variables(
82
+ request_environment_variables: dict[str, str],
83
+ input_payload: Mapping[str, object],
84
+ ) -> dict[str, str]:
85
+ variables = dict(request_environment_variables)
86
+ variables.update(
87
+ validate_environment_variables(
88
+ input_payload.get("environmentVariables"),
89
+ "input.environmentVariables",
90
+ )
91
+ )
92
+ return variables
93
+
94
+
95
+ def validate_draft_input(
96
+ value: object,
97
+ request_environment_variables: dict[str, str] | None = None,
98
+ ) -> DraftInput:
99
+ payload = as_mapping(value)
100
+ return DraftInput(
101
+ source_repo_url=required_string(payload.get("sourceRepoUrl"), "sourceRepoUrl", 1, 300),
102
+ source_branch=optional_string(payload.get("sourceBranch"), "sourceBranch", 120),
103
+ prompt=required_string(payload.get("prompt"), "prompt", 20, 8_000),
104
+ dataset_path=validate_dataset_path(payload.get("datasetPath")),
105
+ environment_variables=merged_environment_variables(
106
+ request_environment_variables or {},
107
+ payload,
108
+ ),
109
+ )
110
+
111
+
112
+ def validate_edit_input(
113
+ value: object,
114
+ request_environment_variables: dict[str, str] | None = None,
115
+ ) -> EditInput:
116
+ payload = as_mapping(value)
117
+ return EditInput(
118
+ target_repo_url=required_string(payload.get("targetRepoUrl"), "targetRepoUrl", 1, 300),
119
+ target_branch=optional_string(payload.get("targetBranch"), "targetBranch", 120),
120
+ target_worktree_path=optional_string(
121
+ payload.get("targetWorktreePath"),
122
+ "targetWorktreePath",
123
+ 2_000,
124
+ ),
125
+ prompt=required_string(payload.get("prompt"), "prompt", 20, 8_000),
126
+ dataset_path=validate_dataset_path(payload.get("datasetPath")),
127
+ environment_variables=merged_environment_variables(
128
+ request_environment_variables or {},
129
+ payload,
130
+ ),
131
+ )
132
+
133
+
134
+ def validate_optimize_input(
135
+ value: object,
136
+ request_environment_variables: dict[str, str] | None = None,
137
+ ) -> OptimizeInput:
138
+ payload = as_mapping(value)
139
+ return OptimizeInput(
140
+ target_repo_url=required_string(payload.get("targetRepoUrl"), "targetRepoUrl", 1, 300),
141
+ target_branch=optional_string(payload.get("targetBranch"), "targetBranch", 120),
142
+ target_worktree_path=optional_string(
143
+ payload.get("targetWorktreePath"),
144
+ "targetWorktreePath",
145
+ 2_000,
146
+ ),
147
+ dataset_path=validate_dataset_path(payload.get("datasetPath")),
148
+ environment_variables=merged_environment_variables(
149
+ request_environment_variables or {},
150
+ payload,
151
+ ),
152
+ )
153
+
154
+
155
+ def validate_training_input(
156
+ value: object,
157
+ request_environment_variables: dict[str, str] | None = None,
158
+ ) -> TrainingInput:
159
+ payload = as_mapping(value)
160
+ return TrainingInput(
161
+ target_repo_url=required_string(payload.get("targetRepoUrl"), "targetRepoUrl", 1, 300),
162
+ target_branch=optional_string(payload.get("targetBranch"), "targetBranch", 120),
163
+ target_worktree_path=optional_string(
164
+ payload.get("targetWorktreePath"),
165
+ "targetWorktreePath",
166
+ 2_000,
167
+ ),
168
+ dataset_path=validate_dataset_path(payload.get("datasetPath")),
169
+ environment_variables=merged_environment_variables(
170
+ request_environment_variables or {},
171
+ payload,
172
+ ),
173
+ )
174
+
175
+
176
+ def validate_request(
177
+ value: object,
178
+ ) -> tuple[str, DraftInput | EditInput | OptimizeInput | TrainingInput, str]:
179
+ payload = as_mapping(value)
180
+ org_id = request_org_id(payload)
181
+ if org_id is None:
182
+ raise ValueError(
183
+ "orgId is required for backend training jobs."
184
+ )
185
+
186
+ request_environment_variables = validate_environment_variables(
187
+ payload.get("environmentVariables"),
188
+ "environmentVariables",
189
+ )
190
+ operation = payload.get("operation")
191
+ if operation == "draft":
192
+ return (
193
+ operation,
194
+ validate_draft_input(payload.get("input"), request_environment_variables),
195
+ org_id,
196
+ )
197
+ if operation == "edit":
198
+ return (
199
+ operation,
200
+ validate_edit_input(payload.get("input"), request_environment_variables),
201
+ org_id,
202
+ )
203
+ if operation == "optimize":
204
+ return (
205
+ operation,
206
+ validate_optimize_input(payload.get("input"), request_environment_variables),
207
+ org_id,
208
+ )
209
+ if operation == "training":
210
+ return (
211
+ operation,
212
+ validate_training_input(payload.get("input"), request_environment_variables),
213
+ org_id,
214
+ )
215
+ raise ValueError("operation must be 'draft', 'edit', 'optimize', or 'training'")
216
+
217
+
218
+ def request_org_id(payload: Mapping[str, object]) -> str | None:
219
+ return optional_string(payload.get("orgId"), "orgId", 100)
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: freesolo-agent
3
+ Version: 0.1.0
4
+ Summary: Thin CLI for queuing Freesolo backend training jobs.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: freesolo>=0.2.4
8
+ Requires-Dist: httpx>=0.27.0
9
+
10
+ # Freesolo Agent CLI
11
+
12
+ Thin command-line client for queuing Freesolo backend training jobs.
13
+
14
+ The public package does not contain the Codex/GitHub/training worker. It only:
15
+
16
+ - validates JSON or interactive setup input
17
+ - verifies `FREESOLO_API_KEY`
18
+ - uploads a local dataset file or folder when `datasetPath` is provided
19
+ - enqueues `/api/training/jobs`
20
+ - polls `/api/training/jobs/{job_id}` until the backend worker finishes
21
+
22
+ The private worker implementation lives under `backend/src/training_agent`.
23
+
24
+ ## Run
25
+
26
+ Interactive setup starts when the CLI is launched from a terminal without JSON
27
+ input:
28
+
29
+ ```bash
30
+ uv run freesolo
31
+ ```
32
+
33
+ Automation can call the JSON interface:
34
+
35
+ ```bash
36
+ uv run freesolo '{"operation":"draft","orgId":"org_123","input":{"sourceRepoUrl":"https://github.com/acme/app.git","prompt":"Create a contract for the feature behavior..."}}'
37
+ ```
38
+
39
+ Supported operations are `draft`, `edit`, `optimize`, and `training`.
40
+
41
+ To provide a local dataset, pass `datasetPath` as either a file or folder path.
42
+ The CLI uploads it to the backend first, then the backend worker materializes it
43
+ as read-only context for the training agent:
44
+
45
+ ```bash
46
+ uv run freesolo '{"operation":"training","orgId":"org_123","input":{"targetRepoUrl":"https://github.com/acme/app-freesolo-training","datasetPath":"/mnt/data/my_dataset"}}'
47
+ ```
48
+
49
+ ## Environment
50
+
51
+ - `FREESOLO_API_KEY`: required.
52
+ - `FREESOLO_BASE_URL`: optional backend URL override. Defaults to production.
53
+ - `FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS`: optional polling interval.
54
+
55
+ Private GitHub app credentials, Codex settings, Tinker dependencies, and training
56
+ runtime configuration belong to the backend worker, not this CLI package.
@@ -0,0 +1,20 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/config.py
4
+ src/client/__init__.py
5
+ src/client/auth.py
6
+ src/client/datasets.py
7
+ src/client/jobs.py
8
+ src/commands/__init__.py
9
+ src/commands/main.py
10
+ src/commands/setup.py
11
+ src/core/__init__.py
12
+ src/core/output.py
13
+ src/core/types.py
14
+ src/core/validation.py
15
+ src/freesolo_agent.egg-info/PKG-INFO
16
+ src/freesolo_agent.egg-info/SOURCES.txt
17
+ src/freesolo_agent.egg-info/dependency_links.txt
18
+ src/freesolo_agent.egg-info/entry_points.txt
19
+ src/freesolo_agent.egg-info/requires.txt
20
+ src/freesolo_agent.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ freesolo = commands.main:main
@@ -0,0 +1,2 @@
1
+ freesolo>=0.2.4
2
+ httpx>=0.27.0
@@ -0,0 +1,4 @@
1
+ client
2
+ commands
3
+ config
4
+ core