evo-cli 0.11.3__tar.gz → 0.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {evo_cli-0.11.3 → evo_cli-0.12.1}/PKG-INFO +55 -1
  2. {evo_cli-0.11.3 → evo_cli-0.12.1}/README.md +53 -0
  3. evo_cli-0.12.1/evo_cli/VERSION +1 -0
  4. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/cli.py +6 -0
  5. evo_cli-0.12.1/evo_cli/commands/cred.py +279 -0
  6. evo_cli-0.12.1/evo_cli/commands/harness.py +213 -0
  7. evo_cli-0.12.1/evo_cli/commands/update.py +291 -0
  8. evo_cli-0.12.1/evo_cli/credentials/__init__.py +27 -0
  9. evo_cli-0.12.1/evo_cli/credentials/doctor.py +73 -0
  10. evo_cli-0.12.1/evo_cli/credentials/google_oauth.py +101 -0
  11. evo_cli-0.12.1/evo_cli/credentials/migrate.py +147 -0
  12. evo_cli-0.12.1/evo_cli/credentials/registry.py +406 -0
  13. evo_cli-0.12.1/evo_cli/credentials/store.py +178 -0
  14. evo_cli-0.12.1/evo_cli/credentials/sync.py +149 -0
  15. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/PKG-INFO +55 -1
  16. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/SOURCES.txt +13 -0
  17. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/requires.txt +1 -0
  18. {evo_cli-0.11.3 → evo_cli-0.12.1}/pyproject.toml +1 -0
  19. evo_cli-0.12.1/tests/test_cred.py +342 -0
  20. evo_cli-0.12.1/tests/test_harness.py +136 -0
  21. evo_cli-0.12.1/tests/test_update.py +190 -0
  22. evo_cli-0.11.3/evo_cli/VERSION +0 -1
  23. {evo_cli-0.11.3 → evo_cli-0.12.1}/Containerfile +0 -0
  24. {evo_cli-0.11.3 → evo_cli-0.12.1}/HISTORY.md +0 -0
  25. {evo_cli-0.11.3 → evo_cli-0.12.1}/LICENSE +0 -0
  26. {evo_cli-0.11.3 → evo_cli-0.12.1}/MANIFEST.in +0 -0
  27. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/__init__.py +0 -0
  28. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/__main__.py +0 -0
  29. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/base.py +0 -0
  30. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/__init__.py +0 -0
  31. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/claude_code.py +0 -0
  32. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/cloudflare.py +0 -0
  33. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/download.py +0 -0
  34. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/fix_claude.py +0 -0
  35. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/gdrive.py +0 -0
  36. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/gh.py +0 -0
  37. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/hwid.py +0 -0
  38. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/hwid_reset.py +0 -0
  39. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/localproxy.py +0 -0
  40. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/mcp.py +0 -0
  41. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/miniconda.py +0 -0
  42. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/netcheck.py +0 -0
  43. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/opencode.py +0 -0
  44. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/plantuml.py +0 -0
  45. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/site2s.py +0 -0
  46. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/ssh.py +0 -0
  47. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/sysmon.py +0 -0
  48. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/wifi.py +0 -0
  49. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/console.py +0 -0
  50. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/mcp_registry.py +0 -0
  51. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  52. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/entry_points.txt +0 -0
  53. {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/top_level.txt +0 -0
  54. {evo_cli-0.11.3 → evo_cli-0.12.1}/setup.cfg +0 -0
  55. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/__init__.py +0 -0
  56. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_claude_code.py +0 -0
  57. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_cli.py +0 -0
  58. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_console.py +0 -0
  59. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_download.py +0 -0
  60. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_fix_claude.py +0 -0
  61. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_gh.py +0 -0
  62. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_mcp.py +0 -0
  63. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_opencode.py +0 -0
  64. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_plantuml.py +0 -0
  65. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_sysmon.py +0 -0
  66. {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.11.3
3
+ Version: 0.12.1
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -16,6 +16,7 @@ Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: cryptography>=41.0
18
18
  Requires-Dist: paramiko>=2.7.0
19
+ Requires-Dist: PyYAML>=6.0
19
20
  Requires-Dist: rich>=13.0
20
21
  Requires-Dist: rich-click>=1.8
21
22
  Provides-Extra: test
@@ -112,3 +113,56 @@ Options:
112
113
  - `-y, --yes` - Skip the confirmation prompt
113
114
  - `-f, --force` - Apply the fix even if the version is not in the affected range
114
115
  - `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
116
+
117
+ #### Credentials
118
+
119
+ `evo` owns the omelet credential store. The source of truth is a folder of one JSON file per service
120
+ (`~/.omelet.d/credentials/`), which compiles into a flat `~/.omelet.json` that older consumers read
121
+ directly.
122
+
123
+ ```bash
124
+ evo cred doctor # health + expiry of every credential, exit 1 if any expired
125
+ evo cred get openai_api_key # print one value by dotted path, nothing else on stdout
126
+ evo cred add openai_api_key # prompt with no echo, write the folder file, recompile
127
+ evo cred refresh --all # refresh Google OAuth access tokens
128
+ evo cred compile # rebuild ~/.omelet.json from the folder
129
+ evo cred sync push # push the folder to a private GitHub repo via gh
130
+ ```
131
+
132
+ Read a value into the environment without it reaching your shell history:
133
+
134
+ ```bash
135
+ eval "$(evo cred get --export OPENAI_API_KEY openai_api_key)"
136
+ ```
137
+
138
+ Configuration is env-driven, with no personal defaults baked in:
139
+
140
+ - `OMELET_DIR` - store root (default `~/.omelet.d`; the folder is `$OMELET_DIR/credentials`)
141
+ - `OMELET_CONFIG` - compiled flat file (default `~/.omelet.json`)
142
+ - `OMELET_SYNC_REPO` - required for `sync`; a **private** GitHub repo as `owner/repo`
143
+ - `OMELET_SYNC_DIR` - folder name inside the sync repo (default `credentials`)
144
+ - `RCLONE_DRIVE_CLIENT_ID` / `RCLONE_DRIVE_CLIENT_SECRET` - OAuth client for the rclone refresh
145
+
146
+ `evo cred sync push` refuses to push to a repo whose visibility is not `PRIVATE`. Never echo a value
147
+ into a shared terminal or a log; `doctor` and `list` only ever print a masked preview.
148
+
149
+ #### Harness Repositories
150
+
151
+ Fast-forward every available repository declared in a harness manifest:
152
+
153
+ ```bash
154
+ evo harness pull
155
+ ```
156
+
157
+ Run the command from a harness directory or any registered member repository. Use `--harness PATH`
158
+ when a repository belongs to multiple harnesses or the harness is not registered.
159
+
160
+ ```bash
161
+ evo harness pull --harness ~/github/my-project-harness
162
+ evo harness pull --repo backend --repo frontend
163
+ evo harness pull --dry-run
164
+ ```
165
+
166
+ The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
167
+ `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
168
+ uses `git pull --ff-only` so the command never creates merge commits.
@@ -85,3 +85,56 @@ Options:
85
85
  - `-y, --yes` - Skip the confirmation prompt
86
86
  - `-f, --force` - Apply the fix even if the version is not in the affected range
87
87
  - `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
88
+
89
+ #### Credentials
90
+
91
+ `evo` owns the omelet credential store. The source of truth is a folder of one JSON file per service
92
+ (`~/.omelet.d/credentials/`), which compiles into a flat `~/.omelet.json` that older consumers read
93
+ directly.
94
+
95
+ ```bash
96
+ evo cred doctor # health + expiry of every credential, exit 1 if any expired
97
+ evo cred get openai_api_key # print one value by dotted path, nothing else on stdout
98
+ evo cred add openai_api_key # prompt with no echo, write the folder file, recompile
99
+ evo cred refresh --all # refresh Google OAuth access tokens
100
+ evo cred compile # rebuild ~/.omelet.json from the folder
101
+ evo cred sync push # push the folder to a private GitHub repo via gh
102
+ ```
103
+
104
+ Read a value into the environment without it reaching your shell history:
105
+
106
+ ```bash
107
+ eval "$(evo cred get --export OPENAI_API_KEY openai_api_key)"
108
+ ```
109
+
110
+ Configuration is env-driven, with no personal defaults baked in:
111
+
112
+ - `OMELET_DIR` - store root (default `~/.omelet.d`; the folder is `$OMELET_DIR/credentials`)
113
+ - `OMELET_CONFIG` - compiled flat file (default `~/.omelet.json`)
114
+ - `OMELET_SYNC_REPO` - required for `sync`; a **private** GitHub repo as `owner/repo`
115
+ - `OMELET_SYNC_DIR` - folder name inside the sync repo (default `credentials`)
116
+ - `RCLONE_DRIVE_CLIENT_ID` / `RCLONE_DRIVE_CLIENT_SECRET` - OAuth client for the rclone refresh
117
+
118
+ `evo cred sync push` refuses to push to a repo whose visibility is not `PRIVATE`. Never echo a value
119
+ into a shared terminal or a log; `doctor` and `list` only ever print a masked preview.
120
+
121
+ #### Harness Repositories
122
+
123
+ Fast-forward every available repository declared in a harness manifest:
124
+
125
+ ```bash
126
+ evo harness pull
127
+ ```
128
+
129
+ Run the command from a harness directory or any registered member repository. Use `--harness PATH`
130
+ when a repository belongs to multiple harnesses or the harness is not registered.
131
+
132
+ ```bash
133
+ evo harness pull --harness ~/github/my-project-harness
134
+ evo harness pull --repo backend --repo frontend
135
+ evo harness pull --dry-run
136
+ ```
137
+
138
+ The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
139
+ `present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
140
+ uses `git pull --ff-only` so the command never creates merge commits.
@@ -0,0 +1 @@
1
+ 0.12.1
@@ -3,10 +3,12 @@ import rich_click as click
3
3
  from evo_cli import __version__
4
4
  from evo_cli.commands.claude_code import setup_claude
5
5
  from evo_cli.commands.cloudflare import cfssh
6
+ from evo_cli.commands.cred import cred_group
6
7
  from evo_cli.commands.download import download
7
8
  from evo_cli.commands.fix_claude import f_claude
8
9
  from evo_cli.commands.gdrive import gdrive
9
10
  from evo_cli.commands.gh import setup_gh
11
+ from evo_cli.commands.harness import harness_group
10
12
  from evo_cli.commands.hwid import hwid
11
13
  from evo_cli.commands.hwid_reset import hwid_reset
12
14
  from evo_cli.commands.localproxy import localproxy
@@ -18,6 +20,7 @@ from evo_cli.commands.plantuml import plantuml
18
20
  from evo_cli.commands.site2s import site2s
19
21
  from evo_cli.commands.ssh import setupssh
20
22
  from evo_cli.commands.sysmon import sysmon
23
+ from evo_cli.commands.update import update
21
24
  from evo_cli.commands.wifi import wifi
22
25
 
23
26
 
@@ -55,9 +58,11 @@ setup_group.add_command(setupssh)
55
58
 
56
59
  cli.add_command(setup_group)
57
60
  cli.add_command(cfssh)
61
+ cli.add_command(cred_group)
58
62
  cli.add_command(download)
59
63
  cli.add_command(f_claude)
60
64
  cli.add_command(gdrive)
65
+ cli.add_command(harness_group)
61
66
  cli.add_command(hwid)
62
67
  cli.add_command(hwid_reset)
63
68
  cli.add_command(site2s)
@@ -66,6 +71,7 @@ cli.add_command(mcp_group)
66
71
  cli.add_command(netcheck)
67
72
  cli.add_command(plantuml)
68
73
  cli.add_command(sysmon)
74
+ cli.add_command(update)
69
75
  cli.add_command(wifi)
70
76
 
71
77
 
@@ -0,0 +1,279 @@
1
+ import getpass
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ import rich_click as click
7
+ from rich.table import Table
8
+
9
+ from evo_cli.console import console, info, success, warning
10
+ from evo_cli.credentials import doctor as doctor_module
11
+ from evo_cli.credentials import google_oauth, migrate, sync
12
+ from evo_cli.credentials.registry import config_path, credentials_dir
13
+ from evo_cli.credentials.store import (
14
+ CredentialError,
15
+ compile_flat,
16
+ get_value,
17
+ read_flat,
18
+ relative_to_store,
19
+ require_folder,
20
+ set_value,
21
+ )
22
+
23
+ HEALTH_STYLE = {
24
+ "ok": "green",
25
+ "expiring": "yellow",
26
+ "EXPIRED": "bold red",
27
+ "deprecated": "dim",
28
+ }
29
+
30
+
31
+ def _guard(func, *args, **kwargs):
32
+ try:
33
+ return func(*args, **kwargs)
34
+ except CredentialError as exc:
35
+ raise click.ClickException(str(exc)) from exc
36
+ except ValueError as exc:
37
+ raise click.ClickException(str(exc)) from exc
38
+
39
+
40
+ def _render(rows):
41
+ table = Table(show_header=True, header_style="accent", box=None, pad_edge=False)
42
+ for column in ("FILE", "SERVICE", "TYPE", "HEALTH", "SECRET", "EXPIRY"):
43
+ table.add_column(column, overflow="fold")
44
+ for row in rows:
45
+ table.add_row(
46
+ row["file"],
47
+ row["service"],
48
+ row["type"],
49
+ f"[{HEALTH_STYLE.get(row['health'], 'white')}]{row['health']}[/]",
50
+ row["secret"],
51
+ row["expiry"],
52
+ )
53
+ console.print(table)
54
+
55
+
56
+ @click.group("cred", help="Read, refresh, and sync the omelet credential store.")
57
+ def cred_group():
58
+ pass
59
+
60
+
61
+ @cred_group.command("get", help="Print one credential by dotted key path.")
62
+ @click.argument("key_path")
63
+ @click.option("--export", "export_var", metavar="VAR", help="Print a shell export statement instead of the raw value.")
64
+ def get(key_path, export_var):
65
+ value = _guard(get_value, key_path)
66
+
67
+ if isinstance(value, (dict, list)):
68
+ sys.stdout.write(json.dumps(value, ensure_ascii=False) + "\n")
69
+ return
70
+
71
+ text = str(value)
72
+ if export_var:
73
+ escaped = text.replace("'", "'\\''")
74
+ sys.stdout.write(f"export {export_var}='{escaped}'\n")
75
+ return
76
+
77
+ sys.stdout.write(text)
78
+ if sys.stdout.isatty():
79
+ sys.stdout.write("\n")
80
+
81
+
82
+ @cred_group.command("list", help="List every credential in the store with its health.")
83
+ def list_credentials():
84
+ _guard(require_folder)
85
+ rows = _guard(doctor_module.scan)
86
+ _render(rows)
87
+ console.print(f"\n{len(rows)} entries in {credentials_dir()}")
88
+
89
+
90
+ @cred_group.command("doctor", help="Report health and expiry; exit 1 if anything is expired.")
91
+ def doctor():
92
+ _guard(require_folder)
93
+ rows = _guard(doctor_module.scan)
94
+ _render(rows)
95
+ expired = [row for row in rows if row["health"] == "EXPIRED"]
96
+ console.print(f"\n{len(rows)} entries in {credentials_dir()}")
97
+ if expired:
98
+ warning(f"{len(expired)} EXPIRED - run: evo cred refresh --all")
99
+ raise click.exceptions.Exit(1)
100
+ success("all credentials healthy")
101
+
102
+
103
+ @cred_group.command("compile", help="Rebuild the flat config from the credentials folder.")
104
+ @click.option("--include-legacy", is_flag=True, help="Also merge entries marked deprecated.")
105
+ def compile_command(include_legacy):
106
+ count, skipped, target = _guard(compile_flat, include_legacy=include_legacy)
107
+ message = f"compiled {count} entries -> {target}"
108
+ if skipped:
109
+ message += f" (skipped {len(skipped)} deprecated: {', '.join(skipped)})"
110
+ success(message)
111
+
112
+
113
+ @cred_group.command("add", help="Add or update a credential, then recompile the flat config.")
114
+ @click.argument("key_path")
115
+ @click.option("--from-stdin", is_flag=True, help="Read the value from stdin.")
116
+ @click.option("--from-env", metavar="VAR", help="Read the value from an environment variable.")
117
+ @click.option("--value", "inline_value", help="Inline value. Avoid: leaks to shell history.")
118
+ @click.option("--json", "parse_json", is_flag=True, help="Parse the value as JSON (bool/number/object/array).")
119
+ def add(key_path, from_stdin, from_env, inline_value, parse_json):
120
+ sources = [bool(from_stdin), bool(from_env), inline_value is not None]
121
+ if sum(sources) > 1:
122
+ raise click.UsageError("Use only one of --from-stdin, --from-env, --value.")
123
+
124
+ if from_stdin:
125
+ value = sys.stdin.read().rstrip("\n")
126
+ elif from_env:
127
+ if from_env not in os.environ:
128
+ raise click.ClickException(f"env var {from_env} not set")
129
+ value = os.environ[from_env]
130
+ elif inline_value is not None:
131
+ value = inline_value
132
+ else:
133
+ value = getpass.getpass(f"value for {key_path} (no echo): ")
134
+ if not value:
135
+ raise click.ClickException("empty value, aborting")
136
+
137
+ if parse_json:
138
+ try:
139
+ value = json.loads(value)
140
+ except json.JSONDecodeError as exc:
141
+ raise click.ClickException(f"--json: failed to parse value: {exc}") from exc
142
+
143
+ path, existed = _guard(set_value, key_path, value)
144
+ count, _, target = _guard(compile_flat)
145
+ verb = "updated" if existed else "added"
146
+ success(f"{verb} {key_path} in {relative_to_store(path)}, recompiled {target} ({count} entries)")
147
+
148
+
149
+ @cred_group.command("refresh", help="Refresh Google OAuth access tokens and recompile.")
150
+ @click.option("--all", "refresh_all", is_flag=True, help="Refresh every refreshable OAuth entry.")
151
+ @click.option(
152
+ "--service",
153
+ type=click.Choice(sorted(google_oauth.SERVICE_ALIASES)),
154
+ help="Refresh a single service.",
155
+ )
156
+ @click.option("--dry-run", is_flag=True, help="Show what would be refreshed without calling Google.")
157
+ def refresh(refresh_all, service, dry_run):
158
+ if refresh_all == bool(service):
159
+ raise click.UsageError("Pass exactly one of --all or --service.")
160
+
161
+ _guard(require_folder)
162
+ target = "*" if refresh_all else google_oauth.SERVICE_ALIASES[service]
163
+ entries = _guard(google_oauth.select_entries, target)
164
+ if not entries:
165
+ raise click.ClickException("no matching refreshable oauth entries")
166
+
167
+ failures = 0
168
+ refreshed = 0
169
+ for path, entry in entries:
170
+ name = entry.get("id", path.name)
171
+ creds, err = google_oauth.resolve_creds(entry)
172
+ if err:
173
+ warning(f"{name}: skip ({err})")
174
+ failures += 1
175
+ continue
176
+
177
+ if dry_run:
178
+ info(
179
+ f"{name}: would POST {google_oauth.TOKEN_URL} "
180
+ f"refresh_token={creds['refresh_token'][:10]}... client_id={creds['client_id'][:12]}..."
181
+ )
182
+ continue
183
+
184
+ try:
185
+ expiry = google_oauth.refresh_entry(path, entry, creds)
186
+ except Exception as exc:
187
+ warning(f"{name}: {google_oauth.describe_error(exc)}")
188
+ failures += 1
189
+ continue
190
+
191
+ refreshed += 1
192
+ success(f"{name}: refreshed, valid until {expiry}")
193
+
194
+ if refreshed:
195
+ count, _, target_path = _guard(compile_flat)
196
+ info(f"recompiled {target_path} ({count} entries)")
197
+
198
+ if failures:
199
+ raise click.exceptions.Exit(1)
200
+
201
+
202
+ @cred_group.command("migrate", help="Split a flat omelet.json into the per-service credentials folder.")
203
+ @click.option("--source", "source", type=click.Path(), help="Flat file to migrate. Defaults to the compiled config.")
204
+ @click.option("--dry-run", is_flag=True, help="Show the plan, write nothing.")
205
+ @click.option("--merge", is_flag=True, help="Migrate into an existing folder, adding only keys not already present.")
206
+ @click.option("--strict", is_flag=True, help="Abort on keys not mapped in the registry instead of routing to misc/.")
207
+ @click.option("--force", is_flag=True, help="Overwrite an existing non-empty folder.")
208
+ def migrate_command(source, dry_run, merge, strict, force):
209
+ result = _guard(migrate.plan, source or config_path(), strict=strict, merge=merge)
210
+
211
+ console.print(f"source: {result['source_path']} ({result['source_keys']} top-level keys)")
212
+ console.print(f"target: {credentials_dir()}{' [MERGE]' if merge else ''}\n")
213
+
214
+ for item in result["actions"]:
215
+ flags = " [unmapped->misc]" if item["misc"] else ""
216
+ if item["entry"].get("status") == "deprecated":
217
+ flags += " [DEPRECATED]"
218
+ if item["entry"].get("expiry"):
219
+ flags += f" expiry={item['entry']['expiry']}"
220
+ console.print(f" {item['action']:6s} {item['rel']:40s} <- {', '.join(item['keys'])}{flags}")
221
+
222
+ if result["unmapped"]:
223
+ warning(f"{len(result['unmapped'])} unmapped key(s) routed to misc/: {', '.join(result['unmapped'])}")
224
+
225
+ if dry_run:
226
+ info(f"dry-run: would write {len(result['actions'])} file(s)")
227
+ return
228
+
229
+ if not result["actions"]:
230
+ info("nothing to do (folder already up to date)")
231
+ return
232
+
233
+ if migrate.folder_has_files() and not (merge or force):
234
+ raise click.ClickException(
235
+ f"refusing: {credentials_dir()} already has files.\n"
236
+ "Use --merge (add missing keys) or --force (overwrite), or point OMELET_DIR elsewhere."
237
+ )
238
+
239
+ backup = _guard(migrate.apply, result)
240
+ info(f"backed up source -> {backup}")
241
+ success(f"wrote {len(result['actions'])} file(s) to {credentials_dir()}")
242
+
243
+ count, _, target_path = _guard(compile_flat)
244
+ success(f"compiled {count} entries -> {target_path}")
245
+
246
+
247
+ @cred_group.group("sync", help="Sync the credentials folder with a private GitHub repo.")
248
+ def sync_group():
249
+ pass
250
+
251
+
252
+ @sync_group.command("push", help="Push the local credentials folder to the sync repo.")
253
+ def sync_push():
254
+ host = _guard(sync.push)
255
+ if host is None:
256
+ info("no changes to push")
257
+ return
258
+ success(f"pushed {sync.dir_in_repo()}/ to {sync.sync_repo()} (from {host})")
259
+
260
+
261
+ @sync_group.command("pull", help="Pull the credentials folder from the sync repo, then recompile.")
262
+ def sync_pull():
263
+ backup = _guard(sync.pull)
264
+ if backup:
265
+ info(f"backed up existing folder -> {backup}")
266
+ success(f"pulled {sync.sync_repo()}:{sync.dir_in_repo()}/ -> {credentials_dir()}")
267
+ count, _, target_path = _guard(compile_flat)
268
+ success(f"compiled {count} entries -> {target_path}")
269
+
270
+
271
+ @cred_group.command("path", help="Print the store locations this machine resolves to.")
272
+ def show_path():
273
+ console.print(f"folder: {credentials_dir()}")
274
+ console.print(f"flat: {config_path()}")
275
+ try:
276
+ keys = [key for key in read_flat() if not key.startswith("_")]
277
+ except CredentialError:
278
+ keys = []
279
+ console.print(f"keys: {len(keys)}")
@@ -0,0 +1,213 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import rich_click as click
8
+ import yaml
9
+
10
+
11
+ def _read_yaml(path, required=True):
12
+ if not path.is_file():
13
+ if required:
14
+ raise click.ClickException(f"Harness manifest not found: {path}")
15
+ return {}
16
+ try:
17
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
18
+ except (OSError, yaml.YAMLError) as exc:
19
+ raise click.ClickException(f"Cannot read {path}: {exc}") from exc
20
+ if not isinstance(data, dict):
21
+ raise click.ClickException(f"Expected a YAML mapping in {path}")
22
+ return data
23
+
24
+
25
+ def _resolve_path(value, base):
26
+ path = Path(os.path.expandvars(str(value))).expanduser()
27
+ return (path if path.is_absolute() else base / path).resolve()
28
+
29
+
30
+ def _load_repos(manifest_path):
31
+ manifest = _read_yaml(manifest_path)
32
+ local = _read_yaml(manifest_path.with_name("harness.local.yaml"), required=False)
33
+ root = manifest_path.parent.resolve()
34
+ workspace_value = (
35
+ os.environ.get("EVO_HARNESS_WORKSPACE") or local.get("workspace") or manifest.get("workspace") or root.parent
36
+ )
37
+ workspace = _resolve_path(workspace_value, root)
38
+ entries = manifest.get("repos", [])
39
+ if not isinstance(entries, list):
40
+ raise click.ClickException(f"Expected 'repos' to be a list in {manifest_path}")
41
+ present_overrides = local.get("present", {}) or {}
42
+ if not isinstance(present_overrides, dict):
43
+ raise click.ClickException(
44
+ f"Expected 'present' to be a mapping in {manifest_path.with_name('harness.local.yaml')}"
45
+ )
46
+
47
+ repos = []
48
+ names = set()
49
+ for index, entry in enumerate(entries):
50
+ if not isinstance(entry, dict):
51
+ raise click.ClickException(f"Expected repos[{index}] to be a mapping in {manifest_path}")
52
+ name = str(entry.get("name", "")).strip()
53
+ if not name:
54
+ raise click.ClickException(f"Expected repos[{index}].name in {manifest_path}")
55
+ if name in names:
56
+ raise click.ClickException(f"Duplicate repo name '{name}' in {manifest_path}")
57
+ names.add(name)
58
+ fallback = workspace / name
59
+ declared = _resolve_path(entry["path"], workspace) if entry.get("path") else None
60
+ path = declared if declared and declared.exists() else fallback.resolve()
61
+ present = present_overrides.get(name, entry.get("present", True))
62
+ repos.append({"name": name, "path": path, "present": bool(present)})
63
+ return manifest, repos
64
+
65
+
66
+ def _contains(parent, child):
67
+ try:
68
+ child.relative_to(parent)
69
+ return True
70
+ except ValueError:
71
+ return False
72
+
73
+
74
+ def _as_manifest(value):
75
+ path = _resolve_path(value, Path.cwd())
76
+ return path / "harness.yaml" if path.is_dir() else path
77
+
78
+
79
+ def _find_manifest(value=None):
80
+ configured = value or os.environ.get("EVO_HARNESS")
81
+ if configured:
82
+ manifest_path = _as_manifest(configured)
83
+ if not manifest_path.is_file():
84
+ raise click.ClickException(f"Harness manifest not found: {manifest_path}")
85
+ return manifest_path
86
+
87
+ cwd = Path.cwd().resolve()
88
+ for base in (cwd, *cwd.parents):
89
+ candidate = base / "harness.yaml"
90
+ if candidate.is_file():
91
+ return candidate
92
+
93
+ registry_path = _resolve_path(
94
+ os.environ.get("EVO_HARNESS_REGISTRY", Path.home() / ".claude" / "harness" / "registry.json"),
95
+ Path.cwd(),
96
+ )
97
+ matches = []
98
+ if registry_path.is_file():
99
+ try:
100
+ registry = json.loads(registry_path.read_text(encoding="utf-8"))
101
+ except (OSError, json.JSONDecodeError) as exc:
102
+ raise click.ClickException(f"Cannot read {registry_path}: {exc}") from exc
103
+ for cluster in registry.get("clusters", []):
104
+ if not isinstance(cluster, dict) or not cluster.get("root"):
105
+ continue
106
+ candidate = _as_manifest(cluster["root"])
107
+ if not candidate.is_file():
108
+ continue
109
+ try:
110
+ _, repos = _load_repos(candidate)
111
+ except click.ClickException:
112
+ continue
113
+ root = candidate.parent.resolve()
114
+ if _contains(root, cwd) or any(_contains(repo["path"], cwd) for repo in repos):
115
+ matches.append(candidate)
116
+
117
+ matches = list(dict.fromkeys(matches))
118
+ if len(matches) == 1:
119
+ return matches[0]
120
+ if len(matches) > 1:
121
+ choices = ", ".join(str(path.parent) for path in matches)
122
+ raise click.ClickException(f"Current directory belongs to multiple harnesses: {choices}. Use --harness PATH.")
123
+ raise click.ClickException("Cannot find harness.yaml. Run inside a harness repo or use --harness PATH.")
124
+
125
+
126
+ def _git(path, *args):
127
+ return subprocess.run(
128
+ ["git", "-C", str(path), *args],
129
+ capture_output=True,
130
+ text=True,
131
+ encoding="utf-8",
132
+ errors="replace",
133
+ check=False,
134
+ )
135
+
136
+
137
+ @click.group("harness", help="Manage repositories declared by a harness manifest.")
138
+ def harness_group():
139
+ pass
140
+
141
+
142
+ @harness_group.command("pull", help="Fast-forward all available repositories in a harness.")
143
+ @click.option(
144
+ "--harness",
145
+ "harness_path",
146
+ type=click.Path(path_type=Path),
147
+ help="Harness directory or harness.yaml path. Defaults to auto-discovery.",
148
+ )
149
+ @click.option("--repo", "repos", multiple=True, help="Pull only this repo name. Repeat for multiple repos.")
150
+ @click.option("--prune/--no-prune", default=True, show_default=True, help="Prune deleted remote refs while pulling.")
151
+ @click.option("--dry-run", is_flag=True, help="Show repositories that would be pulled.")
152
+ def pull(harness_path, repos, prune, dry_run):
153
+ if shutil.which("git") is None:
154
+ raise click.ClickException("git is not installed or is not on PATH")
155
+
156
+ manifest_path = _find_manifest(harness_path)
157
+ manifest, entries = _load_repos(manifest_path)
158
+ available = {entry["name"] for entry in entries}
159
+ unknown = sorted(set(repos) - available)
160
+ if unknown:
161
+ raise click.ClickException(f"Unknown repo name(s): {', '.join(unknown)}")
162
+ selected = [entry for entry in entries if not repos or entry["name"] in repos]
163
+
164
+ click.echo(f"Harness: {manifest.get('name') or manifest_path.parent.name}")
165
+ click.echo(f"Manifest: {manifest_path}")
166
+ results = []
167
+ incomplete = 0
168
+
169
+ for entry in selected:
170
+ name = entry["name"]
171
+ path = entry["path"]
172
+ if not entry["present"]:
173
+ results.append((name, "skipped (present: false)"))
174
+ continue
175
+ if not path.is_dir():
176
+ results.append((name, f"failed (missing: {path})"))
177
+ incomplete += 1
178
+ continue
179
+
180
+ status = _git(path, "status", "--porcelain")
181
+ if status.returncode != 0:
182
+ detail = (status.stderr or status.stdout).strip().splitlines()
183
+ results.append((name, f"failed ({detail[-1] if detail else 'not a git repository'})"))
184
+ incomplete += 1
185
+ continue
186
+ if status.stdout.strip():
187
+ results.append((name, "skipped (uncommitted changes)"))
188
+ incomplete += 1
189
+ continue
190
+ if dry_run:
191
+ results.append((name, "would pull"))
192
+ continue
193
+
194
+ args = ["pull", "--ff-only"]
195
+ if prune:
196
+ args.append("--prune")
197
+ update = _git(path, *args)
198
+ if update.returncode != 0:
199
+ detail = (update.stderr or update.stdout).strip().splitlines()
200
+ results.append((name, f"failed ({detail[-1] if detail else 'git pull failed'})"))
201
+ incomplete += 1
202
+ continue
203
+ head = _git(path, "log", "-1", "--oneline")
204
+ results.append((name, head.stdout.strip() or "updated"))
205
+
206
+ click.echo()
207
+ click.secho("Summary", bold=True)
208
+ width = max((len(name) for name, _ in results), default=0)
209
+ for name, status in results:
210
+ color = "red" if status.startswith("failed") else "yellow" if status.startswith("skipped") else "green"
211
+ click.secho(f" {name:<{width}} {status}", fg=color)
212
+ if incomplete:
213
+ raise click.exceptions.Exit(1)