Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b931951
feat: add package build from source (#101)
aybanda Jan 4, 2026
88b50fa
fix: address CodeRabbit review issues
aybanda Jan 4, 2026
c7cea00
fix: apply black formatting to cli.py
aybanda Jan 4, 2026
5ed2a4b
fix: use Python 3.10+ compatible tarfile extraction
aybanda Jan 4, 2026
0bb1b85
fix: update test to handle members parameter in tarfile.extractall
aybanda Jan 4, 2026
23790fe
fix: improve test mock to handle Path objects correctly
aybanda Jan 4, 2026
8e2439d
fix: improve test_fetch_from_url_tarball mock setup
aybanda Jan 4, 2026
4ae5dbc
fix: update tests for new install method signature and improve fetch …
aybanda Jan 4, 2026
7e6687a
fix: update test_cli_extended.py and improve test_build_from_source_s…
aybanda Jan 4, 2026
bac29c2
fix: resolve command validation failures in configure_build
aybanda Jan 4, 2026
e75f2b2
fix: sanitize make_args in autotools/make builds
aybanda Jan 4, 2026
882d4a9
fix: update test_build_cmake to handle tuple return format
aybanda Jan 4, 2026
38462e0
fix: correct test_build_cmake assertion for tuple format
aybanda Jan 4, 2026
fdd96d9
fix: add error cleanup for temp directory and fix CodeQL workflow
aybanda Jan 4, 2026
a471a6a
fix: remove custom queries from CodeQL workflow to avoid default setu…
aybanda Jan 4, 2026
7a6f6e8
Merge branch 'main' into feature/package-build-from-source--101
Anshgrover23 Jan 7, 2026
8b23ce8
feat: add audit logging and SSRF protection to source build
aybanda Jan 7, 2026
dbb3aea
fix: resolve YAML syntax error in spam-protection workflow
aybanda Jan 7, 2026
cdd5e0f
Resolved conflicts in .github/workflows/codeql.
aybanda Jan 9, 2026
bd4027b
docs: add comprehensive docstring to _install_from_source method
aybanda Jan 9, 2026
84d8425
Merge branch 'main' into feature/package-build-from-source--101
Anshgrover23 Jan 11, 2026
d3740b4
Merge branch 'main' into feature/package-build-from-source--101
Anshgrover23 Jan 11, 2026
ac5cd85
fix: address CodeRabbit review comments for PR #482
aybanda Jan 16, 2026
c2581ab
Merge branch 'main' into feature/package-build-from-source--101
aybanda Jan 16, 2026
7b6a20c
fix: implement CodeRabbit review fixes for source build functionality
aybanda Jan 16, 2026
863cdf8
fix: resolve linting and type issues
aybanda Jan 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 254 additions & 5 deletions cortex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@

from rich.markdown import Markdown

logger = logging.getLogger(__name__)

from cortex.api_key_detector import auto_detect_api_key, setup_api_key
from cortex.ask import AskHandler
from cortex.branding import VERSION, console, cx_header, cx_print, show_banner
from cortex.coordinator import InstallationCoordinator, InstallationStep, StepStatus
from cortex.coordinator import (
InstallationCoordinator,
InstallationResult,
InstallationStep,
StepStatus,
)
from cortex.demo import run_demo
from cortex.dependency_importer import (
DependencyImporter,
Expand Down Expand Up @@ -240,8 +247,9 @@ def notify(self, args):

elif args.notify_action == "enable":
mgr.config["enabled"] = True
# Addressing CodeRabbit feedback: Ideally should use a public method instead of private _save_config,
# but keeping as is for a simple fix (or adding a save method to NotificationManager would be best).
# Addressing CodeRabbit feedback: Ideally should use a public method
# instead of private _save_config, but keeping as is for a simple fix
# (or adding a save method to NotificationManager would be best).
mgr._save_config()
self._print_success("Notifications enabled")
return 0
Expand Down Expand Up @@ -817,6 +825,9 @@ def install(
execute: bool = False,
dry_run: bool = False,
parallel: bool = False,
from_source: bool = False,
source_url: str | None = None,
version: str | None = None,
):
# Validate input first
is_valid, error = validate_install_request(software)
Expand Down Expand Up @@ -851,6 +862,10 @@ def install(
start_time = datetime.now()

try:
# Handle --from-source flag
if from_source:
return self._install_from_source(software, execute, dry_run, source_url, version)
Comment on lines +860 to +862
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 | 🟠 Major

--from-source runs real builds without --execute.
With from_source=True and execute=False, the flow still downloads, installs build deps, and compiles. This violates the “dry‑run by default” requirement for installation operations.

✅ Suggested guard
-            if from_source:
-                return self._install_from_source(software, execute, dry_run, source_url, version)
+            if from_source:
+                if not execute and not dry_run:
+                    dry_run = True
+                return self._install_from_source(software, execute, dry_run, source_url, version)

Based on learnings, dry‑run should be the default for install operations.

🤖 Prompt for AI Agents
In `@cortex/cli.py` around lines 860 - 862, The current branch returns into
_install_from_source(software, execute, dry_run, source_url, version) when
from_source is True but _install_from_source still performs downloads,
dependency installation and compilation even if execute is False; update
_install_from_source (referenced here) to honor the execute/dry_run flags: when
execute is False (or dry_run True) it must not perform any real side-effecting
steps (download, install build deps, run compile commands) and instead only log
or simulate the steps and return a successful dry-run result; ensure all
internal calls that spawn subprocesses or write files check the execute flag
first and that the top-level call from the from_source branch passes the correct
dry_run/execute flags through to enforce dry-run behavior.


self._print_status("🧠", "Understanding request...")

interpreter = CommandInterpreter(api_key=api_key, provider=provider)
Expand Down Expand Up @@ -1516,7 +1531,8 @@ def history(self, limit: int = 20, status: str | None = None, show_id: str | Non
packages += f" +{len(r.packages) - 2}"

print(
f"{r.id:<18} {date:<20} {r.operation_type.value:<12} {packages:<30} {r.status.value:<15}"
f"{r.id:<18} {date:<20} {r.operation_type.value:<12} "
f"{packages:<30} {r.status.value:<15}"
)

return 0
Expand Down Expand Up @@ -2105,7 +2121,8 @@ def _env_template(self, env_mgr: EnvironmentManager, args: argparse.Namespace) -
return self._env_template_apply(env_mgr, args)
else:
self._print_error(
"Please specify: template list, template show <name>, or template apply <name> <app>"
"Please specify: template list, template show <name>, "
"or template apply <name> <app>"
)
return 1

Expand Down Expand Up @@ -2825,6 +2842,220 @@ def progress_callback(current: int, total: int, step: InstallationStep) -> None:
console.print(f"Error: {result.error_message}", style="red")
return 1

def _install_from_source(
self,
package_name: str,
execute: bool,
dry_run: bool,
source_url: str | None,
version: str | None,
) -> int:
Comment on lines +2845 to +2852
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 9, 2026

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add comprehensive docstring for this method.

The _install_from_source() method lacks a docstring. Per coding guidelines, docstrings are required for all public APIs, and this method is a significant entry point for source-build functionality.

📝 Suggested docstring
     def _install_from_source(
         self,
         package_name: str,
         execute: bool,
         dry_run: bool,
         source_url: str | None,
         version: str | None,
     ) -> int:
+        """Build and install a package from source.
+
+        Handles the complete source build workflow including dependency detection,
+        build system detection, compilation, and installation. Integrates with
+        InstallationHistory for audit logging.
+
+        Args:
+            package_name: Name of the package to build (supports package@version syntax)
+            execute: Execute installation commands after building
+            dry_run: Show commands without executing
+            source_url: Optional URL to source code (tarball, GitHub, etc.)
+            version: Optional version to build (can also be specified via package@version)
+
+        Returns:
+            int: Exit code (0 for success, 1 for failure)
+        """
📝 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
def _install_from_source(
self,
package_name: str,
execute: bool,
dry_run: bool,
source_url: str | None,
version: str | None,
) -> int:
def _install_from_source(
self,
package_name: str,
execute: bool,
dry_run: bool,
source_url: str | None,
version: str | None,
) -> int:
"""Build and install a package from source.
Handles the complete source build workflow including dependency detection,
build system detection, compilation, and installation. Integrates with
InstallationHistory for audit logging.
Args:
package_name: Name of the package to build (supports package@version syntax)
execute: Execute installation commands after building
dry_run: Show commands without executing
source_url: Optional URL to source code (tarball, GitHub, etc.)
version: Optional version to build (can also be specified via package@version)
Returns:
int: Exit code (0 for success, 1 for failure)
"""
🤖 Prompt for AI Agents
In @cortex/cli.py around lines 2016 - 2023, Add a comprehensive docstring to the
_install_from_source method: describe its purpose (installing a package from a
source URL), document all parameters (package_name: str, execute: bool, dry_run:
bool, source_url: str | None, version: str | None), explain return value (int
status/exit code), note side effects (invokes build/install, may execute
commands), and list raised exceptions or error cases and any special behavior
for dry_run vs execute; place the docstring immediately below the def
_install_from_source(...) signature in cortex/cli.py using the project’s
docstring style.

✅ Addressed in commit bd4027b

Copy link
Collaborator

Choose a reason for hiding this comment

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

@aybanda Address this one.

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

"""Install a package from a source URL by building and optionally installing it.

This method handles the complete workflow for installing packages from source code:
parsing version information, building the package, and optionally executing
installation commands. It supports dry-run mode for previewing operations and
records all activities in the installation history for audit purposes.

Args:
package_name: Name of the package to install. If version is specified
using "@" syntax (e.g., "python@3.12"), it will be parsed automatically
if version parameter is None.
execute: If True, executes the installation commands after building.
If False, only builds the package and displays commands without executing.
dry_run: If True, performs a dry run showing what commands would be executed
without actually building or installing. Takes precedence over execute.
source_url: Optional URL to the source code repository or tarball.
If None, the SourceBuilder will attempt to locate the source automatically.
version: Optional version string to build. If None and package_name contains
"@", the version will be extracted from package_name.

Returns:
int: Exit status code. Returns 0 on success (build/install completed or
dry-run completed), 1 on failure (build failed or installation failed).

Side Effects:
- Invokes SourceBuilder.build_from_source() to build the package
- May execute installation commands via InstallationCoordinator if execute=True
- Records installation start, progress, and completion in InstallationHistory
- Prints status messages and progress to console
- May use cached builds if available

Raises:
No exceptions are raised directly, but underlying operations may fail:
- SourceBuilder.build_from_source() failures are caught and returned as status 1
- InstallationCoordinator.execute() failures are caught and returned as status 1
- InstallationHistory exceptions are caught and logged as warnings

Special Behavior:
- dry_run=True: Shows build/install commands without executing any operations.
Returns 0 after displaying commands. Installation history is still recorded.
- execute=False, dry_run=False: Builds the package and displays install commands
but does not execute them. Returns 0. User is prompted to run with --execute.
- execute=True, dry_run=False: Builds the package and executes all installation
commands. Returns 0 on success, 1 on failure.
- Version parsing: If package_name contains "@" (e.g., "python@3.12") and version
is None, the version is automatically extracted and package_name is updated.
- Caching: Uses cached builds when available, printing a notification if cache
is used.
"""
from cortex.sandbox.sandbox_executor import SandboxExecutor
from cortex.source_builder import SourceBuilder

# Initialize history for audit logging (same as install() method)
history = InstallationHistory()
install_id = None
start_time = datetime.now()

builder = SourceBuilder()

# Parse version from package name if specified (e.g., python@3.12)
if "@" in package_name and not version:
parts = package_name.split("@")
package_name = parts[0]
version = parts[1] if len(parts) > 1 and parts[1] else None

cx_print(f"Building {package_name} from source...", "info")
if version:
cx_print(f"Version: {version}", "info")
if source_url:
cx_print(f"Source URL: {source_url}", "info")

# Do NOT record installation start yet - wait until after build completes
# so we can record the actual install commands
install_id = None

result = builder.build_from_source(
package_name=package_name,
version=version,
source_url=source_url,
use_cache=True,
dry_run=dry_run, # Pass dry_run to skip full build in dry-run mode
)

if not result.success:
self._print_error(f"Build failed: {result.error_message}")
# Record failed installation (only if we have real install commands)
if result.install_commands and result.build_dir != "<dry-run-no-dir>":
try:
install_id = history.record_installation(
InstallationType.INSTALL,
[package_name],
result.install_commands,
start_time,
)
history.update_installation(
install_id,
InstallationStatus.FAILED,
error_message=result.error_message or "Build failed",
)
except Exception as e:
logger.warning(f"Failed to record installation failure: {e}")
cx_print(f"⚠️ Warning: Could not record installation failure: {e}", "warning")
return 1
Comment on lines +2936 to +2955
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 | 🟠 Major

Record failed builds in history even when install commands are empty.

If the build fails before install_commands are known, the current conditional skips history writes entirely, so failures aren’t audited.

🛠️ Suggested fix
-        if not result.success:
-            self._print_error(f"Build failed: {result.error_message}")
-            # Record failed installation (only if we have real install commands)
-            if result.install_commands and result.build_dir != "<dry-run-no-dir>":
-                try:
-                    install_id = history.record_installation(
-                        InstallationType.INSTALL,
-                        [package_name],
-                        result.install_commands,
-                        start_time,
-                    )
-                    history.update_installation(
-                        install_id,
-                        InstallationStatus.FAILED,
-                        error_message=result.error_message or "Build failed",
-                    )
-                except Exception as e:
-                    logger.warning(f"Failed to record installation failure: {e}")
-                    cx_print(f"⚠️  Warning: Could not record installation failure: {e}", "warning")
-            return 1
+        if not result.success:
+            self._print_error(f"Build failed: {result.error_message}")
+            try:
+                install_id = history.record_installation(
+                    InstallationType.INSTALL,
+                    [package_name],
+                    result.install_commands or [],
+                    start_time,
+                )
+                history.update_installation(
+                    install_id,
+                    InstallationStatus.FAILED,
+                    error_message=result.error_message or "Build failed",
+                )
+            except Exception as e:
+                logger.warning(f"Failed to record installation failure: {e}")
+                cx_print(f"⚠️  Warning: Could not record installation failure: {e}", "warning")
+            return 1
Based on learnings, audit logging to `~/.cortex/history.db` is required for all operations.
🤖 Prompt for AI Agents
In `@cortex/cli.py` around lines 2936 - 2955, The current failure path in the
build handling (the block using result, history.record_installation,
InstallationType.INSTALL) skips writing to history when result.install_commands
is empty; change the conditional so we still record failed builds (using
result.install_commands or an empty list) whenever this is a real run (i.e.,
result.build_dir != "<dry-run-no-dir>") — keep the try/except and
history.update_installation call (use error_message=result.error_message or
"Build failed") and pass [] if result.install_commands is falsy so the failure
is always audited.


if result.cached:
cx_print(f"Using cached build for {package_name}", "info")

# Record successful build/plan now that we have the actual install commands
# Record for all scenarios (dry-run, build-only, and execute)
try:
install_id = history.record_installation(
InstallationType.INSTALL,
[package_name],
result.install_commands,
start_time,
)
except Exception as e:
logger.warning(f"Failed to record installation: {e}")
cx_print(f"⚠️ Warning: Could not record installation: {e}", "warning")
install_id = None

# Use only actual install commands for history and execution
commands = result.install_commands

if dry_run:
cx_print("\nBuild commands (dry run):", "info")
for cmd in result.install_commands:
console.print(f" • {cmd}")
# Record successful dry run
if install_id:
try:
history.update_installation(install_id, InstallationStatus.SUCCESS)
except Exception as e:
logger.warning(f"Failed to update installation record: {e}")
cx_print(f"⚠️ Warning: Could not record installation success: {e}", "warning")
return 0

if not execute:
cx_print("\nBuild completed. Install commands:", "info")
for cmd in result.install_commands:
console.print(f" • {cmd}")
cx_print("Run with --execute to install", "info")
# Record successful build (but not installed)
if install_id:
try:
history.update_installation(install_id, InstallationStatus.SUCCESS)
except Exception as e:
logger.warning(f"Failed to update installation record: {e}")
cx_print(f"⚠️ Warning: Could not record installation success: {e}", "warning")
return 0

# Execute install commands
def progress_callback(current: int, total: int, step: InstallationStep) -> None:
status_emoji = "⏳"
if step.status == StepStatus.SUCCESS:
status_emoji = "✅"
elif step.status == StepStatus.FAILED:
status_emoji = "❌"
console.print(f"[{current}/{total}] {status_emoji} {step.description}")

# Notify about sandbox availability for build/install commands
sandbox_executor = SandboxExecutor()
if sandbox_executor.is_firejail_available():
cx_print("🔒 Build/install commands will run in Firejail sandbox", "info")
else:
cx_print("⚠️ Firejail not available - running commands without sandboxing", "warning")

coordinator = InstallationCoordinator(
commands=result.install_commands,
descriptions=[f"Install {package_name}" for _ in result.install_commands],
timeout=600,
stop_on_error=True,
progress_callback=progress_callback,
)

install_result = coordinator.execute()

if install_result.success:
self._print_success(f"{package_name} built and installed successfully!")
# Record successful installation
if install_id:
try:
history.update_installation(install_id, InstallationStatus.SUCCESS)
console.print(f"\n📝 Installation recorded (ID: {install_id})")
console.print(f" To rollback: cortex rollback {install_id}")

except Exception as e:
logger.warning(f"Failed to update installation record: {e}")
cx_print(f"⚠️ Warning: Could not record installation success: {e}", "warning")
return 0
else:
self._print_error("Installation failed")
error_msg = install_result.error_message or "Installation failed"
if install_result.error_message:
console.print(f"Error: {error_msg}", style="red")
# Record failed installation
if install_id:
try:
history.update_installation(
install_id, InstallationStatus.FAILED, error_message=error_msg
)
except Exception as e:
logger.warning(f"Failed to update installation record: {e}")
cx_print(f"⚠️ Warning: Could not record installation failure: {e}", "warning")
return 1

# --------------------------


Expand Down Expand Up @@ -3026,6 +3257,21 @@ def main():
action="store_true",
help="Enable parallel execution for multi-step installs",
)
install_parser.add_argument(
"--from-source",
action="store_true",
help=("Build and install from source code when binaries unavailable"),
)
install_parser.add_argument(
"--source-url",
type=str,
help="URL to source code (for --from-source)",
)
install_parser.add_argument(
"--pkg-version",
type=str,
help="Version to build (for --from-source)",
)

# Remove command - uninstall with impact analysis
remove_parser = subparsers.add_parser(
Expand Down Expand Up @@ -3596,6 +3842,9 @@ def main():
execute=args.execute,
dry_run=args.dry_run,
parallel=args.parallel,
from_source=getattr(args, "from_source", False),
source_url=getattr(args, "source_url", None),
version=getattr(args, "pkg_version", None),
)
elif args.command == "remove":
# Handle --execute flag to override default dry-run
Expand Down
Loading
Loading