gigaflow 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,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: gigaflow
3
+ Version: 0.1.0
4
+ Summary: GigaFlow CLI — connect Arize Phoenix traces to GigaFlow and analyze them
5
+ Author: GigaFlow Team
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/GigaFlow-AI/gigaflow
8
+ Project-URL: Repository, https://github.com/GigaFlow-AI/gigaflow
9
+ Keywords: gigaflow,arize,phoenix,llm,observability,tracing,cli
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Environment :: Console
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
22
+ Requires-Dist: ruff>=0.1.6; extra == "dev"
23
+ Requires-Dist: mypy>=1.7.0; extra == "dev"
24
+
25
+ # gigaflow CLI
26
+
27
+ Command-line interface for GigaFlow — connect Arize Phoenix traces to GigaFlow and analyze them with Atomic Information Flow (AIF).
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install gigaflow
33
+ ```
34
+
35
+ Or from source:
36
+
37
+ ```bash
38
+ cd cli && pip install -e .
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ gigaflow setup # Connect to Arize Phoenix datasource
45
+ gigaflow traces # List traces (auto-syncs first)
46
+ gigaflow spans <trace_id> # List spans for a trace
47
+ gigaflow aif run <trace_id> # Run AIF analysis
48
+ gigaflow aif run <trace_id> --model gpt-4o --show-atoms
49
+ gigaflow sync # Re-sync from datasource
50
+ gigaflow config show # Show saved config
51
+ gigaflow config clear # Reset config
52
+ ```
53
+
54
+ ## Transform config
55
+
56
+ GigaFlow maps raw Arize Phoenix spans to its own primitives (`llm_call`, `tool_invocation`, `user_input`) using a YAML transform config.
57
+
58
+ The built-in config for Arize Phoenix is at:
59
+
60
+ ```
61
+ gigaflow/transforms/arize_phoenix.yml
62
+ ```
63
+
64
+ It maps the OpenInference span columns (`span_kind`, `attributes.*`) to the fields that AIF reads. If your Arize setup uses different attribute paths, copy the file, edit it, and provide the path during `gigaflow setup` when prompted:
65
+
66
+ ```
67
+ Path to transform.yml (leave blank for built-in Arize Phoenix config): /path/to/my_transform.yml
68
+ ```
69
+
70
+ You can also re-upload a transform config to an existing project without re-running the full wizard:
71
+
72
+ ```bash
73
+ curl -X PUT http://localhost:8000/api/v1/projects/<project_id>/transform \
74
+ -H "Content-Type: text/plain" \
75
+ --data-binary @my_transform.yml
76
+ ```
77
+
78
+ After changing the transform config, re-sync to reclassify your spans:
79
+
80
+ ```bash
81
+ gigaflow sync
82
+ ```
83
+
84
+ ### Transform config format
85
+
86
+ ```yaml
87
+ version: "1.0"
88
+ source: arize_phoenix
89
+
90
+ primitives:
91
+ llm_call:
92
+ filter:
93
+ field: span_kind # top-level DB column
94
+ value: "LLM"
95
+ mapping:
96
+ completion: attributes.output.value # read by AIF for response atoms
97
+ model: attributes.llm.model_name
98
+
99
+ tool_invocation:
100
+ filter:
101
+ field: span_kind
102
+ value: "TOOL"
103
+ mapping:
104
+ tool_output: attributes.output.value # read by AIF for tool atoms
105
+ tool_name: attributes.tool.name
106
+
107
+ user_input:
108
+ filter:
109
+ field: span_kind
110
+ value: "CHAIN"
111
+ mapping:
112
+ content: attributes.input.value # read by AIF for the user query
113
+ ```
114
+
115
+ Mapping values are dot-notation paths traversed against each span row. Nested JSON columns (e.g. `attributes`) are parsed automatically.
116
+
117
+ ## Publish to PyPI
118
+
119
+ ```bash
120
+ cd cli
121
+ pip install build twine
122
+ python -m build
123
+ twine upload dist/*
124
+ ```
@@ -0,0 +1,100 @@
1
+ # gigaflow CLI
2
+
3
+ Command-line interface for GigaFlow — connect Arize Phoenix traces to GigaFlow and analyze them with Atomic Information Flow (AIF).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install gigaflow
9
+ ```
10
+
11
+ Or from source:
12
+
13
+ ```bash
14
+ cd cli && pip install -e .
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ gigaflow setup # Connect to Arize Phoenix datasource
21
+ gigaflow traces # List traces (auto-syncs first)
22
+ gigaflow spans <trace_id> # List spans for a trace
23
+ gigaflow aif run <trace_id> # Run AIF analysis
24
+ gigaflow aif run <trace_id> --model gpt-4o --show-atoms
25
+ gigaflow sync # Re-sync from datasource
26
+ gigaflow config show # Show saved config
27
+ gigaflow config clear # Reset config
28
+ ```
29
+
30
+ ## Transform config
31
+
32
+ GigaFlow maps raw Arize Phoenix spans to its own primitives (`llm_call`, `tool_invocation`, `user_input`) using a YAML transform config.
33
+
34
+ The built-in config for Arize Phoenix is at:
35
+
36
+ ```
37
+ gigaflow/transforms/arize_phoenix.yml
38
+ ```
39
+
40
+ It maps the OpenInference span columns (`span_kind`, `attributes.*`) to the fields that AIF reads. If your Arize setup uses different attribute paths, copy the file, edit it, and provide the path during `gigaflow setup` when prompted:
41
+
42
+ ```
43
+ Path to transform.yml (leave blank for built-in Arize Phoenix config): /path/to/my_transform.yml
44
+ ```
45
+
46
+ You can also re-upload a transform config to an existing project without re-running the full wizard:
47
+
48
+ ```bash
49
+ curl -X PUT http://localhost:8000/api/v1/projects/<project_id>/transform \
50
+ -H "Content-Type: text/plain" \
51
+ --data-binary @my_transform.yml
52
+ ```
53
+
54
+ After changing the transform config, re-sync to reclassify your spans:
55
+
56
+ ```bash
57
+ gigaflow sync
58
+ ```
59
+
60
+ ### Transform config format
61
+
62
+ ```yaml
63
+ version: "1.0"
64
+ source: arize_phoenix
65
+
66
+ primitives:
67
+ llm_call:
68
+ filter:
69
+ field: span_kind # top-level DB column
70
+ value: "LLM"
71
+ mapping:
72
+ completion: attributes.output.value # read by AIF for response atoms
73
+ model: attributes.llm.model_name
74
+
75
+ tool_invocation:
76
+ filter:
77
+ field: span_kind
78
+ value: "TOOL"
79
+ mapping:
80
+ tool_output: attributes.output.value # read by AIF for tool atoms
81
+ tool_name: attributes.tool.name
82
+
83
+ user_input:
84
+ filter:
85
+ field: span_kind
86
+ value: "CHAIN"
87
+ mapping:
88
+ content: attributes.input.value # read by AIF for the user query
89
+ ```
90
+
91
+ Mapping values are dot-notation paths traversed against each span row. Nested JSON columns (e.g. `attributes`) are parsed automatically.
92
+
93
+ ## Publish to PyPI
94
+
95
+ ```bash
96
+ cd cli
97
+ pip install build twine
98
+ python -m build
99
+ twine upload dist/*
100
+ ```
@@ -0,0 +1 @@
1
+ """GigaFlow CLI package."""
@@ -0,0 +1,24 @@
1
+ """Persistent CLI configuration stored in ~/.gigaflow/config.json."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ CONFIG_PATH = Path.home() / ".gigaflow" / "config.json"
7
+
8
+
9
+ def load() -> dict:
10
+ if not CONFIG_PATH.exists():
11
+ return {}
12
+ with open(CONFIG_PATH) as f:
13
+ return json.load(f)
14
+
15
+
16
+ def save(config: dict):
17
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
18
+ with open(CONFIG_PATH, "w") as f:
19
+ json.dump(config, f, indent=2)
20
+
21
+
22
+ def clear():
23
+ if CONFIG_PATH.exists():
24
+ CONFIG_PATH.unlink()
@@ -0,0 +1,78 @@
1
+ """Terminal formatting: headers, prompts, tables."""
2
+
3
+ import getpass
4
+ import sys
5
+
6
+ WIDTH = 62
7
+
8
+
9
+ def header(title: str):
10
+ print()
11
+ print("=" * WIDTH)
12
+ print(f" {title}")
13
+ print("=" * WIDTH)
14
+
15
+
16
+ def section(title: str):
17
+ pad = max(0, WIDTH - len(title) - 4)
18
+ print(f"\n── {title} {'─' * pad}")
19
+
20
+
21
+ def ok(msg: str):
22
+ print(f" ✓ {msg}")
23
+
24
+
25
+ def fail(msg: str):
26
+ print(f" ✗ {msg}", file=sys.stderr)
27
+
28
+
29
+ def info(msg: str):
30
+ print(f" {msg}")
31
+
32
+
33
+ def prompt(label: str, default: str = "", required: bool = False) -> str:
34
+ suffix = f" [{default}]" if default else ""
35
+ while True:
36
+ try:
37
+ val = input(f" {label}{suffix}: ").strip()
38
+ except (EOFError, KeyboardInterrupt):
39
+ print()
40
+ sys.exit(0)
41
+ result = val if val else default
42
+ if result or not required:
43
+ return result
44
+ print(f" {label} is required.")
45
+
46
+
47
+ def prompt_password(label: str) -> str:
48
+ try:
49
+ return getpass.getpass(f" {label}: ")
50
+ except (EOFError, KeyboardInterrupt):
51
+ print()
52
+ sys.exit(0)
53
+
54
+
55
+ def table(rows: list, headers: list, max_col: int = 38):
56
+ """Print a simple fixed-width table."""
57
+ if not rows:
58
+ print(" (no results)")
59
+ return
60
+
61
+ widths = [len(h) for h in headers]
62
+ for row in rows:
63
+ for i, cell in enumerate(row):
64
+ widths[i] = min(max(widths[i], len(str(cell))), max_col)
65
+
66
+ fmt = " " + " ".join(f"{{:<{w}}}" for w in widths)
67
+ sep = " " + " ".join("─" * w for w in widths)
68
+
69
+ print()
70
+ print(fmt.format(*headers))
71
+ print(sep)
72
+ for row in rows:
73
+ cells = []
74
+ for i, cell in enumerate(row):
75
+ s = str(cell) if cell is not None else "-"
76
+ cells.append(s[: widths[i] - 1] + "…" if len(s) > widths[i] else s)
77
+ print(fmt.format(*cells))
78
+ print()
@@ -0,0 +1,24 @@
1
+ """Minimal HTTP client (stdlib only)."""
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+
7
+
8
+ def api(base_url: str, method: str, path: str, body=None, content_type: str = "application/json"):
9
+ data = None
10
+ if body is not None:
11
+ data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
12
+ req = urllib.request.Request(f"{base_url}{path}", data=data, method=method)
13
+ req.add_header("Content-Type", content_type)
14
+ try:
15
+ with urllib.request.urlopen(req) as resp:
16
+ return resp.status, json.loads(resp.read())
17
+ except urllib.error.HTTPError as e:
18
+ raw = e.read()
19
+ try:
20
+ return e.code, json.loads(raw)
21
+ except Exception:
22
+ return e.code, {"error": raw.decode()}
23
+ except urllib.error.URLError as e:
24
+ return None, {"error": str(e.reason)}
@@ -0,0 +1,230 @@
1
+ """Interactive setup wizard: project creation, transform upload, datasource registration, sync."""
2
+
3
+ import importlib.resources
4
+ import sys
5
+
6
+ from gigaflow import _config, _fmt
7
+ from gigaflow._http import api
8
+
9
+
10
+ def _load_default_transform() -> str:
11
+ """Load the built-in Arize Phoenix transform config from the package."""
12
+ ref = importlib.resources.files("gigaflow.transforms").joinpath("arize_phoenix.yml")
13
+ return ref.read_text(encoding="utf-8")
14
+
15
+
16
+ def load_env_file(path: str) -> dict:
17
+ """Parse a .env-style file and return key-value pairs.
18
+
19
+ Supports comments (#), blank lines, and optionally quoted values.
20
+ """
21
+ env: dict[str, str] = {}
22
+ try:
23
+ with open(path) as f:
24
+ for line in f:
25
+ line = line.strip()
26
+ if not line or line.startswith("#"):
27
+ continue
28
+ if "=" not in line:
29
+ continue
30
+ key, _, value = line.partition("=")
31
+ key = key.strip()
32
+ value = value.strip()
33
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
34
+ value = value[1:-1]
35
+ if key:
36
+ env[key] = value
37
+ except OSError as e:
38
+ _fmt.fail(f"Could not read env file: {e}")
39
+ return env
40
+
41
+ ARIZE_TRANSFORM_YAML = _load_default_transform()
42
+
43
+
44
+ def check_backend(base_url: str) -> bool:
45
+ status, resp = api(base_url, "GET", "/health")
46
+ if status is None:
47
+ _fmt.fail(f"Could not reach gigaflow backend at {base_url}")
48
+ _fmt.info("Make sure the backend is running: cd backend && make run")
49
+ return False
50
+ if status != 200:
51
+ _fmt.fail(f"Backend returned {status}: {resp}")
52
+ return False
53
+ _fmt.ok(f"Backend reachable at {base_url}")
54
+ return True
55
+
56
+
57
+ def create_project(base_url: str, name: str) -> str | None:
58
+ status, resp = api(base_url, "POST", "/projects/", {"name": name})
59
+ if status != 200:
60
+ _fmt.fail(f"Failed to create project ({status}): {resp}")
61
+ return None
62
+ project_id = resp["project_id"]
63
+ _fmt.ok(f"Project created: {name}")
64
+ _fmt.info(f"project_id: {project_id}")
65
+ return project_id
66
+
67
+
68
+ def upload_transform(base_url: str, project_id: str, yaml_content: str = ARIZE_TRANSFORM_YAML) -> bool:
69
+ status, resp = api(
70
+ base_url, "PUT", f"/projects/{project_id}/transform",
71
+ yaml_content, content_type="text/plain",
72
+ )
73
+ if status != 200:
74
+ _fmt.fail(f"Failed to upload transform config ({status}): {resp}")
75
+ return False
76
+ primitives = list(resp.get("transform_config", {}).get("primitives", {}).keys())
77
+ _fmt.ok("Transform config uploaded")
78
+ _fmt.info(f"primitives: {', '.join(primitives)}")
79
+ return True
80
+
81
+
82
+ def register_datasource(base_url: str, project_id: str, connection_url: str, source_table: str) -> str | None:
83
+ status, resp = api(base_url, "POST", "/datasources/", {
84
+ "project_id": project_id,
85
+ "name": "arize-phoenix",
86
+ "connection_url": connection_url,
87
+ "source_table": source_table,
88
+ })
89
+ if status != 200:
90
+ _fmt.fail(f"Failed to register datasource ({status}): {resp}")
91
+ return None
92
+ datasource_id = resp["datasource_id"]
93
+ _fmt.ok("Datasource registered")
94
+ _fmt.info(f"datasource_id: {datasource_id}")
95
+ return datasource_id
96
+
97
+
98
+ def do_sync(base_url: str, datasource_id: str) -> tuple[int, int] | None:
99
+ status, resp = api(base_url, "POST", f"/datasources/{datasource_id}/sync")
100
+ if status != 200:
101
+ _fmt.fail(f"Sync failed ({status}): {resp.get('detail', resp)}")
102
+ detail = str(resp.get("detail", ""))
103
+ if "connect" in detail.lower() or status == 502:
104
+ _fmt.info("Could not connect to the source database.")
105
+ _fmt.info("If Arize is running in Docker, try 'host.docker.internal' as the host.")
106
+ return None
107
+ synced_traces = resp.get("synced_traces", 0)
108
+ synced_spans = resp.get("synced_spans", 0)
109
+ _fmt.ok(f"Sync complete: {synced_traces} trace(s), {synced_spans} span(s)")
110
+ return synced_traces, synced_spans
111
+
112
+
113
+ def run_wizard(base_url: str) -> dict | None:
114
+ """
115
+ Interactive wizard. Returns saved config dict on success, None on failure.
116
+ """
117
+ _fmt.header("GigaFlow Setup Wizard")
118
+
119
+ # ── Env file (optional) ───────────────────────────────────────────────────
120
+ env_path = _fmt.prompt("Path to gigaflow.env (leave blank to enter values manually)")
121
+ if env_path:
122
+ env = load_env_file(env_path)
123
+ if env:
124
+ _fmt.ok(f"Loaded env file: {env_path}")
125
+ else:
126
+ env = {}
127
+
128
+ # ── Step 1: backend ───────────────────────────────────────────────────────
129
+ _fmt.section("Step 1: GigaFlow backend")
130
+ if not check_backend(base_url):
131
+ return None
132
+
133
+ # ── Step 2: project ───────────────────────────────────────────────────────
134
+ _fmt.section("Step 2: Project")
135
+ project_name = _fmt.prompt("Project name", env.get("GIGAFLOW_PROJECT_NAME", "arize-phoenix-project"))
136
+ project_id = create_project(base_url, project_name)
137
+ if not project_id:
138
+ return None
139
+
140
+ transform_path = _fmt.prompt(
141
+ "Path to transform.yml (leave blank for built-in Arize Phoenix config)",
142
+ env.get("GIGAFLOW_TRANSFORM_YML", ""),
143
+ )
144
+ if transform_path:
145
+ try:
146
+ with open(transform_path) as f:
147
+ yaml_content = f.read()
148
+ _fmt.ok(f"Loaded transform file: {transform_path}")
149
+ except OSError as e:
150
+ _fmt.fail(f"Could not read transform file: {e}")
151
+ return None
152
+ else:
153
+ yaml_content = ARIZE_TRANSFORM_YAML
154
+ _fmt.info("Using built-in Arize Phoenix transform config")
155
+
156
+ if not upload_transform(base_url, project_id, yaml_content):
157
+ return None
158
+
159
+ # ── Step 3: Arize Phoenix DB ──────────────────────────────────────────────
160
+ _fmt.section("Step 3: Arize Phoenix database")
161
+ print()
162
+ print(" Enter the connection details for the PostgreSQL database")
163
+ print(" that Arize Phoenix writes to.")
164
+ print()
165
+ print(" Tip: if GigaFlow is running in Docker (the default),")
166
+ print(" use 'host.docker.internal' to reach the host machine.")
167
+ print()
168
+ print(" Find the Arize DB port with:")
169
+ print(" docker ps --filter name=arize_agent_example-db --format '{{.Ports}}'")
170
+ print()
171
+
172
+ host = _fmt.prompt("Host", env.get("GIGAFLOW_DB_HOST", "host.docker.internal"))
173
+ port = _fmt.prompt("Port", env.get("GIGAFLOW_DB_PORT", ""), required=True)
174
+ user = _fmt.prompt("User", env.get("GIGAFLOW_DB_USER", "postgres"))
175
+
176
+ if env.get("GIGAFLOW_DB_PASSWORD"):
177
+ password = env["GIGAFLOW_DB_PASSWORD"]
178
+ _fmt.info("Password: [from env file]")
179
+ else:
180
+ password = _fmt.prompt_password("Password")
181
+
182
+ db = _fmt.prompt("Database", env.get("GIGAFLOW_DB_NAME", "postgres"))
183
+ table = _fmt.prompt("Source table", env.get("GIGAFLOW_DB_TABLE", "spans"))
184
+
185
+ connection_url = f"postgresql://{user}:{password}@{host}:{port}/{db}"
186
+
187
+ # ── Step 4: register + sync ───────────────────────────────────────────────
188
+ _fmt.section("Step 4: Register datasource & sync")
189
+ datasource_id = register_datasource(base_url, project_id, connection_url, table)
190
+ if not datasource_id:
191
+ return None
192
+
193
+ result = do_sync(base_url, datasource_id)
194
+ if result is None:
195
+ return None
196
+
197
+ synced_traces, _ = result
198
+ if synced_traces > 0:
199
+ _show_span_preview(base_url, project_id)
200
+
201
+ config: dict = {
202
+ "backend_url": base_url,
203
+ "project_id": project_id,
204
+ "datasource_id": datasource_id,
205
+ }
206
+ _config.save(config)
207
+ _fmt.ok(f"Configuration saved to {_config.CONFIG_PATH}")
208
+ return config
209
+
210
+
211
+ def _show_span_preview(base_url: str, project_id: str):
212
+ status, resp = api(base_url, "GET", f"/traces/?project_id={project_id}")
213
+ if status != 200:
214
+ return
215
+ traces = resp.get("traces", [])
216
+ if not traces:
217
+ return
218
+ trace_id = traces[0]["trace_id"]
219
+ status, resp = api(base_url, "GET", f"/traces/{trace_id}/spans")
220
+ if status != 200:
221
+ return
222
+ spans = resp if isinstance(resp, list) else resp.get("spans", [])
223
+ classified = [s for s in spans if s.get("primitive_type")]
224
+ unclassified = [s for s in spans if not s.get("primitive_type")]
225
+ _fmt.info(f"Sample trace — {len(classified)} classified, {len(unclassified)} unclassified")
226
+ for ptype in ["llm_call", "tool_invocation", "user_input"]:
227
+ match = next((s for s in spans if s.get("primitive_type") == ptype), None)
228
+ if match:
229
+ pd = match.get("primitive_data") or {}
230
+ _fmt.info(f" {ptype}: {pd}")
@@ -0,0 +1,90 @@
1
+ """
2
+ gigaflow — CLI entry point.
3
+
4
+ Commands:
5
+ gigaflow run aif [TRACE_ID] Run AIF analysis (interactive if TRACE_ID omitted)
6
+ gigaflow setup Configure GigaFlow with an Arize Phoenix datasource
7
+ gigaflow sync Re-sync traces from the configured datasource
8
+ gigaflow traces List all traces (auto-syncs first)
9
+ gigaflow spans <trace_id> List spans for a trace (auto-syncs first)
10
+ gigaflow inspect <trace_id> Visualize a trace (orchestration graph + full span data)
11
+ gigaflow projects List all projects
12
+ gigaflow config show Show saved configuration
13
+ gigaflow config clear Clear saved configuration
14
+ """
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+
20
+ from gigaflow import _config
21
+ from gigaflow._setup import load_env_file
22
+ from gigaflow.commands import config, inspect, projects, run, setup, traces
23
+
24
+
25
+ def _build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="gigaflow",
28
+ description="GigaFlow CLI — connect Arize Phoenix traces to GigaFlow and analyze them.",
29
+ formatter_class=argparse.RawDescriptionHelpFormatter,
30
+ epilog="""
31
+ examples:
32
+ gigaflow run aif
33
+ gigaflow run aif <trace_id>
34
+ gigaflow setup
35
+ gigaflow traces
36
+ gigaflow spans <trace_id>
37
+ gigaflow inspect <trace_id>
38
+ gigaflow inspect <trace_id> --cli
39
+ gigaflow inspect <trace_id> --port 8080
40
+ gigaflow sync
41
+ gigaflow config show
42
+ gigaflow config clear
43
+ """,
44
+ )
45
+ parser.add_argument(
46
+ "--backend",
47
+ metavar="URL",
48
+ default=None,
49
+ help="GigaFlow API base URL (default: from config or http://localhost:8000/api/v1)",
50
+ )
51
+ parser.add_argument(
52
+ "--env-file",
53
+ metavar="PATH",
54
+ default=None,
55
+ help="Path to gigaflow.env (default: auto-detects gigaflow.env in current directory)",
56
+ )
57
+
58
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
59
+ run.register(sub)
60
+ setup.register(sub)
61
+ traces.register(sub)
62
+ inspect.register(sub)
63
+ projects.register(sub)
64
+ config.register(sub)
65
+
66
+ return parser
67
+
68
+
69
+ def main():
70
+ parser = _build_parser()
71
+ args = parser.parse_args()
72
+
73
+ if not hasattr(args, "func"):
74
+ parser.print_help()
75
+ sys.exit(0)
76
+
77
+ # Load gigaflow.env and inject keys into os.environ (without overriding existing vars)
78
+ env_file = args.env_file or ("gigaflow.env" if os.path.exists("gigaflow.env") else None)
79
+ if env_file:
80
+ for key, value in load_env_file(env_file).items():
81
+ os.environ.setdefault(key, value)
82
+
83
+ cfg = _config.load()
84
+ base_url = (args.backend or cfg.get("backend_url") or "http://localhost:8000/api/v1").rstrip("/")
85
+
86
+ args.func(args, base_url)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1 @@
1
+ """CLI command modules — one file per top-level command group."""