study-sync 1.0.2__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.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: study_sync
3
+ Version: 1.0.2
4
+ Summary: Offline-first, distributed workspace synchronisation CLI for developers.
5
+ Author-email: Adinath <adinarayan.is23@bmsce.ac.in>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yourusername/studysync
8
+ Project-URL: Documentation, https://github.com/yourusername/studysync#readme
9
+ Project-URL: Bug Tracker, https://github.com/yourusername/studysync/issues
10
+ Project-URL: Changelog, https://github.com/yourusername/studysync/releases
11
+ Keywords: sync,cli,workspace,distributed,version-control,developer-tools
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Filesystems
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: typer[all]>=0.12.0
26
+ Requires-Dist: rich>=13.7.0
27
+ Requires-Dist: requests>=2.31.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
30
+ Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
31
+ Requires-Dist: responses>=0.25.0; extra == "dev"
32
+ Requires-Dist: build>=1.2.0; extra == "dev"
33
+ Requires-Dist: twine>=5.0.0; extra == "dev"
@@ -0,0 +1,101 @@
1
+ # StudySync
2
+
3
+ **Offline-first, distributed workspace synchronisation for developers.**
4
+
5
+ Share and sync files across laptops over a local network or the internet — with cryptographic integrity checking and conflict protection built in.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install studysync
13
+ ```
14
+
15
+ That's it. No environment setup, no config files, no server URL required.
16
+
17
+ ---
18
+
19
+ ## Quick Start (2 steps)
20
+
21
+ **Step 1 — Install**
22
+ ```bash
23
+ pip install studysync
24
+ ```
25
+
26
+ **Step 2 — Join a workspace with your token**
27
+ ```bash
28
+ study join <TOKEN>
29
+ ```
30
+
31
+ You'll receive a `<TOKEN>` from whoever created the workspace. After joining, pull all shared files straight to your current directory:
32
+
33
+ ```bash
34
+ study pull
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Full Workflow
40
+
41
+ ### Create a workspace (team lead / project owner)
42
+ ```bash
43
+ study workspace create my-project
44
+ # Output includes a TOKEN — share it with your team
45
+ ```
46
+
47
+ ### Join an existing workspace (everyone else)
48
+ ```bash
49
+ study join <TOKEN>
50
+ study pull # downloads all files into your current directory
51
+ ```
52
+
53
+ ### Push a file
54
+ ```bash
55
+ study push path/to/file.py
56
+ ```
57
+
58
+ If someone else pushed a newer version since your last pull, you'll see:
59
+
60
+ ```
61
+ ⚠ CONFLICT — Remote has changes. Pull first.
62
+ ```
63
+
64
+ Pull, resolve, then push again.
65
+
66
+ ### Check sync status
67
+ ```bash
68
+ study status
69
+ ```
70
+
71
+ Shows `CLEAN`, `MODIFIED`, `DELETED`, or `UNTRACKED` for every file in your local workspace.
72
+
73
+ ---
74
+
75
+ ## Self-Hosting
76
+
77
+ Advanced users running their own StudySync backend can override the default server:
78
+
79
+ ```bash
80
+ study join <TOKEN> --server https://my-backend.example.com
81
+ ```
82
+
83
+ The URL is saved locally after the first use — subsequent commands pick it up automatically.
84
+
85
+ ---
86
+
87
+ ## How It Works
88
+
89
+ | Feature | Detail |
90
+ |---|---|
91
+ | **Integrity** | Every file is SHA-256 hashed before push and verified after pull |
92
+ | **Conflict protection** | Optimistic Concurrency Control (OCC) — the server rejects a push if remote has a newer version |
93
+ | **Offline-first** | Edits happen locally; the network is only touched on explicit `push` / `pull` |
94
+ | **Zero-payload server** | File bytes stream directly between client and storage; the server only manages metadata |
95
+ | **Silent history** | Every push is versioned server-side — no data is ever permanently overwritten |
96
+
97
+ ---
98
+
99
+ ## License
100
+
101
+ MIT © Adinath
@@ -0,0 +1,81 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ # ---------------------------------------------------------------------------
6
+ # Core metadata
7
+ # ---------------------------------------------------------------------------
8
+ [project]
9
+ name = "study_sync"
10
+ version = "1.0.2"
11
+ description = "Offline-first, distributed workspace synchronisation CLI for developers."
12
+ license = { text = "MIT" }
13
+ requires-python = ">=3.10"
14
+ authors = [
15
+ { name = "Adinath", email = "adinarayan.is23@bmsce.ac.in" },
16
+ ]
17
+ keywords = [
18
+ "sync", "cli", "workspace", "distributed",
19
+ "version-control", "developer-tools",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Environment :: Console",
24
+ "Intended Audience :: Developers",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Operating System :: OS Independent",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Topic :: System :: Filesystems",
33
+ "Topic :: Utilities",
34
+ ]
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Runtime dependencies
38
+ # ---------------------------------------------------------------------------
39
+ dependencies = [
40
+ "typer[all]>=0.12.0",
41
+ "rich>=13.7.0",
42
+ "requests>=2.31.0",
43
+ ]
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Optional extras
47
+ # pip install studysync[dev]
48
+ # ---------------------------------------------------------------------------
49
+ [project.optional-dependencies]
50
+ dev = [
51
+ "pytest>=8.0.0",
52
+ "pytest-mock>=3.14.0",
53
+ "responses>=0.25.0",
54
+ "build>=1.2.0",
55
+ "twine>=5.0.0",
56
+ ]
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Entry point — makes `study` work as a global shell command
60
+ # ---------------------------------------------------------------------------
61
+ [project.scripts]
62
+ study = "studysync.main:app"
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Project URLs shown on the PyPI landing page
66
+ # ---------------------------------------------------------------------------
67
+ [project.urls]
68
+ Homepage = "https://github.com/yourusername/studysync"
69
+ Documentation = "https://github.com/yourusername/studysync#readme"
70
+ "Bug Tracker" = "https://github.com/yourusername/studysync/issues"
71
+ Changelog = "https://github.com/yourusername/studysync/releases"
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Package discovery
75
+ # ---------------------------------------------------------------------------
76
+ [tool.setuptools.packages.find]
77
+ where = ["."]
78
+ include = ["studysync*"]
79
+
80
+ [tool.setuptools.package-data]
81
+ studysync = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: study_sync
3
+ Version: 1.0.2
4
+ Summary: Offline-first, distributed workspace synchronisation CLI for developers.
5
+ Author-email: Adinath <adinarayan.is23@bmsce.ac.in>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yourusername/studysync
8
+ Project-URL: Documentation, https://github.com/yourusername/studysync#readme
9
+ Project-URL: Bug Tracker, https://github.com/yourusername/studysync/issues
10
+ Project-URL: Changelog, https://github.com/yourusername/studysync/releases
11
+ Keywords: sync,cli,workspace,distributed,version-control,developer-tools
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Filesystems
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: typer[all]>=0.12.0
26
+ Requires-Dist: rich>=13.7.0
27
+ Requires-Dist: requests>=2.31.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
30
+ Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
31
+ Requires-Dist: responses>=0.25.0; extra == "dev"
32
+ Requires-Dist: build>=1.2.0; extra == "dev"
33
+ Requires-Dist: twine>=5.0.0; extra == "dev"
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ study_sync.egg-info/PKG-INFO
4
+ study_sync.egg-info/SOURCES.txt
5
+ study_sync.egg-info/dependency_links.txt
6
+ study_sync.egg-info/entry_points.txt
7
+ study_sync.egg-info/requires.txt
8
+ study_sync.egg-info/top_level.txt
9
+ studysync/__init__.py
10
+ studysync/constants.py
11
+ studysync/local_state.py
12
+ studysync/main.py
13
+ studysync/sync_engine.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ study = studysync.main:app
@@ -0,0 +1,10 @@
1
+ typer[all]>=0.12.0
2
+ rich>=13.7.0
3
+ requests>=2.31.0
4
+
5
+ [dev]
6
+ pytest>=8.0.0
7
+ pytest-mock>=3.14.0
8
+ responses>=0.25.0
9
+ build>=1.2.0
10
+ twine>=5.0.0
@@ -0,0 +1 @@
1
+ studysync
@@ -0,0 +1,3 @@
1
+ """StudySync CLI package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,18 @@
1
+ """
2
+ constants.py — Package-wide constants for StudySync CLI.
3
+
4
+ PRODUCTION_SERVER_URL is the single source of truth for the default backend.
5
+ It is used as the fallback in every network call so users who install via
6
+ `pip install studysync` can run `study join <TOKEN>` immediately — no
7
+ --server flag required.
8
+
9
+ Advanced users hosting their own backend can override this at any point:
10
+ study workspace create my-ws --server http://192.168.1.42:8000
11
+ study join <TOKEN> --server https://my-own-instance.com
12
+ The chosen URL is persisted to ~/.study/config.json after the first use,
13
+ so subsequent commands pick it up automatically.
14
+ """
15
+
16
+ # The public StudySync backend. Update this constant when the production
17
+ # deployment URL changes and bump the package version accordingly.
18
+ PRODUCTION_SERVER_URL: str = "https://studysync-backend-pfft.onrender.com"
@@ -0,0 +1,183 @@
1
+ """
2
+ local_state.py — filesystem layout, hashing, manifest, and config I/O.
3
+
4
+ Directory structure
5
+ -------------------
6
+ ~/.study/
7
+ config.json — active workspace config (token, server URL, …)
8
+ manifest.json — local state: {file_path: {version, checksum}}
9
+ workspaces/
10
+ <workspace_name>/
11
+ … — local copies of synced files (mirror of remote)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Root paths
25
+ # ---------------------------------------------------------------------------
26
+
27
+ STUDY_DIR: Path = Path.home() / ".study"
28
+ CONFIG_PATH: Path = STUDY_DIR / "config.json"
29
+ MANIFEST_PATH: Path = STUDY_DIR / "manifest.json"
30
+ WORKSPACES_DIR: Path = STUDY_DIR / "workspaces"
31
+
32
+
33
+ def ensure_dirs() -> None:
34
+ """Create the ~/.study/ directory tree if it does not already exist."""
35
+ STUDY_DIR.mkdir(parents=True, exist_ok=True)
36
+ WORKSPACES_DIR.mkdir(parents=True, exist_ok=True)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Hashing
41
+ # ---------------------------------------------------------------------------
42
+
43
+ HASH_CHUNK_SIZE = 65_536 # 64 KiB
44
+
45
+
46
+ def sha256_file(path: Path) -> str:
47
+ """
48
+ Return the lowercase SHA-256 hex digest of the file at *path*.
49
+
50
+ Reads in chunks to handle arbitrarily large files without loading
51
+ everything into memory.
52
+ """
53
+ h = hashlib.sha256()
54
+ with open(path, "rb") as fh:
55
+ for chunk in iter(lambda: fh.read(HASH_CHUNK_SIZE), b""):
56
+ h.update(chunk)
57
+ return h.hexdigest()
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Config (~/.study/config.json)
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def load_config() -> dict:
65
+ """Return the config dict, or {} if it has not been created yet."""
66
+ if not CONFIG_PATH.exists():
67
+ return {}
68
+ try:
69
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
70
+ except json.JSONDecodeError:
71
+ return {}
72
+
73
+
74
+ def save_config(config: dict) -> None:
75
+ ensure_dirs()
76
+ CONFIG_PATH.write_text(json.dumps(config, indent=2), encoding="utf-8")
77
+
78
+
79
+ def get_config_value(key: str) -> Optional[str]:
80
+ return load_config().get(key)
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Manifest (~/.study/manifest.json)
85
+ # ---------------------------------------------------------------------------
86
+ #
87
+ # Schema:
88
+ # {
89
+ # "src/main.py": {"version": 3, "checksum": "abcdef..."},
90
+ # "docs/README.md": {"version": 1, "checksum": "123456..."}
91
+ # }
92
+
93
+ def load_manifest() -> dict:
94
+ """Return the manifest dict, or {} if not yet initialised."""
95
+ if not MANIFEST_PATH.exists():
96
+ return {}
97
+ try:
98
+ return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
99
+ except json.JSONDecodeError:
100
+ return {}
101
+
102
+
103
+ def save_manifest(manifest: dict) -> None:
104
+ ensure_dirs()
105
+ MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
106
+
107
+
108
+ def get_manifest_entry(file_path: str) -> Optional[dict]:
109
+ """Return the manifest entry for *file_path*, or None if untracked."""
110
+ return load_manifest().get(file_path)
111
+
112
+
113
+ def update_manifest_entry(file_path: str, version: int, checksum: str) -> None:
114
+ """
115
+ Upsert a single manifest entry after a successful push or pull.
116
+ Thread-safety note: for a single-user CLI this is fine; no locking needed.
117
+ """
118
+ manifest = load_manifest()
119
+ manifest[file_path] = {"version": version, "checksum": checksum}
120
+ save_manifest(manifest)
121
+
122
+
123
+ def remove_manifest_entry(file_path: str) -> None:
124
+ """Remove a file from the manifest (e.g. after a local delete)."""
125
+ manifest = load_manifest()
126
+ manifest.pop(file_path, None)
127
+ save_manifest(manifest)
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Workspace file paths
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def workspace_root(workspace_name: str) -> Path:
135
+ """Return (and create) the local directory for the given workspace."""
136
+ p = WORKSPACES_DIR / workspace_name
137
+ p.mkdir(parents=True, exist_ok=True)
138
+ return p
139
+
140
+
141
+ def local_file_path(workspace_name: str, file_path: str) -> Path:
142
+ """
143
+ Translate a workspace-relative *file_path* (e.g. "src/main.py") to its
144
+ absolute path on disk (e.g. ~/.study/workspaces/my-ws/src/main.py).
145
+ """
146
+ return workspace_root(workspace_name) / file_path
147
+
148
+
149
+ def to_relative_path(workspace_name: str, absolute_path: Path) -> str:
150
+ """
151
+ Convert an absolute path inside the workspace directory to a
152
+ workspace-relative POSIX string (e.g. "src/main.py").
153
+ """
154
+ root = workspace_root(workspace_name)
155
+ return absolute_path.relative_to(root).as_posix()
156
+
157
+
158
+ def all_local_files(workspace_name: str) -> list[Path]:
159
+ """Return absolute Paths for every file currently in the local workspace."""
160
+ root = workspace_root(workspace_name)
161
+ if not root.exists():
162
+ return []
163
+ return [p for p in root.rglob("*") if p.is_file()]
164
+
165
+
166
+ def normalize_file_path(raw: str) -> str:
167
+ """
168
+ Normalise a user-supplied path into a workspace-relative POSIX string.
169
+
170
+ Strips leading slashes, collapses '..' (no escapes above root), and
171
+ converts backslashes to forward slashes.
172
+ """
173
+ p = Path(raw.replace("\\", "/"))
174
+ parts: list[str] = []
175
+ for seg in p.parts:
176
+ if seg in ("", ".", "/"):
177
+ continue
178
+ if seg == "..":
179
+ if parts:
180
+ parts.pop()
181
+ else:
182
+ parts.append(seg)
183
+ return "/".join(parts)
@@ -0,0 +1,261 @@
1
+ """
2
+ main.py — Typer CLI entry point for the StudySync `study` command.
3
+
4
+ Install:
5
+ cd cli && pip install -e .
6
+
7
+ Usage:
8
+ study workspace create <name> [--server <url>]
9
+ study join <token> [--server <url>]
10
+ study pull
11
+ study push <file_path>
12
+ study status
13
+ study config
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ import typer
23
+ from rich.console import Console
24
+ from rich.panel import Panel
25
+
26
+ from .constants import PRODUCTION_SERVER_URL
27
+ from .local_state import (
28
+ WORKSPACES_DIR,
29
+ ensure_dirs,
30
+ load_config,
31
+ save_config,
32
+ workspace_root,
33
+ )
34
+ from .sync_engine import SyncEngine
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # App skeleton
38
+ # ---------------------------------------------------------------------------
39
+
40
+ app = typer.Typer(
41
+ name="study",
42
+ help="[bold]StudySync[/bold] — offline-first CLI workspace synchronisation.",
43
+ rich_markup_mode="rich",
44
+ no_args_is_help=True,
45
+ add_completion=True,
46
+ )
47
+
48
+ workspace_app = typer.Typer(
49
+ help="Manage workspaces (create, list).",
50
+ no_args_is_help=True,
51
+ )
52
+ app.add_typer(workspace_app, name="workspace")
53
+
54
+ console = Console()
55
+
56
+ # Default gateway — points at the public production backend so users who
57
+ # install via `pip install studysync` work immediately without --server.
58
+ # Override with --server for self-hosted deployments.
59
+ DEFAULT_SERVER = PRODUCTION_SERVER_URL
60
+
61
+
62
+ # ===========================================================================
63
+ # study workspace create <name>
64
+ # ===========================================================================
65
+
66
+ @workspace_app.command("create")
67
+ def workspace_create(
68
+ name: str = typer.Argument(..., help="Unique workspace name to create on the server."),
69
+ server: str = typer.Option(
70
+ DEFAULT_SERVER,
71
+ "--server",
72
+ "-s",
73
+ help="Base URL of the StudySync server.",
74
+ envvar="STUDYSYNC_SERVER",
75
+ ),
76
+ ) -> None:
77
+ """
78
+ Create a new workspace on the server and save the returned token locally.
79
+
80
+ The printed token is the only credential for joining this workspace —
81
+ share it with collaborators.
82
+ """
83
+ ensure_dirs()
84
+ engine = SyncEngine(server_url=server)
85
+
86
+ with console.status(f"[blue]Creating workspace '[bold]{name}[/bold]'…[/blue]"):
87
+ result = engine.create_workspace(name)
88
+
89
+ token: str = result["access_token"]
90
+ workspace_id: str = result["workspace_id"]
91
+
92
+ save_config(
93
+ {
94
+ "server_url": server,
95
+ "workspace_name": name,
96
+ "workspace_id": workspace_id,
97
+ "workspace_token": token,
98
+ }
99
+ )
100
+ workspace_root(name) # ensure local directory exists
101
+
102
+ console.print(
103
+ Panel(
104
+ f"[green]Workspace '[bold]{name}[/bold]' created.[/green]\n\n"
105
+ f"[bold]Token[/bold] [yellow]{token}[/yellow]\n\n"
106
+ f"Share this token with collaborators:\n"
107
+ f" [cyan]study join {token}[/cyan]",
108
+ title="✓ Workspace Created",
109
+ border_style="green",
110
+ padding=(1, 2),
111
+ )
112
+ )
113
+
114
+
115
+ # ===========================================================================
116
+ # study join <token>
117
+ # ===========================================================================
118
+
119
+ @app.command()
120
+ def join(
121
+ token: str = typer.Argument(..., help="Workspace access token (UUID)."),
122
+ server: str = typer.Option(
123
+ DEFAULT_SERVER,
124
+ "--server",
125
+ "-s",
126
+ help="Base URL of the StudySync server.",
127
+ envvar="STUDYSYNC_SERVER",
128
+ ),
129
+ ) -> None:
130
+ """
131
+ Validate a workspace token and set it as the active workspace.
132
+
133
+ Run [cyan]study pull[/cyan] afterwards to download existing files.
134
+ """
135
+ ensure_dirs()
136
+ engine = SyncEngine(server_url=server)
137
+
138
+ with console.status("[blue]Validating token…[/blue]"):
139
+ result = engine.join_workspace(token)
140
+
141
+ workspace_name: str = result["name"]
142
+ workspace_id: str = result["workspace_id"]
143
+
144
+ save_config(
145
+ {
146
+ "server_url": server,
147
+ "workspace_name": workspace_name,
148
+ "workspace_id": workspace_id,
149
+ "workspace_token": token,
150
+ }
151
+ )
152
+ workspace_root(workspace_name)
153
+
154
+ console.print(
155
+ Panel(
156
+ f"[green]Joined workspace '[bold]{workspace_name}[/bold]'.[/green]\n\n"
157
+ f"Run [cyan]study pull[/cyan] to download all files.",
158
+ title="✓ Joined",
159
+ border_style="green",
160
+ padding=(1, 2),
161
+ )
162
+ )
163
+
164
+
165
+ # ===========================================================================
166
+ # study pull
167
+ # ===========================================================================
168
+
169
+ @app.command()
170
+ def pull() -> None:
171
+ """
172
+ Pull all new or updated files from the remote workspace.
173
+
174
+ Compares the remote file tree (versions + checksums) against the local
175
+ manifest and downloads only what has changed. Local file hashes are
176
+ verified after download.
177
+ """
178
+ SyncEngine().pull()
179
+
180
+
181
+ # ===========================================================================
182
+ # study push <file_path>
183
+ # ===========================================================================
184
+
185
+ @app.command()
186
+ def push(
187
+ file_path: str = typer.Argument(
188
+ ...,
189
+ help=(
190
+ "Path to the file to push. "
191
+ "Can be workspace-relative ('src/main.py') "
192
+ "or an OS path ('/home/user/project/main.py')."
193
+ ),
194
+ ),
195
+ ) -> None:
196
+ """
197
+ Push a local file to the remote workspace.
198
+
199
+ Enforces Optimistic Concurrency Control — if the remote has a newer
200
+ version than your local base, the push is rejected with a [red]CONFLICT[/red]
201
+ warning and you must run [cyan]study pull[/cyan] first.
202
+ """
203
+ SyncEngine().push(file_path)
204
+
205
+
206
+ # ===========================================================================
207
+ # study status
208
+ # ===========================================================================
209
+
210
+ @app.command()
211
+ def status() -> None:
212
+ """
213
+ Show the sync status of every file in the local workspace.
214
+
215
+ [green]CLEAN[/green] — matches the last-known server version.
216
+ [yellow]MODIFIED[/yellow] — changed locally since last push/pull.
217
+ [red]DELETED[/red] — tracked but missing on disk.
218
+ [blue]UNTRACKED[/blue] — on disk but not yet pushed.
219
+ """
220
+ SyncEngine().status()
221
+
222
+
223
+ # ===========================================================================
224
+ # study config
225
+ # ===========================================================================
226
+
227
+ @app.command()
228
+ def config() -> None:
229
+ """Show the current workspace configuration stored in ~/.study/config.json."""
230
+ cfg = load_config()
231
+ if not cfg:
232
+ console.print(
233
+ "[yellow]No workspace configured. "
234
+ "Run [cyan]study workspace create <name>[/cyan] or "
235
+ "[cyan]study join <token>[/cyan].[/yellow]"
236
+ )
237
+ raise typer.Exit(1)
238
+
239
+ lines = [
240
+ f"[bold]Workspace [/bold] {cfg.get('workspace_name', 'N/A')}",
241
+ f"[bold]Server [/bold] {cfg.get('server_url', 'N/A')}",
242
+ f"[bold]Token [/bold] [yellow]{cfg.get('workspace_token', 'N/A')}[/yellow]",
243
+ f"[bold]Workspace ID[/bold] {cfg.get('workspace_id', 'N/A')}",
244
+ f"[bold]Local path [/bold] {WORKSPACES_DIR / cfg.get('workspace_name', '')}",
245
+ ]
246
+ console.print(
247
+ Panel(
248
+ "\n".join(lines),
249
+ title="StudySync Config (~/.study/config.json)",
250
+ border_style="blue",
251
+ padding=(1, 2),
252
+ )
253
+ )
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Entry point (for `python -m studysync.main`)
258
+ # ---------------------------------------------------------------------------
259
+
260
+ if __name__ == "__main__":
261
+ app()
@@ -0,0 +1,595 @@
1
+ """
2
+ sync_engine.py — Networking, OCC logic, and S3 streaming.
3
+
4
+ All HTTP calls to the StudySync server and all direct S3 streaming live here.
5
+ The Typer commands in main.py are thin wrappers that call into SyncEngine.
6
+
7
+ Key design decisions
8
+ --------------------
9
+ * Streaming uploads: We open the local file as a binary file object and wrap
10
+ it in a progress-tracking shim. `requests` reads the wrapper's `.read()`
11
+ method and forwards the Content-Length header we set explicitly, which is
12
+ required for S3 presigned PUT URLs.
13
+ * Streaming downloads: `requests` streaming GET + chunk-writing gives us a
14
+ constant memory footprint regardless of file size.
15
+ * No retries: This is v1. Production systems should add exponential backoff
16
+ with `tenacity` or `urllib3.Retry`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import shutil
23
+ import sys
24
+
25
+ from .constants import PRODUCTION_SERVER_URL
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ import requests
30
+ from rich.console import Console
31
+ from rich.progress import (
32
+ BarColumn,
33
+ DownloadColumn,
34
+ Progress,
35
+ SpinnerColumn,
36
+ TextColumn,
37
+ TimeRemainingColumn,
38
+ TransferSpeedColumn,
39
+ )
40
+ from rich.table import Table
41
+
42
+ from .local_state import (
43
+ all_local_files,
44
+ load_config,
45
+ load_manifest,
46
+ local_file_path,
47
+ sha256_file,
48
+ to_relative_path,
49
+ update_manifest_entry,
50
+ workspace_root,
51
+ WORKSPACES_DIR,
52
+ )
53
+
54
+ console = Console(stderr=False)
55
+
56
+ UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024 # 8 MiB
57
+ DOWNLOAD_CHUNK_SIZE = 8 * 1024 * 1024 # 8 MiB
58
+ HTTP_TIMEOUT = 20 # seconds for metadata calls
59
+ STREAM_TIMEOUT = 600 # seconds for S3 streaming
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Progress-aware file wrapper for streaming uploads
64
+ # ---------------------------------------------------------------------------
65
+
66
+ class _ProgressReader:
67
+ """
68
+ File-like wrapper that reports bytes read to a Rich Progress task.
69
+
70
+ requests calls `.read(size)` on whatever you pass as `data=`, so this
71
+ wrapper intercepts those reads and advances the progress bar. Exposing
72
+ `__len__` lets requests set the Content-Length header automatically, which
73
+ is mandatory for S3 presigned PUT URLs.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ fh,
79
+ total: int,
80
+ progress: Progress,
81
+ task_id,
82
+ ) -> None:
83
+ self._fh = fh
84
+ self._total = total
85
+ self._progress = progress
86
+ self._task_id = task_id
87
+
88
+ def read(self, size: int = -1) -> bytes:
89
+ chunk = self._fh.read(size)
90
+ if chunk:
91
+ self._progress.update(self._task_id, advance=len(chunk))
92
+ return chunk
93
+
94
+ def __len__(self) -> int:
95
+ return self._total
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # SyncEngine
100
+ # ---------------------------------------------------------------------------
101
+
102
+ class SyncEngine:
103
+ """
104
+ Encapsulates all network operations for the StudySync CLI.
105
+
106
+ Parameters override config.json values — useful for workspace create/join
107
+ where config hasn't been saved yet.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ server_url: Optional[str] = None,
113
+ workspace_token: Optional[str] = None,
114
+ workspace_name: Optional[str] = None,
115
+ ) -> None:
116
+ config = load_config()
117
+ raw_url = server_url or config.get("server_url", PRODUCTION_SERVER_URL)
118
+ self.server_url = raw_url.rstrip("/")
119
+ self.workspace_token = workspace_token or config.get("workspace_token")
120
+ self.workspace_name = workspace_name or config.get("workspace_name")
121
+
122
+ # ------------------------------------------------------------------
123
+ # Guard
124
+ # ------------------------------------------------------------------
125
+
126
+ def _require_workspace(self) -> None:
127
+ if not self.workspace_token or not self.workspace_name:
128
+ console.print(
129
+ "[red]No active workspace. "
130
+ "Run [bold]study workspace create <name>[/bold] or "
131
+ "[bold]study join <token>[/bold] first.[/red]"
132
+ )
133
+ sys.exit(1)
134
+
135
+ # ------------------------------------------------------------------
136
+ # Workspace management
137
+ # ------------------------------------------------------------------
138
+
139
+ def create_workspace(self, name: str) -> dict:
140
+ resp = requests.post(
141
+ f"{self.server_url}/workspaces",
142
+ json={"name": name},
143
+ timeout=HTTP_TIMEOUT,
144
+ )
145
+ if resp.status_code == 409:
146
+ console.print(
147
+ f"[red]A workspace named '[bold]{name}[/bold]' already exists on the server.[/red]"
148
+ )
149
+ sys.exit(1)
150
+ _raise_for_status(resp)
151
+ return resp.json()
152
+
153
+ def join_workspace(self, token: str) -> dict:
154
+ resp = requests.post(
155
+ f"{self.server_url}/workspaces/join",
156
+ json={"token": token},
157
+ timeout=HTTP_TIMEOUT,
158
+ )
159
+ if resp.status_code in (401, 404):
160
+ console.print("[red]Invalid token — workspace not found.[/red]")
161
+ sys.exit(1)
162
+ _raise_for_status(resp)
163
+ return resp.json()
164
+
165
+ # ------------------------------------------------------------------
166
+ # Remote state
167
+ # ------------------------------------------------------------------
168
+
169
+ def get_remote_state(self) -> list[dict]:
170
+ self._require_workspace()
171
+ resp = requests.get(
172
+ f"{self.server_url}/sync/state/{self.workspace_token}",
173
+ timeout=HTTP_TIMEOUT,
174
+ )
175
+ _raise_for_status(resp)
176
+ return resp.json()["files"]
177
+
178
+ # ------------------------------------------------------------------
179
+ # pull
180
+ # ------------------------------------------------------------------
181
+
182
+ def pull(self) -> None:
183
+ """
184
+ Compare remote state to the local manifest and download every file
185
+ that is absent or outdated locally.
186
+
187
+ A file is considered up-to-date if:
188
+ 1. Its manifest entry version matches the remote version, AND
189
+ 2. The physical file on disk still matches the manifest checksum.
190
+
191
+ Condition 2 catches the edge case where someone manually edited the
192
+ file without going through `study push`.
193
+ """
194
+ self._require_workspace()
195
+ assert self.workspace_name # guarded by _require_workspace
196
+
197
+ console.print(
198
+ f"[blue]Fetching remote state for workspace "
199
+ f"'[bold]{self.workspace_name}[/bold]'…[/blue]"
200
+ )
201
+
202
+ remote_files = self.get_remote_state()
203
+ if not remote_files:
204
+ console.print("[green]Workspace is empty. Nothing to pull.[/green]")
205
+ return
206
+
207
+ manifest = load_manifest()
208
+ to_download: list[dict] = []
209
+
210
+ for rf in remote_files:
211
+ file_path: str = rf["file_path"]
212
+ remote_version: int = rf["latest_version"]
213
+ remote_checksum: str = rf.get("latest_checksum") or ""
214
+
215
+ local_entry = manifest.get(file_path)
216
+ dest = local_file_path(self.workspace_name, file_path)
217
+
218
+ if local_entry and local_entry["version"] == remote_version:
219
+ # Version matches — verify the physical file hasn't drifted.
220
+ if dest.exists():
221
+ if sha256_file(dest) == remote_checksum:
222
+ continue # Truly up to date
223
+ # File was modified locally without a push — pull wins.
224
+ console.print(
225
+ f"[yellow] Overwriting locally-modified "
226
+ f"'[bold]{file_path}[/bold]' with remote v{remote_version}[/yellow]"
227
+ )
228
+
229
+ to_download.append(rf)
230
+
231
+ if not to_download:
232
+ console.print("[green]✓ Everything is up to date.[/green]")
233
+ return
234
+
235
+ console.print(f"[blue]Downloading [bold]{len(to_download)}[/bold] file(s)…[/blue]")
236
+
237
+ success = 0
238
+ for rf in to_download:
239
+ file_path = rf["file_path"]
240
+ remote_version = rf["latest_version"]
241
+ remote_checksum = rf.get("latest_checksum") or ""
242
+ size_bytes: Optional[int] = rf.get("size_bytes")
243
+
244
+ console.print(f" → [cyan]{file_path}[/cyan] [dim]v{remote_version}[/dim]")
245
+ try:
246
+ dl_info = self._request_download(file_path)
247
+ dest = local_file_path(self.workspace_name, file_path)
248
+ self._stream_download(dl_info["presigned_url"], dest, size_bytes)
249
+
250
+ # Integrity check
251
+ actual_checksum = sha256_file(dest)
252
+ if actual_checksum != remote_checksum:
253
+ console.print(
254
+ f" [red]✗ Checksum mismatch for '{file_path}'. "
255
+ f"Expected {remote_checksum[:12]}… got {actual_checksum[:12]}… "
256
+ "File deleted.[/red]"
257
+ )
258
+ dest.unlink(missing_ok=True)
259
+ continue
260
+
261
+ update_manifest_entry(file_path, remote_version, remote_checksum)
262
+
263
+ # ----------------------------------------------------------
264
+ # Checkout phase: copy from vault → current working directory
265
+ # ----------------------------------------------------------
266
+ cwd = Path(os.getcwd())
267
+ checkout_dest = cwd / file_path
268
+ checkout_dest.parent.mkdir(parents=True, exist_ok=True)
269
+ shutil.copy2(dest, checkout_dest)
270
+
271
+ console.print(
272
+ f" [green]✓ {file_path}[/green] "
273
+ f"[dim]→ ./{Path(file_path).as_posix()}[/dim]"
274
+ )
275
+ success += 1
276
+
277
+ except Exception as exc:
278
+ console.print(f" [red]✗ Failed to download '{file_path}': {exc}[/red]")
279
+
280
+ console.print(
281
+ f"[green]Pull complete — [bold]{success}/{len(to_download)}[/bold] file(s) "
282
+ f"updated in vault and checked out to [bold]{os.getcwd()}[/bold].[/green]"
283
+ )
284
+
285
+ # ------------------------------------------------------------------
286
+ # push
287
+ # ------------------------------------------------------------------
288
+
289
+ def push(self, file_path_arg: str) -> None:
290
+ """
291
+ Push a single file to the remote workspace.
292
+
293
+ Accepts either:
294
+ * A workspace-relative path ("src/main.py")
295
+ * An absolute/relative OS path — the file is copied into the workspace
296
+ directory if it lives outside it.
297
+
298
+ OCC flow:
299
+ 1. Hash the local file.
300
+ 2. Read base_version from the manifest (0 if untracked/new).
301
+ 3. POST /sync/upload-request → 409 means remote has diverged → abort.
302
+ 4. Stream PUT to the presigned S3 URL.
303
+ 5. POST /sync/commit-upload.
304
+ 6. Update manifest.
305
+ """
306
+ self._require_workspace()
307
+ assert self.workspace_name
308
+
309
+ ws_root = workspace_root(self.workspace_name)
310
+
311
+ # Resolve the file and its workspace-relative key
312
+ abs_path, relative = self._resolve_push_path(file_path_arg, ws_root)
313
+
314
+ console.print(f"[blue]Hashing [bold]{relative}[/bold]…[/blue]")
315
+ checksum = sha256_file(abs_path)
316
+ size_bytes = abs_path.stat().st_size
317
+
318
+ # Check against manifest: skip if nothing changed
319
+ manifest = load_manifest()
320
+ local_entry = manifest.get(relative)
321
+ if local_entry and local_entry["checksum"] == checksum:
322
+ console.print(
323
+ f"[yellow]No changes detected in '[bold]{relative}[/bold]'. Nothing to push.[/yellow]"
324
+ )
325
+ return
326
+
327
+ base_version = local_entry["version"] if local_entry else 0
328
+
329
+ # --- OCC gate ---
330
+ console.print(
331
+ f"[blue]Requesting upload slot "
332
+ f"[dim](base_version={base_version})[/dim]…[/blue]"
333
+ )
334
+ resp = requests.post(
335
+ f"{self.server_url}/sync/upload-request",
336
+ json={
337
+ "workspace_token": self.workspace_token,
338
+ "file_path": relative,
339
+ "base_version": base_version,
340
+ "checksum": checksum,
341
+ "size_bytes": size_bytes,
342
+ },
343
+ timeout=HTTP_TIMEOUT,
344
+ )
345
+
346
+ if resp.status_code == 409:
347
+ detail = resp.json().get("detail", "Remote has diverged.")
348
+ console.print(
349
+ f"\n[bold red]⚠ CONFLICT — Remote has changes. Pull first.[/bold red]\n"
350
+ f"[red]{detail}[/red]\n"
351
+ f" Run: [cyan]study pull[/cyan]\n"
352
+ )
353
+ sys.exit(1)
354
+
355
+ _raise_for_status(resp)
356
+ upload_info = resp.json()
357
+
358
+ upload_id: str = upload_info["upload_id"]
359
+ presigned_url: str = upload_info["presigned_url"]
360
+ new_version: int = upload_info["new_version"]
361
+
362
+ # --- Stream to S3 ---
363
+ console.print(f"[blue]Uploading to S3 [dim]({size_bytes:,} bytes)[/dim]…[/blue]")
364
+ try:
365
+ self._stream_upload(presigned_url, abs_path, size_bytes)
366
+ except Exception as exc:
367
+ console.print(f"[red]✗ S3 upload failed: {exc}[/red]")
368
+ console.print(
369
+ "[yellow]The upload slot has been allocated but the file was not written. "
370
+ "You can retry — the slot will expire automatically.[/yellow]"
371
+ )
372
+ sys.exit(1)
373
+
374
+ # --- Commit ---
375
+ console.print("[blue]Committing…[/blue]")
376
+ try:
377
+ commit_resp = requests.post(
378
+ f"{self.server_url}/sync/commit-upload",
379
+ json={"upload_id": upload_id},
380
+ timeout=HTTP_TIMEOUT,
381
+ )
382
+ _raise_for_status(commit_resp)
383
+ except Exception as exc:
384
+ console.print(
385
+ f"[red]✗ Commit failed: {exc}\n"
386
+ "The file was uploaded to S3 but the server did not record it. "
387
+ "Contact your administrator with upload_id=[bold]{upload_id}[/bold].[/red]"
388
+ )
389
+ sys.exit(1)
390
+
391
+ # --- Update manifest ---
392
+ update_manifest_entry(relative, new_version, checksum)
393
+ console.print(
394
+ f"[green]✓ Pushed '[bold]{relative}[/bold]' → v{new_version}[/green]"
395
+ )
396
+
397
+ # ------------------------------------------------------------------
398
+ # status
399
+ # ------------------------------------------------------------------
400
+
401
+ def status(self) -> None:
402
+ """
403
+ Compare every tracked file's on-disk SHA-256 against the manifest.
404
+
405
+ States:
406
+ CLEAN — matches manifest checksum
407
+ MODIFIED — exists on disk but checksum differs
408
+ DELETED — tracked in manifest but missing on disk
409
+ UNTRACKED — present on disk but not in manifest
410
+ """
411
+ self._require_workspace()
412
+ assert self.workspace_name
413
+
414
+ manifest = load_manifest()
415
+ ws_root = workspace_root(self.workspace_name)
416
+
417
+ table = Table(
418
+ title=f"Workspace: [bold]{self.workspace_name}[/bold]",
419
+ show_lines=False,
420
+ header_style="bold",
421
+ )
422
+ table.add_column("File", style="cyan", no_wrap=True)
423
+ table.add_column("Status", justify="center")
424
+ table.add_column("Ver", justify="right", style="dim")
425
+ table.add_column("Checksum", style="dim", no_wrap=True)
426
+
427
+ tracked_paths: set[str] = set(manifest.keys())
428
+ rows: list[tuple] = []
429
+
430
+ for file_path, entry in manifest.items():
431
+ dest = local_file_path(self.workspace_name, file_path)
432
+ ver = str(entry["version"])
433
+ if not dest.exists():
434
+ rows.append((file_path, "[red]DELETED[/red]", ver, entry["checksum"][:14] + "…"))
435
+ else:
436
+ current = sha256_file(dest)
437
+ if current == entry["checksum"]:
438
+ rows.append((file_path, "[green]CLEAN[/green]", ver, current[:14] + "…"))
439
+ else:
440
+ rows.append((file_path, "[yellow]MODIFIED[/yellow]", ver, current[:14] + "…"))
441
+
442
+ # Untracked files
443
+ for abs_path in all_local_files(self.workspace_name):
444
+ rel = to_relative_path(self.workspace_name, abs_path)
445
+ if rel not in tracked_paths:
446
+ rows.append((rel, "[blue]UNTRACKED[/blue]", "-", "-"))
447
+
448
+ if not rows:
449
+ console.print(
450
+ f"[yellow]Workspace '[bold]{self.workspace_name}[/bold]' is empty locally. "
451
+ "Run [cyan]study pull[/cyan] to download files.[/yellow]"
452
+ )
453
+ return
454
+
455
+ for row in sorted(rows, key=lambda r: r[0]):
456
+ table.add_row(*row)
457
+
458
+ console.print(table)
459
+
460
+ # ------------------------------------------------------------------
461
+ # Internal helpers
462
+ # ------------------------------------------------------------------
463
+
464
+ def _request_download(self, file_path: str) -> dict:
465
+ resp = requests.get(
466
+ f"{self.server_url}/sync/download-request",
467
+ params={
468
+ "workspace_token": self.workspace_token,
469
+ "file_path": file_path,
470
+ },
471
+ timeout=HTTP_TIMEOUT,
472
+ )
473
+ _raise_for_status(resp)
474
+ return resp.json()
475
+
476
+ def _stream_download(
477
+ self,
478
+ presigned_url: str,
479
+ dest: Path,
480
+ file_size: Optional[int] = None,
481
+ ) -> None:
482
+ dest.parent.mkdir(parents=True, exist_ok=True)
483
+
484
+ with requests.get(presigned_url, stream=True, timeout=STREAM_TIMEOUT) as resp:
485
+ resp.raise_for_status()
486
+ total = int(resp.headers.get("Content-Length", file_size or 0)) or None
487
+
488
+ with Progress(
489
+ SpinnerColumn(),
490
+ TextColumn("[progress.description]{task.description}"),
491
+ BarColumn(),
492
+ DownloadColumn(),
493
+ TransferSpeedColumn(),
494
+ TimeRemainingColumn(),
495
+ console=console,
496
+ transient=True,
497
+ ) as progress:
498
+ task = progress.add_task(dest.name, total=total)
499
+ with open(dest, "wb") as fh:
500
+ for chunk in resp.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE):
501
+ if chunk:
502
+ fh.write(chunk)
503
+ progress.update(task, advance=len(chunk))
504
+
505
+ def _stream_upload(
506
+ self,
507
+ presigned_url: str,
508
+ local_path: Path,
509
+ size_bytes: int,
510
+ ) -> None:
511
+ with Progress(
512
+ SpinnerColumn(),
513
+ TextColumn("[progress.description]{task.description}"),
514
+ BarColumn(),
515
+ DownloadColumn(),
516
+ TransferSpeedColumn(),
517
+ TimeRemainingColumn(),
518
+ console=console,
519
+ transient=True,
520
+ ) as progress:
521
+ task = progress.add_task(local_path.name, total=size_bytes)
522
+
523
+ with open(local_path, "rb") as fh:
524
+ reader = _ProgressReader(fh, size_bytes, progress, task)
525
+ resp = requests.put(
526
+ presigned_url,
527
+ data=reader,
528
+ headers={
529
+ "Content-Length": str(size_bytes),
530
+ "Content-Type": "application/octet-stream",
531
+ },
532
+ timeout=STREAM_TIMEOUT,
533
+ )
534
+ resp.raise_for_status()
535
+
536
+ def _resolve_push_path(
537
+ self,
538
+ file_path_arg: str,
539
+ ws_root: Path,
540
+ ) -> tuple[Path, str]:
541
+ """
542
+ Return (absolute_path, workspace_relative_posix_key).
543
+
544
+ Tries the arg as:
545
+ 1. A path relative to the workspace root.
546
+ 2. An absolute OS path — if the file is inside the workspace root,
547
+ derive the relative key. If outside, copy it into the workspace
548
+ root (top-level, using the filename only).
549
+ """
550
+ import shutil
551
+
552
+ # Attempt 1: relative to workspace root
553
+ candidate = ws_root / file_path_arg
554
+ if candidate.exists():
555
+ return candidate, file_path_arg.replace("\\", "/")
556
+
557
+ # Attempt 2: treat as an OS path
558
+ os_path = Path(file_path_arg).expanduser().resolve()
559
+ if not os_path.exists():
560
+ console.print(f"[red]File not found: {file_path_arg}[/red]")
561
+ sys.exit(1)
562
+
563
+ try:
564
+ # File is already inside the workspace directory
565
+ relative = os_path.relative_to(ws_root).as_posix()
566
+ return os_path, relative
567
+ except ValueError:
568
+ pass
569
+
570
+ # File is outside the workspace — copy it in
571
+ relative = os_path.name
572
+ dest = ws_root / relative
573
+ console.print(
574
+ f"[yellow]File is outside the workspace. "
575
+ f"Copying to '[bold]{relative}[/bold]' inside workspace.[/yellow]"
576
+ )
577
+ shutil.copy2(os_path, dest)
578
+ return dest, relative
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # Utility
583
+ # ---------------------------------------------------------------------------
584
+
585
+ def _raise_for_status(resp: requests.Response) -> None:
586
+ """Raise with a helpful message on HTTP errors."""
587
+ if not resp.ok:
588
+ try:
589
+ detail = resp.json().get("detail", resp.text)
590
+ except Exception:
591
+ detail = resp.text
592
+ raise requests.HTTPError(
593
+ f"HTTP {resp.status_code}: {detail}",
594
+ response=resp,
595
+ )