Skip to content

Commit f57bbc9

Browse files
committed
better sync
1 parent 10fdd8c commit f57bbc9

8 files changed

Lines changed: 446 additions & 73 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ uv run pytest tests/test_integration_sync.py::test_name # run a single test
2020
2. **Engine** (`engine.py`) — `sync_sessions()` orchestrates idempotency: loads `.convx/index.json`, fingerprints source files (SHA-256), skips unchanged sessions, calls the adapter to parse changed ones, then writes artifacts and updates the index.
2121
3. **Render** (`render.py`) — converts `NormalizedSession` to Markdown transcript or JSON string.
2222
4. **CLI** (`cli.py`) — two main commands built with Typer:
23-
- `sync`: runs inside a project repo, filters sessions by `cwd`, writes flat under `history/<user>/<source>/`
23+
- `sync`: runs inside a project repo, filters sessions by `cwd`, writes flat under `.ai/history/<user>/<source>/` by default
2424
- `backup`: writes to a dedicated repo with full path nesting `history/<user>/<source>/<system>/<relative-cwd>/`
2525

2626
**Idempotency index:** `.convx/index.json` in the output repo. Keyed by `session_key` (`<source_system>:<session_id>`). A session is re-exported only when the source SHA-256 changes or output files are missing.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Export AI conversation sessions into a Git repository using a readable, time-bas
1212
- readable Markdown transcript: `YYYY-MM-DD-HHMM-slug.md`
1313
- hidden normalized JSON: `.YYYY-MM-DD-HHMM-slug.json`
1414
- Organizes history by user and source system:
15-
- `sync`: `history/<user>/<source-system>/` (flat — sessions directly inside)
15+
- `sync`: `.ai/history/<user>/<source-system>/` (flat — sessions directly inside)
1616
- `backup`: `history/<user>/<source-system>/<system-name>/<path-relative-to-home>/...`
1717
- Runs idempotently (only reprocesses changed or new sessions).
1818
- Cursor: supports both single-folder and multi-root (`.code-workspace`) windows — sessions are attributed to the matching repo folder.
@@ -50,7 +50,7 @@ cd /path/to/your/project
5050
uv run convx sync
5151
```
5252

53-
By default syncs Codex, Claude, and Cursor. Use `--source-system codex`, `--source-system claude`, or `--source-system cursor` to sync a single source. No `--output-path` needed — the current directory is used as both the filter and the destination. Sessions are written flat under `history/<user>/<source-system>/` with no machine name or path nesting.
53+
By default syncs Codex, Claude, and Cursor. Use `--source-system codex`, `--source-system claude`, or `--source-system cursor` to sync a single source. No `--output-path` needed — the current directory is used as both the filter and the destination. Sessions are written flat under `.ai/history/<user>/<source-system>/` with no machine name or path nesting.
5454

5555
## backup — full backup command
5656

@@ -73,7 +73,7 @@ uv run convx backup \
7373
- `--user`: user namespace for history path (default: current OS user).
7474
- `--system-name`: system namespace for history path (default: hostname).
7575
- `--dry-run`: discover and plan without writing files.
76-
- `--history-subpath`: folder inside output repo where history is stored (default `history`).
76+
- `--history-subpath`: folder inside output repo where history is stored (default: `sync` = `.ai/history`, `backup` = `history`).
7777
- `--output-path` (backup only): target Git repository (must already contain `.git`).
7878

7979
## Configuration defaults

src/convx_ai/cli.py

Lines changed: 107 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55
import platform
66
import shlex
7+
import subprocess
78
from pathlib import Path
89

910
import typer
@@ -12,7 +13,7 @@
1213
from rich.table import Table
1314

1415
from convx_ai.adapters import default_input_path, get_adapter
15-
from convx_ai.config import ConvxConfig
16+
from convx_ai.config import ConvxConfig, create_config_if_missing
1617
from convx_ai.engine import SyncResult, sync_sessions
1718
from convx_ai.utils import sanitize_segment
1819

@@ -28,6 +29,27 @@ def _require_git_repo(path: Path) -> Path:
2829
return resolved
2930

3031

32+
def _require_git_root(path: Path) -> Path:
33+
resolved = path.expanduser().resolve()
34+
if not resolved.exists():
35+
raise typer.BadParameter(f"Path does not exist: {resolved}")
36+
try:
37+
result = subprocess.run(
38+
["git", "-C", str(resolved), "rev-parse", "--show-toplevel"],
39+
check=False,
40+
capture_output=True,
41+
text=True,
42+
)
43+
except OSError as exc:
44+
raise typer.BadParameter(f"Failed to run git: {exc}") from exc
45+
if result.returncode != 0:
46+
raise typer.BadParameter(f"Not inside a git repository: {resolved}")
47+
root = Path(result.stdout.strip()).resolve()
48+
if not root.exists():
49+
raise typer.BadParameter(f"Resolved git root does not exist: {root}")
50+
return root
51+
52+
3153
def _resolve_output_path(path: Path) -> Path:
3254
resolved = path.expanduser().resolve()
3355
resolved.mkdir(parents=True, exist_ok=True)
@@ -40,6 +62,10 @@ def _resolve_input(source_system: str, input_path: Path | None) -> Path:
4062
return default_input_path(source_system).resolve()
4163

4264

65+
def _resolve_bool(value: bool | None, default: bool) -> bool:
66+
return default if value is None else value
67+
68+
4369
def _print_sync_summary(
4470
result: SyncResult,
4571
*,
@@ -85,50 +111,65 @@ def _source_systems(value: str) -> list[str]:
85111

86112
@app.command("sync")
87113
def sync_command(
88-
source_system: str = typer.Option(
89-
"all",
114+
source_system: str | None = typer.Option(
115+
None,
90116
"--source-system",
91117
help="Source system(s): codex, claude, cursor, or all (default).",
92118
),
93119
input_path: Path | None = typer.Option(
94120
None, "--input-path", help="Source sessions path override (per source)."
95121
),
96-
user: str = typer.Option(
97-
getpass.getuser(), "--user", help="User namespace in output history path."
122+
user: str | None = typer.Option(
123+
None, "--user", help="User namespace in output history path."
98124
),
99-
system_name: str = typer.Option(
100-
platform.node() or "unknown-system",
125+
system_name: str | None = typer.Option(
126+
None,
101127
"--system-name",
102128
help="System namespace in output history path.",
103129
),
104-
history_subpath: str = typer.Option(
105-
"history",
130+
history_subpath: str | None = typer.Option(
131+
None,
106132
"--history-subpath",
107133
help="Subpath inside repo where history is written.",
108134
),
109-
dry_run: bool = typer.Option(
110-
False, "--dry-run", help="Plan export without writing files."
135+
dry_run: bool | None = typer.Option(
136+
None, "--dry-run/--no-dry-run", help="Plan export without writing files."
111137
),
112-
no_redact: bool = typer.Option(
113-
False, "--no-redact", help="Do not redact API keys, tokens, or passwords in output."
138+
redact: bool | None = typer.Option(
139+
None, "--redact/--no-redact", help="Redact API keys, tokens, or passwords in output."
114140
),
115-
with_context: bool = typer.Option(
116-
False, "--with-context", help="Include tool calls and injected context as HTML comments."
141+
with_context: bool | None = typer.Option(
142+
None, "--with-context/--no-with-context", help="Include tool calls and injected context as HTML comments."
117143
),
118-
with_thinking: bool = typer.Option(
119-
False, "--with-thinking", help="Include AI reasoning/thinking blocks as HTML comments."
144+
with_thinking: bool | None = typer.Option(
145+
None, "--with-thinking/--no-with-thinking", help="Include AI reasoning/thinking blocks as HTML comments."
120146
),
121-
skip_if_contains: str = typer.Option(
122-
"CONVX_NO_SYNC",
147+
skip_if_contains: str | None = typer.Option(
148+
None,
123149
"--skip-if-contains",
124150
help="Do not sync conversations that contain this string (pass empty to disable).",
125151
),
126-
overwrite: bool = typer.Option(
127-
False, "--overwrite", help="Re-export all sessions, ignoring cached fingerprints."
152+
overwrite: bool | None = typer.Option(
153+
None, "--overwrite/--no-overwrite", help="Re-export all sessions, ignoring cached fingerprints."
128154
),
129155
) -> None:
130156
"""Sync conversations for the current Git repo into it."""
131-
project_repo = _require_git_repo(Path.cwd())
157+
project_repo = _require_git_root(Path.cwd())
158+
create_config_if_missing(project_repo)
159+
config = ConvxConfig.for_repo(project_repo)
160+
sync_defaults = config.sync
161+
source_system = source_system or sync_defaults.source_system
162+
input_path = input_path or (Path(sync_defaults.input_path) if sync_defaults.input_path else None)
163+
user = user or sync_defaults.user or getpass.getuser()
164+
system_name = system_name or sync_defaults.system_name or platform.node() or "unknown-system"
165+
history_subpath = history_subpath or sync_defaults.history_subpath
166+
dry_run = _resolve_bool(dry_run, sync_defaults.dry_run)
167+
redact = _resolve_bool(redact, sync_defaults.redact)
168+
with_context = _resolve_bool(with_context, sync_defaults.with_context)
169+
with_thinking = _resolve_bool(with_thinking, sync_defaults.with_thinking)
170+
skip_if_contains = sync_defaults.skip_if_contains if skip_if_contains is None else skip_if_contains
171+
overwrite = _resolve_bool(overwrite, sync_defaults.overwrite)
172+
132173
sources = _source_systems(source_system)
133174
total = SyncResult()
134175
console = Console()
@@ -148,7 +189,7 @@ def sync_command(
148189
dry_run=dry_run,
149190
repo_filter_path=project_repo,
150191
flat_output=True,
151-
redact=not no_redact,
192+
redact=redact,
152193
with_context=with_context,
153194
with_thinking=with_thinking,
154195
skip_if_contains=skip_if_contains,
@@ -183,48 +224,62 @@ def backup_command(
183224
output_path: Path = typer.Option(
184225
..., "--output-path", help="Directory to export conversations to (created if missing)."
185226
),
186-
source_system: str = typer.Option(
187-
"all", "--source-system", help="Source system(s): codex, claude, cursor, or all (default)."
227+
source_system: str | None = typer.Option(
228+
None, "--source-system", help="Source system(s): codex, claude, cursor, or all (default)."
188229
),
189230
input_path: Path | None = typer.Option(
190231
None, "--input-path", help="Source sessions path override (per source)."
191232
),
192-
user: str = typer.Option(
193-
getpass.getuser(), "--user", help="User namespace in output history path."
233+
user: str | None = typer.Option(
234+
None, "--user", help="User namespace in output history path."
194235
),
195-
system_name: str = typer.Option(
196-
platform.node() or "unknown-system",
236+
system_name: str | None = typer.Option(
237+
None,
197238
"--system-name",
198239
help="System namespace in output history path.",
199240
),
200-
history_subpath: str = typer.Option(
201-
"history",
241+
history_subpath: str | None = typer.Option(
242+
None,
202243
"--history-subpath",
203244
help="Subpath inside output repo where history is written.",
204245
),
205-
dry_run: bool = typer.Option(
206-
False, "--dry-run", help="Plan export without writing files."
246+
dry_run: bool | None = typer.Option(
247+
None, "--dry-run/--no-dry-run", help="Plan export without writing files."
207248
),
208-
no_redact: bool = typer.Option(
209-
False, "--no-redact", help="Do not redact API keys, tokens, or passwords in output."
249+
redact: bool | None = typer.Option(
250+
None, "--redact/--no-redact", help="Redact API keys, tokens, or passwords in output."
210251
),
211-
with_context: bool = typer.Option(
212-
False, "--with-context", help="Include tool calls and injected context as HTML comments."
252+
with_context: bool | None = typer.Option(
253+
None, "--with-context/--no-with-context", help="Include tool calls and injected context as HTML comments."
213254
),
214-
with_thinking: bool = typer.Option(
215-
False, "--with-thinking", help="Include AI reasoning/thinking blocks as HTML comments."
255+
with_thinking: bool | None = typer.Option(
256+
None, "--with-thinking/--no-with-thinking", help="Include AI reasoning/thinking blocks as HTML comments."
216257
),
217-
skip_if_contains: str = typer.Option(
218-
"CONVX_NO_SYNC",
258+
skip_if_contains: str | None = typer.Option(
259+
None,
219260
"--skip-if-contains",
220261
help="Do not sync conversations that contain this string (pass empty to disable).",
221262
),
222-
overwrite: bool = typer.Option(
223-
False, "--overwrite", help="Re-export all sessions, ignoring cached fingerprints."
263+
overwrite: bool | None = typer.Option(
264+
None, "--overwrite/--no-overwrite", help="Re-export all sessions, ignoring cached fingerprints."
224265
),
225266
) -> None:
226267
"""Full backup of all conversations into a directory (created if missing)."""
227268
output_repo = _resolve_output_path(output_path)
269+
config = ConvxConfig.for_repo(output_repo)
270+
backup_defaults = config.backup
271+
source_system = source_system or backup_defaults.source_system
272+
input_path = input_path or (Path(backup_defaults.input_path) if backup_defaults.input_path else None)
273+
user = user or backup_defaults.user or getpass.getuser()
274+
system_name = system_name or backup_defaults.system_name or platform.node() or "unknown-system"
275+
history_subpath = history_subpath or backup_defaults.history_subpath
276+
dry_run = _resolve_bool(dry_run, backup_defaults.dry_run)
277+
redact = _resolve_bool(redact, backup_defaults.redact)
278+
with_context = _resolve_bool(with_context, backup_defaults.with_context)
279+
with_thinking = _resolve_bool(with_thinking, backup_defaults.with_thinking)
280+
skip_if_contains = backup_defaults.skip_if_contains if skip_if_contains is None else skip_if_contains
281+
overwrite = _resolve_bool(overwrite, backup_defaults.overwrite)
282+
228283
sources = _source_systems(source_system)
229284
total = SyncResult()
230285
console = Console()
@@ -242,7 +297,7 @@ def backup_command(
242297
user=sanitize_segment(user),
243298
system_name=sanitize_segment(system_name),
244299
dry_run=dry_run,
245-
redact=not no_redact,
300+
redact=redact,
246301
with_context=with_context,
247302
with_thinking=with_thinking,
248303
skip_if_contains=skip_if_contains,
@@ -341,14 +396,16 @@ def tui_command(
341396

342397
@hooks_app.command("install")
343398
def hooks_install(
344-
history_subpath: str = typer.Option(
345-
"history",
399+
history_subpath: str | None = typer.Option(
400+
None,
346401
"--history-subpath",
347402
help="Subpath inside repo where history is written (must match sync).",
348403
),
349404
) -> None:
350405
"""Install pre-commit hook that runs convx sync before each commit."""
351406
repo = _require_git_repo(Path.cwd())
407+
config = ConvxConfig.for_repo(repo)
408+
history_subpath = history_subpath or config.hooks.history_subpath
352409
hooks_dir = repo / ".git" / "hooks"
353410
hook_path = hooks_dir / "pre-commit"
354411
script = f"""#!/usr/bin/env sh
@@ -411,14 +468,16 @@ def word_stats_command(
411468
output_path: Path = typer.Option(
412469
Path.cwd(), "--output-path", help="Directory containing exported conversations."
413470
),
414-
history_subpath: str = typer.Option(
415-
"history", "--history-subpath", help="Subpath where history is written (must match sync/backup)."
471+
history_subpath: str | None = typer.Option(
472+
None, "--history-subpath", help="Subpath where history is written (must match sync/backup)."
416473
),
417474
) -> None:
418475
"""Show word count statistics per day per project."""
419476
from convx_ai.stats import compute_word_series
420477

421478
repo = output_path.expanduser().resolve()
479+
config = ConvxConfig.for_repo(repo)
480+
history_subpath = history_subpath or config.word_stats.history_subpath
422481
history_path = repo / history_subpath
423482

424483
if not history_path.exists():

0 commit comments

Comments
 (0)