Skip to content
Closed
88 changes: 87 additions & 1 deletion cortex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3178,6 +3178,7 @@ def show_rich_help():
table.add_row("update", "Check for and install updates")
table.add_row("doctor", "System health check")
table.add_row("troubleshoot", "Interactive system troubleshooter")
table.add_row("tarball-helper", "Tarball/manual build helper (analyze, install-deps, cleanup)")

console.print(table)
console.print()
Expand Down Expand Up @@ -3264,6 +3265,28 @@ def main():

subparsers = parser.add_subparsers(dest="command", help="Available commands")

# Register tarball-helper subparser only if not already present
def add_tarball_helper_subparser(subparsers: argparse._SubParsersAction) -> None:
"""Add tarball-helper subparser if not already present."""
if "tarball-helper" in subparsers.choices:
return
tarball_parser = subparsers.add_parser(
"tarball-helper", help="Tarball/manual build helper (analyze, install-deps, cleanup)"
)
tarball_parser.add_argument(
"action", choices=["analyze", "install-deps", "cleanup"], help="Action to perform"
)
tarball_parser.add_argument(
"path", nargs="?", help="Path to source directory (for analyze/install-deps)"
)
tarball_parser.add_argument(
"--execute",
action="store_true",
help="Actually install dependencies (default: dry-run)",
)

add_tarball_helper_subparser(subparsers)

# Define the docker command and its associated sub-actions
docker_parser = subparsers.add_parser("docker", help="Docker and container utilities")
docker_subs = docker_parser.add_subparsers(dest="docker_action", help="Docker actions")
Expand Down Expand Up @@ -3916,17 +3939,80 @@ def main():
show_rich_help()
return 0

def add_tarball_helper_subparser(subparsers: argparse._SubParsersAction) -> None:
"""Add tarball-helper subparser if not already present."""
if "tarball-helper" in subparsers.choices:
return
tarball_parser = subparsers.add_parser(
"tarball-helper", help="Tarball/manual build helper (analyze, install-deps, cleanup)"
)
tarball_parser.add_argument(
"action", choices=["analyze", "install-deps", "cleanup"], help="Action to perform"
)
tarball_parser.add_argument(
"path", nargs="?", help="Path to source directory (for analyze/install-deps)"
)
tarball_parser.add_argument(
"--execute",
action="store_true",
help="Actually install dependencies (default: dry-run)",
)

add_tarball_helper_subparser(subparsers)

# ...existing code...
# Initialize the CLI handler
cli = CortexCLI(verbose=args.verbose)

try:
# Route the command to the appropriate method inside the cli object
if args.command == "tarball-helper":
from rich.console import Console
from rich.table import Table

from cortex.tarball_helper import TarballHelper

helper = TarballHelper()
if args.action == "analyze":
deps = helper.analyze(args.path or ".")
mapping = helper.suggest_apt_packages(deps)
table = Table(title="Suggested apt packages")
table.add_column("Dependency")
table.add_column("Apt Package")
for dep, pkg in mapping.items():
table.add_row(dep, pkg)
Console().print(table)
elif args.action == "install-deps":
deps = helper.analyze(args.path or ".")
mapping = helper.suggest_apt_packages(deps)
table = Table(title="Installable apt packages (dry-run)")
table.add_column("Dependency")
table.add_column("Apt Package")
for dep, pkg in mapping.items():
table.add_row(dep, pkg)
Console().print(table)
if args.execute:
helper.install_deps(list(mapping.values()))
else:
Console().print(
"[yellow]Dry-run: No packages installed. Use --execute to install.[/yellow]"
)
elif args.action == "cleanup":
pkgs_str = ", ".join(helper.tracked_packages)
confirm = input(
f"Are you sure you want to purge the following packages? {pkgs_str} [y/N]: "
)
if confirm.lower() == "y":
helper.cleanup()
else:
Console().print("[yellow]Cleanup cancelled.[/yellow]")
Comment on lines +4001 to +4008
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle non-interactive cleanup confirmations safely.

input() can raise EOFError/KeyboardInterrupt in non-interactive contexts, which would crash the CLI. Guard it and treat it as a cancellation.

Suggested fix
-            confirm = input(
-                f"Are you sure you want to purge the following packages? {pkgs_str} [y/N]: "
-            )
-            if confirm.lower() == "y":
-                helper.cleanup()
-            else:
-                Console().print("[yellow]Cleanup cancelled.[/yellow]")
+            try:
+                confirm = input(
+                    f"Are you sure you want to purge the following packages? {pkgs_str} [y/N]: "
+                )
+            except (EOFError, KeyboardInterrupt):
+                Console().print("[yellow]Cleanup cancelled.[/yellow]")
+                return 0
+            if confirm.lower() == "y":
+                helper.cleanup()
+            else:
+                Console().print("[yellow]Cleanup cancelled.[/yellow]")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pkgs_str = ", ".join(helper.tracked_packages)
confirm = input(
f"Are you sure you want to purge the following packages? {pkgs_str} [y/N]: "
)
if confirm.lower() == "y":
helper.cleanup()
else:
Console().print("[yellow]Cleanup cancelled.[/yellow]")
pkgs_str = ", ".join(helper.tracked_packages)
try:
confirm = input(
f"Are you sure you want to purge the following packages? {pkgs_str} [y/N]: "
)
except (EOFError, KeyboardInterrupt):
Console().print("[yellow]Cleanup cancelled.[/yellow]")
return 0
if confirm.lower() == "y":
helper.cleanup()
else:
Console().print("[yellow]Cleanup cancelled.[/yellow]")
🤖 Prompt for AI Agents
In `@cortex/cli.py` around lines 4001 - 4008, Wrap the interactive confirmation
call in a try/except around the input(...) in the cleanup flow so EOFError and
KeyboardInterrupt are caught and treated as a cancellation; if an exception
occurs, call Console().print("[yellow]Cleanup cancelled.[/yellow]") and do not
call helper.cleanup(), otherwise proceed to check confirm.lower() == "y" to run
helper.cleanup(); this change should be applied where pkgs_str is built and
input(...) is invoked (refer to helper.tracked_packages, input call,
helper.cleanup(), and Console().print).

return 0
# Route the command to the appropriate method inside the cli object
if args.command == "docker":
if args.docker_action == "permissions":
return cli.docker_permissions(args)
parser.print_help()
return 1

if args.command == "demo":
return cli.demo()
elif args.command == "wizard":
Expand Down
4 changes: 2 additions & 2 deletions cortex/stdin_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,11 +418,11 @@ def run_stdin_handler(
return 0

elif action == "stats":
# Machine-readable stats
# Machine-readable stats (no console output)
analysis = analyze_stdin(data)
import json

print(json.dumps(analysis, indent=2))
sys.stdout.write(json.dumps(analysis, indent=2) + "\n")
return 0

else:
Expand Down
196 changes: 196 additions & 0 deletions cortex/tarball_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""
tarball_helper.py - Tarball/Manual Build Helper for Cortex Linux

Features:
1. Analyze build files (configure, CMakeLists.txt, meson.build, etc.) for requirements
2. Install missing -dev packages automatically
3. Track manual installations for cleanup
4. Suggest package alternatives when available

Usage:
cortex tarball-helper analyze <path>
cortex tarball-helper install-deps <path>
cortex tarball-helper track <package>
cortex tarball-helper cleanup
Comment on lines +10 to +14
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usage docstring at the top mentions "cortex tarball-helper track " but there is no "track" action defined in the CLI argument parser (only analyze, install-deps, and cleanup are valid choices). Either the docstring should be updated to remove this, or the track action should be implemented.

Copilot uses AI. Check for mistakes.
"""

import json
import os
import re
from pathlib import Path

from rich.console import Console
from rich.table import Table

MANUAL_TRACK_FILE = Path.home() / ".cortex" / "manual_builds.json"
console = Console()


class TarballHelper:
def __init__(self):
self.tracked_packages = self._load_tracked_packages()

def suggest_apt_packages(self, deps: list[str]) -> dict[str, str]:
"""Map dependency names to apt packages (simple heuristic)."""
mapping = {}
for dep in deps:
dep_lower = dep.lower()
if dep_lower.startswith("lib"):
pkg = f"{dep_lower}-dev"
else:
pkg = f"lib{dep_lower}-dev"
mapping[dep] = pkg
return mapping

def install_deps(self, pkgs: list[str]) -> None:
"""Install missing -dev packages via apt. Only track successful installs."""
import subprocess

for pkg in pkgs:
console.print(f"[cyan]Installing:[/cyan] {pkg}")
result = subprocess.run(["sudo", "apt-get", "install", "-y", pkg], check=False)
if result.returncode == 0:
self.track(pkg)
else:
console.print(
f"[red]Failed to install:[/red] {pkg} (exit code {result.returncode}). Package will not be tracked for cleanup."
)

def track(self, pkg: str) -> None:
"""Track a package for later cleanup."""
if pkg not in self.tracked_packages:
self.tracked_packages.append(pkg)
self._save_tracked_packages()
console.print(f"[green]Tracked:[/green] {pkg}")

def _load_tracked_packages(self) -> list[str]:
"""Load tracked packages from file, handling corrupt JSON."""
if MANUAL_TRACK_FILE.exists():
try:
with open(MANUAL_TRACK_FILE) as f:
data = json.load(f)
except json.JSONDecodeError:
console.print(
f"[yellow]Warning:[/yellow] Failed to parse {MANUAL_TRACK_FILE}. Ignoring corrupt tracking data."
)
return []
packages = data.get("packages", [])
if not isinstance(packages, list):
return []
return packages
return []

def _save_tracked_packages(self):
MANUAL_TRACK_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(MANUAL_TRACK_FILE, "w") as f:
json.dump({"packages": self.tracked_packages}, f, indent=2)

def analyze(self, path: str) -> list[str]:
"""Analyze build files for dependencies."""
deps = set()
for root, _, files in os.walk(path):
for fname in files:
if fname in (
"CMakeLists.txt",
"configure.ac",
"meson.build",
"Makefile",
"setup.py",
):
fpath = os.path.join(root, fname)
try:
with open(fpath, errors="ignore") as f:
content = f.read()
except Exception:
continue
if fname == "setup.py":
deps.update(self._parse_setup_py_dependencies(content))
else:
deps.update(self._parse_dependencies(fname, content))
return list(deps)

def _parse_dependencies(self, fname: str, content: str) -> list[str]:
"""Extract dependencies from build files using regex or delegate to setup.py parser."""
if fname == "setup.py":
return self._parse_setup_py_dependencies(content)
patterns = {
"CMakeLists.txt": r"find_package\(([-\w]+)",
"meson.build": r"dependency\(['\"]([\w-]+)",
"configure.ac": r"AC_CHECK_LIB\(\[?([\w-]+)",
"Makefile": r"-l([\w-]+)",
}
deps = set()
if fname in patterns:
matches = re.findall(patterns[fname], content, re.DOTALL)
deps.update(matches)
return list(deps)

def _parse_setup_py_dependencies(self, content: str) -> list[str]:
"""Robustly parse install_requires from setup.py using ast and regex fallback."""
import ast

deps = set()
try:
tree = ast.parse(content)
# Look for install_requires in assignments and in setup() call
for node in ast.walk(tree):
# Top-level assignment
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "install_requires":
if isinstance(node.value, (ast.List, ast.Tuple)):
for elt in node.value.elts:
if isinstance(elt, ast.Str):
deps.add(elt.s)
elif (
hasattr(ast, "Constant")
and isinstance(elt, ast.Constant)
and isinstance(elt.value, str)
):
deps.add(elt.value)
# install_requires in setup() call
if (
isinstance(node, ast.Call)
and hasattr(node.func, "id")
and node.func.id == "setup"
):
for kw in node.keywords:
if kw.arg == "install_requires" and isinstance(
kw.value, (ast.List, ast.Tuple)
):
for elt in kw.value.elts:
if isinstance(elt, ast.Str):
deps.add(elt.s)
elif (
hasattr(ast, "Constant")
and isinstance(elt, ast.Constant)
and isinstance(elt.value, str)
):
deps.add(elt.value)
if deps:
return list(deps)
except Exception:
pass
# fallback: try regex for install_requires in assignment or setup()
# Robust regex: match install_requires in assignment or setup(), with arbitrary whitespace/newlines
# Match install_requires assignments with any whitespace/newlines
pattern = r"install_requires\s*=\s*\[(.*?)\]"
matches = re.findall(pattern, content, re.DOTALL)
for m in matches:
# Extract all quoted package names from the captured group
deps.update(re.findall(r"['\"]([^'\"]+)['\"]", m, re.DOTALL))
return list(deps)

def cleanup(self) -> None:
"""Remove tracked packages using apt-get purge."""
import subprocess

if not self.tracked_packages:
console.print("[yellow]No tracked packages to remove.[/yellow]")
return
for pkg in self.tracked_packages:
console.print(f"[yellow]Purging:[/yellow] {pkg}")
subprocess.run(["sudo", "apt-get", "purge", "-y", pkg], check=False)
self.tracked_packages = []
self._save_tracked_packages()
console.print("[green]Cleanup complete.[/green]")
12 changes: 10 additions & 2 deletions tests/test_stdin_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,16 @@ def test_run_stats_action(self, capsys):

assert result == 0
captured = capsys.readouterr()
# Should be valid JSON
data = json.loads(captured.out)
# Find first valid JSON object in output
try:
data = json.loads(captured.out.strip())
except json.JSONDecodeError:
# Try to extract JSON from output if extra text is present
import re

match = re.search(r"({.*})", captured.out, re.DOTALL)
assert match, f"No JSON found in output: {captured.out}"
data = json.loads(match.group(1))
assert "line_count" in data


Expand Down
Loading
Loading