gitwise-cli 0.24.2__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 (125) hide show
  1. gitwise/__init__.py +11 -0
  2. gitwise/__main__.py +113 -0
  3. gitwise/_cli_completions.py +88 -0
  4. gitwise/_cli_dispatch.py +469 -0
  5. gitwise/_cli_introspection.py +275 -0
  6. gitwise/_cli_parser.py +345 -0
  7. gitwise/_cli_setup_agents.py +439 -0
  8. gitwise/_i18n_data.json +1934 -0
  9. gitwise/_paths.py +22 -0
  10. gitwise/_runtime_config.py +246 -0
  11. gitwise/audit.py +338 -0
  12. gitwise/branches.py +183 -0
  13. gitwise/clean.py +197 -0
  14. gitwise/commit.py +142 -0
  15. gitwise/conflicts.py +112 -0
  16. gitwise/context.py +163 -0
  17. gitwise/design.py +383 -0
  18. gitwise/diff.py +309 -0
  19. gitwise/doctor.py +116 -0
  20. gitwise/git.py +254 -0
  21. gitwise/health.py +345 -0
  22. gitwise/i18n.py +99 -0
  23. gitwise/log.py +329 -0
  24. gitwise/merge.py +193 -0
  25. gitwise/optimize.py +212 -0
  26. gitwise/output.py +652 -0
  27. gitwise/pick.py +102 -0
  28. gitwise/pr.py +543 -0
  29. gitwise/py.typed +0 -0
  30. gitwise/schema.py +49 -0
  31. gitwise/setup.py +551 -0
  32. gitwise/setup_agents/__init__.py +36 -0
  33. gitwise/setup_agents/adapters/__init__.py +17 -0
  34. gitwise/setup_agents/adapters/aider.py +5 -0
  35. gitwise/setup_agents/adapters/base.py +5 -0
  36. gitwise/setup_agents/adapters/codex.py +5 -0
  37. gitwise/setup_agents/adapters/continue_adapter.py +5 -0
  38. gitwise/setup_agents/adapters/cursor.py +5 -0
  39. gitwise/setup_agents/adapters/opencode.py +5 -0
  40. gitwise/setup_agents/adapters/pi.py +5 -0
  41. gitwise/setup_agents/exec.py +449 -0
  42. gitwise/setup_agents/format.py +164 -0
  43. gitwise/setup_agents/plan.py +254 -0
  44. gitwise/setup_agents/plan_gitfiles.py +167 -0
  45. gitwise/setup_agents/plan_skills.py +256 -0
  46. gitwise/setup_agents/providers/__init__.py +96 -0
  47. gitwise/setup_agents/providers/aider.py +11 -0
  48. gitwise/setup_agents/providers/base.py +79 -0
  49. gitwise/setup_agents/providers/claude.py +408 -0
  50. gitwise/setup_agents/providers/codex.py +11 -0
  51. gitwise/setup_agents/providers/continue_adapter.py +11 -0
  52. gitwise/setup_agents/providers/cursor.py +11 -0
  53. gitwise/setup_agents/providers/opencode.py +11 -0
  54. gitwise/setup_agents/providers/pi.py +11 -0
  55. gitwise/setup_agents/state.py +141 -0
  56. gitwise/setup_agents/types.py +48 -0
  57. gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
  58. gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
  59. gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
  60. gitwise/share/aider/CONVENTIONS.md.template +8 -0
  61. gitwise/share/aider/aider.conf.yml.template +4 -0
  62. gitwise/share/claude/CLAUDE.md.template +9 -0
  63. gitwise/share/claude/rules/gitwise.md +16 -0
  64. gitwise/share/claude/settings.json.template +47 -0
  65. gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
  66. gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
  67. gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
  68. gitwise/share/codex/agents/gitwise.toml.template +18 -0
  69. gitwise/share/continue/rules/gitwise.md.template +14 -0
  70. gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
  71. gitwise/share/git-config-modern.txt +48 -0
  72. gitwise/share/hooks/commit-msg +22 -0
  73. gitwise/share/hooks/pre-commit +19 -0
  74. gitwise/share/opencode/agents/gitwise.md.template +14 -0
  75. gitwise/share/pi/skills/gitwise.md.template +14 -0
  76. gitwise/share/schemas/v1/input/audit.json +40 -0
  77. gitwise/share/schemas/v1/input/branches.json +51 -0
  78. gitwise/share/schemas/v1/input/clean.json +52 -0
  79. gitwise/share/schemas/v1/input/commands.json +36 -0
  80. gitwise/share/schemas/v1/input/commit.json +63 -0
  81. gitwise/share/schemas/v1/input/completions.json +51 -0
  82. gitwise/share/schemas/v1/input/conflicts.json +46 -0
  83. gitwise/share/schemas/v1/input/context.json +36 -0
  84. gitwise/share/schemas/v1/input/diff.json +56 -0
  85. gitwise/share/schemas/v1/input/doctor.json +36 -0
  86. gitwise/share/schemas/v1/input/health.json +36 -0
  87. gitwise/share/schemas/v1/input/log.json +71 -0
  88. gitwise/share/schemas/v1/input/merge.json +63 -0
  89. gitwise/share/schemas/v1/input/optimize.json +44 -0
  90. gitwise/share/schemas/v1/input/pick.json +63 -0
  91. gitwise/share/schemas/v1/input/pr.json +51 -0
  92. gitwise/share/schemas/v1/input/schema.json +48 -0
  93. gitwise/share/schemas/v1/input/setup-agents.json +108 -0
  94. gitwise/share/schemas/v1/input/setup.json +55 -0
  95. gitwise/share/schemas/v1/input/show.json +46 -0
  96. gitwise/share/schemas/v1/input/snapshot.json +36 -0
  97. gitwise/share/schemas/v1/input/stash.json +68 -0
  98. gitwise/share/schemas/v1/input/status.json +36 -0
  99. gitwise/share/schemas/v1/input/suggest.json +36 -0
  100. gitwise/share/schemas/v1/input/summarize.json +44 -0
  101. gitwise/share/schemas/v1/input/sync.json +55 -0
  102. gitwise/share/schemas/v1/input/tag.json +73 -0
  103. gitwise/share/schemas/v1/input/undo.json +60 -0
  104. gitwise/share/schemas/v1/input/update.json +40 -0
  105. gitwise/share/schemas/v1/input/worktree.json +50 -0
  106. gitwise/show.py +118 -0
  107. gitwise/snapshot.py +110 -0
  108. gitwise/stash.py +188 -0
  109. gitwise/status.py +93 -0
  110. gitwise/suggest.py +148 -0
  111. gitwise/summarize.py +202 -0
  112. gitwise/sync.py +257 -0
  113. gitwise/tag.py +252 -0
  114. gitwise/undo.py +145 -0
  115. gitwise/update.py +42 -0
  116. gitwise/utils/__init__.py +1 -0
  117. gitwise/utils/git_output.py +51 -0
  118. gitwise/utils/json_envelope.py +58 -0
  119. gitwise/utils/parsing.py +34 -0
  120. gitwise/worktree.py +182 -0
  121. gitwise_cli-0.24.2.dist-info/METADATA +151 -0
  122. gitwise_cli-0.24.2.dist-info/RECORD +125 -0
  123. gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
  124. gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
  125. gitwise_cli-0.24.2.dist-info/licenses/LICENSE +21 -0
gitwise/tag.py ADDED
@@ -0,0 +1,252 @@
1
+ """gitwise tag — semver-aware tag management (list/create/delete)."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from .git import require_root, validate_ref
7
+ from .git import run as git_run
8
+ from .i18n import t
9
+ from .output import (
10
+ confirm,
11
+ error,
12
+ ok,
13
+ print_bracket,
14
+ print_header,
15
+ print_json,
16
+ print_table,
17
+ warn,
18
+ )
19
+ from .utils.json_envelope import error_envelope, ok_envelope
20
+
21
+ _SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$")
22
+
23
+
24
+ def _list_tags(root: Path) -> list[dict[str, str]]:
25
+ r = git_run(
26
+ [
27
+ "for-each-ref",
28
+ "--format=%(refname:short)\t%(objectname:short)\t%(creatordate:iso)",
29
+ "refs/tags/",
30
+ ],
31
+ cwd=root,
32
+ check=False,
33
+ )
34
+ if r.returncode != 0 or not r.stdout.strip():
35
+ return []
36
+ tags: list[dict[str, str]] = []
37
+ for line in r.stdout.splitlines():
38
+ parts = line.split("\t", 2)
39
+ if len(parts) < 3:
40
+ continue
41
+ tags.append({"name": parts[0], "sha": parts[1], "date": parts[2]})
42
+ return tags
43
+
44
+
45
+ def _latest_semver(root: Path) -> dict[str, str] | None:
46
+ tags = _list_tags(root)
47
+ semver_tags = [tg for tg in tags if _SEMVER_RE.match(tg["name"])]
48
+ if not semver_tags:
49
+ return None
50
+
51
+ def _sort_key(tg: dict[str, str]) -> tuple[int, ...]:
52
+ m = _SEMVER_RE.match(tg["name"])
53
+ if not m:
54
+ return (0, 0, 0)
55
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
56
+
57
+ semver_tags.sort(key=_sort_key, reverse=True)
58
+ return semver_tags[0]
59
+
60
+
61
+ def _bump_version(version: str, part: str) -> str:
62
+ m = _SEMVER_RE.match(version)
63
+ if not m:
64
+ return version
65
+ major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3))
66
+ pre = m.group(4) or ""
67
+ build = m.group(5) or ""
68
+ prefix = "v" if version.startswith("v") else ""
69
+ if part == "major":
70
+ major += 1
71
+ minor = patch = 0
72
+ elif part == "minor":
73
+ minor += 1
74
+ patch = 0
75
+ else:
76
+ patch += 1
77
+ return f"{prefix}{major}.{minor}.{patch}{pre}{build}"
78
+
79
+
80
+ def _print_tag_list(tags: list[dict[str, str]]) -> None:
81
+ if not tags:
82
+ ok(t("tag_empty"))
83
+ return
84
+ columns = [
85
+ (t("col_tag"), "name"),
86
+ (t("col_sha"), "sha"),
87
+ (t("col_date"), "date"),
88
+ ]
89
+ rows: list[list[str]] = []
90
+ highlight_rows: set[int] = set()
91
+ for i, tag_item in enumerate(tags):
92
+ rows.append([tag_item["name"], tag_item["sha"], tag_item["date"]])
93
+ if _SEMVER_RE.match(tag_item["name"]):
94
+ highlight_rows.add(i)
95
+ print_table(
96
+ title=t("tag_list_title"),
97
+ columns=columns,
98
+ rows=rows,
99
+ highlight_rows=highlight_rows,
100
+ )
101
+
102
+
103
+ def _resolve_tag_name(
104
+ *,
105
+ root: Path,
106
+ bump: str | None,
107
+ name: str | None,
108
+ ) -> str | None:
109
+ if bump:
110
+ latest = _latest_semver(root)
111
+ base = latest["name"] if latest else "0.0.0"
112
+ return _bump_version(base, bump)
113
+ return name
114
+
115
+
116
+ def _run_tag_list(*, root: Path, as_json: bool) -> int:
117
+ tags = _list_tags(root)
118
+ if as_json:
119
+ print_json(ok_envelope(tags=tags, count=len(tags)))
120
+ return 0
121
+ _print_tag_list(tags)
122
+ return 0
123
+
124
+
125
+ def _run_tag_latest(*, root: Path, as_json: bool) -> int:
126
+ latest = _latest_semver(root)
127
+ if as_json:
128
+ print_json(ok_envelope(latest=latest))
129
+ return 0
130
+ if latest:
131
+ print_header(t("tag_latest_title"))
132
+ print_bracket(latest["name"], latest["sha"])
133
+ else:
134
+ warn(t("tag_no_semver"))
135
+ return 0
136
+
137
+
138
+ def _run_tag_create(
139
+ *,
140
+ root: Path,
141
+ bump: str | None,
142
+ name: str | None,
143
+ message: str | None,
144
+ dry_run: bool,
145
+ as_json: bool,
146
+ ) -> int:
147
+ tag_name = _resolve_tag_name(root=root, bump=bump, name=name)
148
+ if not tag_name:
149
+ error(t("tag_name_required"))
150
+ return 1
151
+ if not validate_ref(tag_name):
152
+ error(t("invalid_ref", ref=tag_name))
153
+ return 1
154
+
155
+ args = ["tag", tag_name]
156
+ if message:
157
+ args = ["tag", "-a", tag_name, "-m", message]
158
+
159
+ if dry_run:
160
+ ok(t("tag_create_dry", name=tag_name))
161
+ return 0
162
+
163
+ result = git_run(args, cwd=root, check=False)
164
+ if result.returncode != 0:
165
+ err = t("git_command_failed", cmd="tag", error=result.stderr.strip())
166
+ if as_json:
167
+ print_json(error_envelope(error=err))
168
+ else:
169
+ error(err)
170
+ return 1
171
+
172
+ if as_json:
173
+ print_json(ok_envelope(created=tag_name))
174
+ return 0
175
+ ok(t("tag_created", name=tag_name))
176
+ return 0
177
+
178
+
179
+ def _run_tag_delete(
180
+ *,
181
+ root: Path,
182
+ name: str | None,
183
+ yes: bool,
184
+ dry_run: bool,
185
+ as_json: bool,
186
+ ) -> int:
187
+ if not name:
188
+ error(t("tag_name_required"))
189
+ return 1
190
+ if not validate_ref(name):
191
+ error(t("invalid_ref", ref=name))
192
+ return 1
193
+ if dry_run:
194
+ ok(t("tag_delete_dry", name=name))
195
+ return 0
196
+ if not yes and not confirm(t("confirm_tag_delete", name=name)):
197
+ warn(t("aborted"))
198
+ return 1
199
+
200
+ result = git_run(["tag", "-d", name], cwd=root, check=False)
201
+ if result.returncode != 0:
202
+ err = t("git_command_failed", cmd="tag -d", error=result.stderr.strip())
203
+ if as_json:
204
+ print_json(error_envelope(error=err))
205
+ else:
206
+ error(err)
207
+ return 1
208
+
209
+ if as_json:
210
+ print_json(ok_envelope(deleted=name))
211
+ return 0
212
+ ok(t("tag_deleted", name=name))
213
+ return 0
214
+
215
+
216
+ def run_tag(
217
+ action: str = "list",
218
+ name: str | None = None,
219
+ *,
220
+ bump: str | None = None,
221
+ message: str | None = None,
222
+ dry_run: bool = False,
223
+ yes: bool = False,
224
+ as_json: bool = False,
225
+ ) -> int:
226
+ root, err = require_root()
227
+ if err:
228
+ return err
229
+ if root is None:
230
+ return 1
231
+
232
+ if action == "list":
233
+ return _run_tag_list(root=root, as_json=as_json)
234
+
235
+ if action == "latest":
236
+ return _run_tag_latest(root=root, as_json=as_json)
237
+
238
+ if action == "create":
239
+ return _run_tag_create(
240
+ root=root,
241
+ bump=bump,
242
+ name=name,
243
+ message=message,
244
+ dry_run=dry_run,
245
+ as_json=as_json,
246
+ )
247
+
248
+ if action == "delete":
249
+ return _run_tag_delete(root=root, name=name, yes=yes, dry_run=dry_run, as_json=as_json)
250
+
251
+ error(t("tag_unknown_action", action=action))
252
+ return 1
gitwise/undo.py ADDED
@@ -0,0 +1,145 @@
1
+ """gitwise undo — reflog-based undo to any previous HEAD state."""
2
+
3
+ from .git import require_root, validate_ref
4
+ from .git import run as git_run
5
+ from .i18n import t
6
+ from .output import confirm, error, print_bracket, print_dim, print_header, print_json
7
+ from .utils.json_envelope import ok_envelope
8
+
9
+
10
+ def _resolve_undo_target(
11
+ *, ref: str | None, entries: list[dict[str, str]], steps: int
12
+ ) -> str | None:
13
+ if ref:
14
+ if not validate_ref(ref):
15
+ error(t("invalid_ref", ref=ref))
16
+ return None
17
+ return ref
18
+ if len(entries) >= steps + 1:
19
+ return entries[steps]["hash"]
20
+ error(t("undo_not_enough_history"))
21
+ return None
22
+
23
+
24
+ def _print_undo_dry_run(*, target: str, soft: bool) -> None:
25
+ print_header(t("undo_dry_run_title"))
26
+ mode = "--soft" if soft else "--hard"
27
+ print_bracket(f"git reset {mode}", target[:12])
28
+
29
+
30
+ def _reset_to_target(*, root, target: str, soft: bool) -> int:
31
+ args = ["reset", "--soft" if soft else "--hard", target]
32
+ result = git_run(args, cwd=root, check=False)
33
+ if result.returncode != 0:
34
+ error(result.stderr.strip())
35
+ return 1
36
+ return 0
37
+
38
+
39
+ def _load_reflog_entries(*, root, steps: int) -> list[dict[str, str]] | None:
40
+ result = git_run(
41
+ ["reflog", "--format=%H|gd-ref:%gd|gs:%gs|msg:%s", f"--max-count={steps + 10}"],
42
+ cwd=root,
43
+ check=False,
44
+ )
45
+ if result.returncode != 0:
46
+ error(t("undo_reflog_failed"))
47
+ return None
48
+ entries = _parse_reflog(result.stdout)
49
+ if not entries:
50
+ error(t("undo_no_entries"))
51
+ return None
52
+ return entries
53
+
54
+
55
+ def _run_undo_dry_run(
56
+ *, as_json: bool, target: str, soft: bool, entries: list[dict[str, str]], steps: int
57
+ ) -> int:
58
+ if as_json:
59
+ print_json(
60
+ ok_envelope(
61
+ target=target,
62
+ soft=soft,
63
+ dry_run=True,
64
+ entries=entries[: steps + 1],
65
+ )
66
+ )
67
+ else:
68
+ _print_undo_dry_run(target=target, soft=soft)
69
+ return 0
70
+
71
+
72
+ def _confirm_hard_reset(*, soft: bool, yes: bool, target: str) -> bool:
73
+ if soft or yes:
74
+ return True
75
+ if confirm(t("undo_confirm_hard", ref=target[:12])):
76
+ return True
77
+ print_dim(t("cancelled"))
78
+ return False
79
+
80
+
81
+ def _report_undo_complete(*, as_json: bool, target: str, soft: bool) -> int:
82
+ if as_json:
83
+ print_json(ok_envelope(target=target, soft=soft))
84
+ return 0
85
+ print_header(t("undo_complete_title"))
86
+ print_bracket(t("undo_reset_to"), target[:12])
87
+ return 0
88
+
89
+
90
+ def _parse_reflog(raw: str) -> list[dict[str, str]]:
91
+ entries: list[dict[str, str]] = []
92
+ for line in raw.splitlines():
93
+ parts = line.split("|", 3)
94
+ if len(parts) >= 4:
95
+ entries.append(
96
+ {
97
+ "hash": parts[0].strip(),
98
+ "ref": parts[1].strip(),
99
+ "action": parts[2].strip(),
100
+ "message": parts[3].strip(),
101
+ }
102
+ )
103
+ return entries
104
+
105
+
106
+ def run_undo(
107
+ *,
108
+ ref: str | None = None,
109
+ soft: bool = False,
110
+ steps: int = 1,
111
+ dry_run: bool = False,
112
+ yes: bool = False,
113
+ as_json: bool = False,
114
+ ) -> int:
115
+ root, err = require_root()
116
+ if err:
117
+ return err
118
+ if root is None:
119
+ return 1
120
+
121
+ entries = _load_reflog_entries(root=root, steps=steps)
122
+ if entries is None:
123
+ return 1
124
+
125
+ target = _resolve_undo_target(ref=ref, entries=entries, steps=steps)
126
+ if target is None:
127
+ return 1
128
+
129
+ if dry_run:
130
+ return _run_undo_dry_run(
131
+ as_json=as_json,
132
+ target=target,
133
+ soft=soft,
134
+ entries=entries,
135
+ steps=steps,
136
+ )
137
+
138
+ if not _confirm_hard_reset(soft=soft, yes=yes, target=target):
139
+ return 0
140
+
141
+ reset_rc = _reset_to_target(root=root, target=target, soft=soft)
142
+ if reset_rc != 0:
143
+ return reset_rc
144
+
145
+ return _report_undo_complete(as_json=as_json, target=target, soft=soft)
gitwise/update.py ADDED
@@ -0,0 +1,42 @@
1
+ """Update gitwise via git pull."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .git import run as git_run
6
+ from .i18n import t
7
+ from .output import error, info, print_dim, print_header, print_json
8
+ from .utils.json_envelope import error_envelope, ok_envelope
9
+
10
+
11
+ def run_update(*, dry_run: bool = False, as_json: bool = False) -> int:
12
+ install_dir = Path(__file__).parent.parent
13
+ if not (install_dir / ".git").is_dir():
14
+ if as_json:
15
+ print_json(error_envelope(error=t("update_requires_git_clone")))
16
+ else:
17
+ error(t("update_requires_git_clone"))
18
+ return 1
19
+ if dry_run:
20
+ if as_json:
21
+ print_json(ok_envelope(dry_run=True, dir=str(install_dir)))
22
+ return 0
23
+ print_dim(t("update_dry_run", dir=str(install_dir)))
24
+ return 0
25
+ print_header(t("updating_from", dir=str(install_dir)))
26
+ r = git_run(["pull", "--ff-only"], cwd=install_dir, check=False)
27
+ if r.returncode == 0 and r.stdout.strip() and r.stdout.strip() != "Already up to date.":
28
+ if as_json:
29
+ print_json(ok_envelope(updated=True, output=r.stdout.strip()))
30
+ return 0
31
+ for line in r.stdout.strip().splitlines():
32
+ info(line)
33
+ elif r.returncode != 0:
34
+ if as_json:
35
+ print_json(error_envelope(error=r.stderr.strip() or t("error_updating")))
36
+ return 1
37
+ error(r.stderr.strip() or t("error_updating"))
38
+ return r.returncode
39
+ elif as_json:
40
+ print_json(ok_envelope(updated=False, output=t("already_up_to_date")))
41
+ return 0
42
+ return r.returncode
@@ -0,0 +1 @@
1
+ """Shared utility modules for gitwise."""
@@ -0,0 +1,51 @@
1
+ """Reusable parsers for git command output formats."""
2
+
3
+
4
+ def parse_diffstat_entries(raw: str, *, default_status: str | None = None) -> list[dict[str, str]]:
5
+ entries: list[dict[str, str]] = []
6
+ for line in raw.splitlines():
7
+ if "|" not in line:
8
+ continue
9
+ path_raw, changes_raw = line.split("|", 1)
10
+ path = path_raw.strip()
11
+ changes = changes_raw.strip()
12
+ if not path:
13
+ continue
14
+ entry: dict[str, str] = {"path": path, "changes": changes}
15
+ if default_status is not None:
16
+ entry["status"] = default_status
17
+ entries.append(entry)
18
+ return entries
19
+
20
+
21
+ def parse_name_status_entries(raw: str) -> list[dict[str, str]]:
22
+ entries: list[dict[str, str]] = []
23
+ for line in raw.splitlines():
24
+ parts = line.split("\t")
25
+ if len(parts) < 2:
26
+ continue
27
+ status = parts[0].strip()
28
+ code = status[:1].upper() if status else ""
29
+ if code in {"R", "C"} and len(parts) >= 3:
30
+ old_path = parts[1].strip()
31
+ path = parts[2].strip()
32
+ if not path:
33
+ continue
34
+ entry: dict[str, str] = {"status": status, "path": path}
35
+ if code and code != status:
36
+ entry["code"] = code
37
+ if old_path:
38
+ entry["old_path"] = old_path
39
+ if len(status) > 1 and status[1:].isdigit():
40
+ entry["score"] = status[1:]
41
+ entries.append(entry)
42
+ continue
43
+
44
+ path = parts[-1].strip()
45
+ if not path:
46
+ continue
47
+ entry = {"status": status, "path": path}
48
+ if code and code != status:
49
+ entry["code"] = code
50
+ entries.append(entry)
51
+ return entries
@@ -0,0 +1,58 @@
1
+ """Helpers for standardized JSON envelopes in CLI commands."""
2
+
3
+ from collections.abc import Mapping
4
+
5
+
6
+ def ok_envelope(
7
+ *,
8
+ payload: Mapping[str, object] | None = None,
9
+ version: int = 2,
10
+ **extra: object,
11
+ ) -> dict[str, object]:
12
+ data: dict[str, object] = {}
13
+ if payload is not None:
14
+ data.update(payload)
15
+ data.update(extra)
16
+ data["v"] = version
17
+ data["ok"] = True
18
+ return data
19
+
20
+
21
+ def error_envelope(
22
+ *,
23
+ error: str,
24
+ code: str | None = None,
25
+ hint: str | None = None,
26
+ payload: Mapping[str, object] | None = None,
27
+ version: int = 2,
28
+ **extra: object,
29
+ ) -> dict[str, object]:
30
+ data: dict[str, object] = {}
31
+ if payload is not None:
32
+ data.update(payload)
33
+ data.update(extra)
34
+ data["v"] = version
35
+ data["ok"] = False
36
+ data["error"] = error
37
+ err_item: dict[str, object] = {
38
+ "code": code or "error",
39
+ "message": error,
40
+ }
41
+ if hint:
42
+ err_item["hint"] = hint
43
+ data["errors"] = [err_item]
44
+ return data
45
+
46
+
47
+ def passthrough_envelope(
48
+ *,
49
+ payload: Mapping[str, object] | None = None,
50
+ version: int = 2,
51
+ **extra: object,
52
+ ) -> dict[str, object]:
53
+ data: dict[str, object] = {}
54
+ if payload is not None:
55
+ data.update(payload)
56
+ data.update(extra)
57
+ data["v"] = version
58
+ return data
@@ -0,0 +1,34 @@
1
+ """Shared parsing helpers for CLI text/number normalization."""
2
+
3
+
4
+ def non_empty_lines(text: str) -> list[str]:
5
+ return [line for line in text.splitlines() if line.strip()]
6
+
7
+
8
+ def stripped_non_empty_lines(text: str) -> list[str]:
9
+ return [line.strip() for line in text.splitlines() if line.strip()]
10
+
11
+
12
+ def to_int(value: object, *, default: int = 0) -> int:
13
+ if isinstance(value, int):
14
+ return value
15
+ try:
16
+ return int(str(value).strip())
17
+ except (ValueError, TypeError):
18
+ return default
19
+
20
+
21
+ def parse_two_ints(text: str) -> tuple[int, int] | None:
22
+ parts = text.strip().split()
23
+ if len(parts) != 2:
24
+ return None
25
+ try:
26
+ return int(parts[0]), int(parts[1])
27
+ except (ValueError, TypeError):
28
+ return None
29
+
30
+
31
+ def dict_list(value: object) -> list[dict[str, object]]:
32
+ if not isinstance(value, list):
33
+ return []
34
+ return [item for item in value if isinstance(item, dict)]