Skip to content
Merged
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
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Interactive CLI** — bare `aura` opens an ASCII splash and numbered menu (agents, sessions, run, home, help, version); grouped `aura --help` reference.
- **`rich` dependency** — terminal tables and gradient splash for the CLI.
- **CLI module split** — `aura/cli/commands.py`, `splash.py`, `interactive.py`, `help_ui.py`.
- **`aura agent set`** — update `agent_ref`, purpose, skills, variables, ids, and rules on existing profiles.
- **`aura config show`** — merged global/project config and resolved registry/sessions paths.
- **`aura paths`** — view paths; **`set-project`** and **`set-storage`** persist settings to YAML.
- **Interactive paths submenu** — replaces read-only home; agents menu adds **edit** wizard.
- **Splash polish** — blank line above ASCII logo; smoother Rich truecolor gradient on Windows Terminal.

### Changed

- **`aura` console script** — unchanged entry point (`aura.cli.main:main`); wheel installs the `aura` command with interactive default when no subcommand is given.
- **`AgentRegistry.update_profile`** — registry ref/alias maps stay consistent when `agent_ref` changes.
- **Global config** — optional persisted `project_dir` in `~/.aura/config.yaml`.

## [0.3.3] - 2026-08-21

Expand Down
47 changes: 47 additions & 0 deletions aura/agents/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,53 @@ def save(self, profile: AgentProfile) -> None:
with path.open("w", encoding="utf-8") as f:
json.dump(profile.to_dict(), f, indent=2)

def profile_path(self, aura_id: str) -> Path:
return self._agent_path(aura_id)

def update_profile(self, key: str, **updates: Any) -> AgentProfile:
"""Apply partial updates to an existing agent profile."""
profile = self.resolve(key)
aura_id = profile.aura_id

if "name" in updates and updates["name"] is not None:
new_name = updates["name"]
if new_name != profile.name:
self._register_alias(new_name, aura_id)
profile.name = new_name

if "agent_ref" in updates:
new_ref_raw = updates["agent_ref"]
new_ref = validate_agent_ref(new_ref_raw) if new_ref_raw else None
old_ref = profile.agent_ref
refs: dict[str, str] = self._state.setdefault("refs", {})
if old_ref and refs.get(old_ref) == aura_id and old_ref != new_ref:
del refs[old_ref]
self._save_state()
if new_ref:
self._register_ref(new_ref, aura_id)
profile.agent_ref = new_ref
tenant = tenant_from_ref(new_ref)
if tenant:
profile.ids.setdefault("tenant", tenant)

if "purpose" in updates and updates["purpose"] is not None:
profile.purpose = updates["purpose"]
if "policy_version" in updates and updates["policy_version"] is not None:
profile.policy_version = str(updates["policy_version"])
if "default_mode" in updates and updates["default_mode"] is not None:
profile.default_mode = updates["default_mode"]
if updates.get("skills") is not None:
profile.skills = list(updates["skills"])
if updates.get("variables") is not None:
profile.variables.update(dict(updates["variables"]))
if updates.get("ids") is not None:
profile.ids.update(dict(updates["ids"]))
if updates.get("rules") is not None:
profile.rules = list(updates["rules"])

self.save(profile)
return profile

def get_by_id(self, aura_id: str) -> AgentProfile:
path = self._agent_path(aura_id)
if not path.is_file() and is_legacy_aura_id(aura_id):
Expand Down
239 changes: 232 additions & 7 deletions aura/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pathlib import Path

from aura import __version__, agent, create_agent
from aura.agents.registry import AgentNotFoundError, AgentRegistry
from aura.agents.registry import AgentNotFoundError, AgentRegistry, DuplicateAgentError
from aura.core.compare import compare_sessions
from aura.core.spine import AuditSpine
from aura.exporters.otel import export_session_otel
Expand All @@ -17,7 +17,7 @@
from rich.console import Console
from rich.table import Table

from aura.cli.styles import BORDER_STYLE, CATEGORY_STYLE, ID_STYLE, TABLE_STYLE
from aura.cli.styles import BORDER_STYLE, CATEGORY_STYLE, ID_STYLE, MENU_STYLE, TABLE_STYLE


def cmd_version(*, console: Console | None = None) -> int:
Expand Down Expand Up @@ -218,23 +218,248 @@ def cmd_compare(session_a: str, session_b: str, *, console: Console | None = Non


def cmd_home(*, console: Console | None = None) -> int:
from aura.config import get_config
"""Show resolved paths (alias for paths view)."""
return cmd_paths(console=console)


def _parse_key_value_pairs(
pairs: list[str] | None, label: str
) -> tuple[dict[str, str] | None, str | None]:
if not pairs:
return None, None
result: dict[str, str] = {}
for item in pairs:
if "=" not in item:
return None, f"invalid {label} '{item}' (expected key=value)"
key, value = item.split("=", 1)
key = key.strip()
if not key:
return None, f"invalid {label} '{item}' (empty key)"
result[key] = value.strip()
return result, None


def cmd_agent_set(
key: str,
*,
agent_ref: str | None = None,
purpose: str | None = None,
policy_version: str | None = None,
default_mode: str | None = None,
skills: list[str] | None = None,
variables: list[str] | None = None,
ids: list[str] | None = None,
rules_file: Path | None = None,
rules_json: str | None = None,
console: Console | None = None,
) -> int:
reg = AgentRegistry()
try:
reg.resolve(key)
except AgentNotFoundError:
message = f"not found: {key}"
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 1

var_map, err = _parse_key_value_pairs(variables, "variable")
if err:
if console is None:
print(err, file=sys.stderr)
else:
console.print(err, style="bold #FF9AA2")
return 2
id_map, err = _parse_key_value_pairs(ids, "id")
if err:
if console is None:
print(err, file=sys.stderr)
else:
console.print(err, style="bold #FF9AA2")
return 2

rules: list[dict] | None = None
if rules_file is not None:
if not rules_file.is_file():
message = f"not found: {rules_file}"
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 1
rules = json.loads(rules_file.read_text(encoding="utf-8"))
if not isinstance(rules, list):
message = "rules file must contain a JSON array"
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 2
elif rules_json is not None:
rules = json.loads(rules_json)
if not isinstance(rules, list):
message = "--rules-json must be a JSON array"
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 2

updates: dict = {}
if agent_ref is not None:
updates["agent_ref"] = agent_ref or None
if purpose is not None:
updates["purpose"] = purpose
if policy_version is not None:
updates["policy_version"] = policy_version
if default_mode is not None:
updates["default_mode"] = default_mode
if skills is not None:
updates["skills"] = skills
if var_map is not None:
updates["variables"] = var_map
if id_map is not None:
updates["ids"] = id_map
if rules is not None:
updates["rules"] = rules

if not updates:
message = "no fields to update (pass --ref, --purpose, --skill, etc.)"
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 2

try:
profile = reg.update_profile(key, **updates)
except (ValueError, DuplicateAgentError) as exc:
message = str(exc)
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 2

payload = json.dumps(profile.to_dict(), indent=2)
if console is None:
print(payload)
else:
console.print(payload, style="dim")
console.print(f" saved: {reg.profile_path(profile.aura_id)}", style="dim")
return 0


def cmd_config_show(*, console: Console | None = None) -> int:
from aura.config import config_sources, get_config

cfg = get_config()
payload = {
"home": str(cfg.home),
"project_dir": str(cfg.project_dir) if cfg.project_dir else None,
"values": cfg.values,
"registry": str(cfg.registry_dir()),
"sessions": str(cfg.sessions_dir()),
"state_file": str(cfg.state_file()),
"config_files": [
{"layer": label, "path": str(path), "loaded": loaded}
for label, path, loaded in config_sources(cfg)
],
}
text = json.dumps(payload, indent=2)
if console is None:
print(text)
else:
console.print("AURA config", style=f"bold {TABLE_STYLE}")
for label, path, loaded in config_sources(cfg):
status = "loaded" if loaded else "missing"
console.print(f" {label}: {path} ({status})", style=MENU_STYLE if loaded else "dim")
console.print()
console.print(f" storage: {cfg.values.get('storage', 'global')}", style=ID_STYLE)
console.print(f" registry: {cfg.registry_dir()}", style=CATEGORY_STYLE)
console.print(f" sessions: {cfg.sessions_dir()}", style=CATEGORY_STYLE)
if cfg.project_dir:
console.print(f" project: {cfg.project_dir}", style="dim")
return 0


def cmd_paths(*, console: Console | None = None) -> int:
from aura.config import config_sources, get_config

cfg = get_config()
if console is None:
print(f"AURA_HOME: {cfg.home}")
print(f"storage: {cfg.values.get('storage', 'global')}")
print(f"registry: {cfg.registry_dir()}")
print(f"sessions: {cfg.sessions_dir()}")
print(f"storage: {cfg.values.get('storage', 'global')}")
if cfg.project_dir:
print(f"project: {cfg.project_dir}")
for label, path, loaded in config_sources(cfg):
print(f"config_{label}: {path} ({'loaded' if loaded else 'missing'})")
return 0

console.print("AURA paths", style=f"bold {TABLE_STYLE}")
console.print(f" home: {cfg.home}", style=ID_STYLE)
console.print("Path resolution", style=f"bold {TABLE_STYLE}")
console.print(f" AURA_HOME: {cfg.home}", style=ID_STYLE)
console.print(f" storage mode: {cfg.values.get('storage', 'global')}", style=MENU_STYLE)
console.print(f" registry: {cfg.registry_dir()}", style=CATEGORY_STYLE)
console.print(f" sessions: {cfg.sessions_dir()}", style=CATEGORY_STYLE)
console.print(f" storage mode: {cfg.values.get('storage', 'global')}", style="dim")
if cfg.project_dir:
console.print(f" project: {cfg.project_dir}", style="dim")
console.print()
console.print("Config files", style=f"bold {TABLE_STYLE}")
for label, path, loaded in config_sources(cfg):
status = "ok" if loaded else "missing"
console.print(f" {label}: {path} [{status}]", style="dim" if not loaded else MENU_STYLE)
console.print(
" Tip: aura paths set-project <dir> · aura paths set-storage global|project",
style="dim",
)
return 0


def cmd_paths_set_project(directory: str, *, console: Console | None = None) -> int:
from aura.config import get_config, reload_config, save_global_config

candidate = Path(directory).expanduser()
if not candidate.is_dir():
message = f"not a directory: {candidate}"
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 1
resolved = str(candidate.resolve())
path = save_global_config({"project_dir": resolved}, home=get_config().home)
reload_config(project_dir=resolved)
message = f"project_dir saved to {path}"
if console is None:
print(message)
else:
console.print(message, style=ID_STYLE)
return 0


def cmd_paths_set_storage(mode: str, *, console: Console | None = None) -> int:
from aura.config import get_config, reload_config, save_global_config, save_project_config

if mode not in ("global", "project"):
message = "storage must be 'global' or 'project'"
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 2

cfg = get_config()
if cfg.project_dir and mode == "project":
path = save_project_config({"storage": mode}, cfg.project_dir)
else:
path = save_global_config({"storage": mode}, home=cfg.home)
reload_config(project_dir=cfg.project_dir)
message = f"storage={mode} saved to {path}"
if console is None:
print(message)
else:
console.print(message, style=ID_STYLE)
return 0
Loading
Loading