zjcode 0.0.1__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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,521 @@
1
+ """Lightweight git metadata helpers for state detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from pathlib import Path
8
+ from typing import NamedTuple
9
+ from urllib.parse import urlparse
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _GIT_DIR_POINTER_PREFIX = "gitdir: "
14
+ """Prefix used by worktree-style `.git` files to point at the real git dir."""
15
+
16
+ _GIT_HEAD_REF_PREFIX = "ref: "
17
+ """Prefix used by `HEAD` when it points at a named ref instead of a commit."""
18
+
19
+ _GIT_REF_PREFIXES = ("refs/heads/", "refs/remotes/", "refs/tags/", "refs/")
20
+ """Known git ref prefixes stripped when formatting a branch-like display name."""
21
+
22
+ _git_dir_cache: dict[str, Path] = {}
23
+ """Positive-only cache of resolved git metadata directories keyed by lookup path."""
24
+
25
+
26
+ def _abbreviate_git_ref(ref: str) -> str:
27
+ """Convert a full git ref into a short display name.
28
+
29
+ Args:
30
+ ref: Full git ref name from repository metadata.
31
+
32
+ Returns:
33
+ The abbreviated ref name suitable for display.
34
+ """
35
+ for prefix in _GIT_REF_PREFIXES:
36
+ if ref.startswith(prefix):
37
+ return ref.removeprefix(prefix)
38
+ return ref
39
+
40
+
41
+ def _parse_git_dir_pointer(git_entry: Path) -> Path | None:
42
+ """Resolve a `.git` file containing a `gitdir:` pointer.
43
+
44
+ Args:
45
+ git_entry: `.git` file to parse.
46
+
47
+ Returns:
48
+ The resolved git directory path, or `None` if the file is not a valid
49
+ gitdir pointer.
50
+ """
51
+ try:
52
+ raw = git_entry.read_text(encoding="utf-8").strip()
53
+ except OSError:
54
+ logger.debug("Failed to read gitdir pointer from %s", git_entry, exc_info=True)
55
+ return None
56
+
57
+ if not raw.startswith(_GIT_DIR_POINTER_PREFIX):
58
+ return None
59
+
60
+ pointer = raw.removeprefix(_GIT_DIR_POINTER_PREFIX).strip()
61
+ if not pointer:
62
+ return None
63
+
64
+ git_dir = Path(pointer)
65
+ if not git_dir.is_absolute():
66
+ git_dir = git_entry.parent / git_dir
67
+ return git_dir.resolve(strict=False)
68
+
69
+
70
+ def _normalize_lookup_path(path: str | Path) -> Path:
71
+ """Normalize a lookup path for git metadata discovery.
72
+
73
+ Args:
74
+ path: Directory or file path inside a repository.
75
+
76
+ Returns:
77
+ A normalized absolute path when possible, or the expanded path if full
78
+ resolution fails.
79
+ """
80
+ try:
81
+ return Path(path).expanduser().resolve(strict=False)
82
+ except OSError:
83
+ return Path(path).expanduser()
84
+
85
+
86
+ def _find_git_dir_uncached(path: Path) -> Path | None:
87
+ """Locate the effective git metadata directory without using caches.
88
+
89
+ Args:
90
+ path: Normalized directory or file path inside a repository.
91
+
92
+ Returns:
93
+ The git metadata directory for the repository containing `path`, or
94
+ `None` when no repository can be identified.
95
+ """
96
+ current = path
97
+ if not current.is_dir():
98
+ current = current.parent
99
+
100
+ for directory in (current, *current.parents):
101
+ git_entry = directory / ".git"
102
+ if git_entry.is_dir():
103
+ return git_entry
104
+ if git_entry.is_file():
105
+ git_dir = _parse_git_dir_pointer(git_entry)
106
+ if git_dir is not None and git_dir.is_dir():
107
+ return git_dir
108
+ return None
109
+
110
+ return None
111
+
112
+
113
+ def find_git_dir(path: str | Path) -> Path | None:
114
+ """Locate the effective git metadata directory for a path.
115
+
116
+ Args:
117
+ path: Directory or file path inside a repository.
118
+
119
+ Returns:
120
+ The git metadata directory for the repository containing `path`, or
121
+ `None` when no repository can be identified.
122
+ """
123
+ current = _normalize_lookup_path(path)
124
+ key = str(current)
125
+ cached = _git_dir_cache.get(key)
126
+ if cached is not None:
127
+ return cached
128
+
129
+ git_dir = _find_git_dir_uncached(current)
130
+ if git_dir is not None:
131
+ _git_dir_cache[key] = git_dir
132
+ return git_dir
133
+
134
+
135
+ def find_git_root(path: str | Path) -> Path | None:
136
+ """Locate the repository root for a path.
137
+
138
+ Args:
139
+ path: Directory or file path inside a repository.
140
+
141
+ Returns:
142
+ The repository root containing `path`, or `None` when no repository can
143
+ be identified.
144
+ """
145
+ current = _normalize_lookup_path(path)
146
+ if not current.is_dir():
147
+ current = current.parent
148
+
149
+ for directory in (current, *current.parents):
150
+ git_entry = directory / ".git"
151
+ if git_entry.is_dir():
152
+ return directory
153
+ if git_entry.is_file():
154
+ git_dir = _parse_git_dir_pointer(git_entry)
155
+ if git_dir is not None and git_dir.is_dir():
156
+ return directory
157
+ return None
158
+
159
+ return None
160
+
161
+
162
+ def read_git_branch_from_filesystem(path: str | Path) -> str | None:
163
+ """Read the current git branch from repository metadata.
164
+
165
+ Args:
166
+ path: Directory or file path inside a repository.
167
+
168
+ Returns:
169
+ The abbreviated branch name, `HEAD` for detached HEAD, an empty string
170
+ when `path` is not inside a git repository, or `None` when metadata
171
+ exists but cannot be parsed confidently.
172
+ """
173
+ git_dir = find_git_dir(path)
174
+ if git_dir is None:
175
+ return ""
176
+
177
+ head_path = git_dir / "HEAD"
178
+ try:
179
+ head = head_path.read_text(encoding="utf-8").strip()
180
+ except FileNotFoundError:
181
+ logger.debug("Git HEAD file not found in %s", git_dir)
182
+ return None
183
+ except OSError:
184
+ logger.debug("Failed to read git HEAD from %s", git_dir, exc_info=True)
185
+ return None
186
+
187
+ if not head:
188
+ return ""
189
+ if head.startswith(_GIT_HEAD_REF_PREFIX):
190
+ ref = head.removeprefix(_GIT_HEAD_REF_PREFIX).strip()
191
+ return _abbreviate_git_ref(ref) if ref else None
192
+ return "HEAD"
193
+
194
+
195
+ def read_git_branch_via_subprocess(path: str | Path) -> str:
196
+ """Fall back to `git rev-parse` for unusual repository layouts.
197
+
198
+ Args:
199
+ path: Directory or file path inside a repository.
200
+
201
+ Returns:
202
+ The branch name reported by git, or an empty string on failure.
203
+ """
204
+ import subprocess # noqa: S404 # stdlib subprocess fallback
205
+
206
+ try:
207
+ result = subprocess.run(
208
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
209
+ capture_output=True,
210
+ text=True,
211
+ timeout=2,
212
+ check=False,
213
+ cwd=path,
214
+ )
215
+ if result.returncode == 0:
216
+ return result.stdout.strip()
217
+ except FileNotFoundError:
218
+ pass # git not installed
219
+ except subprocess.TimeoutExpired:
220
+ logger.debug("Git branch detection timed out")
221
+ except OSError:
222
+ logger.debug("Git branch detection failed", exc_info=True)
223
+ return ""
224
+
225
+
226
+ def resolve_git_branch(path: str | Path) -> str:
227
+ """Resolve the current git branch with a filesystem-first strategy.
228
+
229
+ Args:
230
+ path: Directory or file path inside a repository.
231
+
232
+ Returns:
233
+ The current branch name, `HEAD` for detached HEAD, or an empty string
234
+ when no branch can be determined.
235
+ """
236
+ branch = read_git_branch_from_filesystem(path)
237
+ if branch is not None:
238
+ return branch
239
+ return read_git_branch_via_subprocess(path)
240
+
241
+
242
+ _GIT_SHA_RE = re.compile(r"\A[0-9a-f]{40}(?:[0-9a-f]{24})?\Z")
243
+ """Matches a full 40-char SHA-1 (or 64-char SHA-256) git object id."""
244
+
245
+
246
+ def read_git_commit_sha_from_filesystem(path: str | Path) -> str | None:
247
+ """Read the current `HEAD` commit SHA from repository metadata.
248
+
249
+ Resolves a symbolic `HEAD` (`ref: refs/heads/<branch>`) by reading the
250
+ loose ref file, then `packed-refs`. A detached `HEAD` already contains the
251
+ raw SHA.
252
+
253
+ Args:
254
+ path: Directory or file path inside a repository.
255
+
256
+ Returns:
257
+ The full commit SHA (40-char SHA-1 or 64-char SHA-256), an empty string
258
+ when `path` is not inside a git repository, or `None` when metadata
259
+ exists but cannot be resolved.
260
+ """
261
+ git_dir = find_git_dir(path)
262
+ if git_dir is None:
263
+ return ""
264
+
265
+ head_path = git_dir / "HEAD"
266
+ try:
267
+ head = head_path.read_text(encoding="utf-8").strip()
268
+ except FileNotFoundError:
269
+ logger.debug("Git HEAD file not found in %s", git_dir)
270
+ return None
271
+ except OSError:
272
+ logger.debug("Failed to read git HEAD from %s", git_dir, exc_info=True)
273
+ return None
274
+
275
+ if not head:
276
+ return None
277
+ if not head.startswith(_GIT_HEAD_REF_PREFIX):
278
+ # Detached HEAD: the file holds the commit SHA directly.
279
+ return head if _GIT_SHA_RE.match(head) else None
280
+
281
+ ref = head.removeprefix(_GIT_HEAD_REF_PREFIX).strip()
282
+ if not ref:
283
+ return None
284
+
285
+ loose_ref = git_dir / Path(ref)
286
+ try:
287
+ sha = loose_ref.read_text(encoding="utf-8").strip()
288
+ if _GIT_SHA_RE.match(sha):
289
+ return sha
290
+ except FileNotFoundError:
291
+ pass # ref may be packed
292
+ except OSError:
293
+ logger.debug("Failed to read loose ref %s", loose_ref, exc_info=True)
294
+ return None
295
+
296
+ return _read_packed_ref(git_dir, ref)
297
+
298
+
299
+ def _read_packed_ref(git_dir: Path, ref: str) -> str | None:
300
+ """Resolve a ref to its SHA from `packed-refs`.
301
+
302
+ Args:
303
+ git_dir: Resolved git metadata directory.
304
+ ref: Full ref name (e.g. `refs/heads/main`).
305
+
306
+ Returns:
307
+ The full commit SHA, or `None` when the ref is absent or unreadable.
308
+ """
309
+ try:
310
+ packed = (git_dir / "packed-refs").read_text(encoding="utf-8")
311
+ except FileNotFoundError:
312
+ return None
313
+ except OSError:
314
+ logger.debug("Failed to read packed-refs in %s", git_dir, exc_info=True)
315
+ return None
316
+
317
+ for line in packed.splitlines():
318
+ if not line or line.startswith(("#", "^")):
319
+ continue
320
+ sha, _, name = line.partition(" ")
321
+ if name.strip() == ref and _GIT_SHA_RE.match(sha):
322
+ return sha
323
+ return None
324
+
325
+
326
+ def read_git_commit_sha_via_subprocess(path: str | Path) -> str:
327
+ """Fall back to `git rev-parse HEAD` for unusual repository layouts.
328
+
329
+ Args:
330
+ path: Directory or file path inside a repository.
331
+
332
+ Returns:
333
+ The full commit SHA reported by git, or an empty string on failure.
334
+ """
335
+ import subprocess # noqa: S404 # stdlib subprocess fallback
336
+
337
+ try:
338
+ result = subprocess.run(
339
+ ["git", "rev-parse", "HEAD"], # noqa: S607
340
+ capture_output=True,
341
+ text=True,
342
+ timeout=2,
343
+ check=False,
344
+ cwd=path,
345
+ )
346
+ if result.returncode == 0:
347
+ return result.stdout.strip()
348
+ except FileNotFoundError:
349
+ pass # git not installed
350
+ except subprocess.TimeoutExpired:
351
+ logger.debug("Git commit detection timed out")
352
+ except OSError:
353
+ logger.debug("Git commit detection failed", exc_info=True)
354
+ return ""
355
+
356
+
357
+ def resolve_git_commit_sha(path: str | Path) -> str:
358
+ """Resolve the current `HEAD` commit SHA, filesystem-first.
359
+
360
+ Args:
361
+ path: Directory or file path inside a repository.
362
+
363
+ Returns:
364
+ The full commit SHA, or an empty string when none can be determined.
365
+ """
366
+ sha = read_git_commit_sha_from_filesystem(path)
367
+ if sha is not None:
368
+ return sha
369
+ return read_git_commit_sha_via_subprocess(path)
370
+
371
+
372
+ def read_git_remote_url_from_filesystem(path: str | Path) -> str | None:
373
+ """Read the `origin` remote URL from the repository `config` file.
374
+
375
+ Args:
376
+ path: Directory or file path inside a repository.
377
+
378
+ Returns:
379
+ The `origin` remote URL, an empty string when `path` is not inside a
380
+ git repository, or `None` when no `origin` URL is configured.
381
+ """
382
+ git_dir = find_git_dir(path)
383
+ if git_dir is None:
384
+ return ""
385
+
386
+ try:
387
+ raw = (git_dir / "config").read_text(encoding="utf-8")
388
+ except FileNotFoundError:
389
+ return None
390
+ except OSError:
391
+ logger.debug("Failed to read git config in %s", git_dir, exc_info=True)
392
+ return None
393
+
394
+ in_origin = False
395
+ for line in raw.splitlines():
396
+ stripped = line.strip()
397
+ if stripped.startswith("[") and stripped.endswith("]"):
398
+ # Section header, e.g. [remote "origin"]. Match case-insensitively
399
+ # on the remote name to mirror git's own behavior.
400
+ in_origin = stripped.replace(" ", "").lower() == '[remote"origin"]'
401
+ continue
402
+ if in_origin and stripped.lower().startswith("url"):
403
+ _, _, value = stripped.partition("=")
404
+ url = value.strip()
405
+ if url:
406
+ return url
407
+ return None
408
+
409
+
410
+ def read_git_remote_url_via_subprocess(path: str | Path) -> str:
411
+ """Fall back to `git config --get remote.origin.url`.
412
+
413
+ Args:
414
+ path: Directory or file path inside a repository.
415
+
416
+ Returns:
417
+ The `origin` remote URL reported by git, or an empty string on failure.
418
+ """
419
+ import subprocess # noqa: S404 # stdlib subprocess fallback
420
+
421
+ try:
422
+ result = subprocess.run(
423
+ ["git", "config", "--get", "remote.origin.url"], # noqa: S607
424
+ capture_output=True,
425
+ text=True,
426
+ timeout=2,
427
+ check=False,
428
+ cwd=path,
429
+ )
430
+ if result.returncode == 0:
431
+ return result.stdout.strip()
432
+ except FileNotFoundError:
433
+ pass # git not installed
434
+ except subprocess.TimeoutExpired:
435
+ logger.debug("Git remote detection timed out")
436
+ except OSError:
437
+ logger.debug("Git remote detection failed", exc_info=True)
438
+ return ""
439
+
440
+
441
+ def resolve_git_remote_url(path: str | Path) -> str:
442
+ """Resolve the `origin` remote URL, filesystem-first.
443
+
444
+ Args:
445
+ path: Directory or file path inside a repository.
446
+
447
+ Returns:
448
+ The `origin` remote URL, or an empty string when none can be determined.
449
+ """
450
+ url = read_git_remote_url_from_filesystem(path)
451
+ if url is not None:
452
+ return url
453
+ return read_git_remote_url_via_subprocess(path)
454
+
455
+
456
+ _REPO_PROVIDERS: dict[str, str] = {
457
+ "github.com": "github",
458
+ "gitlab.com": "gitlab",
459
+ "bitbucket.org": "bitbucket",
460
+ }
461
+ """Maps a known git host to its `repository_provider` slug."""
462
+
463
+
464
+ class RepositoryMetadata(NamedTuple):
465
+ """Parsed `origin` remote attribution for coding-agent-v1 traces.
466
+
467
+ A `NamedTuple` so callers can still unpack positionally or index, while the
468
+ field names keep the slot order from being load-bearing at every call site.
469
+ """
470
+
471
+ url: str
472
+ """Normalized `https://<host>/<org>/<repo>` URL (credentials stripped)."""
473
+
474
+ provider: str
475
+ """Provider slug: `github`, `gitlab`, `bitbucket`, or `other`."""
476
+
477
+ name: str
478
+ """`org/repo` name (may be nested, e.g. `group/subgroup/project`)."""
479
+
480
+
481
+ def parse_repository_metadata(remote_url: str) -> RepositoryMetadata | None:
482
+ """Derive repository attribution from an `origin` remote URL.
483
+
484
+ Handles both HTTPS (`https://github.com/org/repo.git`) and scp-style SSH
485
+ (`git@github.com:org/repo.git`) remotes, normalizing the URL to its
486
+ canonical `https://<host>/<org>/<repo>` form and stripping any embedded
487
+ credentials.
488
+
489
+ Args:
490
+ remote_url: The raw `origin` remote URL.
491
+
492
+ Returns:
493
+ A `RepositoryMetadata` of the normalized URL, provider slug
494
+ (`github`/`gitlab`/`bitbucket`/`other`), and `org/repo` name,
495
+ or `None` when the URL cannot be parsed.
496
+ """
497
+ url = (remote_url or "").strip()
498
+ if not url:
499
+ return None
500
+
501
+ host: str
502
+ repo_path: str
503
+ if "://" in url:
504
+ parsed = urlparse(url)
505
+ host = (parsed.hostname or "").lower()
506
+ repo_path = parsed.path.lstrip("/")
507
+ else:
508
+ # scp-style: [user@]host:org/repo(.git)
509
+ userhost, sep, repo_path = url.partition(":")
510
+ if not sep:
511
+ return None
512
+ host = userhost.rsplit("@", 1)[-1].lower()
513
+ repo_path = repo_path.lstrip("/")
514
+
515
+ repo_path = repo_path.strip("/").removesuffix(".git").strip("/")
516
+ if not host or not repo_path:
517
+ return None
518
+
519
+ provider = _REPO_PROVIDERS.get(host, "other")
520
+ normalized_url = f"https://{host}/{repo_path}"
521
+ return RepositoryMetadata(normalized_url, provider, repo_path)
@@ -0,0 +1,69 @@
1
+ """Filesystem-path classification shared by the diagnostic CLI commands.
2
+
3
+ `dcode doctor` and `dcode config path` both probe whether config locations
4
+ exist. `Path.exists()` can report `False` for `OSError` cases such as EACCES
5
+ when a parent directory denies traversal, so a bare `.exists()` can hide the
6
+ very permissions problem these commands should diagnose. `classify_path`
7
+ centralizes the guard and reports an unreadable path as a distinct state instead
8
+ of conflating it with "missing".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import errno
14
+ import logging
15
+ from enum import StrEnum
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _MISSING_ERRNOS = {errno.ENOENT, errno.ENOTDIR}
24
+
25
+
26
+ class PathState(StrEnum):
27
+ """Whether a probed path exists, is absent, or could not be read.
28
+
29
+ A `StrEnum` so the value serializes directly to JSON without a custom
30
+ encoder.
31
+ """
32
+
33
+ EXISTS = "exists"
34
+ """The path is present on disk."""
35
+
36
+ MISSING = "missing"
37
+ """The path is absent (and its parents are readable)."""
38
+
39
+ UNREADABLE = "unreadable"
40
+ """Existence could not be determined because `Path.stat()` raised.
41
+
42
+ Typically EACCES when a parent directory denies traversal. Kept distinct
43
+ from `MISSING` so diagnostics can flag it as a genuine problem rather than
44
+ a not-yet-created path.
45
+ """
46
+
47
+
48
+ def classify_path(path: Path) -> PathState:
49
+ """Classify a path as existing, missing, or unreadable.
50
+
51
+ Args:
52
+ path: Filesystem path to probe.
53
+
54
+ Returns:
55
+ `PathState.EXISTS` for a present path, `PathState.MISSING` for expected
56
+ absent-path errors, and `PathState.UNREADABLE` when `Path.stat()`
57
+ raises another `OSError` (e.g. a parent directory denies traversal).
58
+ The error is logged at debug level so an unreadable path is never
59
+ silently indistinguishable from a missing one.
60
+ """
61
+ try:
62
+ path.stat()
63
+ except OSError as exc:
64
+ if exc.errno in _MISSING_ERRNOS:
65
+ return PathState.MISSING
66
+ logger.debug("Could not stat %s", path, exc_info=True)
67
+ return PathState.UNREADABLE
68
+ else:
69
+ return PathState.EXISTS