spex-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.
spex_cli/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ from rich.align import Align
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+ from rich.text import Text
5
+
6
+ from spex_cli.spex import main as _main
7
+
8
+ _BANNER_LINES = [
9
+ " ███████╗██████╗ ███████╗██╗ ██╗",
10
+ " ██╔════╝██╔══██╗██╔════╝╚██╗██╔╝",
11
+ " ███████╗██████╔╝█████╗ ╚███╔╝ ",
12
+ " ╚════██║██╔═══╝ ██╔══╝ ██╔██╗ ",
13
+ " ███████║██║ ███████╗██╔╝ ██╗",
14
+ " ╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝",
15
+ ]
16
+
17
+ _ROW_COLORS = [
18
+ "#F97316",
19
+ "#F97316",
20
+ "#FBBF24",
21
+ "#FBBF24",
22
+ "#EC4899",
23
+ "#EC4899",
24
+ ]
25
+
26
+
27
+ def print_banner() -> None:
28
+ console = Console(stderr=True)
29
+
30
+ art = Text(justify="center")
31
+ for i, (line, color) in enumerate(zip(_BANNER_LINES, _ROW_COLORS)):
32
+ art.append(line, style=f"bold {color}")
33
+ if i < len(_BANNER_LINES) - 1:
34
+ art.append("\n")
35
+
36
+ subtitle = Text(justify="center")
37
+ subtitle.append("Autonomous Engineering experience", style="bold #8B5CF6")
38
+ subtitle.append(" · ", style="dim")
39
+ subtitle.append("v0.1.0", style="#FBBF24")
40
+
41
+ console.print(
42
+ Panel(
43
+ Align.center(Text.assemble(art, "\n\n", subtitle)),
44
+ border_style="#8B5CF6",
45
+ padding=(1, 4),
46
+ )
47
+ )
48
+
49
+
50
+ _BANNER_COMMANDS = frozenset({"healthcheck", "enable", "disable"})
51
+
52
+
53
+ def _should_show_banner() -> bool:
54
+ import sys
55
+ args = sys.argv[1:]
56
+ if not args:
57
+ return True
58
+ first = args[0]
59
+ if first in ("-h", "--help"):
60
+ return True
61
+ if first in _BANNER_COMMANDS:
62
+ return True
63
+ return False
64
+
65
+
66
+ def main() -> None:
67
+ if _should_show_banner():
68
+ print_banner()
69
+ _main()
spex_cli/_help.py ADDED
@@ -0,0 +1,130 @@
1
+ import json
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from rich.text import Text
5
+
6
+ _console = Console()
7
+
8
+ _COMMANDS = [
9
+ ("Developers", [
10
+ ("enable", [], "Set up spex in the current repository"),
11
+ ("disable", [], "Remove spex git hook and revert agent settings"),
12
+ ("healthcheck", [], "Audit hooks and memory integrity"),
13
+ ]),
14
+ ("Memory", [
15
+ ("requirement", ["add", "deprecate"], "Track functional and non-functional requirements"),
16
+ ("decision", ["add", "deprecate"], "Record technical decisions with rationale and impact"),
17
+ ("policy", ["add", "deprecate"], "Project-wide policies and guidelines"),
18
+ ("app", ["add", "update"], "Register applications and libraries"),
19
+ ("plan", ["add", "update-status", "deprecate"], "Feature planning lifecycle"),
20
+ ]),
21
+ ("System", [
22
+ ("trace", ["add"], "Link commits to decisions via git trailers"),
23
+ ("blame", ["<file>[:<lines>]"], "Find decisions and requirements for a file or line range"),
24
+ ("validate-and-route", ["--feature-name", "--target-state"], "Enforce state machine transitions"),
25
+ ]),
26
+ ]
27
+
28
+ _SUBCOMMANDS = {
29
+ "requirement": [
30
+ ("add", "Add a new requirement to memory"),
31
+ ("deprecate", "Deprecate an existing requirement"),
32
+ ],
33
+ "decision": [
34
+ ("add", "Add a new technical decision"),
35
+ ("deprecate", "Mark a decision as obsolete"),
36
+ ],
37
+ "policy": [
38
+ ("add", "Add a new project-wide policy or guideline"),
39
+ ("deprecate", "Deprecate an existing policy"),
40
+ ],
41
+ "app": [
42
+ ("add", "Register a new application or library"),
43
+ ("update", "Update app or library details"),
44
+ ],
45
+ "plan": [
46
+ ("add", "Create a new feature plan"),
47
+ ("update-status", "Update the current status of a plan"),
48
+ ("deprecate", "Abandon a plan"),
49
+ ],
50
+ "trace": [
51
+ ("add", "Link a commit hash to a decision"),
52
+ ],
53
+ }
54
+
55
+
56
+ def print_help() -> None:
57
+ _console.print()
58
+ _console.print(
59
+ " [bold]Usage:[/bold] [bold #F97316]spex[/bold #F97316] "
60
+ "[#FBBF24]<command>[/#FBBF24] [dim][options][/dim]"
61
+ )
62
+ _console.print()
63
+
64
+ for section, commands in _COMMANDS:
65
+ _console.rule(f"[bold #8B5CF6] {section} [/bold #8B5CF6]", style="dim #8B5CF6")
66
+ _console.print()
67
+
68
+ table = Table(box=None, padding=(0, 2, 0, 4), show_header=False, expand=False)
69
+ table.add_column("cmd", style="bold #F97316", no_wrap=True)
70
+ table.add_column("actions", no_wrap=True)
71
+ table.add_column("desc", style="dim", no_wrap=False)
72
+
73
+ for cmd, actions, desc in commands:
74
+ action_text = Text()
75
+ for i, action in enumerate(actions):
76
+ if i > 0:
77
+ action_text.append(" ", style="dim")
78
+ action_text.append(action, style="#FBBF24")
79
+ table.add_row(cmd, action_text, desc)
80
+
81
+ _console.print(table)
82
+ _console.print()
83
+
84
+ _console.print(
85
+ " [dim]Run [/dim][bold #F97316]spex[/bold #F97316]"
86
+ " [dim]<command> --help for more information.[/dim]"
87
+ )
88
+ _console.print()
89
+
90
+
91
+ def print_subcommand_help(command: str) -> None:
92
+ actions = _SUBCOMMANDS.get(command, [])
93
+ _console.print()
94
+ _console.rule(
95
+ f"[bold #8B5CF6] spex {command} [/bold #8B5CF6]",
96
+ style="dim #8B5CF6",
97
+ )
98
+ _console.print()
99
+
100
+ table = Table(box=None, padding=(0, 2, 0, 4), show_header=False, expand=False)
101
+ table.add_column("action", style="bold #FBBF24", no_wrap=True)
102
+ table.add_column("desc", style="dim", no_wrap=True)
103
+
104
+ for action, desc in actions:
105
+ table.add_row(action, desc)
106
+
107
+ _console.print(table)
108
+ _console.print()
109
+ _console.print(
110
+ f" [dim]Run [/dim][bold #F97316]spex {command}[/bold #F97316]"
111
+ " [dim]<action> --help for details.[/dim]"
112
+ )
113
+ _console.print()
114
+
115
+
116
+ def print_help_json() -> None:
117
+ output = {
118
+ "usage": "spex <command> [options]",
119
+ "sections": [
120
+ {
121
+ "section": section,
122
+ "commands": [
123
+ {"name": cmd, "actions": actions, "description": desc}
124
+ for cmd, actions, desc in commands
125
+ ],
126
+ }
127
+ for section, commands in _COMMANDS
128
+ ],
129
+ }
130
+ print(json.dumps(output, indent=2))
spex_cli/agents.py ADDED
@@ -0,0 +1,33 @@
1
+ AGENT_CONFIG = {
2
+ "claude": {
3
+ "name": "Claude Code",
4
+ "folder": ".claude/",
5
+ "settings_file": "claude_settings/settings.json",
6
+ },
7
+ "gemini": {
8
+ "name": "Gemini CLI",
9
+ "folder": ".gemini/",
10
+ "settings_file": "claude_settings/settings.json"
11
+ },
12
+ "cursor-agent": {
13
+ "name": "Cursor",
14
+ "folder": ".cursor/",
15
+ "settings_file": "claude_settings/settings.json"
16
+ },
17
+ "opencode": {
18
+ "name": "opencode",
19
+ "folder": ".opencode/"
20
+ },
21
+ "codex": {
22
+ "name": "Codex CLI",
23
+ "folder": ".codex/"
24
+ },
25
+ "agy": {
26
+ "name": "Antigravity",
27
+ "folder": ".agent/"
28
+ },
29
+ "copilot": {
30
+ "name": "GitHub Copilot",
31
+ "folder": ".github/"
32
+ }
33
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python3 .spex/scripts/spex.py:*)",
5
+ "Bash(python .spex/scripts/spex.py:*)",
6
+ "Edit(.spex/**)"
7
+ ]
8
+ },
9
+ "hooks": {
10
+ "SessionStart": [
11
+ {
12
+ "hooks": [
13
+ {
14
+ "type": "command",
15
+ "command": "cat .spex/init.md"
16
+ }
17
+ ]
18
+ }
19
+ ]
20
+ }
21
+ }
spex_cli/disable.py ADDED
@@ -0,0 +1,173 @@
1
+ """Disable command — removes spex git hook and reverts agent settings."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.prompt import Confirm
11
+ from rich.text import Text
12
+
13
+ from spex_cli.agents import AGENT_CONFIG
14
+ from spex_cli.enable import _agent_settings_src, _select_agent
15
+
16
+ console = Console()
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Helpers
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def _deep_remove(base: dict, to_remove: dict) -> dict:
24
+ """Return base with all entries present in to_remove subtracted."""
25
+ result = dict(base)
26
+ for key, value in to_remove.items():
27
+ if key not in result:
28
+ continue
29
+ if isinstance(result[key], dict) and isinstance(value, dict):
30
+ cleaned = _deep_remove(result[key], value)
31
+ if cleaned:
32
+ result[key] = cleaned
33
+ else:
34
+ del result[key]
35
+ elif isinstance(result[key], list) and isinstance(value, list):
36
+ spex_set = {json.dumps(item, sort_keys=True) for item in value}
37
+ result[key] = [
38
+ item for item in result[key]
39
+ if json.dumps(item, sort_keys=True) not in spex_set
40
+ ]
41
+ if not result[key]:
42
+ del result[key]
43
+ else:
44
+ if result[key] == value:
45
+ del result[key]
46
+ return result
47
+
48
+
49
+ def _revert_agent_settings(repo_root: Path, agent_key: str) -> bool:
50
+ """Remove spex entries from the agent's settings.json. Returns True if file was updated."""
51
+ src = _agent_settings_src(agent_key)
52
+ if src is None:
53
+ return False
54
+
55
+ dst = repo_root / AGENT_CONFIG[agent_key]["folder"] / "settings.json"
56
+ if not dst.exists():
57
+ return False
58
+
59
+ with open(dst) as f:
60
+ existing = json.load(f)
61
+ with open(src) as f:
62
+ spex_settings = json.load(f)
63
+
64
+ cleaned = _deep_remove(existing, spex_settings)
65
+
66
+ with open(dst, "w") as f:
67
+ json.dump(cleaned, f, indent=2)
68
+ f.write("\n")
69
+
70
+ return True
71
+
72
+
73
+ def _remove_git_hook(repo_root: Path) -> bool:
74
+ """Unset core.hooksPath if it points to .spex/hooks. Returns True if unset."""
75
+ result = subprocess.run(
76
+ ["git", "config", "core.hooksPath"],
77
+ capture_output=True,
78
+ text=True,
79
+ cwd=str(repo_root),
80
+ )
81
+ if result.returncode != 0 or result.stdout.strip() != ".spex/hooks":
82
+ return False
83
+
84
+ subprocess.run(
85
+ ["git", "config", "--unset", "core.hooksPath"],
86
+ check=True,
87
+ cwd=str(repo_root),
88
+ )
89
+ return True
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Public entry point
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def run_disable() -> None:
97
+ try:
98
+ repo_root = Path(
99
+ subprocess.check_output(
100
+ ["git", "rev-parse", "--show-toplevel"],
101
+ text=True,
102
+ stderr=subprocess.DEVNULL,
103
+ ).strip()
104
+ )
105
+ except subprocess.CalledProcessError:
106
+ console.print(
107
+ Panel(
108
+ "[red]Not inside a git repository.[/red]\n"
109
+ "Run [bold]spex disable[/bold] from within a git-tracked project.",
110
+ border_style="red",
111
+ title="Error",
112
+ )
113
+ )
114
+ sys.exit(1)
115
+
116
+ console.print(
117
+ Panel(
118
+ Text.assemble(
119
+ ("Disabling Spex for this repository\n", "bold white"),
120
+ (str(repo_root), "dim"),
121
+ ),
122
+ border_style="#EC4899",
123
+ padding=(0, 2),
124
+ )
125
+ )
126
+
127
+ # ── Interactive prompts (collected up front) ────────────────────────────
128
+ console.print()
129
+ console.print("[bold #FBBF24]Step 1[/bold #FBBF24] Select your AI agent")
130
+ agent_key = _select_agent()
131
+ agent_name = AGENT_CONFIG[agent_key]["name"]
132
+
133
+ console.print()
134
+ confirmed = Confirm.ask(
135
+ f" Remove spex git hook and revert [bold]{agent_name}[/bold] settings?",
136
+ default=False,
137
+ )
138
+ if not confirmed:
139
+ console.print("\n [dim]Aborted.[/dim]")
140
+ sys.exit(0)
141
+
142
+ # ── Execution ────────────────────────────────────────────────────────────
143
+ console.print()
144
+ console.print("[bold #FBBF24]Step 2[/bold #FBBF24] Removing git hook")
145
+ if _remove_git_hook(repo_root):
146
+ console.print(" [green]✓[/green] [bold]core.hooksPath[/bold] unset")
147
+ else:
148
+ console.print(" [dim]–[/dim] core.hooksPath was not pointing to .spex/hooks, skipped")
149
+
150
+ agent_settings_src = _agent_settings_src(agent_key)
151
+ if agent_settings_src is not None:
152
+ console.print()
153
+ console.print("[bold #FBBF24]Step 3[/bold #FBBF24] Reverting agent settings")
154
+ agent_folder = AGENT_CONFIG[agent_key]["folder"]
155
+ if _revert_agent_settings(repo_root, agent_key):
156
+ console.print(f" [green]✓[/green] Spex entries removed from [bold]{agent_folder}settings.json[/bold]")
157
+ else:
158
+ console.print(f" [dim]–[/dim] {agent_folder}settings.json not found or nothing to revert")
159
+
160
+ # ── Done ────────────────────────────────────────────────────────────────
161
+ console.print()
162
+ console.print(
163
+ Panel(
164
+ Text.assemble(
165
+ ("Spex disabled.\n\n", "bold #EC4899"),
166
+ ("Your memory (", "white"),
167
+ (".spex/memory/", "bold"),
168
+ (") has not been touched.", "white"),
169
+ ),
170
+ border_style="#EC4899",
171
+ padding=(1, 2),
172
+ )
173
+ )