Starspace logoStarspace
    /Docs

    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.

    Installation & Usagebash
    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):

    .envenv
    STARSPACE_WORKSPACE_ID=your-workspace-uuid
    STARSPACE_WORKSPACE_API_KEY=ak_your_key_here

    1. 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.

    Usagebash
    # Push current directory
    python starspace_sync.py
    
    # Push and clean orphaned cloud files
    python starspace_sync.py --delete
    View full script, starspace_sync.py
    starspace_sync.pypython
    #!/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.

    Usagebash
    # 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-run
    View full script, starspace_pull.py
    starspace_pull.pypython
    #!/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,
        )
    💡 Cost note: Pull uses only GET requests (read-only). Unchanged files are skipped via hash comparison, so repeated pulls cost almost nothing.

    3. Diff / Status

    Compare local files against the cloud workspace without uploading or downloading anything. Shows added (+), modified (~), and deleted (-) files.

    Usagebash
    python starspace_diff.py
    
    # Output:
    #   + src/new_module.py
    #   ~ src/main.py
    #   - old_config.yaml
    #
    # 1 added, 1 modified, 1 deleted, 42 unchanged
    View full script, starspace_diff.py
    starspace_diff.pypython
    #!/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", ""),
        )
    💡 Cost note: Single GET request for the file manifest. Zero write cost.

    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.

    Usagebash
    # Watch with 5-second interval (default)
    python starspace_watch.py
    
    # Faster polling (every 2 seconds)
    python starspace_watch.py --interval 2
    View full script, starspace_watch.py
    starspace_watch.pypython
    #!/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,
        )
    💡 Cost note: Only changed files are uploaded each cycle. If nothing changes, zero API calls are made. A typical coding session with 2–3 saves per minute costs ~1 credit per batch.

    5. Git Post-Commit Hook

    Automatically sync your workspace after every git commit. Ensures the cloud workspace always reflects your latest committed state.

    Installbash
    # 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
    .git/hooks/post-commitbash
    #!/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 -1
    💡 Cost note: One sync per commit. Since most commits touch a small subset of files, the ZIP payload is small and fast.

    Choosing a Strategy

    ScriptDirectionTriggerCost
    starspace_sync.pyLocal → CloudManual1 write credit per batch
    starspace_pull.pyCloud → LocalManualFree (read-only)
    starspace_diff.pyCompareManualFree (read-only)
    starspace_watch.pyLocal → CloudAuto (polling)1 credit per changed batch
    Git hookLocal → CloudAuto (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