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.
- {evo_cli-0.11.3 → evo_cli-0.12.1}/PKG-INFO +55 -1
- {evo_cli-0.11.3 → evo_cli-0.12.1}/README.md +53 -0
- evo_cli-0.12.1/evo_cli/VERSION +1 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/cli.py +6 -0
- evo_cli-0.12.1/evo_cli/commands/cred.py +279 -0
- evo_cli-0.12.1/evo_cli/commands/harness.py +213 -0
- evo_cli-0.12.1/evo_cli/commands/update.py +291 -0
- evo_cli-0.12.1/evo_cli/credentials/__init__.py +27 -0
- evo_cli-0.12.1/evo_cli/credentials/doctor.py +73 -0
- evo_cli-0.12.1/evo_cli/credentials/google_oauth.py +101 -0
- evo_cli-0.12.1/evo_cli/credentials/migrate.py +147 -0
- evo_cli-0.12.1/evo_cli/credentials/registry.py +406 -0
- evo_cli-0.12.1/evo_cli/credentials/store.py +178 -0
- evo_cli-0.12.1/evo_cli/credentials/sync.py +149 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/PKG-INFO +55 -1
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/SOURCES.txt +13 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/requires.txt +1 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/pyproject.toml +1 -0
- evo_cli-0.12.1/tests/test_cred.py +342 -0
- evo_cli-0.12.1/tests/test_harness.py +136 -0
- evo_cli-0.12.1/tests/test_update.py +190 -0
- evo_cli-0.11.3/evo_cli/VERSION +0 -1
- {evo_cli-0.11.3 → evo_cli-0.12.1}/Containerfile +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/HISTORY.md +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/LICENSE +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/MANIFEST.in +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/__init__.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/__main__.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/base.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/console.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/setup.cfg +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/__init__.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_claude_code.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_cli.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_console.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_download.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_gh.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_mcp.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_opencode.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_plantuml.py +0 -0
- {evo_cli-0.11.3 → evo_cli-0.12.1}/tests/test_sysmon.py +0 -0
- {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.
|
|
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)
|