claude-dev-env 1.89.0 → 1.90.0

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.
@@ -0,0 +1,178 @@
1
+ """Unit tests for the enter_worktree_origin_prefetch PreToolUse hook.
2
+
3
+ Uses real git repositories (a "remote" repo cloned into a "local" one) so the
4
+ fetch behavior is exercised against actual git plumbing, not mocked calls.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import io
11
+ import json
12
+ import pathlib
13
+ import subprocess
14
+ import sys
15
+
16
+ import pytest
17
+
18
+ _HOOK_DIR = pathlib.Path(__file__).parent
19
+ _HOOKS_TREE = _HOOK_DIR.parent
20
+ for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
21
+ if each_path not in sys.path:
22
+ sys.path.insert(0, each_path)
23
+
24
+ hook_spec = importlib.util.spec_from_file_location(
25
+ "enter_worktree_origin_prefetch",
26
+ _HOOK_DIR / "enter_worktree_origin_prefetch.py",
27
+ )
28
+ assert hook_spec is not None
29
+ assert hook_spec.loader is not None
30
+ hook_module = importlib.util.module_from_spec(hook_spec)
31
+ hook_spec.loader.exec_module(hook_module)
32
+
33
+
34
+ def _run_git(repo_directory: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
35
+ return subprocess.run(
36
+ ["git", *args],
37
+ cwd=repo_directory,
38
+ capture_output=True,
39
+ text=True,
40
+ check=True,
41
+ )
42
+
43
+
44
+ def _init_repo_with_commit(repo_directory: pathlib.Path) -> None:
45
+ repo_directory.mkdir(parents=True, exist_ok=True)
46
+ _run_git(repo_directory, "init", "--quiet", "--initial-branch=main")
47
+ _run_git(repo_directory, "config", "user.email", "test@example.com")
48
+ _run_git(repo_directory, "config", "user.name", "Test")
49
+ _run_git(repo_directory, "commit", "--allow-empty", "--quiet", "-m", "init")
50
+
51
+
52
+ def _clone_as_local(remote_directory: pathlib.Path, local_directory: pathlib.Path) -> None:
53
+ subprocess.run(
54
+ ["git", "clone", "--quiet", str(remote_directory), str(local_directory)],
55
+ capture_output=True,
56
+ text=True,
57
+ check=True,
58
+ )
59
+ _run_git(local_directory, "config", "user.email", "test@example.com")
60
+ _run_git(local_directory, "config", "user.name", "Test")
61
+
62
+
63
+ def test_is_enter_worktree_creation_true_when_no_path_given() -> None:
64
+ payload = {"tool_name": "EnterWorktree", "tool_input": {}}
65
+ assert hook_module.is_enter_worktree_creation(payload) is True
66
+
67
+
68
+ def test_is_enter_worktree_creation_true_with_name_only() -> None:
69
+ payload = {"tool_name": "EnterWorktree", "tool_input": {"name": "my-branch"}}
70
+ assert hook_module.is_enter_worktree_creation(payload) is True
71
+
72
+
73
+ def test_is_enter_worktree_creation_false_when_path_given() -> None:
74
+ payload = {"tool_name": "EnterWorktree", "tool_input": {"path": "/some/worktree"}}
75
+ assert hook_module.is_enter_worktree_creation(payload) is False
76
+
77
+
78
+ def test_is_enter_worktree_creation_false_for_other_tool() -> None:
79
+ payload = {"tool_name": "Bash", "tool_input": {}}
80
+ assert hook_module.is_enter_worktree_creation(payload) is False
81
+
82
+
83
+ def test_resolve_origin_default_branch_reads_cloned_head(tmp_path: pathlib.Path) -> None:
84
+ remote_directory = tmp_path / "remote"
85
+ local_directory = tmp_path / "local"
86
+ _init_repo_with_commit(remote_directory)
87
+ _clone_as_local(remote_directory, local_directory)
88
+
89
+ resolved_branch = hook_module.resolve_origin_default_branch(str(local_directory))
90
+
91
+ assert resolved_branch == "main"
92
+
93
+
94
+ def test_resolve_origin_default_branch_returns_none_without_origin(
95
+ tmp_path: pathlib.Path,
96
+ ) -> None:
97
+ solo_directory = tmp_path / "solo"
98
+ _init_repo_with_commit(solo_directory)
99
+
100
+ resolved_branch = hook_module.resolve_origin_default_branch(str(solo_directory))
101
+
102
+ assert resolved_branch is None
103
+
104
+
105
+ def test_fetch_origin_branch_updates_stale_local_ref(tmp_path: pathlib.Path) -> None:
106
+ remote_directory = tmp_path / "remote"
107
+ local_directory = tmp_path / "local"
108
+ _init_repo_with_commit(remote_directory)
109
+ _clone_as_local(remote_directory, local_directory)
110
+
111
+ _run_git(remote_directory, "commit", "--allow-empty", "--quiet", "-m", "second")
112
+ remote_head_sha = _run_git(remote_directory, "rev-parse", "HEAD").stdout.strip()
113
+ local_cached_sha_before = _run_git(
114
+ local_directory, "rev-parse", "refs/remotes/origin/main"
115
+ ).stdout.strip()
116
+ assert local_cached_sha_before != remote_head_sha
117
+
118
+ hook_module.fetch_origin_branch(str(local_directory), "main")
119
+
120
+ local_cached_sha_after = _run_git(
121
+ local_directory, "rev-parse", "refs/remotes/origin/main"
122
+ ).stdout.strip()
123
+ assert local_cached_sha_after == remote_head_sha
124
+
125
+
126
+ def test_fetch_origin_branch_never_raises_when_remote_missing(tmp_path: pathlib.Path) -> None:
127
+ solo_directory = tmp_path / "solo"
128
+ _init_repo_with_commit(solo_directory)
129
+
130
+ hook_module.fetch_origin_branch(str(solo_directory), "main")
131
+
132
+
133
+ def test_main_fetches_stale_ref_and_exits_zero(
134
+ tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
135
+ ) -> None:
136
+ remote_directory = tmp_path / "remote"
137
+ local_directory = tmp_path / "local"
138
+ _init_repo_with_commit(remote_directory)
139
+ _clone_as_local(remote_directory, local_directory)
140
+ _run_git(remote_directory, "commit", "--allow-empty", "--quiet", "-m", "second")
141
+ remote_head_sha = _run_git(remote_directory, "rev-parse", "HEAD").stdout.strip()
142
+
143
+ payload = {
144
+ "tool_name": "EnterWorktree",
145
+ "tool_input": {},
146
+ "cwd": str(local_directory),
147
+ }
148
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
149
+
150
+ with pytest.raises(SystemExit) as exit_info:
151
+ hook_module.main()
152
+
153
+ assert exit_info.value.code == 0
154
+ local_cached_sha_after = _run_git(
155
+ local_directory, "rev-parse", "refs/remotes/origin/main"
156
+ ).stdout.strip()
157
+ assert local_cached_sha_after == remote_head_sha
158
+
159
+
160
+ def test_main_exits_zero_for_non_enter_worktree_tool(
161
+ monkeypatch: pytest.MonkeyPatch,
162
+ ) -> None:
163
+ payload = {"tool_name": "Bash", "tool_input": {"command": "ls"}, "cwd": "/tmp"}
164
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
165
+
166
+ with pytest.raises(SystemExit) as exit_info:
167
+ hook_module.main()
168
+
169
+ assert exit_info.value.code == 0
170
+
171
+
172
+ def test_main_exits_zero_on_malformed_json(monkeypatch: pytest.MonkeyPatch) -> None:
173
+ monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
174
+
175
+ with pytest.raises(SystemExit) as exit_info:
176
+ hook_module.main()
177
+
178
+ assert exit_info.value.code == 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.89.0",
3
+ "version": "1.90.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/CLAUDE.md CHANGED
@@ -17,6 +17,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
17
17
  | `conservative-action.md` | Research and recommend when intent is ambiguous; act only on explicit request |
18
18
  | `context7.md` | Use Context7 MCP to fetch current library docs; always prefer live docs over built-in knowledge |
19
19
  | `docstring-prose-matches-implementation.md` | Prose enumerations in docstrings cover every behavior the body applies |
20
+ | `durable-post-artifacts.md` | GitHub post bodies never reference volatile scratch paths; text embeds inline and binary artifacts upload to the `artifacts` release with the permanent URL linked |
20
21
  | `explore-thoroughly.md` | Read relevant files and map existing patterns before proposing a change |
21
22
  | `file-global-constants.md` | File-global constants need at least two same-file references; otherwise move value to `config/` |
22
23
  | `gh-body-file.md` | Use `--body-file` with a temp file for all `gh` commands carrying markdown body content |
@@ -0,0 +1,35 @@
1
+ # Durable Post Artifacts
2
+
3
+ **When this applies:** Any GitHub post that lives on the server — an issue, a pull request, a comment, or a review — created through `gh` (`gh pr create/comment/edit/review`, `gh issue create/comment/edit`) or a GitHub MCP post tool.
4
+
5
+ ## Rule
6
+
7
+ A post lives forever. Job scratch directories, worktrees, and system temp folders do not — they are cleaned soon after the run that made them. So a post must never point at a path in that scratch. The moment the directory is cleaned, the reference breaks and the reader is left with a dead path.
8
+
9
+ Handle the two kinds of content differently:
10
+
11
+ - **Text data** (logs, tables, diffs, stack traces): paste the actual text inline in the post body. Do not link a scratch file that holds it.
12
+ - **Binary artifacts** (images, screenshots, archives): upload the file to the repository's durable `artifacts` release and link the permanent URL. Use the helper:
13
+
14
+ ```
15
+ python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>
16
+ ```
17
+
18
+ It ensures the repository has a prerelease tagged `artifacts`, uploads the file under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints the permanent download URL. Put that URL in the post.
19
+
20
+ ## Volatile paths that must not appear in a post body
21
+
22
+ - A job scratch directory (`.claude-editor/jobs/`)
23
+ - A worktree (`.claude/worktrees/`)
24
+ - A system temp location (`AppData\Local\Temp`, `%TEMP%`, `$env:TEMP`, `/tmp/`)
25
+ - The job scratch environment variable (`$CLAUDE_JOB_DIR`)
26
+
27
+ Both slash directions count.
28
+
29
+ ## Enforcement
30
+
31
+ The `volatile_path_in_post_blocker` PreToolUse hook reads the body of each `gh` post command and each GitHub MCP post call, scans it for these markers, and blocks the post when it finds one. For a `--body-file`, the hook reads the file and scans its contents, so writing the body to a temp file and passing it with `--body-file` stays allowed — what the hook rejects is a volatile path inside the text that gets posted.
32
+
33
+ ## Why
34
+
35
+ A comment that cites an artifact under a job's tmp directory reads fine the moment it is posted and breaks a few minutes later, once the job is cleaned. Embedding text inline and linking binary artifacts to a durable release keeps every post readable for as long as it exists.
package/scripts/CLAUDE.md CHANGED
@@ -6,6 +6,7 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
6
6
 
7
7
  | File | Purpose |
8
8
  |---|---|
9
+ | `gh_artifact_upload.py` | Uploads a file to a repo's durable `artifacts` prerelease under a timestamped asset name and prints the permanent download URL a GitHub post can link |
9
10
  | `setup_project_paths.py` | One-time bootstrap: discovers git repos via `es.exe` (Everything) and writes `~/.claude/project-paths.json`; never hardcodes scan roots |
10
11
  | `sweep_empty_dirs.py` | Deletes empty directories older than a configurable age under a given root; runs once (`--once`) or in continuous-watch mode |
11
12
  | `sync_to_cursor.py` | Entry point for syncing Claude rules to Cursor `.mdc` files; delegates to the `sync_to_cursor/` package |
@@ -7,6 +7,7 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
7
7
  | File | Constants for |
8
8
  |---|---|
9
9
  | `timing.py` | `sweep_empty_dirs.py` — `DEFAULT_AGE_SECONDS` (smallest age before an empty directory is eligible for removal) and `DEFAULT_POLL_INTERVAL` (seconds between sweep passes in continuous-watch mode) |
10
+ | `gh_artifact_upload_constants.py` | `gh_artifact_upload.py` — the `artifacts` release tag, title, and notes body, the GitHub CLI binary name, the asset-name timestamp format and template, the asset download URL template, the notes-file suffix, and the text encoding |
10
11
  | `__init__.py` | Empty package marker |
11
12
 
12
13
  ## Convention
@@ -0,0 +1,43 @@
1
+ """Constants for the gh_artifact_upload script.
2
+
3
+ Per the project's configuration conventions, script-level scalar constants
4
+ live in dev_env_scripts_constants alongside timing.py.
5
+ """
6
+
7
+ ARTIFACTS_RELEASE_TAG: str = "artifacts"
8
+ """Release tag that holds durable post artifacts."""
9
+
10
+ ARTIFACTS_RELEASE_TITLE: str = "Durable post artifacts"
11
+ """Human-readable title for the artifacts release."""
12
+
13
+ ARTIFACTS_RELEASE_NOTES: str = (
14
+ "Permanent storage for binary artifacts linked from GitHub issue and pull "
15
+ "request posts. Job scratch directories are ephemeral and get cleaned soon "
16
+ "after a run; assets uploaded to this prerelease persist so a durable post "
17
+ "can link a stable URL."
18
+ )
19
+ """Release body written when the artifacts release is first created."""
20
+
21
+ GH_BINARY_NAME: str = "gh"
22
+ """The GitHub CLI executable name."""
23
+
24
+ ASSET_NAME_TIMESTAMP_FORMAT: str = "%Y%m%d_%H%M%S"
25
+ """strftime format for the timestamp prefix on an uploaded asset name."""
26
+
27
+ ASSET_NAME_TEMPLATE: str = "{timestamp}_{basename}"
28
+ """Template joining the timestamp prefix and the source file basename."""
29
+
30
+ RELEASE_ASSETS_JSON_KEY: str = "assets"
31
+ """``gh release view --json`` field holding the release's asset list."""
32
+
33
+ ASSET_URL_JSON_KEY: str = "url"
34
+ """Asset field carrying the browser download URL GitHub serves."""
35
+
36
+ ASSET_CREATED_AT_JSON_KEY: str = "createdAt"
37
+ """Asset field carrying the ISO 8601 creation timestamp."""
38
+
39
+ NOTES_FILE_SUFFIX: str = ".md"
40
+ """Suffix for the temporary release-notes file."""
41
+
42
+ UTF8_ENCODING: str = "utf-8"
43
+ """Text encoding for subprocess output and temp files."""
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """Upload a file to a repo's durable 'artifacts' release and print its URL.
3
+
4
+ A durable GitHub post (issue, PR, comment, review) must not link a file under a
5
+ job scratch directory or worktree, because that scratch is cleaned soon after
6
+ the run while the post lives forever. Binary artifacts belong in a permanent
7
+ place instead. This tool ensures the repo has a prerelease tagged ``artifacts``,
8
+ uploads the given file under a timestamped asset name, and prints the permanent
9
+ download URL a post can safely link. The timestamped name keeps each upload a
10
+ distinct asset. An upload never overwrites an earlier one. A same-name collision
11
+ fails loudly instead of replacing the bytes an existing URL already serves.
12
+
13
+ Usage::
14
+
15
+ python3 gh_artifact_upload.py <file-path> <owner/repo>
16
+ -> https://github.com/<owner>/<repo>/releases/download/artifacts/20260707_140233_contact_sheet.png
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import datetime
23
+ import json
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ from dev_env_scripts_constants.gh_artifact_upload_constants import (
31
+ ARTIFACTS_RELEASE_NOTES,
32
+ ARTIFACTS_RELEASE_TAG,
33
+ ARTIFACTS_RELEASE_TITLE,
34
+ ASSET_CREATED_AT_JSON_KEY,
35
+ ASSET_NAME_TEMPLATE,
36
+ ASSET_NAME_TIMESTAMP_FORMAT,
37
+ ASSET_URL_JSON_KEY,
38
+ GH_BINARY_NAME,
39
+ NOTES_FILE_SUFFIX,
40
+ RELEASE_ASSETS_JSON_KEY,
41
+ UTF8_ENCODING,
42
+ )
43
+
44
+
45
+ class ArtifactUploadError(Exception):
46
+ """Raised when creating the release or uploading the asset fails."""
47
+
48
+
49
+ def _run_gh(all_arguments: list[str]) -> subprocess.CompletedProcess[str]:
50
+ return subprocess.run(
51
+ [GH_BINARY_NAME, *all_arguments],
52
+ capture_output=True,
53
+ text=True,
54
+ encoding=UTF8_ENCODING,
55
+ check=False,
56
+ )
57
+
58
+
59
+ def artifacts_release_exists(repository: str) -> bool:
60
+ """Return whether the durable artifacts release already exists in the repo.
61
+
62
+ Args:
63
+ repository: The ``owner/repo`` slug.
64
+
65
+ Returns:
66
+ True when ``gh release view`` finds the ``artifacts`` release.
67
+ """
68
+ completion = _run_gh(
69
+ [
70
+ "release",
71
+ "view",
72
+ ARTIFACTS_RELEASE_TAG,
73
+ "--repo",
74
+ repository,
75
+ "--json",
76
+ "tagName",
77
+ ]
78
+ )
79
+ return completion.returncode == 0
80
+
81
+
82
+ def ensure_artifacts_release(repository: str) -> None:
83
+ """Create the artifacts prerelease when the repo does not already have it.
84
+
85
+ Args:
86
+ repository: The ``owner/repo`` slug.
87
+
88
+ Raises:
89
+ ArtifactUploadError: When the release cannot be created.
90
+ """
91
+ if artifacts_release_exists(repository):
92
+ return
93
+ with tempfile.NamedTemporaryFile(
94
+ mode="w", suffix=NOTES_FILE_SUFFIX, delete=False, encoding=UTF8_ENCODING
95
+ ) as notes_file:
96
+ notes_file.write(ARTIFACTS_RELEASE_NOTES)
97
+ notes_path = notes_file.name
98
+ completion = _run_gh(
99
+ [
100
+ "release",
101
+ "create",
102
+ ARTIFACTS_RELEASE_TAG,
103
+ "--repo",
104
+ repository,
105
+ "--prerelease",
106
+ "--title",
107
+ ARTIFACTS_RELEASE_TITLE,
108
+ "--notes-file",
109
+ notes_path,
110
+ ]
111
+ )
112
+ Path(notes_path).unlink(missing_ok=True)
113
+ if completion.returncode != 0:
114
+ raise ArtifactUploadError(completion.stderr.strip())
115
+
116
+
117
+ def timestamped_asset_name(file_path: str) -> str:
118
+ """Return ``YYYYMMDD_HHMMSS_<basename>`` for the given file path.
119
+
120
+ Args:
121
+ file_path: The source file to be uploaded.
122
+
123
+ Returns:
124
+ The asset name that prefixes the basename with the current timestamp.
125
+ """
126
+ current_timestamp = datetime.datetime.now().strftime(ASSET_NAME_TIMESTAMP_FORMAT)
127
+ return ASSET_NAME_TEMPLATE.format(
128
+ timestamp=current_timestamp, basename=Path(file_path).name
129
+ )
130
+
131
+
132
+ def _uploaded_asset_download_url(repository: str) -> str:
133
+ """Read the just-uploaded asset's real download URL back from GitHub.
134
+
135
+ ::
136
+
137
+ upload "My Report.png" -> GitHub stores "..._My.Report.png"
138
+ read-back -> the URL GitHub serves for that sanitized name
139
+
140
+ GitHub sanitizes an asset filename on upload: spaces and other characters
141
+ become periods. The stored name, and so the download URL, can differ from
142
+ the local name. Reading the release back returns the URL GitHub actually
143
+ serves for the most recently created asset, which is the one just uploaded.
144
+
145
+ Args:
146
+ repository: The ``owner/repo`` slug.
147
+
148
+ Returns:
149
+ The browser download URL of the newest artifacts-release asset.
150
+
151
+ Raises:
152
+ ArtifactUploadError: When the read-back fails, the response cannot be
153
+ parsed, or no asset carries a download URL.
154
+ """
155
+ completion = _run_gh(
156
+ [
157
+ "release",
158
+ "view",
159
+ ARTIFACTS_RELEASE_TAG,
160
+ "--repo",
161
+ repository,
162
+ "--json",
163
+ RELEASE_ASSETS_JSON_KEY,
164
+ ]
165
+ )
166
+ if completion.returncode != 0:
167
+ raise ArtifactUploadError(completion.stderr.strip())
168
+ try:
169
+ parsed_release = json.loads(completion.stdout)
170
+ except json.JSONDecodeError as decode_error:
171
+ raise ArtifactUploadError(f"could not read release assets: {decode_error}")
172
+ all_assets = (
173
+ parsed_release.get(RELEASE_ASSETS_JSON_KEY, [])
174
+ if isinstance(parsed_release, dict)
175
+ else []
176
+ )
177
+ all_dated_assets = [
178
+ each_asset
179
+ for each_asset in all_assets
180
+ if isinstance(each_asset, dict) and each_asset.get(ASSET_CREATED_AT_JSON_KEY)
181
+ ]
182
+ if not all_dated_assets:
183
+ raise ArtifactUploadError("uploaded asset not found on read-back")
184
+ newest_asset = max(
185
+ all_dated_assets,
186
+ key=lambda each_asset: each_asset[ASSET_CREATED_AT_JSON_KEY],
187
+ )
188
+ asset_url = newest_asset.get(ASSET_URL_JSON_KEY)
189
+ if not isinstance(asset_url, str) or not asset_url:
190
+ raise ArtifactUploadError("uploaded asset carries no download URL on read-back")
191
+ return asset_url
192
+
193
+
194
+ def upload_artifact(file_path: str, repository: str) -> str:
195
+ """Upload a file to the artifacts release and return its permanent URL.
196
+
197
+ Args:
198
+ file_path: The source file to upload.
199
+ repository: The ``owner/repo`` slug.
200
+
201
+ Returns:
202
+ The permanent download URL for the uploaded asset.
203
+
204
+ Raises:
205
+ ArtifactUploadError: When the file is missing or the upload fails.
206
+ """
207
+ source_path = Path(file_path)
208
+ if not source_path.is_file():
209
+ raise ArtifactUploadError(f"file not found: {file_path}")
210
+ ensure_artifacts_release(repository)
211
+ asset_name = timestamped_asset_name(file_path)
212
+ with tempfile.TemporaryDirectory() as staging_directory:
213
+ staged_asset_path = Path(staging_directory) / asset_name
214
+ shutil.copyfile(source_path, staged_asset_path)
215
+ completion = _run_gh(
216
+ [
217
+ "release",
218
+ "upload",
219
+ ARTIFACTS_RELEASE_TAG,
220
+ str(staged_asset_path),
221
+ "--repo",
222
+ repository,
223
+ ]
224
+ )
225
+ if completion.returncode != 0:
226
+ raise ArtifactUploadError(completion.stderr.strip())
227
+ return _uploaded_asset_download_url(repository)
228
+
229
+
230
+ def main() -> int:
231
+ """Parse arguments, upload the file, and print the permanent asset URL.
232
+
233
+ Returns:
234
+ ``0`` when the upload succeeds, ``1`` when it fails.
235
+ """
236
+ parser = argparse.ArgumentParser(
237
+ description="Upload a file to a repo's durable 'artifacts' release."
238
+ )
239
+ parser.add_argument("file_path", help="Path to the file to upload.")
240
+ parser.add_argument(
241
+ "repository", metavar="owner/repo", help="Target GitHub repository slug."
242
+ )
243
+ parsed_arguments = parser.parse_args()
244
+ try:
245
+ asset_url = upload_artifact(
246
+ parsed_arguments.file_path, parsed_arguments.repository
247
+ )
248
+ except ArtifactUploadError as upload_error:
249
+ print(f"gh-artifact-upload failed: {upload_error}", file=sys.stderr)
250
+ return 1
251
+ print(asset_url)
252
+ return 0
253
+
254
+
255
+ if __name__ == "__main__":
256
+ sys.exit(main())