squads 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.
- squads/__init__.py +3 -0
- squads/__main__.py +4 -0
- squads/_backends/__init__.py +1 -0
- squads/_backends/_base.py +75 -0
- squads/_backends/_claude_code/__init__.py +6 -0
- squads/_backends/_claude_code/_backend.py +184 -0
- squads/_backends/_claude_code/_claude_md.py +26 -0
- squads/_backends/_claude_code/_frontmatter.py +14 -0
- squads/_backends/_registry.py +25 -0
- squads/_cli/__init__.py +67 -0
- squads/_cli/_comment.py +128 -0
- squads/_cli/_common.py +101 -0
- squads/_cli/_create.py +55 -0
- squads/_cli/_dev.py +50 -0
- squads/_cli/_main.py +341 -0
- squads/_cli/_refs.py +68 -0
- squads/_cli/_role.py +92 -0
- squads/_cli/_skill.py +79 -0
- squads/_clock.py +38 -0
- squads/_discussion.py +155 -0
- squads/_errors.py +29 -0
- squads/_index/__init__.py +1 -0
- squads/_index/_resolver.py +19 -0
- squads/_index/_store.py +70 -0
- squads/_interactions.py +163 -0
- squads/_itemfile.py +37 -0
- squads/_models/__init__.py +1 -0
- squads/_models/_config.py +41 -0
- squads/_models/_enums.py +78 -0
- squads/_models/_extras.py +28 -0
- squads/_models/_index.py +40 -0
- squads/_models/_item.py +95 -0
- squads/_models/_markers.py +43 -0
- squads/_paths.py +125 -0
- squads/_rendering/__init__.py +1 -0
- squads/_rendering/_engine.py +31 -0
- squads/_rendering/templates/agents/item_skill.md.j2 +26 -0
- squads/_rendering/templates/agents/role.md.j2 +37 -0
- squads/_rendering/templates/agents/skill.md.j2 +16 -0
- squads/_rendering/templates/agents/squads_skill.md.j2 +31 -0
- squads/_rendering/templates/claude/claude_section.md.j2 +38 -0
- squads/_rendering/templates/claude/pointer_agent.md.j2 +26 -0
- squads/_rendering/templates/claude/pointer_skill.md.j2 +10 -0
- squads/_rendering/templates/claude/settings.json.j2 +10 -0
- squads/_rendering/templates/items/bug.md.j2 +17 -0
- squads/_rendering/templates/items/decision.md.j2 +12 -0
- squads/_rendering/templates/items/epic.md.j2 +14 -0
- squads/_rendering/templates/items/feature.md.j2 +15 -0
- squads/_rendering/templates/items/guide.md.j2 +14 -0
- squads/_rendering/templates/items/review.md.j2 +14 -0
- squads/_rendering/templates/items/task.md.j2 +15 -0
- squads/_rendering/templates/workflow.md.j2 +18 -0
- squads/_roles/__init__.py +1 -0
- squads/_roles/_catalog.py +266 -0
- squads/_sections.py +106 -0
- squads/_service.py +927 -0
- squads/_util.py +20 -0
- squads/_workflow.py +148 -0
- squads/py.typed +0 -0
- squads-0.1.0.dist-info/METADATA +241 -0
- squads-0.1.0.dist-info/RECORD +64 -0
- squads-0.1.0.dist-info/WHEEL +4 -0
- squads-0.1.0.dist-info/entry_points.txt +3 -0
- squads-0.1.0.dist-info/licenses/LICENSE +21 -0
squads/__init__.py
ADDED
squads/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private subpackage — import members from its underscore modules."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Pluggable agent-backend interface. Claude Code is the first implementation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from squads._models._item import Item
|
|
9
|
+
from squads._paths import SquadPaths
|
|
10
|
+
from squads._roles._catalog import RoleDef
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Artifact:
|
|
15
|
+
"""A tool-owned file the backend generated (path is project-root-relative)."""
|
|
16
|
+
|
|
17
|
+
path: str
|
|
18
|
+
kind: str # agent | skill | settings | claude_md
|
|
19
|
+
backend: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class RoleView:
|
|
24
|
+
"""The roster entry passed to backends (decoupled from RoleDef internals)."""
|
|
25
|
+
|
|
26
|
+
slug: str
|
|
27
|
+
full_name: str
|
|
28
|
+
title: str
|
|
29
|
+
is_default: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BackendContext:
|
|
34
|
+
paths: SquadPaths
|
|
35
|
+
version: str
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def root(self) -> Path:
|
|
39
|
+
return self.paths.root
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def squad_dir(self) -> Path:
|
|
43
|
+
return self.paths.squad_dir
|
|
44
|
+
|
|
45
|
+
def rel(self, path: Path) -> str:
|
|
46
|
+
"""Project-root-relative, forward-slash path (for pointers and Artifact paths)."""
|
|
47
|
+
return os.path.relpath(path, self.root).replace(os.sep, "/")
|
|
48
|
+
|
|
49
|
+
def root_relative(self, item: Item) -> str:
|
|
50
|
+
"""Root-relative path to an item's markdown file (for pointer references)."""
|
|
51
|
+
return self.rel(self.paths.abspath(item.path))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AgentBackend(ABC):
|
|
55
|
+
name: str
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def ensure_scaffold(self, ctx: BackendContext) -> list[Artifact]:
|
|
59
|
+
"""Create backend dirs and base config (idempotent; never clobber user content)."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def write_managed(self, ctx: BackendContext, roster: list[RoleView]) -> list[Artifact]:
|
|
63
|
+
"""(Re)write roster/version-dependent tool files: the skill + CLAUDE.md section."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def generate_role_pointer(self, ctx: BackendContext, item: Item, role: RoleDef) -> Artifact:
|
|
67
|
+
"""Write the thin pointer file that loads a role's real definition."""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def generate_skill_pointer(self, ctx: BackendContext, item: Item) -> Artifact:
|
|
71
|
+
"""Write the thin pointer file that loads a skill's real definition."""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def remove_artifacts(self, ctx: BackendContext, item: Item) -> None:
|
|
75
|
+
"""Delete the pointer file(s) for an item."""
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Claude Code backend: writes thin pointer files into ``.claude/`` plus managed skill & CLAUDE.md.
|
|
2
|
+
|
|
3
|
+
The real definitions live under the squad folder; these files only route the agent there.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from squads import _interactions as interactions
|
|
12
|
+
from squads._backends._base import AgentBackend, Artifact, BackendContext, RoleView
|
|
13
|
+
from squads._backends._claude_code import _claude_md as claude_md
|
|
14
|
+
from squads._backends._claude_code._frontmatter import normalize_model, oneline
|
|
15
|
+
from squads._models._enums import ItemType
|
|
16
|
+
from squads._models._extras import ExtraKey as X
|
|
17
|
+
from squads._models._item import Item
|
|
18
|
+
from squads._rendering._engine import render
|
|
19
|
+
from squads._roles._catalog import RoleDef
|
|
20
|
+
|
|
21
|
+
_AGENTS = "agents"
|
|
22
|
+
_SKILLS = "skills"
|
|
23
|
+
_SKILL_FILE = "SKILL.md"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClaudeCodeBackend(AgentBackend):
|
|
27
|
+
name = "claude_code"
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------ scaffold
|
|
30
|
+
def ensure_scaffold(self, ctx: BackendContext) -> list[Artifact]:
|
|
31
|
+
cdir = ctx.paths.claude_dir
|
|
32
|
+
(cdir / _AGENTS).mkdir(parents=True, exist_ok=True)
|
|
33
|
+
(cdir / _SKILLS / "squads").mkdir(parents=True, exist_ok=True)
|
|
34
|
+
settings = cdir / "settings.json"
|
|
35
|
+
self._merge_settings(settings)
|
|
36
|
+
return [Artifact(ctx.rel(settings), "settings", self.name)]
|
|
37
|
+
|
|
38
|
+
def _merge_settings(self, settings: Path) -> None:
|
|
39
|
+
default: dict[str, Any] = json.loads(render("claude/settings.json.j2"))
|
|
40
|
+
if settings.exists():
|
|
41
|
+
current: dict[str, Any]
|
|
42
|
+
try:
|
|
43
|
+
current = json.loads(settings.read_text(encoding="utf-8"))
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
current = {}
|
|
46
|
+
perms: dict[str, Any] = current.setdefault("permissions", {})
|
|
47
|
+
allow: list[str] = perms.setdefault("allow", [])
|
|
48
|
+
for rule in default["permissions"]["allow"]:
|
|
49
|
+
if rule not in allow:
|
|
50
|
+
allow.append(rule)
|
|
51
|
+
perms.setdefault("deny", [])
|
|
52
|
+
settings.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
|
|
53
|
+
else:
|
|
54
|
+
settings.write_text(json.dumps(default, indent=2) + "\n", encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ managed files
|
|
57
|
+
def write_managed(self, ctx: BackendContext, roster: list[RoleView]) -> list[Artifact]:
|
|
58
|
+
squad_dir = ctx.paths.config.squad_dir
|
|
59
|
+
artifacts: list[Artifact] = []
|
|
60
|
+
# squads skill (real body under squads/agents/skills/, thin pointer in .claude/)
|
|
61
|
+
artifacts += self._write_managed_skill(
|
|
62
|
+
ctx,
|
|
63
|
+
name="squads",
|
|
64
|
+
description=(
|
|
65
|
+
"How to track work on this project with the squads (`sq`) CLI: create/transition "
|
|
66
|
+
"items, comment, link context. Use whenever you start, hand off, or update work."
|
|
67
|
+
),
|
|
68
|
+
body=render("agents/squads_skill.md.j2", version=ctx.version, squad_dir=squad_dir),
|
|
69
|
+
)
|
|
70
|
+
# CLAUDE.md managed section
|
|
71
|
+
default = next((r for r in roster if r.is_default), None)
|
|
72
|
+
section = render(
|
|
73
|
+
"claude/claude_section.md.j2",
|
|
74
|
+
squad_dir=squad_dir,
|
|
75
|
+
roles=[{"full_name": r.full_name, "title": r.title, "slug": r.slug} for r in roster],
|
|
76
|
+
default_role_full_name=default.full_name if default else "the manager",
|
|
77
|
+
default_role_slug=default.slug if default else "manager",
|
|
78
|
+
)
|
|
79
|
+
claude_md.inject(ctx.paths.claude_md, section)
|
|
80
|
+
artifacts.append(Artifact(ctx.rel(ctx.paths.claude_md), "claude_md", self.name))
|
|
81
|
+
artifacts.extend(self._write_item_skills(ctx, roster))
|
|
82
|
+
return artifacts
|
|
83
|
+
|
|
84
|
+
def _write_managed_skill(
|
|
85
|
+
self, ctx: BackendContext, *, name: str, description: str, body: str
|
|
86
|
+
) -> list[Artifact]:
|
|
87
|
+
"""Write a managed skill's real body under squads/ and a thin pointer in .claude/."""
|
|
88
|
+
body_path = ctx.squad_dir / _AGENTS / _SKILLS / f"{name}.md"
|
|
89
|
+
body_path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
body_path.write_text(body, encoding="utf-8")
|
|
91
|
+
pointer = ctx.paths.claude_dir / _SKILLS / name / _SKILL_FILE
|
|
92
|
+
pointer.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
pointer.write_text(
|
|
94
|
+
render(
|
|
95
|
+
"claude/pointer_skill.md.j2",
|
|
96
|
+
slug=name,
|
|
97
|
+
description=oneline(description),
|
|
98
|
+
squad_path=ctx.rel(body_path),
|
|
99
|
+
),
|
|
100
|
+
encoding="utf-8",
|
|
101
|
+
)
|
|
102
|
+
return [
|
|
103
|
+
Artifact(ctx.rel(body_path), "skill_body", self.name),
|
|
104
|
+
Artifact(ctx.rel(pointer), "skill_pointer", self.name),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def _write_item_skills(self, ctx: BackendContext, roster: list[RoleView]) -> list[Artifact]:
|
|
108
|
+
"""One managed skill per item type, with a section per *active* interacting role."""
|
|
109
|
+
by_slug = {r.slug: r for r in roster}
|
|
110
|
+
out: list[Artifact] = []
|
|
111
|
+
for item_type in interactions.managed_item_types():
|
|
112
|
+
pb = interactions.PLAYBOOK[item_type]
|
|
113
|
+
sections: list[dict[str, str]] = []
|
|
114
|
+
for guide in pb.roles:
|
|
115
|
+
if guide.slug == interactions.DEV:
|
|
116
|
+
sections.append({"title": "developers", "text": guide.text})
|
|
117
|
+
elif guide.slug in by_slug:
|
|
118
|
+
r = by_slug[guide.slug]
|
|
119
|
+
sections.append({"title": f"{r.full_name} (`{r.slug}`)", "text": guide.text})
|
|
120
|
+
name = interactions.item_skill_name(item_type)
|
|
121
|
+
body = render(
|
|
122
|
+
"agents/item_skill.md.j2",
|
|
123
|
+
title=item_type.value.capitalize(),
|
|
124
|
+
version=ctx.version,
|
|
125
|
+
overview=pb.overview,
|
|
126
|
+
lifecycle=pb.lifecycle,
|
|
127
|
+
commands=list(pb.commands),
|
|
128
|
+
sections=sections,
|
|
129
|
+
)
|
|
130
|
+
out += self._write_managed_skill(
|
|
131
|
+
ctx,
|
|
132
|
+
name=name,
|
|
133
|
+
description=(
|
|
134
|
+
f"Working with {item_type.value} items in this squad: "
|
|
135
|
+
"lifecycle, commands, and role-specific guidance."
|
|
136
|
+
),
|
|
137
|
+
body=body,
|
|
138
|
+
)
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------ pointers
|
|
142
|
+
def generate_role_pointer(self, ctx: BackendContext, item: Item, role: RoleDef) -> Artifact:
|
|
143
|
+
pointer = ctx.paths.claude_dir / _AGENTS / f"{role.slug}.md"
|
|
144
|
+
pointer.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
pointer.write_text(
|
|
146
|
+
render(
|
|
147
|
+
"claude/pointer_agent.md.j2",
|
|
148
|
+
slug=role.slug,
|
|
149
|
+
full_name=role.full_name,
|
|
150
|
+
role_title=role.title,
|
|
151
|
+
description=oneline(role.description),
|
|
152
|
+
model=normalize_model(role.model),
|
|
153
|
+
color=role.color,
|
|
154
|
+
squad_path=ctx.root_relative(item),
|
|
155
|
+
skills=interactions.skills_for_role(role.slug),
|
|
156
|
+
),
|
|
157
|
+
encoding="utf-8",
|
|
158
|
+
)
|
|
159
|
+
return Artifact(ctx.rel(pointer), "agent", self.name)
|
|
160
|
+
|
|
161
|
+
def generate_skill_pointer(self, ctx: BackendContext, item: Item) -> Artifact:
|
|
162
|
+
slug = item.extra.get(X.SLUG, item.slug)
|
|
163
|
+
pointer = ctx.paths.claude_dir / _SKILLS / slug / _SKILL_FILE
|
|
164
|
+
pointer.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
description = item.extra.get(X.DESCRIPTION) or item.description or item.title
|
|
166
|
+
pointer.write_text(
|
|
167
|
+
render(
|
|
168
|
+
"claude/pointer_skill.md.j2",
|
|
169
|
+
slug=slug,
|
|
170
|
+
description=oneline(description),
|
|
171
|
+
squad_path=ctx.root_relative(item),
|
|
172
|
+
),
|
|
173
|
+
encoding="utf-8",
|
|
174
|
+
)
|
|
175
|
+
return Artifact(ctx.rel(pointer), "skill_pointer", self.name)
|
|
176
|
+
|
|
177
|
+
def remove_artifacts(self, ctx: BackendContext, item: Item) -> None:
|
|
178
|
+
slug = item.extra.get(X.SLUG, item.slug)
|
|
179
|
+
if item.type is ItemType.SKILL:
|
|
180
|
+
skill_dir = ctx.paths.claude_dir / _SKILLS / slug
|
|
181
|
+
if skill_dir.is_dir():
|
|
182
|
+
shutil.rmtree(skill_dir)
|
|
183
|
+
else:
|
|
184
|
+
(ctx.paths.claude_dir / _AGENTS / f"{slug}.md").unlink(missing_ok=True)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Manage the squads-owned section of CLAUDE.md, delimited by stable markers."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
START = "<!-- squads:start -->"
|
|
6
|
+
END = "<!-- squads:end -->"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def wrap(section_body: str) -> str:
|
|
10
|
+
return f"{START}\n{section_body.rstrip()}\n{END}\n"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inject(claude_md: Path, section_body: str) -> None:
|
|
14
|
+
"""Insert or replace the managed section, preserving everything else in the file."""
|
|
15
|
+
block = wrap(section_body)
|
|
16
|
+
if not claude_md.exists():
|
|
17
|
+
claude_md.write_text(f"# Project guidance\n\n{block}", encoding="utf-8")
|
|
18
|
+
return
|
|
19
|
+
text = claude_md.read_text(encoding="utf-8")
|
|
20
|
+
si, ei = text.find(START), text.find(END)
|
|
21
|
+
if si != -1 and ei != -1:
|
|
22
|
+
new = text[:si] + block.rstrip("\n") + text[ei + len(END) :]
|
|
23
|
+
claude_md.write_text(new, encoding="utf-8")
|
|
24
|
+
else:
|
|
25
|
+
sep = "" if text.endswith("\n") else "\n"
|
|
26
|
+
claude_md.write_text(f"{text}{sep}\n{block}", encoding="utf-8")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Helpers for emitting valid Claude Code config."""
|
|
2
|
+
|
|
3
|
+
_VALID_MODELS = {"sonnet", "opus", "haiku", "inherit"}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_model(model: str | None) -> str | None:
|
|
7
|
+
if model is None:
|
|
8
|
+
return None
|
|
9
|
+
return model if model in _VALID_MODELS else None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def oneline(text: str) -> str:
|
|
13
|
+
"""Collapse to a single line so it is safe inside double-quoted YAML."""
|
|
14
|
+
return " ".join(text.split()).replace('"', "'")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Name → backend lookup. New backends register here."""
|
|
2
|
+
|
|
3
|
+
from squads._backends._base import AgentBackend
|
|
4
|
+
from squads._errors import SquadsError
|
|
5
|
+
|
|
6
|
+
_REGISTRY: dict[str, type[AgentBackend]] = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(cls: type[AgentBackend]) -> type[AgentBackend]:
|
|
10
|
+
_REGISTRY[cls.name] = cls
|
|
11
|
+
return cls
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_backend(name: str) -> AgentBackend:
|
|
15
|
+
# Import for side-effect registration of the built-in backend.
|
|
16
|
+
import importlib
|
|
17
|
+
|
|
18
|
+
importlib.import_module("squads._backends._claude_code")
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
return _REGISTRY[name]()
|
|
22
|
+
except KeyError:
|
|
23
|
+
raise SquadsError(
|
|
24
|
+
f"unknown backend {name!r} (available: {', '.join(_REGISTRY) or 'none'})"
|
|
25
|
+
) from None
|
squads/_cli/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The Typer application, exposed as both `squads` and `sq`."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from squads import __version__
|
|
6
|
+
from squads._cli import _common as common
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="sq",
|
|
10
|
+
help=(
|
|
11
|
+
"Manage a team of AI agents: bootstrap roles & skills, track work with stable IDs.\n\n"
|
|
12
|
+
"New here? Run `sq workflow` for how the team works, or `sq <command> --help` for details."
|
|
13
|
+
),
|
|
14
|
+
epilog="Team workflow: `sq workflow` · per-command help: `sq <command> --help`",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
add_completion=False,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _version_cb(value: bool):
|
|
21
|
+
if value:
|
|
22
|
+
common.console.print(f"squads {__version__}")
|
|
23
|
+
raise typer.Exit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def main_callback(
|
|
28
|
+
dir: str | None = typer.Option(
|
|
29
|
+
None,
|
|
30
|
+
"--dir",
|
|
31
|
+
help="Operate on the squad folder at PATH (overrides config/walk-up).",
|
|
32
|
+
metavar="PATH",
|
|
33
|
+
),
|
|
34
|
+
at: str | None = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--at",
|
|
37
|
+
help="Forge timestamps for this command (ISO 8601, UTC) — for migrating history.",
|
|
38
|
+
metavar="WHEN",
|
|
39
|
+
),
|
|
40
|
+
version: bool = typer.Option(
|
|
41
|
+
False, "--version", callback=_version_cb, is_eager=True, help="Show version and exit."
|
|
42
|
+
),
|
|
43
|
+
):
|
|
44
|
+
common.set_active_dir(dir)
|
|
45
|
+
common.apply_timestamp(at)
|
|
46
|
+
common.version_notice()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Register commands (imported after `app` is defined; they decorate it).
|
|
50
|
+
from squads._cli import ( # noqa: E402
|
|
51
|
+
_comment,
|
|
52
|
+
_create,
|
|
53
|
+
_dev,
|
|
54
|
+
_main,
|
|
55
|
+
_refs,
|
|
56
|
+
_role,
|
|
57
|
+
_skill,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
app.add_typer(_create.create_app, name="create", help="Create a tracked item.")
|
|
61
|
+
app.add_typer(_role.role_app, name="role", help="Manage agent roles.")
|
|
62
|
+
app.add_typer(_comment.story_app, name="story", help="Manage a feature's user stories.")
|
|
63
|
+
app.add_typer(_comment.subtask_app, name="subtask", help="Manage a task's subtasks.")
|
|
64
|
+
app.add_typer(_refs.ref_app, name="ref", help="Manage reference edges.")
|
|
65
|
+
app.add_typer(_dev.dev_app, name="dev", help="Manage developer roles.")
|
|
66
|
+
app.add_typer(_skill.skill_app, name="skill", help="Manage agent skills.")
|
|
67
|
+
app.add_typer(_main.guide_app, name="guide", help="Manage project guides.")
|
squads/_cli/_comment.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""`sq comment`, `sq story`, `sq subtask` — collaboration on item files."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from squads._cli import app
|
|
9
|
+
from squads._cli._common import console, e, get_service, handle_errors
|
|
10
|
+
from squads._service import BlockResult
|
|
11
|
+
|
|
12
|
+
story_app = typer.Typer(no_args_is_help=True, help="Manage a feature's user stories.")
|
|
13
|
+
subtask_app = typer.Typer(no_args_is_help=True, help="Manage a task's subtasks.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
@handle_errors
|
|
18
|
+
def comment(
|
|
19
|
+
item_id: str = typer.Argument(...),
|
|
20
|
+
message: list[str] = typer.Option(..., "-m", "--message", help="A talking point (repeatable)."),
|
|
21
|
+
as_: str = typer.Option("operator", "--as", help="Author: a role slug or 'operator'."),
|
|
22
|
+
story: str | None = typer.Option(None, "--story", help="Target a user story (e.g. US1)."),
|
|
23
|
+
subtask: str | None = typer.Option(None, "--subtask", help="Target a subtask (e.g. ST1)."),
|
|
24
|
+
):
|
|
25
|
+
"""Append a timestamped comment to an item's discussion."""
|
|
26
|
+
svc = get_service()
|
|
27
|
+
svc.comment(item_id, list(message), as_slug=as_, story=story, subtask=subtask)
|
|
28
|
+
where = f" ({story or subtask})" if (story or subtask) else ""
|
|
29
|
+
console.print(f"commented on {item_id}{where} as [bold]{svc.author(as_)}[/bold]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _print_block(parent_id: str, res: BlockResult, json_out: bool) -> None:
|
|
33
|
+
if json_out:
|
|
34
|
+
console.print_json(
|
|
35
|
+
json.dumps(
|
|
36
|
+
{
|
|
37
|
+
"local_id": res.local_id,
|
|
38
|
+
"file": str(res.path),
|
|
39
|
+
"region": res.body_tag,
|
|
40
|
+
"start_line": res.start_line,
|
|
41
|
+
"end_line": res.end_line,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
return
|
|
46
|
+
console.print(f"added [bold]{res.local_id}[/bold] to {parent_id}")
|
|
47
|
+
console.print(
|
|
48
|
+
f" write in [cyan]{res.path}[/cyan] between "
|
|
49
|
+
f"[dim]<!-- sq:{res.body_tag} -->[/dim] (line {res.start_line}) "
|
|
50
|
+
f"and its [dim]:end[/dim] (line {res.end_line})"
|
|
51
|
+
)
|
|
52
|
+
console.print(" [dim]free-form paragraphs or bullets; leave the marker lines untouched.[/dim]")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@story_app.command("add")
|
|
56
|
+
@handle_errors
|
|
57
|
+
def story_add(
|
|
58
|
+
feature_id: str = typer.Argument(...),
|
|
59
|
+
title: str = typer.Argument("", help="Optional short label; write the full story in the body."),
|
|
60
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
61
|
+
):
|
|
62
|
+
"""Scaffold a user story (free-form body + its own discussion) on a feature."""
|
|
63
|
+
svc = get_service()
|
|
64
|
+
_print_block(feature_id, svc.add_story(feature_id, title), json_out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@story_app.command("list")
|
|
68
|
+
@handle_errors
|
|
69
|
+
def story_list(feature_id: str = typer.Argument(...)):
|
|
70
|
+
"""List a feature's user stories."""
|
|
71
|
+
svc = get_service()
|
|
72
|
+
stories = svc.list_stories(feature_id)
|
|
73
|
+
if not stories:
|
|
74
|
+
console.print("[dim]no user stories[/dim]")
|
|
75
|
+
return
|
|
76
|
+
table = Table(box=None, pad_edge=False)
|
|
77
|
+
table.add_column("ID")
|
|
78
|
+
table.add_column("Story")
|
|
79
|
+
for sid, text in stories:
|
|
80
|
+
table.add_row(sid, e(text))
|
|
81
|
+
console.print(table)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@subtask_app.command("add")
|
|
85
|
+
@handle_errors
|
|
86
|
+
def subtask_add(
|
|
87
|
+
task_id: str = typer.Argument(...),
|
|
88
|
+
title: str = typer.Argument("", help="Optional checklist label; write detail in the body."),
|
|
89
|
+
story: str | None = typer.Option(
|
|
90
|
+
None,
|
|
91
|
+
"--story",
|
|
92
|
+
help="User story this subtask implements (e.g. US2; must exist in the parent feature).",
|
|
93
|
+
),
|
|
94
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
95
|
+
):
|
|
96
|
+
"""Scaffold a subtask (free-form body + its own discussion) on a task."""
|
|
97
|
+
svc = get_service()
|
|
98
|
+
_print_block(task_id, svc.add_subtask(task_id, title, story=story), json_out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@subtask_app.command("list")
|
|
102
|
+
@handle_errors
|
|
103
|
+
def subtask_list(task_id: str = typer.Argument(...)):
|
|
104
|
+
"""List a task's subtasks (with their checkbox and user-story map)."""
|
|
105
|
+
svc = get_service()
|
|
106
|
+
subs = svc.list_subtasks(task_id)
|
|
107
|
+
if not subs:
|
|
108
|
+
console.print("[dim]no subtasks[/dim]")
|
|
109
|
+
return
|
|
110
|
+
table = Table(box=None, pad_edge=False)
|
|
111
|
+
table.add_column("ID")
|
|
112
|
+
table.add_column("Subtask")
|
|
113
|
+
for sid, text in subs:
|
|
114
|
+
table.add_row(sid, e(text))
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@subtask_app.command("done")
|
|
119
|
+
@handle_errors
|
|
120
|
+
def subtask_done(
|
|
121
|
+
task_id: str = typer.Argument(...),
|
|
122
|
+
local_id: str = typer.Argument(..., metavar="STn"),
|
|
123
|
+
undo: bool = typer.Option(False, "--undo", help="Re-open the subtask."),
|
|
124
|
+
):
|
|
125
|
+
"""Mark a subtask done (or re-open it with --undo)."""
|
|
126
|
+
svc = get_service()
|
|
127
|
+
svc.set_subtask_done(task_id, local_id, done=not undo)
|
|
128
|
+
console.print(f"{task_id} {local_id} → {'open' if undo else 'done'}")
|
squads/_cli/_common.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Shared CLI helpers: console, error handling, service resolution, value parsing."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
|
|
10
|
+
from squads import __version__, _clock
|
|
11
|
+
from squads._errors import SquadsError
|
|
12
|
+
from squads._models._enums import ItemType, Status
|
|
13
|
+
from squads._paths import resolve
|
|
14
|
+
from squads._service import Service, open_service
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
err_console = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
# The active squad folder from the global --dir option, set once by the root callback.
|
|
20
|
+
_active_dir: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_active_dir(value: str | None) -> None:
|
|
24
|
+
global _active_dir
|
|
25
|
+
_active_dir = value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def apply_timestamp(at: str | None) -> None:
|
|
29
|
+
"""Honour the global ``--at`` option: forge `clock.now()` for this invocation (or clear it)."""
|
|
30
|
+
if at is None:
|
|
31
|
+
_clock.set_now(None)
|
|
32
|
+
return
|
|
33
|
+
try:
|
|
34
|
+
_clock.set_now(_clock.parse_iso(at))
|
|
35
|
+
except ValueError:
|
|
36
|
+
err_console.print(
|
|
37
|
+
f"[red]error:[/red] invalid --at timestamp {at!r} "
|
|
38
|
+
"(use ISO 8601, e.g. 2024-01-15 or 2024-01-15T09:30:00Z)"
|
|
39
|
+
)
|
|
40
|
+
raise typer.Exit(2) from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def e(value: object) -> str:
|
|
44
|
+
"""Escape a dynamic string so Rich does not interpret ``[...]`` as markup."""
|
|
45
|
+
return escape(str(value))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_service() -> Service:
|
|
49
|
+
return open_service(_active_dir)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def handle_errors[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
|
|
53
|
+
@functools.wraps(fn)
|
|
54
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
55
|
+
try:
|
|
56
|
+
return fn(*args, **kwargs)
|
|
57
|
+
except SquadsError as exc:
|
|
58
|
+
err_console.print(f"[red]error:[/red] {exc}")
|
|
59
|
+
raise typer.Exit(1) from exc
|
|
60
|
+
|
|
61
|
+
return wrapper
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _vtuple(version: str) -> tuple[int, ...]:
|
|
65
|
+
parts: list[int] = []
|
|
66
|
+
for p in version.split("."):
|
|
67
|
+
num = "".join(c for c in p if c.isdigit())
|
|
68
|
+
parts.append(int(num) if num else 0)
|
|
69
|
+
return tuple(parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def version_notice() -> None:
|
|
73
|
+
"""Print a non-fatal notice if the installed squads is newer than the managed files."""
|
|
74
|
+
try:
|
|
75
|
+
sp = resolve(_active_dir)
|
|
76
|
+
except SquadsError:
|
|
77
|
+
return # not initialized yet (e.g. before `sq init`)
|
|
78
|
+
recorded = sp.config.squads_version
|
|
79
|
+
if recorded and _vtuple(__version__) > _vtuple(recorded):
|
|
80
|
+
err_console.print(
|
|
81
|
+
f"[yellow]squads {__version__} detected (managed files at {recorded}). "
|
|
82
|
+
f"Run `sq sync` to refresh them.[/yellow]"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_type(value: str) -> ItemType:
|
|
87
|
+
try:
|
|
88
|
+
return ItemType(value)
|
|
89
|
+
except ValueError:
|
|
90
|
+
choices = ", ".join(t.value for t in ItemType)
|
|
91
|
+
raise SquadsError(f"unknown type {value!r} (one of: {choices})") from None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_status(value: str) -> Status:
|
|
95
|
+
# accept either the canonical value ("InProgress") or a loose form ("in_progress", "inprogress")
|
|
96
|
+
norm = value.replace("_", "").replace("-", "").lower()
|
|
97
|
+
for s in Status:
|
|
98
|
+
if s.value.lower() == norm or s.value == value:
|
|
99
|
+
return s
|
|
100
|
+
choices = ", ".join(s.value for s in Status)
|
|
101
|
+
raise SquadsError(f"unknown status {value!r} (one of: {choices})") from None
|