CLI & Sync Scripts
A collection of ways to sync your local codebase with a Starspace workspace. You can use the official CLI or the lightweight standalone Python scripts.
Official CLI
The official Starspace CLI is a Python package that provides a robust way to interact with your workspaces. It is currently in private beta but you can view the repository at github.com/starspace-run/starspace-cli.
pip install git+https://github.com/starspace-run/starspace-cli.git
# Sync current directory to workspace
starspace sync --workspace-id <uuid> --api-key <key>Standalone Scripts
If you prefer not to install the CLI, you can use these standalone Python scripts. They use the Workspace Management REST API with ZIP-based batch uploads and hash-based deltas.
Prerequisites
- Python 3.10+
pip install requests python-dotenv- A workspace API key (
ak_…), generate one from Workspace Settings → Connections
Setup
Create a .env file in your project root (add it to .gitignore):
STARSPACE_WORKSPACE_ID=your-workspace-uuid
STARSPACE_WORKSPACE_API_KEY=ak_your_key_here1. Push (local → cloud)
Uploads your local files to the workspace. Uses ZIP compression to minimize bandwidth. Pass --delete to remove cloud files that don't exist locally.
# Push current directory
python starspace_sync.py
# Push and clean orphaned cloud files
python starspace_sync.py --deleteView full script, starspace_sync.py
#!/usr/bin/env python3
"""starspace_sync.py, Push local files to an Starspace workspace via the REST API."""
import os, io, zipfile, argparse, requests
from pathlib import Path
from dotenv import load_dotenv
BASE_URL = "https://api.starspace.run/functions/v1/workspace-management/v1/workspaces"
# ── Configuration ────────────────────────────────────────────────
IGNORE_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".mypy_cache",
".pytest_cache", ".cursor", ".vscode", "data"}
IGNORE_FILES = {".env", ".DS_Store"}
ALLOWED_EXT = {".py", ".md", ".txt", ".json", ".yaml", ".yml", ".sh",
".js", ".ts", ".html", ".css", ".c", ".cpp", ".h",
".hpp", ".rs", ".go", ".sql", ".toml", ".lock"}
MAX_FILE_SIZE = 1_000_000 # 1 MB
def should_ignore(path: Path) -> bool:
for part in path.parts:
if part in IGNORE_DIRS:
return True
if path.name in IGNORE_FILES:
return True
if path.suffix not in ALLOWED_EXT:
return True
return False
def sync(workspace_id: str, api_key: str, delete_missing: bool = False):
root = Path.cwd()
all_files = []
for dirpath, dirs, fnames in os.walk(root):
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
for f in fnames:
fp = Path(dirpath) / f
rel = fp.relative_to(root)
if not should_ignore(rel) and fp.stat().st_size <= MAX_FILE_SIZE:
all_files.append((fp, rel))
BATCH = 500
for i in range(0, len(all_files), BATCH):
batch = all_files[i:i + BATCH]
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for fp, rel in batch:
zf.write(fp, rel)
buf.seek(0)
hdrs = {"Authorization": f"Bearer {api_key}",
"Content-Type": "application/zip"}
if delete_missing and len(all_files) <= BATCH:
hdrs["X-Delete-Missing"] = "true"
r = requests.post(
f"{BASE_URL}/{workspace_id}/files/sync",
headers=hdrs, data=buf)
print(f"Batch {i // BATCH + 1}: {r.status_code}")
if __name__ == "__main__":
load_dotenv()
p = argparse.ArgumentParser()
p.add_argument("--workspace-id")
p.add_argument("--delete", action="store_true")
a = p.parse_args()
sync(
a.workspace_id or os.getenv("STARSPACE_WORKSPACE_ID", ""),
os.getenv("STARSPACE_WORKSPACE_API_KEY", ""),
a.delete,
)2. Pull (cloud → local)
Downloads workspace files to your local disk. Compares SHA-256 hashes to skip unchanged files, only fetches what's actually different. Use --dry-run to preview changes without writing.
# Pull to current directory
python starspace_pull.py
# Pull to a specific folder
python starspace_pull.py --dest ./workspace-copy
# Preview what would be fetched
python starspace_pull.py --dry-runView full script, starspace_pull.py
#!/usr/bin/env python3
"""starspace_pull.py, Pull workspace files to local disk (hash-based delta)."""
import os, hashlib, argparse, requests
from pathlib import Path
from dotenv import load_dotenv
BASE_URL = "https://api.starspace.run/functions/v1/workspace-management/v1/workspaces"
def local_hash(path: Path) -> str | None:
if not path.exists():
return None
return hashlib.sha256(path.read_bytes()).hexdigest()
def pull(workspace_id: str, api_key: str, dest: str = ".", dry_run: bool = False):
root = Path(dest).resolve()
root.mkdir(parents=True, exist_ok=True)
hdrs = {"Authorization": f"Bearer {api_key}"}
# 1. List remote files
r = requests.get(f"{BASE_URL}/{workspace_id}/files", headers=hdrs)
r.raise_for_status()
remote_files = r.json().get("files", [])
print(f"Remote: {len(remote_files)} files")
fetched, skipped = 0, 0
for entry in remote_files:
rel = entry["path"]
remote_hash = entry.get("content_hash")
local_path = root / rel
# Skip if local hash matches remote
lh = local_hash(local_path)
if lh and remote_hash and lh == remote_hash:
skipped += 1
continue
if dry_run:
status = "new" if lh is None else "modified"
print(f" [{status}] {rel}")
fetched += 1
continue
# Fetch file content
fr = requests.get(
f"{BASE_URL}/{workspace_id}/files/{rel}",
headers=hdrs)
if fr.status_code != 200:
print(f" [error] {rel}: {fr.status_code}")
continue
local_path.parent.mkdir(parents=True, exist_ok=True)
data = fr.json()
local_path.write_text(data.get("content", ""), encoding="utf-8")
fetched += 1
verb = "Would fetch" if dry_run else "Fetched"
print(f"{verb} {fetched}, skipped {skipped} unchanged")
if __name__ == "__main__":
load_dotenv()
p = argparse.ArgumentParser()
p.add_argument("--workspace-id")
p.add_argument("--dest", default=".")
p.add_argument("--dry-run", action="store_true")
a = p.parse_args()
pull(
a.workspace_id or os.getenv("STARSPACE_WORKSPACE_ID", ""),
os.getenv("STARSPACE_WORKSPACE_API_KEY", ""),
a.dest,
a.dry_run,
)3. Diff / Status
Compare local files against the cloud workspace without uploading or downloading anything. Shows added (+), modified (~), and deleted (-) files.
python starspace_diff.py
# Output:
# + src/new_module.py
# ~ src/main.py
# - old_config.yaml
#
# 1 added, 1 modified, 1 deleted, 42 unchangedView full script, starspace_diff.py
#!/usr/bin/env python3
"""starspace_diff.py, Compare local vs cloud workspace (no writes)."""
import os, hashlib, argparse, requests
from pathlib import Path
from dotenv import load_dotenv
BASE_URL = "https://api.starspace.run/functions/v1/workspace-management/v1/workspaces"
IGNORE_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".mypy_cache",
".pytest_cache", ".cursor", ".vscode", "data"}
ALLOWED_EXT = {".py", ".md", ".txt", ".json", ".yaml", ".yml", ".sh",
".js", ".ts", ".html", ".css", ".c", ".cpp", ".h",
".hpp", ".rs", ".go", ".sql", ".toml", ".lock"}
MAX_FILE_SIZE = 1_000_000
def file_hash(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def diff(workspace_id: str, api_key: str):
hdrs = {"Authorization": f"Bearer {api_key}"}
# Fetch remote manifest
r = requests.get(f"{BASE_URL}/{workspace_id}/files", headers=hdrs)
r.raise_for_status()
remote = {f["path"]: f.get("content_hash") for f in r.json().get("files", [])}
# Build local manifest
root = Path.cwd()
local = {}
for dirpath, dirs, fnames in os.walk(root):
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
for f in fnames:
fp = Path(dirpath) / f
rel = str(fp.relative_to(root))
if fp.suffix in ALLOWED_EXT and fp.stat().st_size <= MAX_FILE_SIZE:
local[rel] = file_hash(fp)
added = set(local) - set(remote)
deleted = set(remote) - set(local)
modified = {p for p in set(local) & set(remote)
if remote[p] and local[p] != remote[p]}
unchanged = len(set(local) & set(remote)) - len(modified)
for p in sorted(added):
print(f" + {p}")
for p in sorted(modified):
print(f" ~ {p}")
for p in sorted(deleted):
print(f" - {p}")
print(f"\n{len(added)} added, {len(modified)} modified, "
f"{len(deleted)} deleted, {unchanged} unchanged")
if __name__ == "__main__":
load_dotenv()
p = argparse.ArgumentParser()
p.add_argument("--workspace-id")
a = p.parse_args()
diff(
a.workspace_id or os.getenv("STARSPACE_WORKSPACE_ID", ""),
os.getenv("STARSPACE_WORKSPACE_API_KEY", ""),
)4. Watch (auto-push)
Polls for local file changes every N seconds and pushes only the changed files. Uses mtime + size metadata comparison, no extra dependencies needed (no watchdog). Great for keeping MCP agents always fresh during active development.
# Watch with 5-second interval (default)
python starspace_watch.py
# Faster polling (every 2 seconds)
python starspace_watch.py --interval 2View full script, starspace_watch.py
#!/usr/bin/env python3
"""starspace_watch.py, Auto-push local changes to workspace on file save."""
import os, io, time, hashlib, zipfile, argparse, requests
from pathlib import Path
from dotenv import load_dotenv
BASE_URL = "https://api.starspace.run/functions/v1/workspace-management/v1/workspaces"
IGNORE_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".mypy_cache",
".pytest_cache", ".cursor", ".vscode", "data"}
ALLOWED_EXT = {".py", ".md", ".txt", ".json", ".yaml", ".yml", ".sh",
".js", ".ts", ".html", ".css", ".c", ".cpp", ".h",
".hpp", ".rs", ".go", ".sql", ".toml", ".lock"}
MAX_FILE_SIZE = 1_000_000
def scan(root: Path):
"""Returns {rel_path: (mtime, size)} for all eligible files."""
result = {}
for dirpath, dirs, fnames in os.walk(root):
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
for f in fnames:
fp = Path(dirpath) / f
rel = str(fp.relative_to(root))
if fp.suffix in ALLOWED_EXT:
try:
st = fp.stat()
if st.st_size <= MAX_FILE_SIZE:
result[rel] = (st.st_mtime, st.st_size)
except OSError:
pass
return result
def push_files(workspace_id, api_key, root, paths):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for rel in paths:
fp = root / rel
if fp.exists():
zf.write(fp, rel)
buf.seek(0)
r = requests.post(
f"{BASE_URL}/{workspace_id}/files/sync",
headers={"Authorization": f"Bearer {api_key}",
"Content-Type": "application/zip"},
data=buf)
return r.status_code
def watch(workspace_id, api_key, interval=5):
root = Path.cwd()
print(f"Watching {root} (every {interval}s)... Ctrl+C to stop")
prev = scan(root)
while True:
time.sleep(interval)
curr = scan(root)
changed = []
for path, meta in curr.items():
if path not in prev or prev[path] != meta:
changed.append(path)
if changed:
code = push_files(workspace_id, api_key, root, changed)
ts = time.strftime("%H:%M:%S")
print(f"[{ts}] Pushed {len(changed)} file(s) → {code}")
prev = curr
if __name__ == "__main__":
load_dotenv()
p = argparse.ArgumentParser()
p.add_argument("--workspace-id")
p.add_argument("--interval", type=int, default=5)
a = p.parse_args()
watch(
a.workspace_id or os.getenv("STARSPACE_WORKSPACE_ID", ""),
os.getenv("STARSPACE_WORKSPACE_API_KEY", ""),
a.interval,
)5. Git Post-Commit Hook
Automatically sync your workspace after every git commit. Ensures the cloud workspace always reflects your latest committed state.
# Install the hook
cat > .git/hooks/post-commit << 'HOOK'
#!/bin/sh
python starspace_sync.py 2>&1 | tail -1
HOOK
chmod +x .git/hooks/post-commit#!/bin/sh
# .git/hooks/post-commit, auto-sync workspace after each commit
# Make executable: chmod +x .git/hooks/post-commit
python starspace_sync.py 2>&1 | tail -1Choosing a Strategy
| Script | Direction | Trigger | Cost |
|---|---|---|---|
starspace_sync.py | Local → Cloud | Manual | 1 write credit per batch |
starspace_pull.py | Cloud → Local | Manual | Free (read-only) |
starspace_diff.py | Compare | Manual | Free (read-only) |
starspace_watch.py | Local → Cloud | Auto (polling) | 1 credit per changed batch |
| Git hook | Local → Cloud | Auto (on commit) | 1 credit per commit |
Built-in Safety
Batch size: 500 files per upload (stays within API limits)
File size cap: 1 MB per file (large files break the search index)
Ignored dirs: .git, __pycache__, .venv, node_modules, data, etc.
Allowed extensions: .py, .md, .json, .yaml, .ts, .js, and more
Hash-based delta: Pull and diff scripts compare SHA-256 hashes to avoid redundant transfers
See Also
- Workspace API Reference, Full REST endpoint documentation
- MCP Server, Connect AI assistants to your synced workspace