lattice-cli 0.1.0__py3-none-any.whl

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.
lattice/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Lattice package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
lattice/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from lattice.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
lattice/agent.py ADDED
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ SKILL_NAME = "lattice-workflow"
10
+
11
+ MEMORY_SNIPPET = (
12
+ "Reach for Lattice when planning, testing, or reviewing a finite constrained surface "
13
+ "with interacting states such as roles, permissions, feature flags, modes, providers, "
14
+ "optional fields, component props, rendering branches, template variants, config "
15
+ "matrices, lifecycle states, or cross-product edge cases. Extract a schema with "
16
+ "parameters, values, and true constraints; run `lattice validate`; then run "
17
+ "`lattice generate`; use the generated rows as the source of truth for plan review, "
18
+ "rendered variants, fixtures, or missing tests instead of hand-enumerating pairwise "
19
+ "combinations."
20
+ )
21
+
22
+ DEFAULT_PROMPT = (
23
+ "Use $lattice-workflow. Inspect this feature, test surface, component variant space, "
24
+ "template, or config matrix; extract a Lattice schema; validate it; generate pairwise "
25
+ "rows; and use the rows to strengthen the plan, tests, fixtures, or visual review."
26
+ )
27
+
28
+ GENERIC_AGENT_INSTRUCTIONS = f"""# Lattice Agent Instructions
29
+
30
+ Lattice is harness-agnostic. Use these instructions in OpenClaw, Hermes, Codex, xAI-backed agents, Claude Code, or any agent that can run shell commands.
31
+
32
+ Memory:
33
+ {MEMORY_SNIPPET}
34
+
35
+ Default prompt:
36
+ {DEFAULT_PROMPT}
37
+
38
+ Workflow:
39
+ 1. Check whether `lattice --help` works.
40
+ 2. Extract one coherent schema with parameters, values, and true constraints.
41
+ 3. Encode nonsensical or impossible combinations as constraints. Do not strip constraints and mark invalid rows after generation. Lattice supports exclusion-style constraints such as `invalid_pair` and `higher_order`, not only `conditional` parameters.
42
+ 4. Run `lattice validate` before generation.
43
+ 5. Run `lattice generate` and treat the generated rows as the source of truth.
44
+ 6. Map rows to the active evaluator: plan review, tests, fixtures, rendered variants, screenshots, visual diffs, sandbox calls, or another harness-specific action.
45
+
46
+ Prefer JSON or YAML on stdin:
47
+
48
+ ```bash
49
+ cat schema.json | lattice validate
50
+ cat schema.json | lattice generate
51
+ ```
52
+ """
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class AgentSetup:
57
+ surface: str
58
+ skill_name: str
59
+ skill_path: Path
60
+ memory: str
61
+ default_prompt: str
62
+
63
+ def to_json(self) -> dict[str, Any]:
64
+ return {
65
+ "surface": self.surface,
66
+ "skill_name": self.skill_name,
67
+ "skill_path": str(self.skill_path),
68
+ "memory": self.memory,
69
+ "default_prompt": self.default_prompt,
70
+ }
71
+
72
+
73
+ def default_codex_skill_path() -> Path:
74
+ codex_home = os.environ.get("CODEX_HOME")
75
+ base = Path(codex_home).expanduser() if codex_home else Path.home() / ".codex"
76
+ return base / "skills" / SKILL_NAME
77
+
78
+
79
+ def default_claude_skill_path() -> Path:
80
+ return Path.home() / ".claude" / "skills" / SKILL_NAME
81
+
82
+
83
+ def codex_available() -> bool:
84
+ return (
85
+ shutil.which("codex") is not None
86
+ or (Path.home() / ".codex").exists()
87
+ or bool(os.environ.get("CODEX_HOME"))
88
+ )
89
+
90
+
91
+ def claude_code_available() -> bool:
92
+ return shutil.which("claude") is not None or (Path.home() / ".claude").exists()
93
+
94
+
95
+ def install_codex_skill(target: str | None = None) -> AgentSetup:
96
+ destination = Path(target).expanduser() if target else default_codex_skill_path()
97
+ _copy_bundled_skill("codex", destination)
98
+ return AgentSetup(
99
+ surface="codex",
100
+ skill_name=SKILL_NAME,
101
+ skill_path=destination,
102
+ memory=MEMORY_SNIPPET,
103
+ default_prompt=DEFAULT_PROMPT,
104
+ )
105
+
106
+
107
+ def install_generic_skill(target: str) -> AgentSetup:
108
+ destination = Path(target).expanduser()
109
+ _copy_bundled_skill("generic", destination)
110
+ return AgentSetup(
111
+ surface="generic",
112
+ skill_name=SKILL_NAME,
113
+ skill_path=destination,
114
+ memory=MEMORY_SNIPPET,
115
+ default_prompt=DEFAULT_PROMPT,
116
+ )
117
+
118
+
119
+ def install_claude_skill(target: str | None = None, *, force: bool = False) -> AgentSetup:
120
+ if not force and not claude_code_available():
121
+ raise RuntimeError("Claude Code was not detected. Re-run with --force to install anyway.")
122
+
123
+ destination = Path(target).expanduser() if target else default_claude_skill_path()
124
+ _copy_bundled_skill("claude", destination)
125
+ return AgentSetup(
126
+ surface="claude-code",
127
+ skill_name=SKILL_NAME,
128
+ skill_path=destination,
129
+ memory=MEMORY_SNIPPET,
130
+ default_prompt=DEFAULT_PROMPT,
131
+ )
132
+
133
+
134
+ def codex_skill_installed(target: str | None = None) -> bool:
135
+ destination = Path(target).expanduser() if target else default_codex_skill_path()
136
+ return (destination / "SKILL.md").is_file()
137
+
138
+
139
+ def claude_skill_installed(target: str | None = None) -> bool:
140
+ destination = Path(target).expanduser() if target else default_claude_skill_path()
141
+ return (destination / "SKILL.md").is_file()
142
+
143
+
144
+ def _copy_bundled_skill(resource_name: str, destination: Path) -> None:
145
+ source = Path(__file__).with_name("resources") / resource_name / SKILL_NAME
146
+ if not source.is_dir():
147
+ raise FileNotFoundError(f"Bundled {resource_name} skill `{SKILL_NAME}` was not found.")
148
+
149
+ destination.parent.mkdir(parents=True, exist_ok=True)
150
+ shutil.copytree(source, destination, dirs_exist_ok=True)
lattice/cli.py ADDED
@@ -0,0 +1,488 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import time
7
+ from collections.abc import Callable, Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from lattice.agent import (
12
+ AgentSetup,
13
+ DEFAULT_PROMPT,
14
+ GENERIC_AGENT_INSTRUCTIONS,
15
+ MEMORY_SNIPPET,
16
+ claude_code_available,
17
+ claude_skill_installed,
18
+ codex_available,
19
+ codex_skill_installed,
20
+ default_claude_skill_path,
21
+ default_codex_skill_path,
22
+ install_claude_skill,
23
+ install_codex_skill,
24
+ install_generic_skill,
25
+ )
26
+ from lattice.constraints import ConstraintEngine, ConstraintError
27
+ from lattice.formatters import render_output
28
+ from lattice.ipog import GenerationProgress, generate_covering_array
29
+ from lattice.parser import ModelIOError, ValidationError, load_model
30
+
31
+ DEFAULT_AGENT_FORMAT = "text"
32
+ SCHEMA_HELP = (
33
+ "Schema features: constraints include invalid_pair, higher_order, "
34
+ "forward_dep, bidirectional, conditional, and forced; parameters may use "
35
+ "weights. Run `lattice agent instructions` for the full schema workflow."
36
+ )
37
+
38
+
39
+ def main(argv: Sequence[str] | None = None) -> int:
40
+ parser = _build_parser()
41
+ args = parser.parse_args(argv)
42
+
43
+ if args.command == "agent":
44
+ return _run_agent_command(args)
45
+
46
+ try:
47
+ model = load_model(args.model, strength_override=getattr(args, "strength", None))
48
+ engine = ConstraintEngine(model)
49
+ if not engine.can_complete({}):
50
+ raise ValidationError(["Model has no valid complete assignments."])
51
+ except ValidationError as exc:
52
+ return _emit_error(args, "validation_error", exc.errors, exit_code=1)
53
+ except ConstraintError as exc:
54
+ return _emit_error(args, "constraint_error", [str(exc)], exit_code=1)
55
+ except ModelIOError as exc:
56
+ return _emit_error(args, "io_error", [str(exc)], exit_code=2)
57
+
58
+ if args.command == "validate":
59
+ _print_validation_success(args, model)
60
+ return 0
61
+
62
+ try:
63
+ progress_callback = (
64
+ _build_progress_callback(args.progress_every)
65
+ if getattr(args, "progress", False)
66
+ else None
67
+ )
68
+ result = generate_covering_array(
69
+ model,
70
+ strength=model.strength,
71
+ seed=args.seed,
72
+ pool_size=args.pool_size,
73
+ progress=progress_callback,
74
+ max_rows=args.max_rows,
75
+ stop_after_coverage=args.stop_after_coverage,
76
+ )
77
+ except ValueError as exc:
78
+ return _emit_error(args, "generation_error", [str(exc)], exit_code=1)
79
+
80
+ print(render_output(model, result, output_format=args.format, seed=args.seed))
81
+ return 0
82
+
83
+
84
+ def _build_parser() -> argparse.ArgumentParser:
85
+ parser = argparse.ArgumentParser(prog="lattice")
86
+ subparsers = parser.add_subparsers(dest="command", required=True)
87
+
88
+ generate = subparsers.add_parser(
89
+ "generate",
90
+ help="Generate deterministic scenarios from a schema.",
91
+ epilog=SCHEMA_HELP,
92
+ )
93
+ generate.add_argument("model", nargs="?", help="Path to a JSON or YAML schema. Reads stdin when omitted.")
94
+ generate.add_argument("--strength", type=int, help="Override the schema strength.")
95
+ generate.add_argument("--format", default="json", choices=("table", "json", "csv", "summary"))
96
+ generate.add_argument("--seed", type=int, default=42)
97
+ generate.add_argument("--pool-size", type=int, default=50)
98
+ generate.add_argument(
99
+ "--max-rows",
100
+ type=int,
101
+ help="Generate at most the first N scenarios.",
102
+ )
103
+ generate.add_argument(
104
+ "--stop-after-coverage",
105
+ type=float,
106
+ help="Stop after the first generated scenario reaching this cumulative coverage percent.",
107
+ )
108
+ generate.add_argument("--progress", action="store_true", help="Print generation progress to stderr.")
109
+ generate.add_argument(
110
+ "--progress-every",
111
+ type=int,
112
+ default=10,
113
+ help="With --progress, print every N generated scenarios plus the final scenario.",
114
+ )
115
+
116
+ validate = subparsers.add_parser(
117
+ "validate",
118
+ help="Validate a schema without generating scenarios.",
119
+ epilog=SCHEMA_HELP,
120
+ )
121
+ validate.add_argument("model", nargs="?", help="Path to a JSON or YAML schema. Reads stdin when omitted.")
122
+ validate.add_argument("--strength", type=int, help="Override the schema strength.")
123
+ validate.add_argument("--format", default="json", choices=("json", "text"))
124
+
125
+ agent = subparsers.add_parser("agent", help="Install and describe coding-agent integrations.")
126
+ agent_subparsers = agent.add_subparsers(dest="agent_command", required=True)
127
+
128
+ bootstrap = agent_subparsers.add_parser(
129
+ "bootstrap",
130
+ help="Install skills for detected known harnesses and print universal agent guidance.",
131
+ )
132
+ bootstrap.add_argument("--target", help="Codex skill directory. Defaults to $CODEX_HOME or ~/.codex.")
133
+ bootstrap.add_argument("--claude-target", help="Claude Code skill directory. Defaults to ~/.claude/skills.")
134
+ bootstrap.add_argument("--force-codex", action="store_true", help="Install the Codex skill even if Codex is not detected.")
135
+ bootstrap.add_argument("--skip-codex", action="store_true", help="Do not install the Codex skill.")
136
+ bootstrap.add_argument("--skip-claude", action="store_true", help="Do not install the Claude Code skill.")
137
+ bootstrap.add_argument("--force-claude", action="store_true", help="Install the Claude Code skill even if Claude Code is not detected.")
138
+ bootstrap.add_argument("--format", default=DEFAULT_AGENT_FORMAT, choices=("text", "json"))
139
+
140
+ install_skill = agent_subparsers.add_parser("install-codex-skill", help="Install the bundled Codex skill.")
141
+ install_skill.add_argument("--target", help="Destination skill directory. Defaults to $CODEX_HOME or ~/.codex.")
142
+ install_skill.add_argument("--format", default=DEFAULT_AGENT_FORMAT, choices=("text", "json"))
143
+
144
+ install_generic = agent_subparsers.add_parser(
145
+ "install-skill",
146
+ help="Install the bundled generic skill into any harness-specific skill directory.",
147
+ )
148
+ install_generic.add_argument("target", help="Destination skill directory for the target harness.")
149
+ install_generic.add_argument("--format", default=DEFAULT_AGENT_FORMAT, choices=("text", "json"))
150
+
151
+ install_claude = agent_subparsers.add_parser("install-claude-skill", help="Install the bundled Claude Code skill.")
152
+ install_claude.add_argument("--target", help="Destination skill directory. Defaults to ~/.claude/skills.")
153
+ install_claude.add_argument("--force", action="store_true", help="Install even if Claude Code is not detected.")
154
+ install_claude.add_argument("--format", default=DEFAULT_AGENT_FORMAT, choices=("text", "json"))
155
+
156
+ memory = agent_subparsers.add_parser("memory", help="Print the persistent memory snippet for agents.")
157
+ memory.add_argument("--format", default=DEFAULT_AGENT_FORMAT, choices=("text", "json"))
158
+
159
+ instructions = agent_subparsers.add_parser(
160
+ "instructions",
161
+ help="Print harness-agnostic instructions for any coding agent.",
162
+ )
163
+ instructions.add_argument("--format", default=DEFAULT_AGENT_FORMAT, choices=("text", "json"))
164
+
165
+ doctor = agent_subparsers.add_parser("doctor", help="Check whether agent skills are installed.")
166
+ doctor.add_argument("--target", help="Codex skill directory to check. Defaults to $CODEX_HOME or ~/.codex.")
167
+ doctor.add_argument("--claude-target", help="Claude Code skill directory to check. Defaults to ~/.claude/skills.")
168
+ doctor.add_argument("--format", default=DEFAULT_AGENT_FORMAT, choices=("text", "json"))
169
+ return parser
170
+
171
+
172
+ def _build_progress_callback(every: int) -> Callable[[GenerationProgress], None]:
173
+ if every < 1:
174
+ raise ValueError("--progress-every must be at least 1")
175
+
176
+ started = time.monotonic()
177
+
178
+ def report(progress: GenerationProgress) -> None:
179
+ elapsed = time.monotonic() - started
180
+ if progress.event == "enumerating":
181
+ eta = _format_eta(elapsed, progress.covered, progress.total)
182
+ print(
183
+ "lattice progress: "
184
+ f"enumerating interactions "
185
+ f"checked={progress.covered}/{progress.total} "
186
+ f"progress={progress.cumulative_pct:.1f}% "
187
+ f"elapsed={_format_seconds(elapsed)} "
188
+ f"eta={eta}",
189
+ file=sys.stderr,
190
+ flush=True,
191
+ )
192
+ return
193
+
194
+ if progress.event == "enumerated":
195
+ print(
196
+ "lattice progress: "
197
+ f"enumerated {progress.total} valid interactions; starting generation",
198
+ file=sys.stderr,
199
+ flush=True,
200
+ )
201
+ return
202
+
203
+ if progress.event == "scenario" and progress.scenario % every != 0 and progress.uncovered != 0:
204
+ return
205
+
206
+ eta = _format_eta(elapsed, progress.covered, progress.total)
207
+ label = "stopped " if progress.event == "stopped" else ""
208
+ print(
209
+ "lattice progress: "
210
+ f"{label}scenario={progress.scenario} "
211
+ f"covered={progress.covered}/{progress.total} "
212
+ f"coverage={progress.cumulative_pct:.1f}% "
213
+ f"uncovered={progress.uncovered} "
214
+ f"elapsed={_format_seconds(elapsed)} "
215
+ f"eta={eta}",
216
+ file=sys.stderr,
217
+ flush=True,
218
+ )
219
+
220
+ return report
221
+
222
+
223
+ def _format_eta(elapsed: float, covered: int, total: int) -> str:
224
+ if total <= 0 or covered <= 0:
225
+ return "unknown"
226
+ if covered >= total:
227
+ return "0s"
228
+ remaining = elapsed * ((total - covered) / covered)
229
+ return _format_seconds(remaining)
230
+
231
+
232
+ def _format_seconds(seconds: float) -> str:
233
+ seconds = max(0, int(round(seconds)))
234
+ minutes, sec = divmod(seconds, 60)
235
+ hours, minutes = divmod(minutes, 60)
236
+ if hours:
237
+ return f"{hours}h{minutes:02d}m{sec:02d}s"
238
+ if minutes:
239
+ return f"{minutes}m{sec:02d}s"
240
+ return f"{sec}s"
241
+
242
+
243
+ def _print_errors(errors: list[str]) -> None:
244
+ for error in errors:
245
+ print(error, file=sys.stderr)
246
+
247
+
248
+ def _emit_error(args: argparse.Namespace, code: str, errors: list[str], *, exit_code: int) -> int:
249
+ if getattr(args, "format", None) == "json":
250
+ print(json.dumps({"status": "error", "code": code, "errors": errors}, indent=2))
251
+ else:
252
+ _print_errors(errors)
253
+ return exit_code
254
+
255
+
256
+ def _print_validation_success(args: argparse.Namespace, model) -> None:
257
+ if args.format == "json":
258
+ print(
259
+ json.dumps(
260
+ {
261
+ "status": "ok",
262
+ "valid": True,
263
+ "schema": {
264
+ "name": model.model_name,
265
+ "parameter_count": len(model.parameters),
266
+ "constraint_count": len(model.constraints),
267
+ "strength": model.strength,
268
+ },
269
+ },
270
+ indent=2,
271
+ )
272
+ )
273
+ return
274
+
275
+ print(
276
+ "\n".join(
277
+ [
278
+ "Schema is valid.",
279
+ f"name: {model.model_name}",
280
+ f"parameters: {len(model.parameters)}",
281
+ f"constraints: {len(model.constraints)}",
282
+ f"strength: {model.strength}",
283
+ ]
284
+ )
285
+ )
286
+
287
+
288
+ def _run_agent_command(args: argparse.Namespace) -> int:
289
+ if args.agent_command == "bootstrap":
290
+ codex_detected = codex_available()
291
+ claude_available = claude_code_available()
292
+ codex_skill_path = Path(args.target).expanduser() if args.target else default_codex_skill_path()
293
+ claude_skill_path = Path(args.claude_target).expanduser() if args.claude_target else default_claude_skill_path()
294
+ codex_setup, codex_skipped_reason = _maybe_install_agent_skill(
295
+ detected=codex_detected,
296
+ force=args.force_codex,
297
+ skip=args.skip_codex,
298
+ skip_flag="--skip-codex",
299
+ unavailable_reason="Codex was not detected",
300
+ install=lambda: install_codex_skill(args.target),
301
+ )
302
+ claude_setup, claude_skipped_reason = _maybe_install_agent_skill(
303
+ detected=claude_available,
304
+ force=args.force_claude,
305
+ skip=args.skip_claude,
306
+ skip_flag="--skip-claude",
307
+ unavailable_reason="Claude Code was not detected",
308
+ install=lambda: install_claude_skill(args.claude_target, force=args.force_claude),
309
+ )
310
+
311
+ if args.format == "json":
312
+ payload = {
313
+ "status": "ok",
314
+ "codex": _agent_surface_payload(
315
+ detected=codex_detected,
316
+ setup=codex_setup,
317
+ skill_path=codex_skill_path,
318
+ skipped_reason=codex_skipped_reason,
319
+ ),
320
+ "claude": _agent_surface_payload(
321
+ detected=claude_available,
322
+ setup=claude_setup,
323
+ skill_path=claude_skill_path,
324
+ skipped_reason=claude_skipped_reason,
325
+ ),
326
+ "memory": MEMORY_SNIPPET,
327
+ "default_prompt": DEFAULT_PROMPT,
328
+ "instructions_command": "lattice agent instructions",
329
+ "generic_skill_command": "lattice agent install-skill <target>",
330
+ }
331
+ print(json.dumps(payload, indent=2))
332
+ return 0
333
+
334
+ _print_agent_install_status("Codex", codex_setup, codex_skipped_reason)
335
+ _print_agent_install_status("Claude Code", claude_setup, claude_skipped_reason)
336
+ print("")
337
+ print("Agent memory:")
338
+ print(MEMORY_SNIPPET)
339
+ print("")
340
+ print("Default prompt:")
341
+ print(DEFAULT_PROMPT)
342
+ print("")
343
+ print("Any other harness:")
344
+ print("Run `lattice agent instructions`, or `lattice agent install-skill <target>` if the harness accepts a SKILL.md directory.")
345
+ return 0
346
+
347
+ if args.agent_command == "install-codex-skill":
348
+ setup = install_codex_skill(args.target)
349
+ return _emit_agent_setup(setup, "Codex", args.format)
350
+
351
+ if args.agent_command == "install-skill":
352
+ setup = install_generic_skill(args.target)
353
+ return _emit_agent_setup(setup, "generic", args.format)
354
+
355
+ if args.agent_command == "install-claude-skill":
356
+ try:
357
+ setup = install_claude_skill(args.target, force=args.force)
358
+ except RuntimeError as exc:
359
+ if args.format == "json":
360
+ print(json.dumps({"status": "unavailable", "error": str(exc)}, indent=2))
361
+ else:
362
+ print(str(exc), file=sys.stderr)
363
+ return 1
364
+
365
+ return _emit_agent_setup(
366
+ setup,
367
+ "Claude Code",
368
+ args.format,
369
+ extra={"available": claude_code_available()},
370
+ )
371
+
372
+ if args.agent_command == "memory":
373
+ if args.format == "json":
374
+ print(json.dumps({"memory": MEMORY_SNIPPET}, indent=2))
375
+ else:
376
+ print(MEMORY_SNIPPET)
377
+ return 0
378
+
379
+ if args.agent_command == "instructions":
380
+ if args.format == "json":
381
+ print(
382
+ json.dumps(
383
+ {
384
+ "instructions": GENERIC_AGENT_INSTRUCTIONS,
385
+ "memory": MEMORY_SNIPPET,
386
+ "default_prompt": DEFAULT_PROMPT,
387
+ },
388
+ indent=2,
389
+ )
390
+ )
391
+ else:
392
+ print(GENERIC_AGENT_INSTRUCTIONS)
393
+ return 0
394
+
395
+ if args.agent_command == "doctor":
396
+ codex_path = default_codex_skill_path() if args.target is None else args.target
397
+ claude_path = default_claude_skill_path() if args.claude_target is None else args.claude_target
398
+ codex_detected = codex_available()
399
+ codex_installed = codex_skill_installed(args.target)
400
+ claude_available = claude_code_available()
401
+ claude_installed = claude_skill_installed(args.claude_target)
402
+ known_harness_detected = codex_detected or claude_available
403
+ known_skills_ok = (not codex_detected or codex_installed) and (not claude_available or claude_installed)
404
+ ok = known_skills_ok
405
+ payload = {
406
+ "status": "ok" if ok else "missing",
407
+ "generic_instructions_available": True,
408
+ "known_harness_detected": known_harness_detected,
409
+ "codex_available": codex_detected,
410
+ "codex_skill_installed": codex_installed,
411
+ "codex_skill_path": str(codex_path),
412
+ "claude_code_available": claude_available,
413
+ "claude_skill_installed": claude_installed,
414
+ "claude_skill_path": str(claude_path),
415
+ }
416
+ if args.format == "json":
417
+ print(json.dumps(payload, indent=2))
418
+ else:
419
+ print("Lattice agent setup")
420
+ print("cli: ok")
421
+ print("generic instructions: available (`lattice agent instructions`)")
422
+ print(f"codex: {'available' if codex_detected else 'not detected'}")
423
+ print(f"codex skill: {'installed' if codex_installed else 'missing'}")
424
+ print(f"codex path: {codex_path}")
425
+ print(f"claude code: {'available' if claude_available else 'not detected'}")
426
+ print(f"claude skill: {'installed' if claude_installed else 'missing'}")
427
+ print(f"claude path: {claude_path}")
428
+ return 0 if ok else 1
429
+
430
+ print(f"Unknown agent command `{args.agent_command}`.", file=sys.stderr)
431
+ return 2
432
+
433
+
434
+ def _maybe_install_agent_skill(
435
+ *,
436
+ detected: bool,
437
+ force: bool,
438
+ skip: bool,
439
+ skip_flag: str,
440
+ unavailable_reason: str,
441
+ install: Callable[[], AgentSetup],
442
+ ) -> tuple[AgentSetup | None, str | None]:
443
+ if skip:
444
+ return None, f"skipped by {skip_flag}"
445
+ if detected or force:
446
+ return install(), None
447
+ return None, unavailable_reason
448
+
449
+
450
+ def _agent_surface_payload(
451
+ *,
452
+ detected: bool,
453
+ setup: AgentSetup | None,
454
+ skill_path: Path,
455
+ skipped_reason: str | None,
456
+ ) -> dict[str, Any]:
457
+ return {
458
+ "available": detected,
459
+ "installed": setup is not None,
460
+ "skill_path": str(setup.skill_path if setup else skill_path),
461
+ "skipped_reason": skipped_reason,
462
+ }
463
+
464
+
465
+ def _emit_agent_setup(
466
+ setup: AgentSetup,
467
+ label: str,
468
+ output_format: str,
469
+ *,
470
+ extra: dict[str, Any] | None = None,
471
+ ) -> int:
472
+ if output_format == "json":
473
+ print(json.dumps({"status": "ok", **(extra or {}), **setup.to_json()}, indent=2))
474
+ return 0
475
+
476
+ print(f"Installed `{setup.skill_name}` {label} skill at {setup.skill_path}")
477
+ return 0
478
+
479
+
480
+ def _print_agent_install_status(
481
+ label: str,
482
+ setup: AgentSetup | None,
483
+ skipped_reason: str | None,
484
+ ) -> None:
485
+ if setup is not None:
486
+ print(f"Installed `{setup.skill_name}` {label} skill at {setup.skill_path}")
487
+ elif skipped_reason:
488
+ print(f"{label} skill: not installed ({skipped_reason})")