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 +69 -0
- spex_cli/_help.py +130 -0
- spex_cli/agents.py +33 -0
- spex_cli/claude_settings/settings.json +21 -0
- spex_cli/disable.py +173 -0
- spex_cli/enable.py +287 -0
- spex_cli/hooks/hook_trace_commit.py +58 -0
- spex_cli/hooks/post-commit +6 -0
- spex_cli/models.py +326 -0
- spex_cli/skills/init.md +36 -0
- spex_cli/skills/spex/SKILL.md +311 -0
- spex_cli/skills/spex/references/conflict-resolution.md +34 -0
- spex_cli/skills/spex/references/knowledge-capture.md +124 -0
- spex_cli/skills/spex/references/memory-research.md +42 -0
- spex_cli/skills/spex/references/orchestration-guide.md +397 -0
- spex_cli/skills/spex-onboard/SKILL.md +207 -0
- spex_cli/skills/spex-reflect/SKILL.md +381 -0
- spex_cli/spex.py +1580 -0
- spex_cli-0.1.0.dist-info/METADATA +54 -0
- spex_cli-0.1.0.dist-info/RECORD +22 -0
- spex_cli-0.1.0.dist-info/WHEEL +4 -0
- spex_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|
+
)
|