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.
Files changed (64) hide show
  1. squads/__init__.py +3 -0
  2. squads/__main__.py +4 -0
  3. squads/_backends/__init__.py +1 -0
  4. squads/_backends/_base.py +75 -0
  5. squads/_backends/_claude_code/__init__.py +6 -0
  6. squads/_backends/_claude_code/_backend.py +184 -0
  7. squads/_backends/_claude_code/_claude_md.py +26 -0
  8. squads/_backends/_claude_code/_frontmatter.py +14 -0
  9. squads/_backends/_registry.py +25 -0
  10. squads/_cli/__init__.py +67 -0
  11. squads/_cli/_comment.py +128 -0
  12. squads/_cli/_common.py +101 -0
  13. squads/_cli/_create.py +55 -0
  14. squads/_cli/_dev.py +50 -0
  15. squads/_cli/_main.py +341 -0
  16. squads/_cli/_refs.py +68 -0
  17. squads/_cli/_role.py +92 -0
  18. squads/_cli/_skill.py +79 -0
  19. squads/_clock.py +38 -0
  20. squads/_discussion.py +155 -0
  21. squads/_errors.py +29 -0
  22. squads/_index/__init__.py +1 -0
  23. squads/_index/_resolver.py +19 -0
  24. squads/_index/_store.py +70 -0
  25. squads/_interactions.py +163 -0
  26. squads/_itemfile.py +37 -0
  27. squads/_models/__init__.py +1 -0
  28. squads/_models/_config.py +41 -0
  29. squads/_models/_enums.py +78 -0
  30. squads/_models/_extras.py +28 -0
  31. squads/_models/_index.py +40 -0
  32. squads/_models/_item.py +95 -0
  33. squads/_models/_markers.py +43 -0
  34. squads/_paths.py +125 -0
  35. squads/_rendering/__init__.py +1 -0
  36. squads/_rendering/_engine.py +31 -0
  37. squads/_rendering/templates/agents/item_skill.md.j2 +26 -0
  38. squads/_rendering/templates/agents/role.md.j2 +37 -0
  39. squads/_rendering/templates/agents/skill.md.j2 +16 -0
  40. squads/_rendering/templates/agents/squads_skill.md.j2 +31 -0
  41. squads/_rendering/templates/claude/claude_section.md.j2 +38 -0
  42. squads/_rendering/templates/claude/pointer_agent.md.j2 +26 -0
  43. squads/_rendering/templates/claude/pointer_skill.md.j2 +10 -0
  44. squads/_rendering/templates/claude/settings.json.j2 +10 -0
  45. squads/_rendering/templates/items/bug.md.j2 +17 -0
  46. squads/_rendering/templates/items/decision.md.j2 +12 -0
  47. squads/_rendering/templates/items/epic.md.j2 +14 -0
  48. squads/_rendering/templates/items/feature.md.j2 +15 -0
  49. squads/_rendering/templates/items/guide.md.j2 +14 -0
  50. squads/_rendering/templates/items/review.md.j2 +14 -0
  51. squads/_rendering/templates/items/task.md.j2 +15 -0
  52. squads/_rendering/templates/workflow.md.j2 +18 -0
  53. squads/_roles/__init__.py +1 -0
  54. squads/_roles/_catalog.py +266 -0
  55. squads/_sections.py +106 -0
  56. squads/_service.py +927 -0
  57. squads/_util.py +20 -0
  58. squads/_workflow.py +148 -0
  59. squads/py.typed +0 -0
  60. squads-0.1.0.dist-info/METADATA +241 -0
  61. squads-0.1.0.dist-info/RECORD +64 -0
  62. squads-0.1.0.dist-info/WHEEL +4 -0
  63. squads-0.1.0.dist-info/entry_points.txt +3 -0
  64. squads-0.1.0.dist-info/licenses/LICENSE +21 -0
squads/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """squads — manage a team of AI agents working on a code project."""
2
+
3
+ __version__ = "0.1.0"
squads/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from squads._cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -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,6 @@
1
+ from squads._backends._claude_code._backend import ClaudeCodeBackend
2
+ from squads._backends._registry import register
3
+
4
+ register(ClaudeCodeBackend)
5
+
6
+ __all__ = ["ClaudeCodeBackend"]
@@ -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
@@ -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.")
@@ -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