Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ cortex install "tools for video compression"
| **Docker Permission Fixer** | Fix root-owned bind mount issues automatically |
| **Audit Trail** | Complete history in `~/.cortex/history.db` |
| **Hardware-Aware** | Detects GPU, CPU, memory for optimized packages |
| **System Cloning** | Capture, version, and replicate system states via templates |
| **Multi-LLM Support** | Works with Claude, GPT-4, or local Ollama models |

---
Expand Down Expand Up @@ -165,6 +166,9 @@ cortex history

# Rollback an installation
cortex rollback <installation-id>

# Create a system backup template
cortex template create base-state --description "Baseline configuration"
```

### Role Management
Expand All @@ -190,6 +194,7 @@ cortex role set <slug>
| `cortex sandbox <cmd>` | Test packages in Docker sandbox |
| `cortex history` | View all past installations |
| `cortex rollback <id>` | Undo a specific installation |
| `cortex template <cmd>` | System cloning and templating (create, deploy, list, show, delete, export, import) |
| `cortex --version` | Show version information |
| `cortex --help` | Display help message |

Expand Down Expand Up @@ -414,6 +419,7 @@ pip install -e .
- [x] Dry-run preview mode
- [x] Docker bind-mount permission fixer
- [x] Automatic Role Discovery (AI-driven system context sensing)
- [x] System Cloning & Templating (Capture, Version, Clone)

### In Progress
- [ ] Conflict resolution UI
Expand Down
252 changes: 239 additions & 13 deletions cortex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Optional

from rich.markdown import Markdown

Expand All @@ -24,13 +24,7 @@
format_package_list,
)
from cortex.env_manager import EnvironmentManager, get_env_manager
from cortex.i18n import (
SUPPORTED_LANGUAGES,
LanguageConfig,
get_language,
set_language,
t,
)
from cortex.i18n import SUPPORTED_LANGUAGES, LanguageConfig, get_language, set_language, t
from cortex.installation_history import InstallationHistory, InstallationStatus, InstallationType
from cortex.llm.interpreter import CommandInterpreter
from cortex.network_config import NetworkConfig
Expand Down Expand Up @@ -2747,6 +2741,196 @@ def systemd(self, service: str, action: str = "status", verbose: bool = False):

return run_systemd_helper(service, action, verbose)

def template(self, args: argparse.Namespace) -> int:
"""Handle template commands"""
from cortex.template_manager import TemplateManager

manager = TemplateManager()
cmd = args.template_command

if not cmd:
console.print(
"[yellow]Usage: cortex template [create|deploy|list|show|delete|export|import][/yellow]"
)
return 1

if cmd == "create":
return self._template_create(manager, args)
elif cmd == "list":
return self._template_list(manager)
elif cmd == "show":
return self._template_show(manager, args)
elif cmd == "deploy":
return self._template_deploy(manager, args)
elif cmd == "delete":
return self._template_delete(manager, args)
elif cmd == "export":
return self._template_export(manager, args)
elif cmd == "import":
return self._template_import(manager, args)

return 0

def _parse_template_spec(self, name_spec: str) -> tuple[str, str | None]:
"""Parse template name and version from spec (name:v1 or name-v1)."""
if ":" in name_spec:
parts = name_spec.split(":", 1)
return parts[0], parts[1]
elif "-v" in name_spec:
idx = name_spec.rfind("-v")
return name_spec[:idx], name_spec[idx + 1 :]
return name_spec, None
Comment on lines +2774 to +2782
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

Avoid mis-parsing template names containing “-v”.
The current -v split triggers on any name with -v (e.g., dev-tools), producing a bogus version. Only treat -v as a version suffix when followed by a digit.

🔧 Suggested fix
-        elif "-v" in name_spec:
-            idx = name_spec.rfind("-v")
-            return name_spec[:idx], name_spec[idx + 1 :]
+        elif "-v" in name_spec:
+            idx = name_spec.rfind("-v")
+            if idx != -1 and idx + 2 < len(name_spec) and name_spec[idx + 2].isdigit():
+                return name_spec[:idx], name_spec[idx + 1 :]
         return name_spec, None
📝 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 _parse_template_spec(self, name_spec: str) -> tuple[str, str | None]:
"""Parse template name and version from spec (name:v1 or name-v1)."""
if ":" in name_spec:
parts = name_spec.split(":", 1)
return parts[0], parts[1]
elif "-v" in name_spec:
idx = name_spec.rfind("-v")
return name_spec[:idx], name_spec[idx + 1 :]
return name_spec, None
def _parse_template_spec(self, name_spec: str) -> tuple[str, str | None]:
"""Parse template name and version from spec (name:v1 or name-v1)."""
if ":" in name_spec:
parts = name_spec.split(":", 1)
return parts[0], parts[1]
elif "-v" in name_spec:
idx = name_spec.rfind("-v")
if idx != -1 and idx + 2 < len(name_spec) and name_spec[idx + 2].isdigit():
return name_spec[:idx], name_spec[idx + 1 :]
return name_spec, None
🤖 Prompt for AI Agents
In `@cortex/cli.py` around lines 2055 - 2063, The _parse_template_spec function
incorrectly treats any occurrence of "-v" as a version separator (breaking names
like "dev-tools"); change the logic to only split on the last "-v" when it is
immediately followed by a digit (e.g., match "-v" + digit). Specifically, in
_parse_template_spec check for "-v" presence with name_spec.rfind("-v") then
verify that there is a character after the "-v" and that that character is a
digit before slicing; when valid return name_spec[:idx] and the version as
name_spec[idx+2:], otherwise fall through to return the whole spec as the name
and None for version.


def _template_create(self, manager: Any, args: argparse.Namespace) -> int:
"""Handle 'template create' command."""
console.print("📸 [bold]Capturing system state...[/bold]")
version = manager.create_template(args.name, args.description, package_sources=args.sources)

template_data = manager.get_template(args.name, version)
if not template_data:
return 1

pkgs = template_data["config"].get("packages", [])
apps = [p for p in pkgs if p["source"] != "service"]
services = [p for p in pkgs if p["source"] == "service"]

console.print(f" - {len(apps)} packages")
console.print(
f" - {len(template_data['config'].get('preferences', {})) + len(template_data['config'].get('environment_variables', {}))} configurations"
)
console.print(f" - {len(services)} services")
console.print(f"[green]✓[/green] Template saved: [bold]{args.name}-{version}[/bold]\n")
return 0

def _template_list(self, manager: Any) -> int:
"""Handle 'template list' command."""
templates = manager.list_templates()
if not templates:
console.print("No templates found.")
return 0

console.print("\n[bold cyan]Available System Templates:[/bold cyan]\n")
for template in templates:
console.print(
f"• [bold]{template['name']}[/bold] (latest: {template['latest_version']})"
)
for v in template["versions"]:
console.print(f" - {v['version']} ({v['created_at'][:10]}): {v['description']}")
console.print()
return 0

def _template_show(self, manager: Any, args: argparse.Namespace) -> int:
"""Handle 'template show' command."""
name, version = self._parse_template_spec(args.name)
template_data = manager.get_template(name, version)
if not template_data:
console.print(f"[red]Error:[/red] Template '{args.name}' not found.")
return 1

console.print(f"\n[bold]{template_data['name']}:{template_data['version']}[/bold]")
console.print(f"Description: {template_data['description']}")
console.print(f"Created: {template_data['created_at']}")
console.print(f"OS: {template_data['config'].get('os', 'unknown')}")

pkgs = template_data["config"].get("packages", [])
apps = [p for p in pkgs if p["source"] != "service"]
services = [p for p in pkgs if p["source"] == "service"]

console.print("\n[bold]Summary:[/bold]")
console.print(f"- {len(apps)} Packages")
console.print(
f"- {len(template_data['config'].get('preferences', {})) + len(template_data['config'].get('environment_variables', {}))} Configurations"
)
console.print(f"- {len(services)} Services")
return 0

def _template_deploy(self, manager: Any, args: argparse.Namespace) -> int:
"""Handle 'template deploy' command."""
from cortex.config_manager import ConfigManager

name, version = self._parse_template_spec(args.name)
template_data = manager.get_template(name, version)
if not template_data:
console.print(f"[red]Error:[/red] Template '{args.name}' not found.")
return 1

config_manager = ConfigManager()

# Safety default: deploy is dry-run unless --execute is passed
is_dry_run = args.dry_run or not args.execute

if is_dry_run:
console.print(
f"\n[bold cyan]Previewing deployment of {template_data['name']}:{template_data['version']}[/bold cyan]\n"
)
diff = config_manager.diff_configuration(template_data["config"])

if diff["packages_to_install"]:
console.print(
f"📦 [bold]To Install:[/bold] {len(diff['packages_to_install'])} packages"
)
if diff["services_to_update"]:
console.print(
f"⚙️ [bold]To Update:[/bold] {len(diff['services_to_update'])} services"
)

if not diff["packages_to_install"] and not diff["services_to_update"]:
console.print("[green]System already matches template.[/green]")
return 0

target_label = f" to {args.to}" if hasattr(args, "to") and args.to else ""
console.print(f"🚀 [bold]Deploying template{target_label}...[/bold]")
result = config_manager.import_configuration(
str(
manager.base_dir
/ template_data["name"]
/ template_data["version"]
/ "template.yaml"
),
force=args.force,
)

if result:
console.print(" [green]✓[/green] Packages installed")
console.print(" [green]✓[/green] Configurations applied")
console.print(" [green]✓[/green] Services started")
console.print(f"[green]✓[/green] System cloned successfully{target_label}\n")
return 0
return 1

def _template_delete(self, manager: Any, args: argparse.Namespace) -> int:
"""Handle 'template delete' command."""
name, version = self._parse_template_spec(args.name)
if manager.delete_template(name, version):
spec = f"{name}:{version}" if version else name
console.print(f"[green]✓[/green] Deleted template: {spec}")
return 0
console.print(f"[red]Error:[/red] Template '{args.name}' not found.")
return 1

def _template_export(self, manager: Any, args: argparse.Namespace) -> int:
"""Handle 'template export' command."""
name, version = self._parse_template_spec(args.name)
try:
path = manager.export_template(name, version or "v1", args.file)
console.print(f"[green]✓[/green] Template exported to: {path}")
return 0
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
return 1

def _template_import(self, manager: Any, args: argparse.Namespace) -> int:
"""Handle 'template import' command."""
try:
name, version = manager.import_template(args.file)
console.print(f"[green]✓[/green] Template imported: [bold]{name}:{version}[/bold]")
return 0
except (FileNotFoundError, ValueError) as e:
console.print(f"[red]Error:[/red] {e}")
return 1

return 0

def gpu(self, action: str = "status", mode: str = None, verbose: bool = False):
"""Hybrid GPU (Optimus) manager"""
from cortex.gpu_manager import run_gpu_manager
Expand Down Expand Up @@ -4088,6 +4272,50 @@ def main():
benchmark_parser = subparsers.add_parser("benchmark", help="Run AI performance benchmark")
benchmark_parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")

# Template commands
template_parser = subparsers.add_parser("template", help="System cloning and templating")
template_subparsers = template_parser.add_subparsers(dest="template_command")

# Create
create_p = template_subparsers.add_parser("create", help="Create system template")
create_p.add_argument("name", help="Template name")
create_p.add_argument("--description", "-d", default="", help="Template description")
create_p.add_argument(
"--sources", "-s", nargs="+", help="Sources to capture (apt, pip, npm, service)"
)

# Deploy
deploy_p = template_subparsers.add_parser("deploy", help="Deploy template")
deploy_p.add_argument("name", help="Template name[:version]")
deploy_p.add_argument(
"--dry-run", action="store_true", help="Preview changes (default behavior)"
)
deploy_p.add_argument(
"--execute", action="store_true", help="Apply changes; defaults to dry-run"
)
deploy_p.add_argument("--force", action="store_true", help="Skip compatibility checks")
deploy_p.add_argument("--to", help="Target system (e.g., server-02)")

# List
template_subparsers.add_parser("list", help="List all templates")

# Show
show_p = template_subparsers.add_parser("show", help="Show template details")
show_p.add_argument("name", help="Template name[:version]")

# Delete
del_p = template_subparsers.add_parser("delete", help="Delete template")
del_p.add_argument("name", help="Template name[:version]")

# Export
exp_p = template_subparsers.add_parser("export", help="Export template to ZIP")
exp_p.add_argument("name", help="Template name[:version]")
exp_p.add_argument("file", help="Output zip file")

# Import
imp_p = template_subparsers.add_parser("import", help="Import template from ZIP")
imp_p.add_argument("file", help="Input zip file")

# Systemd helper command
systemd_parser = subparsers.add_parser("systemd", help="Systemd service helper (plain English)")
systemd_parser.add_argument("service", help="Service name")
Expand Down Expand Up @@ -4788,12 +5016,10 @@ def main():
return cli.status()
elif args.command == "benchmark":
return cli.benchmark(verbose=getattr(args, "verbose", False))
elif args.command == "template":
return cli.template(args)
elif args.command == "systemd":
return cli.systemd(
args.service,
action=getattr(args, "action", "status"),
verbose=getattr(args, "verbose", False),
)
return cli.systemd(args.service, args.action, args.verbose)
elif args.command == "gpu":
return cli.gpu(
action=getattr(args, "action", "status"),
Expand Down
Loading