evo-cli 0.18.0__tar.gz → 0.19.0__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 (119) hide show
  1. {evo_cli-0.18.0 → evo_cli-0.19.0}/PKG-INFO +17 -1
  2. {evo_cli-0.18.0 → evo_cli-0.19.0}/README.md +16 -0
  3. evo_cli-0.19.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/__init__.py +3 -1
  5. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/_paths.py +4 -0
  6. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/_render.py +10 -0
  7. evo_cli-0.19.0/evo_cli/commands/harness/clone.py +123 -0
  8. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/pull.py +3 -7
  9. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/views.py +4 -0
  10. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli.egg-info/PKG-INFO +17 -1
  11. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli.egg-info/SOURCES.txt +2 -0
  12. evo_cli-0.19.0/tests/test_harness_clone.py +270 -0
  13. evo_cli-0.18.0/evo_cli/VERSION +0 -1
  14. {evo_cli-0.18.0 → evo_cli-0.19.0}/Containerfile +0 -0
  15. {evo_cli-0.18.0 → evo_cli-0.19.0}/HISTORY.md +0 -0
  16. {evo_cli-0.18.0 → evo_cli-0.19.0}/LICENSE +0 -0
  17. {evo_cli-0.18.0 → evo_cli-0.19.0}/MANIFEST.in +0 -0
  18. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/__init__.py +0 -0
  19. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/__main__.py +0 -0
  20. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/base.py +0 -0
  21. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/cli.py +0 -0
  22. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/__init__.py +0 -0
  23. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/agent_toy.py +0 -0
  24. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/claude_code.py +0 -0
  25. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/cloudflare.py +0 -0
  26. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/cred.py +0 -0
  27. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/download.py +0 -0
  28. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/fix_claude.py +0 -0
  29. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/gdrive.py +0 -0
  30. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/gh.py +0 -0
  31. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/_dag.py +0 -0
  32. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/_git.py +0 -0
  33. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/_model.py +0 -0
  34. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/_mutate.py +0 -0
  35. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/_server.py +0 -0
  36. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/check.py +0 -0
  37. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/edit.py +0 -0
  38. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/serve.py +0 -0
  39. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/show.py +0 -0
  40. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/index-CsALFg16.css +0 -0
  41. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/index-DwEXL5pV.js +0 -0
  42. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  43. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  44. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  45. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  46. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  47. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  48. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  49. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  50. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  51. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  52. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  53. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  54. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/harness/web/index.html +0 -0
  55. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/hwid.py +0 -0
  56. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/hwid_reset.py +0 -0
  57. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/localproxy.py +0 -0
  58. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/mcp.py +0 -0
  59. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/miniconda.py +0 -0
  60. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/netcheck.py +0 -0
  61. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/opencode.py +0 -0
  62. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/plantuml.py +0 -0
  63. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/serp.py +0 -0
  64. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/site2s.py +0 -0
  65. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/ssh.py +0 -0
  66. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/sysmon.py +0 -0
  67. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/tts.py +0 -0
  68. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/update.py +0 -0
  69. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/commands/wifi.py +0 -0
  70. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/console.py +0 -0
  71. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/credentials/__init__.py +0 -0
  72. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/credentials/doctor.py +0 -0
  73. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/credentials/google_oauth.py +0 -0
  74. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/credentials/migrate.py +0 -0
  75. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/credentials/oauth_flow.py +0 -0
  76. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/credentials/registry.py +0 -0
  77. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/credentials/store.py +0 -0
  78. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/credentials/sync.py +0 -0
  79. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/mcp_registry.py +0 -0
  80. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/serp/__init__.py +0 -0
  81. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/serp/client.py +0 -0
  82. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/serp/creds.py +0 -0
  83. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/serp/errors.py +0 -0
  84. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/serp/install.py +0 -0
  85. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/serp/render.py +0 -0
  86. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/__init__.py +0 -0
  87. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/chunking.py +0 -0
  88. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/core.py +0 -0
  89. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/creds.py +0 -0
  90. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/errors.py +0 -0
  91. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/http.py +0 -0
  92. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/openai.py +0 -0
  93. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/player.py +0 -0
  94. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli/tts/vbee.py +0 -0
  95. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  96. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli.egg-info/entry_points.txt +0 -0
  97. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli.egg-info/requires.txt +0 -0
  98. {evo_cli-0.18.0 → evo_cli-0.19.0}/evo_cli.egg-info/top_level.txt +0 -0
  99. {evo_cli-0.18.0 → evo_cli-0.19.0}/pyproject.toml +0 -0
  100. {evo_cli-0.18.0 → evo_cli-0.19.0}/setup.cfg +0 -0
  101. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/__init__.py +0 -0
  102. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_agent_toy.py +0 -0
  103. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_claude_code.py +0 -0
  104. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_cli.py +0 -0
  105. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_console.py +0 -0
  106. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_cred.py +0 -0
  107. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_download.py +0 -0
  108. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_fix_claude.py +0 -0
  109. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_gh.py +0 -0
  110. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_harness.py +0 -0
  111. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_harness_dag.py +0 -0
  112. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_mcp.py +0 -0
  113. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_opencode.py +0 -0
  114. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_plantuml.py +0 -0
  115. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_serp.py +0 -0
  116. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_sysmon.py +0 -0
  117. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_tts.py +0 -0
  118. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_update.py +0 -0
  119. {evo_cli-0.18.0 → evo_cli-0.19.0}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.18.0
3
+ Version: 0.19.0
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
@@ -159,6 +159,21 @@ else has the flat file open.
159
159
 
160
160
  #### Harness Repositories
161
161
 
162
+ Clone every repository a harness manifest declares but this machine does not have yet:
163
+
164
+ ```bash
165
+ evo harness clone
166
+ evo harness clone --dry-run # show what lands where, change nothing
167
+ evo harness clone --repo backend --depth 1
168
+ evo harness clone --all # include repos marked present: false
169
+ ```
170
+
171
+ Each repo lands on its declared `path`, falling back to `<workspace>/<name>`, so a fresh machine
172
+ ends up with the layout the manifest, the contract seams and the exec-plans already assume. A repo
173
+ already on disk is left alone; if its `origin` is not the remote the manifest names, the summary
174
+ says so instead of touching it. When the manifest names a `branch`, the clone moves onto it, and a
175
+ branch nobody has pushed yet is reported rather than treated as a failure.
176
+
162
177
  Fast-forward every available repository declared in a harness manifest:
163
178
 
164
179
  ```bash
@@ -299,6 +314,7 @@ evo harness plans # progress across every exec-plan
299
314
  evo harness show <plan>
300
315
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
301
316
  evo harness graph <plan>:steps # the same DAG as an adjacency list
317
+ evo harness clone # clone every declared repo into its declared path
302
318
  evo harness pull # fast-forward every repo
303
319
  ```
304
320
 
@@ -131,6 +131,21 @@ else has the flat file open.
131
131
 
132
132
  #### Harness Repositories
133
133
 
134
+ Clone every repository a harness manifest declares but this machine does not have yet:
135
+
136
+ ```bash
137
+ evo harness clone
138
+ evo harness clone --dry-run # show what lands where, change nothing
139
+ evo harness clone --repo backend --depth 1
140
+ evo harness clone --all # include repos marked present: false
141
+ ```
142
+
143
+ Each repo lands on its declared `path`, falling back to `<workspace>/<name>`, so a fresh machine
144
+ ends up with the layout the manifest, the contract seams and the exec-plans already assume. A repo
145
+ already on disk is left alone; if its `origin` is not the remote the manifest names, the summary
146
+ says so instead of touching it. When the manifest names a `branch`, the clone moves onto it, and a
147
+ branch nobody has pushed yet is reported rather than treated as a failure.
148
+
134
149
  Fast-forward every available repository declared in a harness manifest:
135
150
 
136
151
  ```bash
@@ -271,6 +286,7 @@ evo harness plans # progress across every exec-plan
271
286
  evo harness show <plan>
272
287
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
273
288
  evo harness graph <plan>:steps # the same DAG as an adjacency list
289
+ evo harness clone # clone every declared repo into its declared path
274
290
  evo harness pull # fast-forward every repo
275
291
  ```
276
292
 
@@ -0,0 +1 @@
1
+ 0.19.0
@@ -1,6 +1,7 @@
1
1
  import rich_click as click
2
2
 
3
3
  from evo_cli.commands.harness.check import check
4
+ from evo_cli.commands.harness.clone import clone
4
5
  from evo_cli.commands.harness.edit import debt, question, repo, step
5
6
  from evo_cli.commands.harness.pull import pull
6
7
  from evo_cli.commands.harness.serve import serve
@@ -21,9 +22,10 @@ def harness_group():
21
22
  evo harness graph <plan> print a DAG as an adjacency list
22
23
  evo harness check <plan> check what the plan claims against real git
23
24
  evo harness step <plan> 3 done mark a step
25
+ evo harness clone clone every declared repo into its declared path
24
26
  evo harness pull fast-forward every repo
25
27
  """
26
28
 
27
29
 
28
- for command in (serve, repos, seams, plans, show, graph, check, step, debt, question, repo, pull):
30
+ for command in (serve, repos, seams, plans, show, graph, check, step, debt, question, repo, clone, pull):
29
31
  harness_group.add_command(command)
@@ -62,6 +62,10 @@ def load_repos(manifest_path):
62
62
  {
63
63
  "name": name,
64
64
  "path": path,
65
+ # Where the manifest says the repo belongs, whether or not it is there yet.
66
+ # `path` falls back to the workspace when the declared path is missing, so a
67
+ # command that creates the checkout has to read this one instead.
68
+ "declared_path": declared or fallback.resolve(),
65
69
  "present": bool(present) and path.is_dir(),
66
70
  "declared_present": bool(present),
67
71
  "role": str(entry.get("role") or ""),
@@ -85,6 +85,16 @@ def print_graph(title: str, graph: dict) -> None:
85
85
  print_warnings(graph["warnings"])
86
86
 
87
87
 
88
+ def print_summary(results: list) -> None:
89
+ """One `name status` line per repo, coloured by the verb the status starts with."""
90
+ click.echo()
91
+ click.secho("Summary", bold=True)
92
+ width = max((len(name) for name, _ in results), default=0)
93
+ for name, status in results:
94
+ color = "red" if status.startswith("failed") else "yellow" if status.startswith("skipped") else "green"
95
+ click.secho(f" {name:<{width}} {status}", fg=color)
96
+
97
+
88
98
  def fail_if(condition: bool) -> None:
89
99
  if condition:
90
100
  raise click.exceptions.Exit(1)
@@ -0,0 +1,123 @@
1
+ import re
2
+ import shutil
3
+
4
+ import rich_click as click
5
+
6
+ from evo_cli.commands.harness._paths import find_manifest, git, harness_option, load_repos
7
+ from evo_cli.commands.harness._render import print_summary
8
+
9
+ SCHEME_RE = re.compile(r"^\w+://")
10
+ USER_RE = re.compile(r"^[^@/]+@")
11
+
12
+
13
+ def normalize_remote(url):
14
+ """Reduce a remote URL to host + path so the ssh and https forms of one repo compare equal."""
15
+ text = str(url).strip().rstrip("/")
16
+ if text.endswith(".git"):
17
+ text = text[: -len(".git")]
18
+ text = USER_RE.sub("", SCHEME_RE.sub("", text))
19
+ return text.replace(":", "/", 1).lower()
20
+
21
+
22
+ def detail_of(result, fallback):
23
+ lines = ((result.stderr or "") + (result.stdout or "")).strip().splitlines()
24
+ return lines[-1] if lines else fallback
25
+
26
+
27
+ def already_cloned(entry, target):
28
+ """A checkout is already there. Say so, and say if it came from a different remote."""
29
+ declared = entry["origin"]
30
+ actual = git(target, "remote", "get-url", "origin").stdout.strip()
31
+ if declared and actual and normalize_remote(declared) != normalize_remote(actual):
32
+ return f"skipped (already cloned, but origin is {actual})"
33
+ return "skipped (already cloned)"
34
+
35
+
36
+ def checkout_declared_branch(entry, target):
37
+ """Move onto the branch the manifest names. A branch that is not on origin is not an error.
38
+
39
+ Cloning with `--branch` would fail the whole clone for a branch that has not been pushed yet,
40
+ which is a normal state for a plan in progress. Clone the default branch, then try to move.
41
+ """
42
+ branch = entry["branch"]
43
+ if not branch or branch.upper() == "TBD":
44
+ return ""
45
+ current = git(target, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
46
+ if current == branch:
47
+ return ""
48
+ if git(target, "checkout", branch, timeout=120).returncode != 0:
49
+ return f" (branch {branch!r} is not on origin, left on {current or 'HEAD'})"
50
+ return f" (on {branch})"
51
+
52
+
53
+ @click.command("clone", help="Clone the repositories a harness declares into their declared paths.")
54
+ @harness_option
55
+ @click.option("--repo", "repos", multiple=True, help="Clone only this repo name. Repeat for multiple repos.")
56
+ @click.option("--all", "include_absent", is_flag=True, help="Also clone repos the manifest marks present: false.")
57
+ @click.option("--depth", type=int, help="Shallow clone with this many commits of history.")
58
+ @click.option("--dry-run", is_flag=True, help="Show what would be cloned, and where.")
59
+ def clone(harness_path, repos, include_absent, depth, dry_run):
60
+ """Clone every repository the harness manifest declares but this machine does not have.
61
+
62
+ Each repo lands on its declared `path`, falling back to `<workspace>/<name>`, so a fresh
63
+ machine ends up with the layout the manifest, the contract seams and the exec-plans already
64
+ assume. Repos already on disk are left alone.
65
+ """
66
+ if shutil.which("git") is None:
67
+ raise click.ClickException("git is not installed or is not on PATH")
68
+
69
+ manifest_path = find_manifest(harness_path)
70
+ manifest, entries = load_repos(manifest_path)
71
+ available = {entry["name"] for entry in entries}
72
+ unknown = sorted(set(repos) - available)
73
+ if unknown:
74
+ raise click.ClickException(f"Unknown repo name(s): {', '.join(unknown)}")
75
+ selected = [entry for entry in entries if not repos or entry["name"] in repos]
76
+
77
+ click.echo(f"Harness: {manifest.get('name') or manifest_path.parent.name}")
78
+ click.echo(f"Manifest: {manifest_path}")
79
+ results = []
80
+ failures = 0
81
+
82
+ for entry in selected:
83
+ name = entry["name"]
84
+ target = entry["declared_path"]
85
+
86
+ # `present: false` means the manifest knows about the repo and deliberately does not want
87
+ # it here. Naming it with --repo, or asking for --all, overrides that.
88
+ if not entry["declared_present"] and not include_absent and not repos:
89
+ results.append((name, "skipped (present: false)"))
90
+ continue
91
+ if (target / ".git").is_dir():
92
+ results.append((name, already_cloned(entry, target)))
93
+ continue
94
+ if entry["path"] != target and (entry["path"] / ".git").is_dir():
95
+ results.append((name, f"skipped (already cloned at {entry['path']}, manifest says {target})"))
96
+ continue
97
+ if target.exists() and (not target.is_dir() or any(target.iterdir())):
98
+ results.append((name, f"failed (path is taken and is not an empty directory: {target})"))
99
+ failures += 1
100
+ continue
101
+ if not entry["origin"]:
102
+ results.append((name, "failed (no origin declared in the manifest)"))
103
+ failures += 1
104
+ continue
105
+ if dry_run:
106
+ results.append((name, f"would clone {entry['origin']} into {target}"))
107
+ continue
108
+
109
+ target.parent.mkdir(parents=True, exist_ok=True)
110
+ args = ["clone"]
111
+ if depth:
112
+ args += ["--depth", str(depth)]
113
+ args += [entry["origin"], str(target)]
114
+ result = git(target.parent, *args, timeout=900)
115
+ if result.returncode != 0:
116
+ results.append((name, f"failed ({detail_of(result, 'git clone failed')})"))
117
+ failures += 1
118
+ continue
119
+ results.append((name, f"cloned into {target}{checkout_declared_branch(entry, target)}"))
120
+
121
+ print_summary(results)
122
+ if failures:
123
+ raise click.exceptions.Exit(1)
@@ -3,6 +3,7 @@ import shutil
3
3
  import rich_click as click
4
4
 
5
5
  from evo_cli.commands.harness._paths import find_manifest, git, harness_option, load_repos
6
+ from evo_cli.commands.harness._render import print_summary
6
7
 
7
8
 
8
9
  @click.command("pull", help="Fast-forward all available repositories in a harness.")
@@ -34,7 +35,7 @@ def pull(harness_path, repos, prune, dry_run):
34
35
  results.append((name, "skipped (present: false)"))
35
36
  continue
36
37
  if not path.is_dir():
37
- results.append((name, f"failed (missing: {path})"))
38
+ results.append((name, f"failed (missing: {path} - run `evo harness clone`)"))
38
39
  incomplete += 1
39
40
  continue
40
41
 
@@ -64,11 +65,6 @@ def pull(harness_path, repos, prune, dry_run):
64
65
  head = git(path, "log", "-1", "--oneline")
65
66
  results.append((name, head.stdout.strip() or "updated"))
66
67
 
67
- click.echo()
68
- click.secho("Summary", bold=True)
69
- width = max((len(name) for name, _ in results), default=0)
70
- for name, status in results:
71
- color = "red" if status.startswith("failed") else "yellow" if status.startswith("skipped") else "green"
72
- click.secho(f" {name:<{width}} {status}", fg=color)
68
+ print_summary(results)
73
69
  if incomplete:
74
70
  raise click.exceptions.Exit(1)
@@ -71,6 +71,10 @@ def repos(harness_path, with_git):
71
71
  grid.add_row(f"[bold]{entry['name']}[/]", entry["role"] or "-", branch, state, one_line(entry["note"], width))
72
72
  console.print(grid)
73
73
 
74
+ missing = [entry["name"] for entry in info["repos"] if not entry["present"]]
75
+ if missing:
76
+ console.print(f"\n[red]{len(missing)} repo(s) missing[/] - run [accent]evo harness clone[/] to fetch them")
77
+
74
78
  if info["proposals_pending"]:
75
79
  console.print(f"\n[yellow]{len(info['proposals_pending'])} pending proposal(s)[/] in proposals/_pending")
76
80
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.18.0
3
+ Version: 0.19.0
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
@@ -159,6 +159,21 @@ else has the flat file open.
159
159
 
160
160
  #### Harness Repositories
161
161
 
162
+ Clone every repository a harness manifest declares but this machine does not have yet:
163
+
164
+ ```bash
165
+ evo harness clone
166
+ evo harness clone --dry-run # show what lands where, change nothing
167
+ evo harness clone --repo backend --depth 1
168
+ evo harness clone --all # include repos marked present: false
169
+ ```
170
+
171
+ Each repo lands on its declared `path`, falling back to `<workspace>/<name>`, so a fresh machine
172
+ ends up with the layout the manifest, the contract seams and the exec-plans already assume. A repo
173
+ already on disk is left alone; if its `origin` is not the remote the manifest names, the summary
174
+ says so instead of touching it. When the manifest names a `branch`, the clone moves onto it, and a
175
+ branch nobody has pushed yet is reported rather than treated as a failure.
176
+
162
177
  Fast-forward every available repository declared in a harness manifest:
163
178
 
164
179
  ```bash
@@ -299,6 +314,7 @@ evo harness plans # progress across every exec-plan
299
314
  evo harness show <plan>
300
315
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
301
316
  evo harness graph <plan>:steps # the same DAG as an adjacency list
317
+ evo harness clone # clone every declared repo into its declared path
302
318
  evo harness pull # fast-forward every repo
303
319
  ```
304
320
 
@@ -50,6 +50,7 @@ evo_cli/commands/harness/_paths.py
50
50
  evo_cli/commands/harness/_render.py
51
51
  evo_cli/commands/harness/_server.py
52
52
  evo_cli/commands/harness/check.py
53
+ evo_cli/commands/harness/clone.py
53
54
  evo_cli/commands/harness/edit.py
54
55
  evo_cli/commands/harness/pull.py
55
56
  evo_cli/commands/harness/serve.py
@@ -103,6 +104,7 @@ tests/test_download.py
103
104
  tests/test_fix_claude.py
104
105
  tests/test_gh.py
105
106
  tests/test_harness.py
107
+ tests/test_harness_clone.py
106
108
  tests/test_harness_dag.py
107
109
  tests/test_mcp.py
108
110
  tests/test_opencode.py
@@ -0,0 +1,270 @@
1
+ import importlib
2
+ import subprocess
3
+
4
+ from click.testing import CliRunner
5
+
6
+ from evo_cli.cli import cli
7
+
8
+ # harness/__init__ binds the command object to the name `clone`, shadowing the submodule,
9
+ # so the module has to be fetched by path rather than imported by name.
10
+ clone_command = importlib.import_module("evo_cli.commands.harness.clone")
11
+
12
+
13
+ def _result(returncode=0, stdout="", stderr=""):
14
+ return subprocess.CompletedProcess([], returncode, stdout, stderr)
15
+
16
+
17
+ def _write_harness(root, workspace, extra=""):
18
+ """alpha declares a path outside <workspace>/<name>; beta relies on the fallback."""
19
+ root.mkdir()
20
+ (root / "harness.yaml").write_text(
21
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n"
22
+ f"- name: alpha\n path: {(workspace / 'nested' / 'alpha').as_posix()}\n"
23
+ " origin: https://github.com/acme/alpha.git\n"
24
+ "- name: beta\n origin: git@github.com:acme/beta.git\n" + extra,
25
+ encoding="utf-8",
26
+ )
27
+
28
+
29
+ def _checkout(path, origin="https://github.com/acme/alpha.git"):
30
+ (path / ".git").mkdir(parents=True)
31
+ return origin
32
+
33
+
34
+ def test_clone_command_is_registered():
35
+ assert "clone" in cli.commands["harness"].commands
36
+
37
+
38
+ def test_clone_dry_run_targets_the_declared_path_not_the_workspace_fallback(tmp_path, monkeypatch):
39
+ workspace = tmp_path / "repos"
40
+ workspace.mkdir()
41
+ root = tmp_path / "cluster"
42
+ _write_harness(root, workspace)
43
+ calls = []
44
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: calls.append((path, args)) or _result())
45
+
46
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--dry-run"])
47
+
48
+ assert result.exit_code == 0
49
+ assert f"would clone https://github.com/acme/alpha.git into {(workspace / 'nested' / 'alpha').resolve()}" in (
50
+ result.output
51
+ )
52
+ assert f"would clone git@github.com:acme/beta.git into {(workspace / 'beta').resolve()}" in result.output
53
+ assert calls == []
54
+
55
+
56
+ def test_clone_runs_git_clone_from_the_parent_directory(tmp_path, monkeypatch):
57
+ workspace = tmp_path / "repos"
58
+ workspace.mkdir()
59
+ root = tmp_path / "cluster"
60
+ _write_harness(root, workspace)
61
+ calls = []
62
+
63
+ def fake_git(path, *args, **kwargs):
64
+ calls.append((path, args))
65
+ return _result()
66
+
67
+ monkeypatch.setattr(clone_command, "git", fake_git)
68
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "beta", "--depth", "1"])
69
+
70
+ target = (workspace / "beta").resolve()
71
+ assert result.exit_code == 0
72
+ assert calls == [(target.parent, ("clone", "--depth", "1", "git@github.com:acme/beta.git", str(target)))]
73
+ assert f"cloned into {target}" in result.output
74
+
75
+
76
+ def test_clone_creates_the_parent_of_a_nested_declared_path(tmp_path, monkeypatch):
77
+ workspace = tmp_path / "repos"
78
+ workspace.mkdir()
79
+ root = tmp_path / "cluster"
80
+ _write_harness(root, workspace)
81
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
82
+
83
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "alpha"])
84
+
85
+ assert result.exit_code == 0
86
+ assert (workspace / "nested").is_dir()
87
+
88
+
89
+ def test_clone_leaves_an_existing_checkout_alone(tmp_path, monkeypatch):
90
+ workspace = tmp_path / "repos"
91
+ workspace.mkdir()
92
+ root = tmp_path / "cluster"
93
+ _write_harness(root, workspace)
94
+ _checkout(workspace / "nested" / "alpha")
95
+ # The ssh form of the same remote must not read as a different repository.
96
+ monkeypatch.setattr(
97
+ clone_command, "git", lambda path, *args, **kwargs: _result(stdout="git@github.com:acme/alpha.git\n")
98
+ )
99
+
100
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "alpha"])
101
+
102
+ assert result.exit_code == 0
103
+ assert "alpha skipped (already cloned)" in result.output
104
+
105
+
106
+ def test_clone_reports_a_checkout_whose_origin_is_a_different_repo(tmp_path, monkeypatch):
107
+ workspace = tmp_path / "repos"
108
+ workspace.mkdir()
109
+ root = tmp_path / "cluster"
110
+ _write_harness(root, workspace)
111
+ _checkout(workspace / "nested" / "alpha")
112
+ monkeypatch.setattr(
113
+ clone_command, "git", lambda path, *args, **kwargs: _result(stdout="https://github.com/other/fork.git\n")
114
+ )
115
+
116
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "alpha"])
117
+
118
+ assert result.exit_code == 0
119
+ assert "skipped (already cloned, but origin is https://github.com/other/fork.git)" in result.output
120
+
121
+
122
+ def test_clone_reports_a_checkout_sitting_somewhere_other_than_the_declared_path(tmp_path, monkeypatch):
123
+ workspace = tmp_path / "repos"
124
+ workspace.mkdir()
125
+ root = tmp_path / "cluster"
126
+ _write_harness(root, workspace)
127
+ _checkout(workspace / "alpha")
128
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
129
+
130
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "alpha"])
131
+
132
+ assert result.exit_code == 0
133
+ assert f"already cloned at {(workspace / 'alpha').resolve()}" in result.output
134
+ assert f"manifest says {(workspace / 'nested' / 'alpha').resolve()}" in result.output
135
+
136
+
137
+ def test_clone_refuses_to_write_into_an_occupied_path(tmp_path, monkeypatch):
138
+ workspace = tmp_path / "repos"
139
+ workspace.mkdir()
140
+ (workspace / "beta").mkdir()
141
+ (workspace / "beta" / "notes.txt").write_text("mine", encoding="utf-8")
142
+ root = tmp_path / "cluster"
143
+ _write_harness(root, workspace)
144
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
145
+
146
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "beta"])
147
+
148
+ assert result.exit_code == 1
149
+ assert "failed (path is taken and is not an empty directory" in result.output
150
+
151
+
152
+ def test_clone_reports_the_git_failure_and_exits_nonzero(tmp_path, monkeypatch):
153
+ workspace = tmp_path / "repos"
154
+ workspace.mkdir()
155
+ root = tmp_path / "cluster"
156
+ _write_harness(root, workspace)
157
+ monkeypatch.setattr(
158
+ clone_command,
159
+ "git",
160
+ lambda path, *args, **kwargs: _result(returncode=128, stderr="fatal: repository not found\n"),
161
+ )
162
+
163
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "beta"])
164
+
165
+ assert result.exit_code == 1
166
+ assert "failed (fatal: repository not found)" in result.output
167
+
168
+
169
+ def test_clone_skips_present_false_unless_asked(tmp_path, monkeypatch):
170
+ workspace = tmp_path / "repos"
171
+ workspace.mkdir()
172
+ root = tmp_path / "cluster"
173
+ _write_harness(root, workspace, extra="- name: gamma\n present: false\n origin: https://x.test/gamma.git\n")
174
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
175
+
176
+ skipped = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--dry-run"])
177
+ forced = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--dry-run", "--all"])
178
+ named = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--dry-run", "--repo", "gamma"])
179
+
180
+ assert "gamma skipped (present: false)" in skipped.output
181
+ assert f"would clone https://x.test/gamma.git into {(workspace / 'gamma').resolve()}" in forced.output
182
+ assert f"would clone https://x.test/gamma.git into {(workspace / 'gamma').resolve()}" in named.output
183
+
184
+
185
+ def test_clone_fails_a_repo_with_no_origin(tmp_path, monkeypatch):
186
+ workspace = tmp_path / "repos"
187
+ workspace.mkdir()
188
+ root = tmp_path / "cluster"
189
+ root.mkdir()
190
+ (root / "harness.yaml").write_text(
191
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n- name: alpha\n",
192
+ encoding="utf-8",
193
+ )
194
+ monkeypatch.setattr(clone_command, "git", lambda path, *args, **kwargs: _result())
195
+
196
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root)])
197
+
198
+ assert result.exit_code == 1
199
+ assert "failed (no origin declared in the manifest)" in result.output
200
+
201
+
202
+ def test_clone_moves_onto_the_declared_branch_when_it_exists(tmp_path, monkeypatch):
203
+ workspace = tmp_path / "repos"
204
+ workspace.mkdir()
205
+ root = tmp_path / "cluster"
206
+ root.mkdir()
207
+ (root / "harness.yaml").write_text(
208
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n"
209
+ "- name: beta\n origin: git@github.com:acme/beta.git\n branch: feat/new-intent-flow\n",
210
+ encoding="utf-8",
211
+ )
212
+ calls = []
213
+
214
+ def fake_git(path, *args, **kwargs):
215
+ calls.append(args)
216
+ if args[0] == "rev-parse":
217
+ return _result(stdout="main\n")
218
+ return _result()
219
+
220
+ monkeypatch.setattr(clone_command, "git", fake_git)
221
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root)])
222
+
223
+ assert result.exit_code == 0
224
+ assert ("checkout", "feat/new-intent-flow") in calls
225
+ assert "(on feat/new-intent-flow)" in result.output
226
+
227
+
228
+ def test_clone_keeps_a_clone_whose_declared_branch_is_not_pushed_yet(tmp_path, monkeypatch):
229
+ workspace = tmp_path / "repos"
230
+ workspace.mkdir()
231
+ root = tmp_path / "cluster"
232
+ root.mkdir()
233
+ (root / "harness.yaml").write_text(
234
+ f"name: test-cluster\nworkspace: {workspace.as_posix()}\nrepos:\n"
235
+ "- name: beta\n origin: git@github.com:acme/beta.git\n branch: feat/unpushed\n",
236
+ encoding="utf-8",
237
+ )
238
+
239
+ def fake_git(path, *args, **kwargs):
240
+ if args[0] == "rev-parse":
241
+ return _result(stdout="main\n")
242
+ if args[0] == "checkout":
243
+ return _result(returncode=1, stderr="error: pathspec did not match\n")
244
+ return _result()
245
+
246
+ monkeypatch.setattr(clone_command, "git", fake_git)
247
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root)])
248
+
249
+ assert result.exit_code == 0
250
+ assert "cloned into" in result.output
251
+ assert "branch 'feat/unpushed' is not on origin, left on main" in result.output
252
+
253
+
254
+ def test_clone_rejects_unknown_repo(tmp_path):
255
+ workspace = tmp_path / "repos"
256
+ workspace.mkdir()
257
+ root = tmp_path / "cluster"
258
+ _write_harness(root, workspace)
259
+
260
+ result = CliRunner().invoke(cli, ["harness", "clone", "--harness", str(root), "--repo", "nope"])
261
+
262
+ assert result.exit_code == 1
263
+ assert "Unknown repo name(s): nope" in result.output
264
+
265
+
266
+ def test_normalize_remote_matches_ssh_and_https_forms():
267
+ assert clone_command.normalize_remote("git@github.com:acme/repo.git") == "github.com/acme/repo"
268
+ assert clone_command.normalize_remote("https://github.com/acme/repo.git") == "github.com/acme/repo"
269
+ assert clone_command.normalize_remote("ssh://git@github.com/acme/repo/") == "github.com/acme/repo"
270
+ assert clone_command.normalize_remote("https://github.com/acme/other") != "github.com/acme/repo"
@@ -1 +0,0 @@
1
- 0.18.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes