mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-11 03:31:55 +00:00
feat(profile): shareable profile distributions via git (#20831)
* feat(profile): shareable profile distributions (pack/install/update/info) Closes #20456. Turns a profile into a portable, versioned artifact. Packs SOUL.md, config, skills, cron, and an env-var manifest into a tar.gz that others can install from a local path, URL, or git repo. Updates re-pull the distribution while preserving user data (memories, sessions, auth.json, .env) and the user's config.yaml overrides. New subcommands (under hermes profile, no parallel tree): hermes profile pack <name> [-o FILE] hermes profile install <source> [--name N] [--alias] [--force] [-y] hermes profile update <name> [--force-config] [-y] hermes profile info <name> Manifest (distribution.yaml at the profile root): name, version, hermes_requires, author, env_requires, distribution_owned. Security: - Installer shows manifest + env-var requirements before mutating disk; confirmation required unless -y. - auth.json and .env are never packed (same exclude set as profile export). - Cron jobs are packed but NOT auto-scheduled — user is pointed at 'hermes -p <name> cron list' to review. - Archive extraction rejects path traversal (../ members). - Alias creation is opt-in via --alias. Update semantics: - Distribution-owned paths (SOUL.md, skills/, cron/, mcp.json, manifest): replaced from the new archive. - config.yaml: preserved by default; --force-config to overwrite. - User-owned paths (memories/, sessions/, auth.json, .env, state.db*, logs/, workspace/, plans/, home/, *_cache/, local/): never touched. Version pin: hermes_requires accepts >=, <=, ==, !=, >, < or a bare version (treated as >=). Install fails with a clear error when the running Hermes version doesn't satisfy the spec. Sources supported by 'install': - Local .tar.gz / .tgz archive - Local directory - HTTP(S) URL pointing to a .tar.gz (uses httpx, already a dep) - Git URL (github.com/user/repo, https://..., git@..., ssh://, git://) Tests: 43 new unit tests (manifest parsing, version checks, env template, pack/install/update round-trip, config-preservation, security). E2E validated via real CLI invocations against an isolated HERMES_HOME covering pack, install with confirmation, update preservation, update --force-config, decline-preview, duplicate-install rejection, and version-requirement rejection. * refactor(profile-dist): git-only — drop tar.gz/HTTP transports and pack Scope-cut on top of the original distribution PR: a profile distribution is now exclusively a git repository (or a local directory during development). The tar.gz / HTTP archive transports and the matching `hermes profile pack` subcommand have been removed. Why: * GitHub tags, branches, and commits are already the right versioning primitive. Tag pushes do for us what 'pack + upload' did. * `hermes profile export` / `import` already cover local backup and restore; they are not a distribution format and stay untouched. * One transport means one install/update code path, one doc page, and one mental model. The extra source types doubled the surface for no real user win — GitHub auto-attaches release tarballs, and `git bundle` / `git clone --mirror` cover the airgap case. Changes: * hermes_cli/profile_distribution.py — removed pack_profile, _fetch_tar_archive (_http_fetch), _safe_extract, _archive_roots, _safe_parts, _find_dist_root, tarfile/io/urlparse imports. The new _stage_source has two arms: git URL → clone, local directory → use in place. * hermes_cli/main.py — removed the 'pack' subparser and action handler. Install help text updated to match the reduced source list. * tests/hermes_cli/test_profile_distribution.py — rewritten around a local-directory staging fixture. The install/update/describe suites now build a distribution tree on disk directly and install from it, which is what a real git clone produces after .git is stripped. Dropped TestPack, TestFindDistRoot, and the tar-specific security test. New tests cover _looks_like_git_url, env_example emission, hermes_requires enforcement, and 'installer does not import credentials if an author mistakenly leaks them in the staging tree'. * website/docs/reference/profile-commands.md — 'Distribution commands' section rewritten around git. Added a 'Publishing a distribution' section. export/import stay documented as local backup/restore. * website/docs/reference/cli-commands.md — dropped 'pack' from the profile subcommand table. * website/package.json — 'lint:diagrams' now passes --exclude-code-blocks to ascii-guard. Without it, markdown tables and box-drawing diagrams inside fenced code blocks were being misidentified as malformed ASCII boxes, blocking the PR's docs-site-checks CI with 8 false-positive errors. Validation: * Targeted suite: tests/hermes_cli/test_profile_distribution.py — 56/56 pass (down from 43 — reorganized to cover the new local-dir paths). * Regression: test_profiles.py + test_profile_export_credentials.py 102/102 still pass. export/import behaviour unchanged. * Docs lint: ascii-guard lint --exclude-code-blocks docs returns 0 errors (was 8 on the PR before the flag bump). * E2E: ran the real `hermes profile install`/`info` against a local staging dir under an isolated HERMES_HOME — install writes SOUL.md + skills to the target profile, info reads the manifest back, a bogus source produces a clear error, and `hermes profile pack` is now rejected by argparse as expected. * feat(profile-dist): distribution-aware list/show/delete + installed_at + env preview Polish pass on top of the git-only scope cut. Five additions, all small, wiring into existing commands rather than adding new surface. 1. `installed_at` timestamp on the manifest * Stamped automatically inside plan_install() on both fresh install and update — ISO-8601 UTC, seconds resolution. * Surfaced in `hermes profile info` as `Installed: <ts>`. * Lets users tell "installed 6 months ago, needs update" from "installed yesterday" without guessing from file mtimes. 2. `hermes profile list` grows a `Distribution` column * Plain profiles: "—" * Distribution profiles: "<name>@<version>" (e.g. `telemetry@1.2.3`) * ProfileInfo gains three optional fields — distribution_name, distribution_version, distribution_source — populated by a new _read_distribution_meta() helper that swallows manifest read errors so a broken distribution.yaml in one profile can't break `list` for the others. 3. `hermes profile show` and `hermes profile delete` surface distribution provenance * show: `Distribution: name@version` + `Installed from: <source>` plus a pointer to `hermes profile info <name>` for the full manifest. * delete: same lines in the pre-confirmation preview, so a user deleting "telemetry" can see it came from `github.com/kyle/telemetry-distribution` before they type `telemetry` to confirm. No change to the confirmation gate itself — deletion semantics are identical to plain profiles. 4. Install preview checks env vars against the current environment * Replaces the "Env vars you'll need to set:" header with a simpler "Env vars:" block. * Each required var is labeled: - `✓ set` — already in `os.environ` OR present as a key in the target profile's existing .env (update case). - `needs setting` — required but not found in either place. - `—` — optional. * Mirrors pip's "Requirement already satisfied" UX: no unnecessary nagging about keys the user already has configured. 5. Docs: private distributions * New "Private distributions" section in website/docs/reference/profile-commands.md explaining that we shell out to the user's `git` binary, so SSH keys / credential helpers / GitHub CLI stored creds all work transparently. One paragraph, two examples. * `hermes profile info` section updated to mention `Installed:`. Module-level hoist: * `from datetime import datetime, timezone` was previously lazy-imported inside plan_install(). Hoisted to module scope so tests can monkeypatch `hermes_cli.profile_distribution.datetime` to freeze time. Tests (+7): * TestInstalledAtStamp.test_install_stamps_installed_at — format check (4-digit year, 'T', +00:00 suffix). * TestInstalledAtStamp.test_update_refreshes_installed_at — freezes datetime.now() to 2099-01-01 and confirms update writes a new stamp. * TestProfileInfoDistribution.test_installed_distribution_shows_in_list — ProfileInfo.distribution_{name,version,source} populated after install. * TestProfileInfoDistribution.test_plain_profile_has_no_distribution_fields — plain profiles have None. * TestProfileInfoDistribution.test_malformed_manifest_does_not_break_list — broken distribution.yaml in one profile doesn't break list_profiles(). Validation: * 163/163 tests pass (56 distribution + 102 profile regression + 5 new from this commit — up from 158). * docs-lint: 0 errors. * E2E verified: install preview shows ✓/needs-setting per env var, `profile list` shows Distribution column, `profile show` + `delete` preview mentions source URL, `info` shows Installed: timestamp. * fix(profile-dist): clean errors + warn when overwriting plain profiles Two small polish fixes found during collision sweeps of the PR: 1. ValueError from validate_profile_name now caught cleanly * A distribution.yaml whose 'name' field can't be used as a profile identifier (spaces, path traversal, etc.) raises ValueError from hermes_cli.profiles.validate_profile_name, which was escaping as a raw Python traceback from 'hermes profile install/update/info'. * Broadened the except clause in all three handlers to catch (DistributionError, ValueError) — users now see: Error: Invalid profile name '../../etc/passwd'. Must match [a-z0-9][a-z0-9_-]{0,63} instead of a stack trace. 2. Install preview distinguishes plain profile overwrite from distribution re-install * When plan.target_dir exists and IS a distribution (has distribution.yaml), preview still shows the mild (profile exists — will overwrite distribution-owned files only) * When plan.target_dir exists but is a HAND-BUILT plain profile (no distribution.yaml), preview now shows a loud warning: ⚠ Profile exists but is NOT a distribution. Installing here will overwrite its SOUL.md, skills/, cron/, and mcp.json. Your memories, sessions, auth.json, and .env will be preserved, but any hand-edits to distribution-owned files will be lost. * Users who type 'hermes profile install foo --force' against a profile they hand-built now see what they're signing up for. User data is still safe (memories, sessions, auth, .env are in USER_OWNED_EXCLUDE), but custom SOUL/skills get stomped. Tests (+2): * TestErrorSurfaces.test_bad_profile_name_raises_valueerror_not_traceback * TestErrorSurfaces.test_path_traversal_name_rejected Validation: * 165/165 tests pass (was 163). * E2E: bad manifest names produce 'Error: Invalid profile name ...' with no traceback; installing over a plain profile shows the warning; re-installing over an existing distribution shows the normal overwrite message. * Bad HTTPS URLs still produce 'Error: git clone failed: ...' — git itself generates a clean enough message that no wrapper is needed. * 'install .' works correctly from any cwd. * fix(profiles): reject reserved names at validate time Before: `hermes profile create hermes` / `profile install` / `profile rename` all silently accepted reserved names like `hermes`, `test`, `tmp`, `root`, `sudo`. The profile directory was created; only alias creation failed (via check_alias_collision), leaving a confusingly-named profile on disk — e.g. `~/.hermes/profiles/hermes/` sitting next to `~/.hermes/` itself. The reserved set already exists (_RESERVED_NAMES, introduced alongside alias collision detection). This commit moves the check up one layer to validate_profile_name so every entry point — create, install, import, rename, dashboard web API — shares the same gate. The error message points the user at the cause without being cryptic: Error: Profile name 'hermes' is reserved — it collides with either the Hermes installation itself or a common system binary. Pick a different name. `default` continues to pass through (it's a special alias for ~/.hermes). _HERMES_SUBCOMMANDS (`chat`, `model`, `gateway`, etc.) stays at alias-collision time only — those are fine as bare profile names with `--no-alias`. Tests (+5): test_reserved_names_rejected parametrized over the full _RESERVED_NAMES set, matching the existing pattern in TestValidateProfileName. No existing test uses a reserved name as a profile identifier (greppped create_profile("hermes|test|tmp|root|sudo") — zero hits). Validation: * 170/170 tests pass in the profile suites. * E2E: `profile create hermes`, `profile install` with manifest name=hermes, and `profile install ... --name hermes` all produce the same clean `Error: Profile name 'hermes' is reserved ...` with rc=1 and no traceback. Normal names (`mybot`) still work.
This commit is contained in:
parent
cf648a9b7e
commit
f209a35859
8 changed files with 1791 additions and 6 deletions
|
|
@ -8157,8 +8157,14 @@ def cmd_profile(args):
|
|||
return
|
||||
|
||||
# Header
|
||||
print(f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} {'Alias'}")
|
||||
print(f" {'─' * 15} {'─' * 27} {'─' * 11} {'─' * 12}")
|
||||
print(
|
||||
f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} "
|
||||
f"{'Alias':<12} {'Distribution'}"
|
||||
)
|
||||
print(
|
||||
f" {'─' * 15} {'─' * 27} {'─' * 11} "
|
||||
f"{'─' * 11} {'─' * 20}"
|
||||
)
|
||||
|
||||
for p in profiles:
|
||||
marker = (
|
||||
|
|
@ -8172,7 +8178,12 @@ def cmd_profile(args):
|
|||
alias = p.name if p.alias_path else "—"
|
||||
if p.is_default:
|
||||
alias = "—"
|
||||
print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias}")
|
||||
if p.distribution_name:
|
||||
dist = f"{p.distribution_name}@{p.distribution_version or '?'}"
|
||||
dist = dist[:30]
|
||||
else:
|
||||
dist = "—"
|
||||
print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias:<12} {dist}")
|
||||
print()
|
||||
|
||||
elif action == "use":
|
||||
|
|
@ -8311,6 +8322,7 @@ def cmd_profile(args):
|
|||
_read_config_model,
|
||||
_check_gateway_running,
|
||||
_count_skills,
|
||||
_read_distribution_meta,
|
||||
)
|
||||
|
||||
if not profile_exists(name):
|
||||
|
|
@ -8320,6 +8332,7 @@ def cmd_profile(args):
|
|||
model, provider = _read_config_model(profile_dir)
|
||||
gw = _check_gateway_running(profile_dir)
|
||||
skills = _count_skills(profile_dir)
|
||||
dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)
|
||||
wrapper = _get_wrapper_dir() / name
|
||||
|
||||
print(f"\nProfile: {name}")
|
||||
|
|
@ -8334,6 +8347,11 @@ def cmd_profile(args):
|
|||
print(
|
||||
f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}"
|
||||
)
|
||||
if dist_name:
|
||||
print(f"Distribution: {dist_name}@{dist_version or '?'}")
|
||||
if dist_source:
|
||||
print(f"Installed from: {dist_source}")
|
||||
print(f" (run `hermes profile info {name}` for full manifest)")
|
||||
if wrapper.exists():
|
||||
print(f"Alias: {wrapper}")
|
||||
print()
|
||||
|
|
@ -8414,6 +8432,208 @@ def cmd_profile(args):
|
|||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
elif action == "install":
|
||||
import tempfile
|
||||
from hermes_cli.profile_distribution import (
|
||||
plan_install,
|
||||
install_distribution,
|
||||
DistributionError,
|
||||
)
|
||||
|
||||
try:
|
||||
# Preview: stage the distribution into a scratch dir, show the
|
||||
# manifest, then do the real install. The double-stage avoids
|
||||
# any side-effects if the user declines.
|
||||
with tempfile.TemporaryDirectory(prefix="hermes_dist_preview_") as tmp:
|
||||
plan = plan_install(
|
||||
args.source,
|
||||
Path(tmp),
|
||||
override_name=getattr(args, "install_name", None),
|
||||
)
|
||||
_render_distribution_plan(plan)
|
||||
|
||||
if not getattr(args, "yes", False):
|
||||
try:
|
||||
answer = input("\nProceed with install? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = ""
|
||||
if answer not in ("y", "yes"):
|
||||
print("Install cancelled.")
|
||||
return
|
||||
|
||||
plan = install_distribution(
|
||||
args.source,
|
||||
name=getattr(args, "install_name", None),
|
||||
force=getattr(args, "force", False),
|
||||
create_alias=getattr(args, "alias", False),
|
||||
)
|
||||
print(f"\n✓ Installed '{plan.manifest.name}' v{plan.manifest.version}")
|
||||
print(f" Profile path: {plan.target_dir}")
|
||||
if plan.manifest.env_requires:
|
||||
print(
|
||||
f" Next: copy .env.EXAMPLE to .env and fill in required keys:\n"
|
||||
f" {plan.target_dir}/.env.EXAMPLE"
|
||||
)
|
||||
if plan.has_cron:
|
||||
print(
|
||||
" Cron jobs were included but are NOT scheduled automatically.\n"
|
||||
f" Review them with: hermes -p {plan.manifest.name} cron list"
|
||||
)
|
||||
print(f"\n Use with: hermes -p {plan.manifest.name} chat")
|
||||
except (DistributionError, ValueError) as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
elif action == "update":
|
||||
from hermes_cli.profile_distribution import (
|
||||
update_distribution,
|
||||
read_manifest,
|
||||
DistributionError,
|
||||
)
|
||||
from hermes_cli.profiles import get_profile_dir, normalize_profile_name
|
||||
|
||||
name = args.profile_name
|
||||
try:
|
||||
canon = normalize_profile_name(name)
|
||||
current = read_manifest(get_profile_dir(canon))
|
||||
if current is None:
|
||||
print(
|
||||
f"Error: Profile '{canon}' is not a distribution (no distribution.yaml). "
|
||||
"Only profiles installed via `hermes profile install` can be updated."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
force_config = getattr(args, "force_config", False)
|
||||
if not getattr(args, "yes", False):
|
||||
print(f"\nUpdate '{canon}' from: {current.source or '(no source)'}")
|
||||
print(f" Currently at version {current.version}")
|
||||
if force_config:
|
||||
print(" --force-config set: config.yaml WILL be overwritten.")
|
||||
else:
|
||||
print(" config.yaml will be preserved (pass --force-config to overwrite).")
|
||||
print(" User data (memories, sessions, auth, .env) will NOT be touched.")
|
||||
try:
|
||||
answer = input("\nProceed? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = ""
|
||||
if answer not in ("y", "yes"):
|
||||
print("Update cancelled.")
|
||||
return
|
||||
|
||||
plan = update_distribution(canon, force_config=force_config)
|
||||
print(f"\n✓ Updated '{plan.manifest.name}' → v{plan.manifest.version}")
|
||||
if plan.has_cron:
|
||||
print(
|
||||
" Cron files were refreshed. Review with: "
|
||||
f"hermes -p {plan.manifest.name} cron list"
|
||||
)
|
||||
except (DistributionError, ValueError) as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
elif action == "info":
|
||||
from hermes_cli.profile_distribution import describe_distribution, DistributionError
|
||||
|
||||
try:
|
||||
data = describe_distribution(args.profile_name)
|
||||
except (DistributionError, ValueError) as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
if not data:
|
||||
print(
|
||||
f"Profile '{args.profile_name}' is not a distribution "
|
||||
"(no distribution.yaml)."
|
||||
)
|
||||
return
|
||||
print(f"\nDistribution: {data.get('name')}")
|
||||
print(f"Version: {data.get('version', '?')}")
|
||||
if data.get("description"):
|
||||
print(f"Description: {data['description']}")
|
||||
if data.get("author"):
|
||||
print(f"Author: {data['author']}")
|
||||
if data.get("license"):
|
||||
print(f"License: {data['license']}")
|
||||
if data.get("hermes_requires"):
|
||||
print(f"Requires: Hermes {data['hermes_requires']}")
|
||||
if data.get("source"):
|
||||
print(f"Source: {data['source']}")
|
||||
if data.get("installed_at"):
|
||||
print(f"Installed: {data['installed_at']}")
|
||||
env_reqs = data.get("env_requires") or []
|
||||
if env_reqs:
|
||||
print("\nEnvironment variables:")
|
||||
for er in env_reqs:
|
||||
tag = "required" if er.get("required", True) else "optional"
|
||||
line = f" {er['name']} ({tag})"
|
||||
if er.get("description"):
|
||||
line += f" — {er['description']}"
|
||||
print(line)
|
||||
if er.get("default") is not None:
|
||||
print(f" default: {er['default']}")
|
||||
print()
|
||||
|
||||
|
||||
def _render_distribution_plan(plan) -> None:
|
||||
"""Print a human-readable summary of a pending distribution install."""
|
||||
from hermes_cli.profile_distribution import MANIFEST_FILENAME
|
||||
mf = plan.manifest
|
||||
print(f"\nDistribution: {mf.name} v{mf.version}")
|
||||
if mf.description:
|
||||
print(f" {mf.description}")
|
||||
if mf.author:
|
||||
print(f" Author: {mf.author}")
|
||||
if mf.hermes_requires:
|
||||
print(f" Requires: Hermes {mf.hermes_requires}")
|
||||
print(f" Source: {plan.provenance}")
|
||||
print(f" Target: {plan.target_dir}")
|
||||
if plan.existing:
|
||||
# Distinguish "updating an existing distribution" (well-understood
|
||||
# semantics — dist-owned overwritten, config preserved, user data
|
||||
# untouched) from "overwriting a hand-built plain profile" (same
|
||||
# mechanics but the user didn't sign up for this when they created
|
||||
# the profile manually).
|
||||
existing_is_distribution = (plan.target_dir / MANIFEST_FILENAME).is_file()
|
||||
if existing_is_distribution:
|
||||
print(" (profile exists — will overwrite distribution-owned files only)")
|
||||
else:
|
||||
print(
|
||||
" ⚠ Profile exists but is NOT a distribution. Installing here will\n"
|
||||
" overwrite its SOUL.md, skills/, cron/, and mcp.json.\n"
|
||||
" Your memories, sessions, auth.json, and .env will be preserved,\n"
|
||||
" but any hand-edits to distribution-owned files will be lost."
|
||||
)
|
||||
if mf.env_requires:
|
||||
print("\n Env vars:")
|
||||
for er in mf.env_requires:
|
||||
tag = "required" if er.required else "optional"
|
||||
# Check both the current shell environment and the target profile's
|
||||
# .env file so we don't nag about keys the user already has set up.
|
||||
already = os.environ.get(er.name) is not None
|
||||
if not already and plan.target_dir.is_dir():
|
||||
env_path = plan.target_dir / ".env"
|
||||
if env_path.is_file():
|
||||
try:
|
||||
for raw in env_path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key = line.split("=", 1)[0].strip()
|
||||
if key == er.name:
|
||||
already = True
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
status = "✓ set" if already else ("needs setting" if er.required else "—")
|
||||
line = f" • {er.name} ({tag}, {status})"
|
||||
if er.description:
|
||||
line += f" — {er.description}"
|
||||
print(line)
|
||||
if plan.has_cron:
|
||||
print(
|
||||
"\n ⚠ This distribution ships cron jobs. They will NOT run "
|
||||
"automatically — review and enable manually."
|
||||
)
|
||||
|
||||
|
||||
def _report_dashboard_status() -> int:
|
||||
"""Print ``hermes dashboard`` PIDs and return the count.
|
||||
|
|
@ -10663,6 +10883,63 @@ Examples:
|
|||
help="Profile name (default: inferred from archive)",
|
||||
)
|
||||
|
||||
# ---------- Distribution subcommands (issue #20456) ----------
|
||||
profile_install = profile_subparsers.add_parser(
|
||||
"install",
|
||||
help="Install a profile distribution from a git URL or local directory",
|
||||
description=(
|
||||
"Install a Hermes profile distribution. SOURCE can be a git URL "
|
||||
"(github.com/user/repo, https://..., git@...) or a local "
|
||||
"directory containing distribution.yaml at its root."
|
||||
),
|
||||
)
|
||||
profile_install.add_argument(
|
||||
"source",
|
||||
help="Distribution source (git URL or local directory)",
|
||||
)
|
||||
profile_install.add_argument(
|
||||
"--name", dest="install_name", metavar="NAME",
|
||||
help="Override profile name (default: read from manifest)",
|
||||
)
|
||||
profile_install.add_argument(
|
||||
"--alias", action="store_true",
|
||||
help="Create a shell wrapper alias for the installed profile",
|
||||
)
|
||||
profile_install.add_argument(
|
||||
"--force", action="store_true",
|
||||
help="Overwrite an existing profile of the same name (user data preserved)",
|
||||
)
|
||||
profile_install.add_argument(
|
||||
"-y", "--yes", action="store_true",
|
||||
help="Skip manifest preview confirmation",
|
||||
)
|
||||
|
||||
profile_update = profile_subparsers.add_parser(
|
||||
"update",
|
||||
help="Re-pull a distribution and apply updates (user data preserved)",
|
||||
description=(
|
||||
"Fetch the distribution from its recorded source and overwrite "
|
||||
"distribution-owned files (SOUL.md, skills/, cron/, mcp.json). "
|
||||
"User data (memories, sessions, auth, .env) is never touched. "
|
||||
"config.yaml is preserved unless --force-config is passed."
|
||||
),
|
||||
)
|
||||
profile_update.add_argument("profile_name", help="Profile to update")
|
||||
profile_update.add_argument(
|
||||
"--force-config", action="store_true",
|
||||
help="Also overwrite config.yaml (normally preserved to keep user overrides)",
|
||||
)
|
||||
profile_update.add_argument(
|
||||
"-y", "--yes", action="store_true",
|
||||
help="Skip confirmation",
|
||||
)
|
||||
|
||||
profile_info = profile_subparsers.add_parser(
|
||||
"info",
|
||||
help="Show a profile's distribution manifest (version, requirements, source)",
|
||||
)
|
||||
profile_info.add_argument("profile_name", help="Profile to inspect")
|
||||
|
||||
profile_parser.set_defaults(func=cmd_profile)
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue