hftracer 0.1.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.
- hftracer-0.1.0/.github/workflows/ci.yml +119 -0
- hftracer-0.1.0/.gitignore +8 -0
- hftracer-0.1.0/.python-version +1 -0
- hftracer-0.1.0/.release-please-manifest.json +1 -0
- hftracer-0.1.0/CHANGELOG.md +15 -0
- hftracer-0.1.0/CONTRIBUTING.md +39 -0
- hftracer-0.1.0/LICENSE +21 -0
- hftracer-0.1.0/PKG-INFO +47 -0
- hftracer-0.1.0/README.md +34 -0
- hftracer-0.1.0/pyproject.toml +35 -0
- hftracer-0.1.0/release-please-config.json +16 -0
- hftracer-0.1.0/src/hftracer/__init__.py +1 -0
- hftracer-0.1.0/src/hftracer/__main__.py +3 -0
- hftracer-0.1.0/src/hftracer/cli.py +160 -0
- hftracer-0.1.0/src/hftracer/config.py +87 -0
- hftracer-0.1.0/tests/conftest.py +27 -0
- hftracer-0.1.0/tests/manual-integration.md +40 -0
- hftracer-0.1.0/tests/test_cli.py +359 -0
- hftracer-0.1.0/tests/test_config.py +76 -0
- hftracer-0.1.0/uv.lock +406 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
name: CI and release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["**"]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
checks:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
with:
|
|
18
|
+
persist-credentials: false
|
|
19
|
+
- uses: actions/setup-python@v7
|
|
20
|
+
with:
|
|
21
|
+
python-version-file: .python-version
|
|
22
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
23
|
+
with:
|
|
24
|
+
version: "0.11.2"
|
|
25
|
+
enable-cache: true
|
|
26
|
+
- run: uv sync --locked
|
|
27
|
+
- run: uv run --no-sync ruff check .
|
|
28
|
+
- run: uv run --no-sync ruff format --check .
|
|
29
|
+
- run: uv run --no-sync ty check
|
|
30
|
+
- run: uv run --no-sync pytest
|
|
31
|
+
- run: uv build
|
|
32
|
+
|
|
33
|
+
release:
|
|
34
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
35
|
+
needs: checks
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
concurrency:
|
|
38
|
+
group: release-please
|
|
39
|
+
cancel-in-progress: false
|
|
40
|
+
permissions:
|
|
41
|
+
contents: write
|
|
42
|
+
issues: write
|
|
43
|
+
pull-requests: write
|
|
44
|
+
actions: write
|
|
45
|
+
outputs:
|
|
46
|
+
release_created: ${{ steps.release.outputs.release_created }}
|
|
47
|
+
tag_name: ${{ steps.release.outputs.tag_name }}
|
|
48
|
+
sha: ${{ steps.release.outputs.sha }}
|
|
49
|
+
steps:
|
|
50
|
+
- uses: googleapis/release-please-action@v5
|
|
51
|
+
id: release
|
|
52
|
+
with:
|
|
53
|
+
target-branch: main
|
|
54
|
+
- name: Run checks for the release PR
|
|
55
|
+
if: steps.release.outputs.pr != ''
|
|
56
|
+
env:
|
|
57
|
+
GH_TOKEN: ${{ github.token }}
|
|
58
|
+
GH_REPO: ${{ github.repository }}
|
|
59
|
+
RELEASE_PR: ${{ steps.release.outputs.pr }}
|
|
60
|
+
run: gh workflow run ci.yml --ref "$(jq -r .headBranchName <<< "$RELEASE_PR")"
|
|
61
|
+
|
|
62
|
+
build:
|
|
63
|
+
if: needs.release.outputs.release_created == 'true'
|
|
64
|
+
needs: release
|
|
65
|
+
runs-on: ubuntu-latest
|
|
66
|
+
steps:
|
|
67
|
+
- uses: actions/checkout@v7
|
|
68
|
+
with:
|
|
69
|
+
ref: ${{ needs.release.outputs.sha }}
|
|
70
|
+
persist-credentials: false
|
|
71
|
+
- uses: actions/setup-python@v7
|
|
72
|
+
with:
|
|
73
|
+
python-version-file: .python-version
|
|
74
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
75
|
+
with:
|
|
76
|
+
version: "0.11.2"
|
|
77
|
+
enable-cache: true
|
|
78
|
+
- run: uv sync --locked --no-dev
|
|
79
|
+
- run: uv build
|
|
80
|
+
- name: Upload distributions
|
|
81
|
+
uses: actions/upload-artifact@v7
|
|
82
|
+
with:
|
|
83
|
+
name: dist
|
|
84
|
+
path: dist/*
|
|
85
|
+
if-no-files-found: error
|
|
86
|
+
|
|
87
|
+
pypi:
|
|
88
|
+
needs: build
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
environment: pypi
|
|
91
|
+
permissions:
|
|
92
|
+
id-token: write
|
|
93
|
+
steps:
|
|
94
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
95
|
+
with:
|
|
96
|
+
version: "0.11.2"
|
|
97
|
+
enable-cache: false
|
|
98
|
+
- uses: actions/download-artifact@v8
|
|
99
|
+
with:
|
|
100
|
+
name: dist
|
|
101
|
+
path: dist
|
|
102
|
+
- run: uv publish --trusted-publishing always
|
|
103
|
+
|
|
104
|
+
github:
|
|
105
|
+
needs: [release, build, pypi]
|
|
106
|
+
runs-on: ubuntu-latest
|
|
107
|
+
permissions:
|
|
108
|
+
contents: write
|
|
109
|
+
steps:
|
|
110
|
+
- uses: actions/download-artifact@v8
|
|
111
|
+
with:
|
|
112
|
+
name: dist
|
|
113
|
+
path: dist
|
|
114
|
+
- name: Attach distributions to the release
|
|
115
|
+
env:
|
|
116
|
+
GH_TOKEN: ${{ github.token }}
|
|
117
|
+
GH_REPO: ${{ github.repository }}
|
|
118
|
+
RELEASE_TAG: ${{ needs.release.outputs.tag_name }}
|
|
119
|
+
run: gh release upload "$RELEASE_TAG" dist/*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{".":"0.1.0"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add private bucket session sync ([a394504](https://github.com/twangodev/hftracer/commit/a39450451cffbbc3709230c3d472f853ef7a1f77))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* simplify description and organize readme ([5ef9e09](https://github.com/twangodev/hftracer/commit/5ef9e097480b95d0790780359b495ec008eb81c1))
|
|
14
|
+
* simplify readme and add Shields.io badges ([9566b87](https://github.com/twangodev/hftracer/commit/9566b87fcc5ac8bf53c6dd1d5ffa542b0b5ac827))
|
|
15
|
+
* trim readme badges and development section ([4390d6a](https://github.com/twangodev/hftracer/commit/4390d6af8a96a64d6849e718aea9530e685126f8))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Usage & development
|
|
2
|
+
|
|
3
|
+
## Usage
|
|
4
|
+
|
|
5
|
+
| Command | Behavior |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `hftracer init` | Prompt for a private bucket; default: `<username>/traces` |
|
|
8
|
+
| `hftracer init --bucket namespace/name` | Configure without prompting |
|
|
9
|
+
| `hftracer sync` | Upload new and changed files with HF progress bars |
|
|
10
|
+
| `hftracer sync --dry-run` | Preview counts and transfer sizes |
|
|
11
|
+
| `hftracer sync --preview` | Alias for `--dry-run` |
|
|
12
|
+
|
|
13
|
+
- Authentication: existing HF login or `HF_TOKEN`.
|
|
14
|
+
- Configuration: `$XDG_CONFIG_HOME/hftracer/config.json`; default: `~/.config/hftracer/config.json`.
|
|
15
|
+
- Remote paths: `devices/<device>/codex/{sessions,archived_sessions}/` and `devices/<device>/claude/projects/`.
|
|
16
|
+
- Device identity persists across setup runs. Keep each device's config separate.
|
|
17
|
+
- All files, including hidden and supporting files, are included unchanged.
|
|
18
|
+
- HF compares size and modification time. Same-size changes with unchanged timestamps may be skipped.
|
|
19
|
+
- Uploads finalize in batches. Ctrl+C preserves finalized files; the current batch may need retrying.
|
|
20
|
+
- Re-run after active sessions finish for a settled copy.
|
|
21
|
+
- Missing sources are skipped. Transfer failures return a nonzero exit code.
|
|
22
|
+
- Schedule externally with cron, systemd timers, or a Docker scheduler.
|
|
23
|
+
|
|
24
|
+
## Development
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
uv sync --locked
|
|
28
|
+
uv run pytest
|
|
29
|
+
uv run ty check
|
|
30
|
+
uv run ruff check .
|
|
31
|
+
uv run ruff format --check .
|
|
32
|
+
uv build
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Tests use synthetic files and mocked HF calls. Live checks are described in
|
|
36
|
+
[manual integration](tests/manual-integration.md).
|
|
37
|
+
|
|
38
|
+
Use focused Conventional Commits, such as `feat: ...`, `fix: ...`, `docs: ...`,
|
|
39
|
+
and `ci: ...`.
|
hftracer-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Ding
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
hftracer-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hftracer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronize agent traces to Hugging Face buckets.
|
|
5
|
+
Project-URL: Repository, https://github.com/twangodev/hftracer
|
|
6
|
+
Project-URL: Issues, https://github.com/twangodev/hftracer/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Requires-Dist: huggingface-hub>=1.9.0
|
|
11
|
+
Requires-Dist: typer>=0.24.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# hftracer
|
|
15
|
+
|
|
16
|
+
[](https://www.python.org/downloads/)
|
|
17
|
+
[](LICENSE)
|
|
18
|
+
|
|
19
|
+
Synchronize agent traces to Hugging Face buckets.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv tool install hftracer
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uvx --from huggingface-hub hf auth login
|
|
31
|
+
hftracer init
|
|
32
|
+
hftracer sync --dry-run
|
|
33
|
+
hftracer sync
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Supported agents
|
|
37
|
+
|
|
38
|
+
| Agent | Sources |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| Codex | `~/.codex/sessions`, `~/.codex/archived_sessions` |
|
|
41
|
+
| Claude Code | `~/.claude/projects` |
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- Incremental uploads with progress bars
|
|
46
|
+
- Original files and directory structure preserved
|
|
47
|
+
- Separate device namespaces; no remote deletions
|
hftracer-0.1.0/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# hftracer
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Synchronize agent traces to Hugging Face buckets.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
uv tool install hftracer
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
uvx --from huggingface-hub hf auth login
|
|
18
|
+
hftracer init
|
|
19
|
+
hftracer sync --dry-run
|
|
20
|
+
hftracer sync
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Supported agents
|
|
24
|
+
|
|
25
|
+
| Agent | Sources |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| Codex | `~/.codex/sessions`, `~/.codex/archived_sessions` |
|
|
28
|
+
| Claude Code | `~/.claude/projects` |
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Incremental uploads with progress bars
|
|
33
|
+
- Original files and directory structure preserved
|
|
34
|
+
- Separate device namespaces; no remote deletions
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hftracer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Synchronize agent traces to Hugging Face buckets."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.14"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"huggingface-hub>=1.9.0",
|
|
10
|
+
"typer>=0.24.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
hftracer = "hftracer.cli:app"
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/twangodev/hftracer"
|
|
18
|
+
Issues = "https://github.com/twangodev/hftracer/issues"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=9.0",
|
|
27
|
+
"ruff>=0.14",
|
|
28
|
+
"ty>=0.0.1",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
3
|
+
"release-type": "python",
|
|
4
|
+
"include-component-in-tag": false,
|
|
5
|
+
"packages": {
|
|
6
|
+
".": {
|
|
7
|
+
"extra-files": [
|
|
8
|
+
{
|
|
9
|
+
"type": "toml",
|
|
10
|
+
"path": "uv.lock",
|
|
11
|
+
"jsonpath": "$.package[?(@.name.value == 'hftracer')].version"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sync native coding-agent sessions to private Hugging Face Storage Buckets."""
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Discover local agent directories and delegate transfers to Hugging Face."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from contextlib import redirect_stdout
|
|
5
|
+
from typing import Annotated, NoReturn
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from huggingface_hub import HfApi
|
|
9
|
+
|
|
10
|
+
from hftracer.config import (
|
|
11
|
+
Config,
|
|
12
|
+
config_path,
|
|
13
|
+
discover_sources,
|
|
14
|
+
load_config,
|
|
15
|
+
new_device_id,
|
|
16
|
+
save_config,
|
|
17
|
+
validate_bucket_id,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
help="Sync Codex and Claude Code sessions to a private Hugging Face bucket.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=False,
|
|
24
|
+
pretty_exceptions_enable=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fail(message: str) -> NoReturn:
|
|
29
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_config() -> Config | None:
|
|
34
|
+
try:
|
|
35
|
+
return load_config()
|
|
36
|
+
except (OSError, ValueError) as exc:
|
|
37
|
+
fail(f"Cannot read configuration at {config_path()}: {exc}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_private(api: HfApi, bucket_id: str) -> None:
|
|
41
|
+
if api.bucket_info(bucket_id).private is not True:
|
|
42
|
+
raise ValueError(f"Bucket {bucket_id} is public; select a private bucket.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_size(size: int) -> str:
|
|
46
|
+
amount = float(size)
|
|
47
|
+
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
48
|
+
if amount < 1024:
|
|
49
|
+
return f"{amount:.3g} {unit}"
|
|
50
|
+
amount /= 1024
|
|
51
|
+
return f"{amount:.3g} TiB"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def init(
|
|
56
|
+
bucket: Annotated[
|
|
57
|
+
str | None,
|
|
58
|
+
typer.Option(
|
|
59
|
+
"--bucket", help="Create or select namespace/name without prompting."
|
|
60
|
+
),
|
|
61
|
+
] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Configure a private bucket and a persistent namespace for this device."""
|
|
64
|
+
previous = read_config()
|
|
65
|
+
api = HfApi()
|
|
66
|
+
try:
|
|
67
|
+
username = api.whoami()["name"]
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
fail(
|
|
70
|
+
"Cannot authenticate with Hugging Face. "
|
|
71
|
+
f"Run `hf auth login` or set HF_TOKEN. {exc}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if bucket is None:
|
|
75
|
+
default = previous.bucket_id if previous else f"{username}/traces"
|
|
76
|
+
bucket = typer.prompt("Bucket (namespace/name)", default=default)
|
|
77
|
+
try:
|
|
78
|
+
validate_bucket_id(bucket)
|
|
79
|
+
api.create_bucket(bucket, private=True, exist_ok=True)
|
|
80
|
+
require_private(api, bucket)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
fail(f"Cannot configure bucket: {exc}")
|
|
83
|
+
|
|
84
|
+
config = Config(bucket, previous.device_id if previous else new_device_id())
|
|
85
|
+
try:
|
|
86
|
+
sources = discover_sources()
|
|
87
|
+
save_config(config)
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
fail(f"Cannot finish setup: {exc}")
|
|
90
|
+
typer.secho(f"Configured {config.bucket_id}", fg=typer.colors.GREEN)
|
|
91
|
+
if sources:
|
|
92
|
+
typer.secho(
|
|
93
|
+
f"Sources: {', '.join(remote for _, remote in sources)}",
|
|
94
|
+
fg=typer.colors.CYAN,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
typer.secho("No agent directories found yet.", fg=typer.colors.YELLOW)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def sync(
|
|
102
|
+
dry_run: Annotated[
|
|
103
|
+
bool,
|
|
104
|
+
typer.Option(
|
|
105
|
+
"--dry-run", "--preview", help="Preview planned changes without uploading."
|
|
106
|
+
),
|
|
107
|
+
] = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Upload new and modified files; never delete remote files."""
|
|
110
|
+
config = read_config()
|
|
111
|
+
if config is None:
|
|
112
|
+
fail("No configuration found. Run `hftracer init` first.")
|
|
113
|
+
api = HfApi()
|
|
114
|
+
try:
|
|
115
|
+
require_private(api, config.bucket_id)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
fail(f"Cannot verify private bucket: {exc}")
|
|
118
|
+
try:
|
|
119
|
+
sources = discover_sources()
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
fail(f"Cannot discover agent directories: {exc}")
|
|
122
|
+
if not sources:
|
|
123
|
+
typer.secho(
|
|
124
|
+
"No agent directories found. Nothing to sync.", fg=typer.colors.YELLOW
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
mode = "Preview" if dry_run else "Sync"
|
|
129
|
+
upload_label = "to upload" if dry_run else "uploaded"
|
|
130
|
+
typer.secho(f"{mode}: {config.bucket_id}", fg=typer.colors.CYAN)
|
|
131
|
+
failed = False
|
|
132
|
+
for source, remote in sources:
|
|
133
|
+
destination = (
|
|
134
|
+
f"hf://buckets/{config.bucket_id}/devices/{config.device_id}/{remote}/"
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
with open(os.devnull, "w") as sink, redirect_stdout(sink):
|
|
138
|
+
plan = api.sync_bucket(
|
|
139
|
+
str(source),
|
|
140
|
+
destination,
|
|
141
|
+
delete=False,
|
|
142
|
+
dry_run=dry_run,
|
|
143
|
+
quiet=dry_run,
|
|
144
|
+
)
|
|
145
|
+
summary = plan.summary()
|
|
146
|
+
size = format_size(int(summary["total_size"]))
|
|
147
|
+
typer.echo(
|
|
148
|
+
f"{remote}: {summary['uploads']} {upload_label}, "
|
|
149
|
+
f"{summary['skips']} unchanged ({size})"
|
|
150
|
+
)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
typer.secho(f"Error for {remote}: {exc}", fg=typer.colors.RED, err=True)
|
|
153
|
+
failed = True
|
|
154
|
+
if failed:
|
|
155
|
+
command = "hftracer sync --dry-run" if dry_run else "hftracer sync"
|
|
156
|
+
fail(f"Some sources failed. Run `{command}` again to retry.")
|
|
157
|
+
if dry_run:
|
|
158
|
+
typer.secho("No files uploaded.", fg=typer.colors.YELLOW)
|
|
159
|
+
else:
|
|
160
|
+
typer.secho("Sync complete.", fg=typer.colors.GREEN)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Small, local configuration and fixed agent-directory discovery."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
SOURCES = (
|
|
13
|
+
(".codex/sessions", "codex/sessions"),
|
|
14
|
+
(".codex/archived_sessions", "codex/archived_sessions"),
|
|
15
|
+
(".claude/projects", "claude/projects"),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_bucket_id(bucket_id: str) -> None:
|
|
20
|
+
"""Require a bucket ID, rather than a URL or a bucket subdirectory."""
|
|
21
|
+
if not re.fullmatch(
|
|
22
|
+
r"[A-Za-z0-9_][A-Za-z0-9_.-]*/[A-Za-z0-9_][A-Za-z0-9_.-]*", bucket_id
|
|
23
|
+
):
|
|
24
|
+
raise ValueError("Bucket must be in namespace/name format.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Config:
|
|
29
|
+
bucket_id: str
|
|
30
|
+
device_id: str
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
validate_bucket_id(self.bucket_id)
|
|
34
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", self.device_id):
|
|
35
|
+
raise ValueError("Invalid device namespace in configuration.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
40
|
+
return (
|
|
41
|
+
(Path(base) if base else Path.home() / ".config") / "hftracer" / "config.json"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_config() -> Config | None:
|
|
46
|
+
try:
|
|
47
|
+
content = config_path().read_text(encoding="utf-8")
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
return None
|
|
50
|
+
data = json.loads(content)
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
raise ValueError("Configuration must be a JSON object.")
|
|
53
|
+
bucket_id, device_id = data.get("bucket_id"), data.get("device_id")
|
|
54
|
+
if not isinstance(bucket_id, str) or not isinstance(device_id, str):
|
|
55
|
+
raise ValueError("Configuration requires bucket_id and device_id strings.")
|
|
56
|
+
return Config(bucket_id=bucket_id, device_id=device_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_config(config: Config) -> None:
|
|
60
|
+
path = config_path()
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
temporary_path: Path | None = None
|
|
63
|
+
try:
|
|
64
|
+
with tempfile.NamedTemporaryFile(
|
|
65
|
+
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
|
66
|
+
) as stream:
|
|
67
|
+
temporary_path = Path(stream.name)
|
|
68
|
+
json.dump(asdict(config), stream, indent=2)
|
|
69
|
+
stream.write("\n")
|
|
70
|
+
stream.flush()
|
|
71
|
+
os.fsync(stream.fileno())
|
|
72
|
+
os.replace(temporary_path, path)
|
|
73
|
+
finally:
|
|
74
|
+
if temporary_path is not None:
|
|
75
|
+
temporary_path.unlink(missing_ok=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def new_device_id() -> str:
|
|
79
|
+
hostname = re.sub(r"[^a-z0-9-]+", "-", socket.gethostname().lower()).strip("-")
|
|
80
|
+
return f"{hostname or 'device'}-{uuid4().hex}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def discover_sources() -> list[tuple[Path, str]]:
|
|
84
|
+
home = Path.home()
|
|
85
|
+
return [
|
|
86
|
+
(home / local, remote) for local, remote in SOURCES if (home / local).is_dir()
|
|
87
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
from unittest.mock import MagicMock, create_autospec
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from huggingface_hub import HfApi, SyncPlan
|
|
7
|
+
|
|
8
|
+
from hftracer import cli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
13
|
+
home = tmp_path / "home"
|
|
14
|
+
home.mkdir()
|
|
15
|
+
monkeypatch.setenv("HOME", str(home))
|
|
16
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
|
17
|
+
return home
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def api(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
|
22
|
+
mock = create_autospec(HfApi, instance=True)
|
|
23
|
+
mock.whoami.return_value = {"name": "alice"}
|
|
24
|
+
mock.bucket_info.return_value = SimpleNamespace(private=True)
|
|
25
|
+
mock.sync_bucket.return_value = SyncPlan(source="", dest="", timestamp="")
|
|
26
|
+
monkeypatch.setattr(cli, "HfApi", lambda: mock)
|
|
27
|
+
return mock
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Manual private-bucket integration check
|
|
2
|
+
|
|
3
|
+
Use synthetic files and a dedicated private bucket. This procedure is not part
|
|
4
|
+
of the automated tests and requires existing HF authentication with bucket write
|
|
5
|
+
access. Run it only when you intend to create that bucket and upload test data.
|
|
6
|
+
|
|
7
|
+
1. Create two temporary home directories representing devices A and B, with
|
|
8
|
+
separate `XDG_CONFIG_HOME` directories. Set `HF_TOKEN` through your normal
|
|
9
|
+
credential mechanism before changing `HOME`, so the HF client does not lose
|
|
10
|
+
access to its usual credential cache. Do not copy your real session directories.
|
|
11
|
+
2. In device A's home, create files beneath `.codex/sessions/2026/10/`,
|
|
12
|
+
`.codex/archived_sessions/`, and `.claude/projects/example/nested/`. Include a
|
|
13
|
+
`.jsonl` file, a hidden file, and a binary file with known contents. Keep a copy
|
|
14
|
+
of the synthetic fixture tree outside these source directories for comparison.
|
|
15
|
+
3. With device A's `HOME` and `XDG_CONFIG_HOME`, run
|
|
16
|
+
`uv run hftracer init --bucket YOUR_USER/hftracer-integration`, then
|
|
17
|
+
`uv run hftracer sync --dry-run`. Verify the plan lists the fixture uploads
|
|
18
|
+
while the bucket stays empty. The `--preview` alias should produce the same
|
|
19
|
+
plan. Run `uv run hftracer sync` to transfer the files.
|
|
20
|
+
Verify the bucket is private and files appear under
|
|
21
|
+
`devices/<device-A>/` with the expected agent and directory prefixes. There
|
|
22
|
+
should be no hftracer metadata or manifest objects.
|
|
23
|
+
4. Run sync again without changing the fixture files. HF's sync summary should
|
|
24
|
+
show no uploads and should report skipped files.
|
|
25
|
+
5. Modify an existing file (changing its size and modification time), add another,
|
|
26
|
+
and delete a third locally. Run sync. Only the modified and new files should be
|
|
27
|
+
uploaded; the locally deleted file must remain remotely.
|
|
28
|
+
6. Download `hf://buckets/YOUR_USER/hftracer-integration/devices/<device-A>/` into
|
|
29
|
+
a separate temporary directory using `hf buckets sync`. Compare downloaded
|
|
30
|
+
file bytes against the original fixture copy plus the modifications. Verify
|
|
31
|
+
hidden files, binary contents, nested paths, and the retained deleted file.
|
|
32
|
+
7. Create a file at the same relative session path in device B's temporary home,
|
|
33
|
+
using different contents. Initialize device B against the same bucket and sync.
|
|
34
|
+
Verify its device ID differs from A's and both versions remain at their
|
|
35
|
+
respective prefixes. Re-run A's init and confirm its namespace stays unchanged.
|
|
36
|
+
8. Optionally change the test bucket to public, then run sync and verify it fails
|
|
37
|
+
before any upload. Restore it to private through HF before further testing.
|
|
38
|
+
|
|
39
|
+
Remove the temporary local directories when finished. Remote cleanup is manual;
|
|
40
|
+
hftracer never deletes the bucket or its files.
|