rio-cli 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,45 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # Dependencies
4
+ node_modules
5
+ .pnp
6
+ .pnp.js
7
+
8
+ # Local env files
9
+ .env
10
+ .env.local
11
+ .env.development.local
12
+ .env.test.local
13
+ .env.production.local
14
+
15
+ # Testing
16
+ coverage
17
+
18
+ # Turbo
19
+ .turbo
20
+
21
+ # Vercel
22
+ .vercel
23
+
24
+ # Build Outputs
25
+ .next/
26
+ out/
27
+ build
28
+ dist
29
+
30
+
31
+ # Debug
32
+ npm-debug.log*
33
+ yarn-debug.log*
34
+ yarn-error.log*
35
+
36
+ # Misc
37
+ .DS_Store
38
+ *.pem
39
+ .venv/
40
+ __pycache__/
41
+ *.pyc
42
+
43
+ tsconfig.tsbuildinfo
44
+
45
+ .config/
rio_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026, JayTheCoder77
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
rio_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: rio-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line client for Rio, an automated code review tool.
5
+ Project-URL: Homepage, https://github.com/JayTheCoder77/rio
6
+ Project-URL: Repository, https://github.com/JayTheCoder77/rio
7
+ Author: JayTheCoder77
8
+ License-Expression: ISC
9
+ License-File: LICENSE
10
+ Requires-Python: <3.13,>=3.12
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: pyyaml>=6.0
13
+ Requires-Dist: rich>=15.0.0
14
+ Requires-Dist: rio-review-core<0.2.0,>=0.1.0
15
+ Requires-Dist: typer>=0.27.1
16
+ Description-Content-Type: text/markdown
17
+
18
+ # rio-cli
19
+
20
+ The command-line client for [Rio](https://github.com/JayTheCoder77/rio), an automated
21
+ code review tool. Review your local diffs from the terminal, backed by the same
22
+ AI review engine used by Rio's GitHub App.
23
+
24
+ ## Installation
25
+
26
+ ```sh
27
+ pip install rio-cli
28
+ ```
29
+
30
+ This installs the `rio` command.
31
+
32
+ ## Usage
33
+
34
+ ```sh
35
+ # Review currently staged changes
36
+ rio review --staged
37
+
38
+ # Review uncommitted changes (staged + unstaged)
39
+ rio review --uncommitted
40
+
41
+ # Review the last commit
42
+ rio review --committed
43
+
44
+ # Review a diff from a file
45
+ rio review --diff path/to.diff
46
+
47
+ # Include untracked files alongside any of the above
48
+ rio review --staged --include-untracked
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ Rio reads `~/.config/rio/config.toml` for the AI engine URL and API key:
54
+
55
+ ```toml
56
+ [api]
57
+ url = "https://your-rio-instance.example.com"
58
+ api_key = "your-api-key"
59
+ ```
60
+
61
+ If no config file is found, the CLI defaults to `http://localhost:8000` for local
62
+ development against a self-hosted `ai-engine`.
63
+
64
+ ## License
65
+
66
+ [ISC](LICENSE) © 2026 JayTheCoder77
@@ -0,0 +1,49 @@
1
+ # rio-cli
2
+
3
+ The command-line client for [Rio](https://github.com/JayTheCoder77/rio), an automated
4
+ code review tool. Review your local diffs from the terminal, backed by the same
5
+ AI review engine used by Rio's GitHub App.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ pip install rio-cli
11
+ ```
12
+
13
+ This installs the `rio` command.
14
+
15
+ ## Usage
16
+
17
+ ```sh
18
+ # Review currently staged changes
19
+ rio review --staged
20
+
21
+ # Review uncommitted changes (staged + unstaged)
22
+ rio review --uncommitted
23
+
24
+ # Review the last commit
25
+ rio review --committed
26
+
27
+ # Review a diff from a file
28
+ rio review --diff path/to.diff
29
+
30
+ # Include untracked files alongside any of the above
31
+ rio review --staged --include-untracked
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ Rio reads `~/.config/rio/config.toml` for the AI engine URL and API key:
37
+
38
+ ```toml
39
+ [api]
40
+ url = "https://your-rio-instance.example.com"
41
+ api_key = "your-api-key"
42
+ ```
43
+
44
+ If no config file is found, the CLI defaults to `http://localhost:8000` for local
45
+ development against a self-hosted `ai-engine`.
46
+
47
+ ## License
48
+
49
+ [ISC](LICENSE) © 2026 JayTheCoder77
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "rio-cli"
3
+ version = "0.1.0"
4
+ description = "Command-line client for Rio, an automated code review tool."
5
+ readme = "README.md"
6
+ license = "ISC"
7
+ authors = [{ name = "JayTheCoder77" }]
8
+ requires-python = ">=3.12,<3.13"
9
+ dependencies = [
10
+ "rio-review-core>=0.1.0,<0.2.0",
11
+ "httpx>=0.28.1",
12
+ "rich>=15.0.0",
13
+ "typer>=0.27.1",
14
+ "pyyaml>=6.0"
15
+ ]
16
+
17
+ [project.scripts]
18
+ rio = "rio_cli.main:app"
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/JayTheCoder77/rio"
22
+ Repository = "https://github.com/JayTheCoder77/rio"
23
+
24
+ [tool.uv.sources]
25
+ rio-review-core = { workspace = true }
26
+
27
+ [tool.uv]
28
+ package = true
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["rio_cli"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ include = ["rio_cli", "README.md", "LICENSE"]
File without changes
@@ -0,0 +1,40 @@
1
+ from pathlib import Path
2
+
3
+ import tomllib
4
+
5
+ from rio_cli.utils import _fail
6
+
7
+ DEFAULT_AI_ENGINE_URL = "http://localhost:8000"
8
+ CONFIG_PATH = Path.home() / ".config" / "rio" / "config.toml"
9
+
10
+ def get_ai_engine_url() -> str:
11
+ if not CONFIG_PATH.exists():
12
+ return DEFAULT_AI_ENGINE_URL
13
+
14
+ try:
15
+ with CONFIG_PATH.open("rb") as f:
16
+ data = tomllib.load(f)
17
+ except tomllib.TOMLDecodeError as e:
18
+ _fail(f"Error : invalid config file at {CONFIG_PATH} : {e}")
19
+
20
+ url = data.get("api", {}).get("url")
21
+ if not url:
22
+ _fail(f"Error : {CONFIG_PATH} exists but has no [api] url set.")
23
+
24
+ return url
25
+
26
+ def get_api_key() -> str | None:
27
+ if not CONFIG_PATH.exists():
28
+ return None
29
+
30
+ try:
31
+ with CONFIG_PATH.open("rb") as f:
32
+ data = tomllib.load(f)
33
+ except tomllib.TOMLDecodeError as e:
34
+ _fail(f"Error : invalid config file at {CONFIG_PATH} : {e}")
35
+
36
+ api_key = data.get("api" , {}).get("api_key")
37
+ if api_key is None:
38
+ return None
39
+
40
+ return api_key
@@ -0,0 +1,90 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from rio_cli.utils import _fail, _run_git_diff, _try_run_git_diff
5
+
6
+
7
+ def _get_staged_diff() -> str:
8
+ return _run_git_diff(["--staged"], "No staged changes found.")
9
+
10
+ def _get_uncommitted_diff() -> str:
11
+ return _run_git_diff(["HEAD"], "No uncommitted changes found.")
12
+
13
+ def _get_diff_from_file(path: str) -> str:
14
+ try:
15
+ return Path(path).read_text()
16
+ except FileNotFoundError:
17
+ _fail(f"Error: File not found : {path}")
18
+
19
+ def _try_staged_diff() -> str:
20
+ return _try_run_git_diff(["--staged"])
21
+
22
+ def _try_uncommitted_diff() -> str:
23
+ return _try_run_git_diff(["HEAD"])
24
+
25
+ def _try_committed_diff() -> str:
26
+ upstream_check = subprocess.run(
27
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
28
+ capture_output=True,
29
+ text=True,
30
+ check=False,
31
+ )
32
+ if upstream_check.returncode != 0:
33
+ return "" # no upstream configured — treat as "nothing to show", not an error
34
+
35
+ upstream_ref = upstream_check.stdout.strip()
36
+ return _try_run_git_diff([f"{upstream_ref}...HEAD"])
37
+
38
+ def _get_committed_diff() -> str:
39
+ """Hard-fail variant for the explicit --committed flag."""
40
+ upstream_check = subprocess.run(
41
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
42
+ capture_output=True,
43
+ text=True,
44
+ check=False,
45
+ )
46
+ if upstream_check.returncode != 0:
47
+ _fail("Error: no upstream branch configured — can't determine committed-but-unpushed changes.")
48
+
49
+ upstream_ref = upstream_check.stdout.strip()
50
+ return _run_git_diff([f"{upstream_ref}...HEAD"],"No committed (unpushed) changes found.")
51
+
52
+
53
+ def _get_combined_diff() -> str:
54
+ """Default (no-flags) behavior: committed-unpushed + uncommitted, whichever exist."""
55
+ committed = _try_committed_diff()
56
+ uncommitted = _try_run_git_diff(["HEAD"])
57
+
58
+ combined = committed + uncommitted
59
+ if not combined.strip():
60
+ _fail("No committed or uncommitted changes found.")
61
+
62
+ return combined
63
+
64
+ def _get_untracked_files() -> list[str]:
65
+ result = subprocess.run(["git", "ls-files", "--others","--exclude-standard"],
66
+ capture_output=True,
67
+ text=True,
68
+ check=False,
69
+ )
70
+ if result.returncode != 0:
71
+ return []
72
+ return [line for line in result.stdout.splitlines() if line.strip()]
73
+
74
+ def _get_untracked_diff() -> str:
75
+ """Synthetic diffs for untracked files, via git diff --no-index against /dev/null."""
76
+ paths = _get_untracked_files()
77
+ diffs = []
78
+ for path in paths:
79
+ result = subprocess.run(
80
+ ["git", "diff", "--no-index", "/dev/null", path],
81
+ capture_output=True,
82
+ text=True,
83
+ check=False,
84
+ )
85
+ if result.returncode not in (0, 1):
86
+ continue # real error for this file — skip rather than fail the whole command
87
+ if result.stdout.strip():
88
+ diffs.append(result.stdout)
89
+
90
+ return "\n".join(diffs)
@@ -0,0 +1,82 @@
1
+ import typer
2
+
3
+ from rio_cli.git import (
4
+ _get_combined_diff,
5
+ _get_committed_diff,
6
+ _get_diff_from_file,
7
+ _get_staged_diff,
8
+ _get_uncommitted_diff,
9
+ _get_untracked_diff,
10
+ _try_committed_diff,
11
+ _try_staged_diff,
12
+ _try_uncommitted_diff,
13
+ )
14
+ from rio_cli.output import render_findings
15
+ from rio_cli.post import _post_review
16
+ from rio_cli.utils import _fail
17
+
18
+ app = typer.Typer()
19
+
20
+
21
+ @app.callback()
22
+ def callback():
23
+ pass
24
+
25
+ def _resolve_diff(
26
+ staged: bool,
27
+ uncommitted: bool,
28
+ committed: bool,
29
+ diff_file: str | None,
30
+ include_untracked: bool,
31
+ ) -> str:
32
+ if include_untracked:
33
+ # soft: let empty scopes pass through, only fail if everything (incl. untracked) is empty
34
+ if diff_file:
35
+ base = _get_diff_from_file(diff_file)
36
+ elif uncommitted:
37
+ base = _try_uncommitted_diff()
38
+ elif committed:
39
+ base = _try_committed_diff()
40
+ elif staged:
41
+ base = _try_staged_diff()
42
+ else:
43
+ base = _try_committed_diff() + _try_uncommitted_diff()
44
+
45
+ untracked = _get_untracked_diff()
46
+ combined = base + untracked
47
+ if not combined.strip():
48
+ _fail("No changes found (including untracked files).")
49
+ return combined
50
+
51
+ # hard: existing behavior, each scope fails loudly if empty
52
+ if diff_file:
53
+ return _get_diff_from_file(diff_file)
54
+ elif uncommitted:
55
+ return _get_uncommitted_diff()
56
+ elif committed:
57
+ return _get_committed_diff()
58
+ elif staged:
59
+ return _get_staged_diff()
60
+ else:
61
+ return _get_combined_diff()
62
+
63
+ @app.command()
64
+ def review(
65
+ staged : bool = typer.Option(False , "--staged"),
66
+ uncommitted: bool = typer.Option(False, "--uncommitted"),
67
+ committed: bool = typer.Option(False, "--committed"),
68
+ diff_file : str | None = typer.Option(None , "--diff"),
69
+ include_untracked: bool = typer.Option(False, "--include-untracked"),
70
+ ):
71
+ """ Print Findings for a local diff """
72
+ flags_set = sum([staged, uncommitted, committed, diff_file is not None])
73
+ if flags_set > 1:
74
+ _fail("Error: --staged, --uncommitted, --committed, and --diff cannot be used together.")
75
+
76
+ diff_text = _resolve_diff(staged, uncommitted,committed, diff_file, include_untracked)
77
+
78
+ result = _post_review(diff_text)
79
+ render_findings(result)
80
+
81
+ if __name__ == "__main__":
82
+ app()
@@ -0,0 +1,39 @@
1
+ from collections import defaultdict
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rio_core.models import Finding
6
+
7
+ console = Console()
8
+
9
+ SEVERITY_ORDER = ["critical", "warning", "info"]
10
+ SEVERITY_COLOR = {
11
+ "critical": "red",
12
+ "warning": "yellow",
13
+ "info": "cyan",
14
+ }
15
+
16
+ def render_findings(findings : list[Finding]) -> None:
17
+ if not findings:
18
+ console.print("[green]No findings - looks clean.[/green]")
19
+ return
20
+
21
+ grouped : dict[str, list[Finding]] = defaultdict(list)
22
+
23
+ for f in findings:
24
+ grouped[f.severity].append(f)
25
+
26
+ for severity in SEVERITY_ORDER:
27
+ group = grouped.get(severity)
28
+ if not group:
29
+ continue
30
+
31
+ color = SEVERITY_COLOR[severity]
32
+ console.print(f"\n[bold {color}]{severity.upper()} ({len(group)})[/bold {color}]")
33
+
34
+ for f in group:
35
+ console.print(Panel(
36
+ f"{f.message}\n\n[dim]{f.rationale}[/dim]",
37
+ title=f"{f.file}:{f.line}",
38
+ border_style=color,
39
+ ))
@@ -0,0 +1,35 @@
1
+ import httpx
2
+ from rio_core.models import Finding
3
+
4
+ from rio_cli.config import get_ai_engine_url, get_api_key
5
+ from rio_cli.rio_config import load_rio_config
6
+ from rio_cli.utils import _fail
7
+
8
+
9
+ def _post_review(diff_text : str) -> list[Finding]:
10
+ headers : dict = {}
11
+ api_key = get_api_key()
12
+ if api_key:
13
+ headers["Authorization"] = f"Bearer {api_key}"
14
+
15
+ rio_config = load_rio_config()
16
+ ai_engine_url = get_ai_engine_url()
17
+ try:
18
+ response = httpx.post(
19
+ f"{ai_engine_url}/v1/review",
20
+ json={"diff" : diff_text , "config" : rio_config.model_dump()},
21
+ headers=headers,
22
+ timeout=120.0
23
+ )
24
+ except httpx.ConnectError:
25
+ _fail(f"Error : could not connect to ai-engine at {ai_engine_url}.")
26
+
27
+ except httpx.TimeoutException:
28
+ _fail("Error: ai-engine request timed out.")
29
+
30
+ if response.status_code != 200:
31
+ _fail(f"Error: ai-engine returned {response.status_code}: {response.text}")
32
+
33
+ data = response.json()
34
+ return [Finding(**f) for f in data["findings"]]
35
+
@@ -0,0 +1,34 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+ from rio_core.config import RioConfig
6
+
7
+
8
+ def _get_repo_root() -> Path | None:
9
+ result = subprocess.run(
10
+ ["git", "rev-parse", "--show-toplevel"],
11
+ capture_output=True,
12
+ text=True,
13
+ check=False,
14
+ )
15
+ if result.returncode != 0:
16
+ return None
17
+ return Path(result.stdout.strip())
18
+
19
+
20
+ def load_rio_config() -> RioConfig:
21
+ repo_root = _get_repo_root()
22
+ if repo_root is None:
23
+ return RioConfig()
24
+
25
+ config_path = repo_root / ".rio.yml"
26
+ if not config_path.exists():
27
+ return RioConfig()
28
+
29
+ try:
30
+ data = yaml.safe_load(config_path.read_text())
31
+ except yaml.YAMLError:
32
+ return RioConfig()
33
+
34
+ return RioConfig(**(data or {}))
@@ -0,0 +1,33 @@
1
+ import subprocess
2
+ from typing import NoReturn
3
+
4
+ import typer
5
+
6
+
7
+ def _fail(message : str) -> NoReturn:
8
+ typer.echo(message , err=True)
9
+ raise typer.Exit(code=1)
10
+
11
+ def _try_run_git_diff(args: list[str]) -> str:
12
+ """Run git diff with the given args. Returns ""on any failure or empty result."""
13
+ try:
14
+ result = subprocess.run(
15
+ ["git", "diff", *args],
16
+ capture_output=True,
17
+ text=True,
18
+ check=False,
19
+ )
20
+ except FileNotFoundError:
21
+ return ""
22
+
23
+ if result.returncode != 0:
24
+ return ""
25
+
26
+ return result.stdout
27
+
28
+ def _run_git_diff(args: list[str], empty_message: str) -> str:
29
+ """Run git diff, hard-failing (via _fail) on any error or empty result."""
30
+ output = _try_run_git_diff(args)
31
+ if not output.strip():
32
+ _fail(empty_message)
33
+ return output