project-kit-cli 0.3.0__tar.gz → 0.4.0__tar.gz

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 (69) hide show
  1. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/PKG-INFO +19 -1
  2. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/README.md +18 -0
  3. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/pyproject.toml +2 -2
  4. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/pyproject.toml.orig +2 -2
  5. project_kit_cli-0.4.0/src/project_kit/__init__.py +14 -0
  6. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-start/SKILL.md +8 -3
  7. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/cli.py +38 -0
  8. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/doctor.py +15 -0
  9. project_kit_cli-0.4.0/src/project_kit/entry.py +36 -0
  10. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/help.py +1 -0
  11. project_kit_cli-0.4.0/src/project_kit/statusline.py +347 -0
  12. project_kit_cli-0.3.0/src/project_kit/__init__.py +0 -8
  13. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/LICENSE +0 -0
  14. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/__main__.py +0 -0
  15. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/agents.py +0 -0
  16. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/ai_attribution.py +0 -0
  17. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/approval.py +0 -0
  18. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/hooks/commit-msg +0 -0
  19. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/project/skills-catalog.yml +0 -0
  20. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/project/workflow.yml +0 -0
  21. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-amend/SKILL.md +0 -0
  22. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-decide/SKILL.md +0 -0
  23. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-discover/SKILL.md +0 -0
  24. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-foundations/SKILL.md +0 -0
  25. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-grill/SKILL.md +0 -0
  26. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-handoff/SKILL.md +0 -0
  27. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-plan/SKILL.md +0 -0
  28. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-provision/SKILL.md +0 -0
  29. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-skills/SKILL.md +0 -0
  30. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/skills/pk-workflow/SKILL.md +0 -0
  31. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/adr.md +0 -0
  32. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/backlog.example.json +0 -0
  33. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/brief.md +0 -0
  34. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/constitution-input.md +0 -0
  35. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/development-workflow.md +0 -0
  36. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/adr.yml +0 -0
  37. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/bug.yml +0 -0
  38. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/config.yml +0 -0
  39. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/epic.yml +0 -0
  40. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/feature.yml +0 -0
  41. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/github/pull_request_template.md +0 -0
  42. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/gitlab/issue_templates/adr.md +0 -0
  43. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/gitlab/issue_templates/bug.md +0 -0
  44. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/gitlab/issue_templates/epic.md +0 -0
  45. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/gitlab/issue_templates/feature.md +0 -0
  46. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/gitlab/merge_request_templates/Default.md +0 -0
  47. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/assets/templates/interview.md +0 -0
  48. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/backlog.py +0 -0
  49. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/config.py +0 -0
  50. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/context_files.py +0 -0
  51. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/fsutil.py +0 -0
  52. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/gh.py +0 -0
  53. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/glab.py +0 -0
  54. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/handoff.py +0 -0
  55. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/installer.py +0 -0
  56. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/labels.py +0 -0
  57. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/manifest.py +0 -0
  58. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/project_choice.py +0 -0
  59. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/providers/__init__.py +0 -0
  60. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/providers/github.py +0 -0
  61. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/providers/gitlab.py +0 -0
  62. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/provision.py +0 -0
  63. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/provision_gitlab.py +0 -0
  64. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/provision_issues.py +0 -0
  65. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/skills.py +0 -0
  66. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/state.py +0 -0
  67. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/templating.py +0 -0
  68. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/upgrade.py +0 -0
  69. {project_kit_cli-0.3.0 → project_kit_cli-0.4.0}/src/project_kit/workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: project-kit-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Project definition and dev-workflow kit that hands off to Spec Kit
5
5
  Keywords: project-management,spec-kit,claude-code,workflow,github
6
6
  Author: Miguel Tomás
@@ -138,6 +138,7 @@ It never rewrites issues that already exist, and re-running it is always safe.
138
138
  | `pjkit backlog validate` | Validate `.project-kit/backlog.json` |
139
139
  | `pjkit provision [--dry-run] [--project X]` | Create milestones, branches, the Project and issues on GitHub |
140
140
  | `pjkit handoff [--install-speckit]` | Generate the Spec Kit constitution input |
141
+ | `pjkit statusline [--install]` | One line for Claude Code's status bar: model, context, usage limits and the project phase |
141
142
  | `pjkit context [--dry-run]` | Write or refresh the project block in `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` |
142
143
  | `pjkit skills list\|show\|mark` | Recommended third-party skills, their install command, and what the project decided |
143
144
 
@@ -155,6 +156,23 @@ writes the context file each one reads (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`).
155
156
  between its markers is managed; the rest of the file is yours. Only Claude Code is started by
156
157
  `init`; for the others, open the agent and ask it to run the `pk-start` skill.
157
158
 
159
+ ## Status bar
160
+
161
+ Claude Code can show a line of your choosing at the bottom of the screen. `pjkit statusline`
162
+ fills it with the model, the context in use, the 5-hour and 7-day usage limits (Pro and Max
163
+ plans) and where the project is in the flow:
164
+
165
+ ```
166
+ [Opus · high] · ctx 23% · 5h 41% (↻ 17:00) · 7d 12% · ~$1.23 · fase: decide → /pk-decide
167
+ ```
168
+
169
+ `init` asks whether you want it, and `pjkit statusline --install` sets it up in
170
+ `.claude/settings.json` (`--remove` undoes it). It is never turned on without a yes. If you
171
+ already have a status bar, yours is kept and runs first. It is one line: when the terminal is
172
+ narrow, the cost goes first, then the 7-day limit, the effort and the context. The cost is an
173
+ estimate at list price. More in
174
+ [docs/statusline.md](https://github.com/miguelmatg/project-kit/blob/main/docs/statusline.md).
175
+
158
176
  ## GitLab
159
177
 
160
178
  `pjkit init --provider gitlab` makes `provision` use `glab`: phases become milestones, kind,
@@ -118,6 +118,7 @@ It never rewrites issues that already exist, and re-running it is always safe.
118
118
  | `pjkit backlog validate` | Validate `.project-kit/backlog.json` |
119
119
  | `pjkit provision [--dry-run] [--project X]` | Create milestones, branches, the Project and issues on GitHub |
120
120
  | `pjkit handoff [--install-speckit]` | Generate the Spec Kit constitution input |
121
+ | `pjkit statusline [--install]` | One line for Claude Code's status bar: model, context, usage limits and the project phase |
121
122
  | `pjkit context [--dry-run]` | Write or refresh the project block in `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` |
122
123
  | `pjkit skills list\|show\|mark` | Recommended third-party skills, their install command, and what the project decided |
123
124
 
@@ -135,6 +136,23 @@ writes the context file each one reads (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`).
135
136
  between its markers is managed; the rest of the file is yours. Only Claude Code is started by
136
137
  `init`; for the others, open the agent and ask it to run the `pk-start` skill.
137
138
 
139
+ ## Status bar
140
+
141
+ Claude Code can show a line of your choosing at the bottom of the screen. `pjkit statusline`
142
+ fills it with the model, the context in use, the 5-hour and 7-day usage limits (Pro and Max
143
+ plans) and where the project is in the flow:
144
+
145
+ ```
146
+ [Opus · high] · ctx 23% · 5h 41% (↻ 17:00) · 7d 12% · ~$1.23 · fase: decide → /pk-decide
147
+ ```
148
+
149
+ `init` asks whether you want it, and `pjkit statusline --install` sets it up in
150
+ `.claude/settings.json` (`--remove` undoes it). It is never turned on without a yes. If you
151
+ already have a status bar, yours is kept and runs first. It is one line: when the terminal is
152
+ narrow, the cost goes first, then the 7-day limit, the effort and the context. The cost is an
153
+ estimate at list price. More in
154
+ [docs/statusline.md](https://github.com/miguelmatg/project-kit/blob/main/docs/statusline.md).
155
+
138
156
  ## GitLab
139
157
 
140
158
  `pjkit init --provider gitlab` makes `provision` use `glab`: phases become milestones, kind,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "project-kit-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Project definition and dev-workflow kit that hands off to Spec Kit"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -31,7 +31,7 @@ Repository = "https://github.com/miguelmatg/project-kit"
31
31
  Issues = "https://github.com/miguelmatg/project-kit/issues"
32
32
 
33
33
  [project.scripts]
34
- pjkit = "project_kit.cli:main"
34
+ pjkit = "project_kit.entry:main"
35
35
 
36
36
  [tool.uv.build-backend]
37
37
  module-name = "project_kit"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "project-kit-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Project definition and dev-workflow kit that hands off to Spec Kit"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -25,7 +25,7 @@ Repository = "https://github.com/miguelmatg/project-kit"
25
25
  Issues = "https://github.com/miguelmatg/project-kit/issues"
26
26
 
27
27
  [project.scripts]
28
- pjkit = "project_kit.cli:main"
28
+ pjkit = "project_kit.entry:main"
29
29
 
30
30
  [tool.uv.build-backend]
31
31
  # The distribution is project-kit-cli (the name free on PyPI); the package keeps its name.
@@ -0,0 +1,14 @@
1
+ """project-kit: define, plan and set up a project before handing off to Spec Kit."""
2
+
3
+
4
+ def __getattr__(name: str) -> str:
5
+ """``__version__`` is looked up on first use: reading package metadata costs more than the
6
+ whole status line command, which runs at every update of the agent's status bar."""
7
+ if name == "__version__":
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ return version("project-kit-cli")
12
+ except PackageNotFoundError: # running from a source tree that was never installed
13
+ return "0.0.0"
14
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -28,7 +28,12 @@ now. Nothing is created in GitHub or GitLab here.
28
28
  `commit-msg` hook that strips the co-author trailer of any other assistant;
29
29
  - ask before running `git config core.hooksPath .githooks`, which switches the hook on for
30
30
  this clone. Each teammate runs that command once.
31
- 4. **Ask how far to go now**, with a selection list (`AskUserQuestion`, see
31
+ 4. **Status bar (Claude Code only).** If `config.yml` lists `claude` and `pjkit doctor` shows
32
+ the status bar as not set up, ask with a selection list (**Yes, show it** / **No, thanks**)
33
+ whether to show the model, the usage limits and the project phase in the status bar. Only on
34
+ a yes run `pjkit statusline --install`; it keeps any status bar the user already has and
35
+ runs it first. Never set it up without that yes. `pjkit statusline --remove` undoes it.
36
+ 5. **Ask how far to go now**, with a selection list (`AskUserQuestion`, see
32
37
  `.project-kit/templates/interview.md`) and exactly these two options. Put B first with
33
38
  `(Recommended)` unless the user said they are in a hurry:
34
39
 
@@ -36,8 +41,8 @@ now. Nothing is created in GitHub or GitLab here.
36
41
  |--------|--------------|
37
42
  | **B. Start defining the project** | Continue now with the discovery interview |
38
43
  | **A. Only initialise** | Stop here. The user comes back later with `/pk-discover` |
39
- 5. **If A:** run `pjkit status`, show it, and explain that `/pk-discover` is next. Done.
40
- 6. **If B:** ask for a short description of the project in the user's own words, a few
44
+ 6. **If A:** run `pjkit status`, show it, and explain that `/pk-discover` is next. Done.
45
+ 7. **If B:** ask for a short description of the project in the user's own words, a few
41
46
  sentences, a pasted note or "nothing yet, ask me". Then follow the `pk-discover` skill
42
47
  (`.claude/skills/pk-discover/SKILL.md`) using that description as the starting point.
43
48
 
@@ -17,6 +17,7 @@ from project_kit import (
17
17
  provision,
18
18
  provision_gitlab,
19
19
  skills,
20
+ statusline,
20
21
  upgrade,
21
22
  )
22
23
  from project_kit import (
@@ -84,6 +85,19 @@ def build_parser() -> argparse.ArgumentParser:
84
85
  "is attached)",
85
86
  )
86
87
 
88
+ init.add_argument(
89
+ "--statusline",
90
+ action=argparse.BooleanOptionalAction,
91
+ help="show model, usage limits and the project phase in Claude Code's status bar "
92
+ "(asked when omitted and a terminal is attached; never turned on without a yes)",
93
+ )
94
+
95
+ st = sub.add_parser(
96
+ "statusline",
97
+ help="one line for the agent's status bar (reads the agent's JSON on stdin)",
98
+ )
99
+ statusline.add_arguments(st)
100
+
87
101
  up = sub.add_parser(
88
102
  "upgrade",
89
103
  help="bring the skills and templates installed by init up to this version",
@@ -251,8 +265,29 @@ def _help() -> int:
251
265
  return 0
252
266
 
253
267
 
268
+ def _offer_statusline(root: Path, requested: bool | None, *, fresh: bool) -> None:
269
+ """Set up Claude Code's status bar if the user says yes. Never without a yes.
270
+
271
+ ``requested`` is the flag; without it the question is asked only in a new project with a
272
+ terminal, so running ``init`` again does not ask what was already answered.
273
+ """
274
+ if "claude" not in load_config(root).agents or statusline.is_configured(root):
275
+ return
276
+ if requested is None:
277
+ if not (fresh and sys.stdin.isatty()):
278
+ return
279
+ before = statusline.existing_command(root)
280
+ keep = f" Your current bar ({before}) is kept and runs first." if before else ""
281
+ requested = _ask(
282
+ "Show the model, usage limits and the project phase in Claude Code's status bar?" + keep
283
+ )
284
+ if requested:
285
+ print(statusline.install(root).message)
286
+
287
+
254
288
  def _init(args: argparse.Namespace) -> int:
255
289
  root = Path.cwd()
290
+ fresh = not config_path(root).exists()
256
291
  choice = args.ai_coauthor
257
292
  if choice is None and sys.stdin.isatty() and not config_path(root).exists():
258
293
  remove = _ask("Remove AI assistants (Claude, Copilot...) as co-authors of commits and PRs?")
@@ -283,6 +318,7 @@ def _init(args: argparse.Namespace) -> int:
283
318
  print(f"pjkit init: {exc}", file=sys.stderr)
284
319
  return 1
285
320
  _print_init_report(report)
321
+ _offer_statusline(root, args.statusline, fresh=fresh)
286
322
  if not _offer_agent(root, args.agent, load_config(root).agents):
287
323
  print(NEXT_AFTER_INIT)
288
324
  return 0
@@ -476,6 +512,8 @@ def main(argv: Sequence[str] | None = None) -> int:
476
512
  return _provision(args)
477
513
  if args.command == "handoff":
478
514
  return _handoff(args)
515
+ if args.command == "statusline":
516
+ return statusline.run(args)
479
517
  if args.command == "context":
480
518
  return _context(dry_run=args.dry_run)
481
519
  if args.command == "skills":
@@ -200,6 +200,20 @@ def check_agents(which: Which, cwd: Path) -> list[Check]:
200
200
  return checks
201
201
 
202
202
 
203
+ def check_statusline(cwd: Path) -> list[Check]:
204
+ """Whether Claude Code's status bar shows project-kit; optional, so never a warning."""
205
+ from project_kit import statusline
206
+
207
+ try:
208
+ if "claude" not in load_config(cwd).agents:
209
+ return []
210
+ except ConfigError:
211
+ return []
212
+ if statusline.is_configured(cwd):
213
+ return [Check("status bar", Status.OK, "configured in .claude/settings.json")]
214
+ return [Check("status bar", Status.OK, "not set up (optional: pjkit statusline --install)")]
215
+
216
+
203
217
  def run_checks(
204
218
  *,
205
219
  gh: Gh | None = None,
@@ -224,6 +238,7 @@ def run_checks(
224
238
  check_uv(which, runner),
225
239
  check_speckit(which, runner, here),
226
240
  *check_agents(which, here),
241
+ *check_statusline(here),
227
242
  ]
228
243
 
229
244
 
@@ -0,0 +1,36 @@
1
+ """The ``pjkit`` command's entry point.
2
+
3
+ ``statusline`` runs at every update of an agent's status bar, so it must start in a fraction of
4
+ a second: it is dispatched here, before ``cli`` imports every command of the program. Every
5
+ other command goes through ``cli.main`` unchanged.
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+ from collections.abc import Sequence
11
+
12
+
13
+ def _utf8_stdout() -> None:
14
+ """Print UTF-8 whatever the console code page is (the line has arrows and accents)."""
15
+ try:
16
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
17
+ except (AttributeError, ValueError):
18
+ pass
19
+
20
+
21
+ def main(argv: Sequence[str] | None = None) -> int:
22
+ args = list(sys.argv[1:] if argv is None else argv)
23
+ if args and args[0] == "statusline" and "--help" not in args and "-h" not in args:
24
+ from project_kit import statusline
25
+
26
+ parser = argparse.ArgumentParser(prog="pjkit statusline")
27
+ statusline.add_arguments(parser)
28
+ _utf8_stdout()
29
+ return statusline.run(parser.parse_args(args[1:]))
30
+ from project_kit.cli import main as cli_main
31
+
32
+ return cli_main(args)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ sys.exit(main())
@@ -31,6 +31,7 @@ TERMINAL_COMMANDS = (
31
31
  "pjkit skills list|show|mark|detect",
32
32
  "recommended skills for this project and what was decided",
33
33
  ),
34
+ ("pjkit statusline [--install]", "one-line status bar: model, limits and project phase"),
34
35
  ("pjkit help", "show this guide"),
35
36
  )
36
37
 
@@ -0,0 +1,347 @@
1
+ """``pjkit statusline``: one line for the agent's status bar - model, context, usage limits and
2
+ where the project is in the flow.
3
+
4
+ The agent runs the command at every update and hands it a JSON document on stdin. What is in
5
+ that JSON belongs to the agent, so reading it is isolated in an *adapter* per agent
6
+ (``ADAPTERS``): it turns the agent's document into ``Metrics`` and nothing else in this module
7
+ knows the shape. Only Claude Code has a documented status bar today, so it has the only
8
+ adapter; another agent needs a new adapter and an entry in ``ADAPTERS``.
9
+
10
+ The project's part (phase and next skill) comes from ``.project-kit/`` and does not depend on
11
+ the agent. Everything is optional: a missing field, a document that is not JSON, or a directory
12
+ that is not a project still prints something sensible, and nothing here touches the network.
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from collections.abc import Callable, Mapping
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ DEFAULT_WIDTH = 120
28
+ COMMAND = "pjkit statusline"
29
+ SETTINGS = Path(".claude") / "settings.json"
30
+ _SEP = " · "
31
+ _ANSI = re.compile("\x1b\\[[0-9;]*m")
32
+ _GREEN, _YELLOW, _RED, _DIM = "32", "33", "31", "2"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Metrics:
37
+ """What the status bar shows, from whichever agent supplied it. ``None``: not available."""
38
+
39
+ model: str | None = None
40
+ effort: str | None = None
41
+ context_pct: float | None = None
42
+ cost_usd: float | None = None
43
+ five_hour_pct: float | None = None
44
+ five_hour_resets: float | None = None # unix seconds
45
+ seven_day_pct: float | None = None
46
+ cwd: str | None = None
47
+
48
+
49
+ def _get(data: Any, *path: str) -> Any:
50
+ for key in path:
51
+ if not isinstance(data, Mapping):
52
+ return None
53
+ data = data.get(key)
54
+ return data
55
+
56
+
57
+ def _number(value: Any) -> float | None:
58
+ return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else None
59
+
60
+
61
+ def _text(value: Any) -> str | None:
62
+ return value if isinstance(value, str) and value.strip() else None
63
+
64
+
65
+ def claude_adapter(data: Mapping[str, Any]) -> Metrics:
66
+ """Claude Code's status line document (https://code.claude.com/docs/en/statusline)."""
67
+ return Metrics(
68
+ model=_text(_get(data, "model", "display_name")),
69
+ effort=_text(_get(data, "effort", "level")),
70
+ context_pct=_number(_get(data, "context_window", "used_percentage")),
71
+ cost_usd=_number(_get(data, "cost", "total_cost_usd")),
72
+ five_hour_pct=_number(_get(data, "rate_limits", "five_hour", "used_percentage")),
73
+ five_hour_resets=_number(_get(data, "rate_limits", "five_hour", "resets_at")),
74
+ seven_day_pct=_number(_get(data, "rate_limits", "seven_day", "used_percentage")),
75
+ cwd=_text(_get(data, "workspace", "current_dir")) or _text(data.get("cwd")),
76
+ )
77
+
78
+
79
+ ADAPTERS: dict[str, Callable[[Mapping[str, Any]], Metrics]] = {"claude": claude_adapter}
80
+
81
+
82
+ def parse_input(raw: str, agent: str = "claude") -> Metrics:
83
+ """Metrics from the agent's document; empty ones if it is missing or not what we expect."""
84
+ try:
85
+ data = json.loads(raw) if raw.strip() else {}
86
+ except json.JSONDecodeError:
87
+ return Metrics()
88
+ return ADAPTERS[agent](data) if isinstance(data, Mapping) else Metrics()
89
+
90
+
91
+ # --- the project's part -------------------------------------------------------------------
92
+
93
+
94
+ def project_segment(root: Path) -> str:
95
+ """``fase: decide -> /pk-decide`` from the project's state; empty outside a project."""
96
+ if not (root / ".project-kit" / "config.yml").is_file():
97
+ return "" # not a project: do not even load the YAML reader
98
+ from project_kit.config import ConfigError, load_config
99
+ from project_kit.state import StateError, load_state
100
+ from project_kit.workflow import WorkflowError, load_workflow
101
+
102
+ try:
103
+ load_config(root)
104
+ workflow, state = load_workflow(root), load_state(root)
105
+ except (ConfigError, StateError, WorkflowError):
106
+ return ""
107
+ step = workflow.next_step(state)
108
+ if step is None:
109
+ return "fase: completa"
110
+ return f"fase: {step.id} → /{step.skill}"
111
+
112
+
113
+ # --- rendering ----------------------------------------------------------------------------
114
+
115
+
116
+ def _paint(text: str, code: str, color: bool) -> str:
117
+ return f"\033[{code}m{text}\033[0m" if color else text
118
+
119
+
120
+ def _level(pct: float) -> str:
121
+ return _GREEN if pct < 60 else _YELLOW if pct < 85 else _RED
122
+
123
+
124
+ def _clock(epoch: float, now: float) -> str:
125
+ local = time.localtime(epoch)
126
+ if epoch - now > 20 * 3600:
127
+ return time.strftime("%d/%m %H:%M", local)
128
+ return time.strftime("%H:%M", local)
129
+
130
+
131
+ def _pieces(metrics: Metrics, segment: str, dropped: set[str], color: bool, now: float) -> str:
132
+ """The line with the parts in ``dropped`` left out."""
133
+ out: list[str] = []
134
+ label = metrics.model
135
+ if label and metrics.effort and "effort" not in dropped:
136
+ label = f"{label} · {metrics.effort}"
137
+ if label and "model" not in dropped:
138
+ out.append(_paint(f"[{label}]", _DIM, color))
139
+ if metrics.context_pct is not None and "context" not in dropped:
140
+ pct = f"{metrics.context_pct:.0f}%"
141
+ out.append(f"ctx {_paint(pct, _level(metrics.context_pct), color)}")
142
+ if metrics.five_hour_pct is not None:
143
+ pct = f"{metrics.five_hour_pct:.0f}%"
144
+ text = f"5h {_paint(pct, _level(metrics.five_hour_pct), color)}"
145
+ if metrics.five_hour_resets:
146
+ text += f" (↻ {_clock(metrics.five_hour_resets, now)})"
147
+ out.append(text)
148
+ if metrics.seven_day_pct is not None and "seven" not in dropped:
149
+ pct = f"{metrics.seven_day_pct:.0f}%"
150
+ out.append(f"7d {_paint(pct, _level(metrics.seven_day_pct), color)}")
151
+ if metrics.cost_usd is not None and "cost" not in dropped:
152
+ out.append(f"~${metrics.cost_usd:.2f}")
153
+ if segment:
154
+ out.append(segment)
155
+ return _SEP.join(out)
156
+
157
+
158
+ # What goes first when the line is too long: the least important to the most.
159
+ _DROP_ORDER = ("cost", "seven", "effort", "context", "model")
160
+
161
+
162
+ def render(
163
+ metrics: Metrics,
164
+ segment: str = "",
165
+ *,
166
+ width: int = DEFAULT_WIDTH,
167
+ color: bool = True,
168
+ now: float | None = None,
169
+ ) -> str:
170
+ """One line. The 5-hour limit and the project's phase are the last to go."""
171
+ now = time.time() if now is None else now
172
+ dropped: set[str] = set()
173
+ for step in (None, *_DROP_ORDER):
174
+ if step:
175
+ dropped.add(step)
176
+ line = _pieces(metrics, segment, dropped, color, now)
177
+ if len(_ANSI.sub("", line)) <= width:
178
+ return line
179
+ return line
180
+
181
+
182
+ # --- wrapping the bar the user already has -------------------------------------------------
183
+
184
+
185
+ def run_wrapped(command: str, raw: str, timeout: float = 2.0) -> str:
186
+ """First line the user's own status command prints for this document; empty if it fails."""
187
+ try:
188
+ proc = subprocess.run(
189
+ command, shell=True, input=raw, capture_output=True, text=True,
190
+ encoding="utf-8", timeout=timeout,
191
+ ) # fmt: skip
192
+ except (OSError, subprocess.SubprocessError):
193
+ return ""
194
+ lines = (proc.stdout or "").strip().splitlines() if proc.returncode == 0 else []
195
+ return lines[0].strip() if lines else ""
196
+
197
+
198
+ def build_line(
199
+ raw: str,
200
+ root: Path,
201
+ *,
202
+ segment_only: bool = False,
203
+ wrap: str = "",
204
+ width: int = DEFAULT_WIDTH,
205
+ color: bool = True,
206
+ ) -> str:
207
+ """What the command prints for one update."""
208
+ segment = project_segment(root)
209
+ if segment_only:
210
+ return segment
211
+ before = run_wrapped(wrap, raw) if wrap else ""
212
+ metrics = parse_input(raw)
213
+ own = render(metrics, segment, width=max(width - len(before) - 3, 20), color=color)
214
+ return _SEP.join(p for p in (before, own) if p)
215
+
216
+
217
+ # --- configuring the agent's settings -----------------------------------------------------
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class Outcome:
222
+ changed: bool
223
+ message: str
224
+
225
+
226
+ def _read_settings(path: Path) -> dict[str, Any] | None:
227
+ if not path.exists():
228
+ return {}
229
+ try:
230
+ data = json.loads(path.read_text(encoding="utf-8"))
231
+ except (OSError, json.JSONDecodeError):
232
+ return None
233
+ return data if isinstance(data, dict) else None
234
+
235
+
236
+ def _command_of(settings: Mapping[str, Any]) -> str:
237
+ bar = settings.get("statusLine")
238
+ if isinstance(bar, Mapping) and bar.get("type", "command") == "command":
239
+ command = bar.get("command")
240
+ return command if isinstance(command, str) else ""
241
+ return ""
242
+
243
+
244
+ def existing_command(root: Path, home: Path | None = None) -> str:
245
+ """The status command the user has: the project's, else the one in their own settings."""
246
+ home = home if home is not None else Path.home()
247
+ for path in (root / SETTINGS, home / SETTINGS):
248
+ data = _read_settings(path)
249
+ if data and (command := _command_of(data)):
250
+ return command
251
+ return ""
252
+
253
+
254
+ def is_configured(root: Path) -> bool:
255
+ data = _read_settings(root / SETTINGS)
256
+ return bool(data) and COMMAND in _command_of(data)
257
+
258
+
259
+ def install(root: Path, home: Path | None = None, *, dry_run: bool = False) -> Outcome:
260
+ """Point the project's Claude Code status line at ``pjkit statusline``.
261
+
262
+ An existing command (the project's or the user's own) is kept: it runs first and ours is
263
+ added after it. Settings that cannot be read are never overwritten.
264
+ """
265
+ from project_kit.fsutil import atomic_write_text
266
+
267
+ path = root / SETTINGS
268
+ data = _read_settings(path)
269
+ if data is None:
270
+ return Outcome(False, f"{SETTINGS.as_posix()} is not a valid JSON object; left alone.")
271
+ if COMMAND in _command_of(data):
272
+ return Outcome(False, "The status bar is already configured.")
273
+ before = existing_command(root, home)
274
+ command = f"{COMMAND} --wrap {json.dumps(before)}" if before else COMMAND
275
+ data["statusLine"] = {"type": "command", "command": command}
276
+ if not dry_run:
277
+ atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
278
+ kept = f" Your existing bar ({before}) is kept and runs first." if before else ""
279
+ return Outcome(True, f"Status bar set in {SETTINGS.as_posix()}.{kept}")
280
+
281
+
282
+ def remove(root: Path, *, dry_run: bool = False) -> Outcome:
283
+ """Undo ``install``: restore the wrapped command, or drop ``statusLine`` if there was none."""
284
+ from project_kit.fsutil import atomic_write_text
285
+
286
+ path = root / SETTINGS
287
+ data = _read_settings(path)
288
+ if data is None:
289
+ return Outcome(False, f"{SETTINGS.as_posix()} is not a valid JSON object; left alone.")
290
+ command = _command_of(data)
291
+ if COMMAND not in command:
292
+ return Outcome(False, "The status bar was not set by project-kit; nothing to remove.")
293
+ match = re.search(r"--wrap (\".*\")\s*$", command)
294
+ if match:
295
+ data["statusLine"] = {"type": "command", "command": json.loads(match.group(1))}
296
+ else:
297
+ del data["statusLine"]
298
+ if not dry_run:
299
+ atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
300
+ return Outcome(True, "Status bar removed.")
301
+
302
+
303
+ # --- the command line -----------------------------------------------------------------------
304
+
305
+
306
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
307
+ parser.add_argument("--install", action="store_true", help="set it up in .claude/settings.json")
308
+ parser.add_argument("--remove", action="store_true", help="undo --install")
309
+ parser.add_argument(
310
+ "--dry-run", action="store_true", help="with --install/--remove: change nothing"
311
+ )
312
+ parser.add_argument("--segment", action="store_true", help="only the project's part, to embed")
313
+ parser.add_argument(
314
+ "--wrap", default="", help="a status command to run first (set by --install)"
315
+ )
316
+ parser.add_argument("--width", type=int, help="columns available (default: $COLUMNS or 120)")
317
+ parser.add_argument("--no-color", action="store_true", help="plain text (NO_COLOR also works)")
318
+
319
+
320
+ def run(args: argparse.Namespace) -> int:
321
+ root = Path.cwd()
322
+ if args.install or args.remove:
323
+ from project_kit.config import ConfigError, load_config
324
+
325
+ try:
326
+ load_config(root)
327
+ except ConfigError as exc:
328
+ print(f"pjkit statusline: {exc}", file=sys.stderr)
329
+ return 1
330
+ outcome = (install if args.install else remove)(root, dry_run=args.dry_run)
331
+ print(("Dry run: " if args.dry_run and outcome.changed else "") + outcome.message)
332
+ return 0 if outcome.changed or "already" in outcome.message else 1
333
+ raw = "" if sys.stdin.isatty() else sys.stdin.read()
334
+ cwd = parse_input(raw).cwd
335
+ project = Path(cwd) if cwd and Path(cwd).is_dir() else root
336
+ width = args.width or int(os.environ.get("COLUMNS", "") or DEFAULT_WIDTH)
337
+ print(
338
+ build_line(
339
+ raw,
340
+ project,
341
+ segment_only=args.segment,
342
+ wrap=args.wrap,
343
+ width=width,
344
+ color=not (args.no_color or os.environ.get("NO_COLOR")),
345
+ )
346
+ )
347
+ return 0
@@ -1,8 +0,0 @@
1
- """project-kit: define, plan and set up a project before handing off to Spec Kit."""
2
-
3
- from importlib.metadata import PackageNotFoundError, version
4
-
5
- try:
6
- __version__ = version("project-kit-cli")
7
- except PackageNotFoundError: # running from a source tree that was never installed
8
- __version__ = "0.0.0"
File without changes