spawnpoint 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.
@@ -0,0 +1,8 @@
1
+ venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .env
7
+ *.pyc
8
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mihir Gupta
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.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: spawnpoint
3
+ Version: 0.1.0
4
+ Summary: Spawn multi-repo worktree workspaces for feature development
5
+ Project-URL: Homepage, https://github.com/mihirgupta0900/spawnpoint
6
+ Project-URL: Repository, https://github.com/mihirgupta0900/spawnpoint
7
+ Project-URL: Issues, https://github.com/mihirgupta0900/spawnpoint/issues
8
+ Author-email: Mihir Gupta <mihirgupta0900@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: developer-tools,git,monorepo,workspace,worktree
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: inquirerpy>=0.3
20
+ Requires-Dist: rich>=13
21
+ Requires-Dist: typer>=0.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Spawnpoint
25
+
26
+ Spawn multi-repo worktree workspaces for feature development.
27
+
28
+ Working on a feature that spans multiple repos? Spawnpoint creates a dedicated folder with git worktrees from each repo on the same branch, installs dependencies, and copies over config files — so you can start coding (or start a Claude session) immediately.
29
+
30
+ ## Install
31
+
32
+ ```
33
+ curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh
34
+ ```
35
+
36
+ Or with pipx:
37
+
38
+ ```
39
+ pipx install spawnpoint
40
+ ```
41
+
42
+ Or with pip:
43
+
44
+ ```
45
+ pip install spawnpoint
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ```
51
+ spawnpoint create # select repos, name a branch, spawn worktrees
52
+ spawnpoint cleanup # select and remove worktree workspaces
53
+ ```
54
+
55
+ On first run, Spawnpoint will ask you to configure your scan directories and workspace location.
56
+
57
+ ## How It Works
58
+
59
+ 1. **Select repos** — Spawnpoint scans your code directories and presents a fuzzy-searchable list of git repos
60
+ 2. **Name a branch** — Enter a branch name for your feature
61
+ 3. **Spawn** — For each repo, Spawnpoint:
62
+ - Creates a git worktree (or new branch if needed)
63
+ - Initializes submodules
64
+ - Copies `.env` files, `CLAUDE.md`, and other config files from the original repo
65
+ - Installs dependencies (detects npm/pnpm/yarn/bun, pip/uv/poetry, bundler, go modules)
66
+
67
+ All worktrees land in a single folder (`~/.spawnpoint/workspaces/<branch-name>/`) so you can open the whole workspace in your editor or start an AI coding session.
68
+
69
+ ## Commands
70
+
71
+ | Command | Description |
72
+ |---|---|
73
+ | `spawnpoint create` | Spawn worktree workspaces |
74
+ | `spawnpoint cleanup` | Remove worktree workspaces |
75
+ | `spawnpoint init` | Run interactive setup |
76
+ | `spawnpoint config` | View current config |
77
+ | `spawnpoint config --edit` | Edit config in $EDITOR |
78
+ | `spawnpoint config --reset` | Reset to defaults |
79
+ | `spawnpoint update` | Update to latest version |
80
+ | `spawnpoint --version` | Show version |
81
+
82
+ ## Configuration
83
+
84
+ Config lives at `~/.spawnpoint/config.toml`:
85
+
86
+ ```toml
87
+ # Directories to scan for git repos
88
+ scan_dirs = ['~/code', '~/projects']
89
+
90
+ # Where workspaces are created
91
+ worktree_dir = '~/.spawnpoint/workspaces'
92
+
93
+ # How deep to scan for repos (1-4)
94
+ scan_depth = 2
95
+
96
+ # Files/dirs to copy into new worktrees
97
+ copy_patterns_globs = ['.env*']
98
+ copy_patterns_files = ['AGENT.md', 'CLAUDE.md', 'GEMINI.md']
99
+ copy_patterns_dirs = ['.vscode', 'docs']
100
+
101
+ # Branch priority for base branch selection
102
+ branch_priority = ['development', 'staging', 'main', 'master']
103
+
104
+ # Auto-install dependencies after worktree creation
105
+ auto_install_deps = true
106
+ ```
107
+
108
+ ## Requirements
109
+
110
+ - Python 3.10+
111
+ - git
112
+
113
+ ## Uninstall
114
+
115
+ ```
116
+ pipx uninstall spawnpoint
117
+ rm -rf ~/.spawnpoint
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,99 @@
1
+ # Spawnpoint
2
+
3
+ Spawn multi-repo worktree workspaces for feature development.
4
+
5
+ Working on a feature that spans multiple repos? Spawnpoint creates a dedicated folder with git worktrees from each repo on the same branch, installs dependencies, and copies over config files — so you can start coding (or start a Claude session) immediately.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh
11
+ ```
12
+
13
+ Or with pipx:
14
+
15
+ ```
16
+ pipx install spawnpoint
17
+ ```
18
+
19
+ Or with pip:
20
+
21
+ ```
22
+ pip install spawnpoint
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```
28
+ spawnpoint create # select repos, name a branch, spawn worktrees
29
+ spawnpoint cleanup # select and remove worktree workspaces
30
+ ```
31
+
32
+ On first run, Spawnpoint will ask you to configure your scan directories and workspace location.
33
+
34
+ ## How It Works
35
+
36
+ 1. **Select repos** — Spawnpoint scans your code directories and presents a fuzzy-searchable list of git repos
37
+ 2. **Name a branch** — Enter a branch name for your feature
38
+ 3. **Spawn** — For each repo, Spawnpoint:
39
+ - Creates a git worktree (or new branch if needed)
40
+ - Initializes submodules
41
+ - Copies `.env` files, `CLAUDE.md`, and other config files from the original repo
42
+ - Installs dependencies (detects npm/pnpm/yarn/bun, pip/uv/poetry, bundler, go modules)
43
+
44
+ All worktrees land in a single folder (`~/.spawnpoint/workspaces/<branch-name>/`) so you can open the whole workspace in your editor or start an AI coding session.
45
+
46
+ ## Commands
47
+
48
+ | Command | Description |
49
+ |---|---|
50
+ | `spawnpoint create` | Spawn worktree workspaces |
51
+ | `spawnpoint cleanup` | Remove worktree workspaces |
52
+ | `spawnpoint init` | Run interactive setup |
53
+ | `spawnpoint config` | View current config |
54
+ | `spawnpoint config --edit` | Edit config in $EDITOR |
55
+ | `spawnpoint config --reset` | Reset to defaults |
56
+ | `spawnpoint update` | Update to latest version |
57
+ | `spawnpoint --version` | Show version |
58
+
59
+ ## Configuration
60
+
61
+ Config lives at `~/.spawnpoint/config.toml`:
62
+
63
+ ```toml
64
+ # Directories to scan for git repos
65
+ scan_dirs = ['~/code', '~/projects']
66
+
67
+ # Where workspaces are created
68
+ worktree_dir = '~/.spawnpoint/workspaces'
69
+
70
+ # How deep to scan for repos (1-4)
71
+ scan_depth = 2
72
+
73
+ # Files/dirs to copy into new worktrees
74
+ copy_patterns_globs = ['.env*']
75
+ copy_patterns_files = ['AGENT.md', 'CLAUDE.md', 'GEMINI.md']
76
+ copy_patterns_dirs = ['.vscode', 'docs']
77
+
78
+ # Branch priority for base branch selection
79
+ branch_priority = ['development', 'staging', 'main', 'master']
80
+
81
+ # Auto-install dependencies after worktree creation
82
+ auto_install_deps = true
83
+ ```
84
+
85
+ ## Requirements
86
+
87
+ - Python 3.10+
88
+ - git
89
+
90
+ ## Uninstall
91
+
92
+ ```
93
+ pipx uninstall spawnpoint
94
+ rm -rf ~/.spawnpoint
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+ source venv/bin/activate
3
+ python3 cleanup.py "$@"
@@ -0,0 +1,323 @@
1
+ import shutil
2
+ import subprocess
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ import typer
9
+ from InquirerPy import inquirer
10
+ from rich.console import Console
11
+ from rich.progress import track
12
+
13
+ app = typer.Typer()
14
+ console = Console()
15
+
16
+ HOME = Path.home()
17
+ WORK_DIRS = [
18
+ HOME / "code" / "work" / "worktrees",
19
+ HOME / "code" / "work",
20
+ ]
21
+
22
+
23
+ @dataclass
24
+ class WorktreeInfo:
25
+ worktree_path: Path
26
+ parent_repo_path: Optional[Path]
27
+ branch_name: str
28
+ is_dirty: bool
29
+ last_modified: datetime
30
+
31
+
32
+ @dataclass
33
+ class BranchFolder:
34
+ path: Path
35
+ name: str
36
+ worktrees: List[WorktreeInfo] = field(default_factory=list)
37
+
38
+ @property
39
+ def oldest_modified(self) -> datetime:
40
+ if not self.worktrees:
41
+ return datetime.fromtimestamp(self.path.stat().st_mtime, tz=timezone.utc)
42
+ return min(w.last_modified for w in self.worktrees)
43
+
44
+ @property
45
+ def any_dirty(self) -> bool:
46
+ return any(w.is_dirty for w in self.worktrees)
47
+
48
+
49
+ def parse_git_file(path: Path) -> Optional[Path]:
50
+ """Parse a .git file to extract the gitdir path and resolve the parent repo."""
51
+ git_path = path / ".git"
52
+ if not git_path.is_file():
53
+ return None
54
+ try:
55
+ content = git_path.read_text().strip()
56
+ if not content.startswith("gitdir:"):
57
+ return None
58
+ gitdir = Path(content.split("gitdir:", 1)[1].strip())
59
+ if not gitdir.is_absolute():
60
+ gitdir = (path / gitdir).resolve()
61
+ # gitdir is like /path/to/repo/.git/worktrees/<name>
62
+ # Walk up 3 levels: <name> -> worktrees -> .git -> repo
63
+ parent_repo = gitdir.parent.parent.parent
64
+ if (parent_repo / ".git").exists():
65
+ return parent_repo
66
+ except Exception:
67
+ pass
68
+ return None
69
+
70
+
71
+ def get_branch_name(path: Path) -> str:
72
+ result = subprocess.run(
73
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
74
+ cwd=path, capture_output=True, text=True,
75
+ )
76
+ return result.stdout.strip() if result.returncode == 0 else "unknown"
77
+
78
+
79
+ def is_dirty(path: Path) -> bool:
80
+ result = subprocess.run(
81
+ ["git", "status", "--porcelain"],
82
+ cwd=path, capture_output=True, text=True,
83
+ )
84
+ return bool(result.stdout.strip()) if result.returncode == 0 else False
85
+
86
+
87
+ def get_mtime(path: Path) -> datetime:
88
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
89
+
90
+
91
+ def scan_worktree(path: Path) -> Optional[WorktreeInfo]:
92
+ """Scan a single directory that has a .git file (worktree)."""
93
+ parent_repo = parse_git_file(path)
94
+ if parent_repo is None:
95
+ # .git file broken or not a worktree
96
+ return None
97
+ return WorktreeInfo(
98
+ worktree_path=path,
99
+ parent_repo_path=parent_repo,
100
+ branch_name=get_branch_name(path),
101
+ is_dirty=is_dirty(path),
102
+ last_modified=get_mtime(path),
103
+ )
104
+
105
+
106
+ def scan_single_dir(work_dir: Path) -> List[BranchFolder]:
107
+ """Scan a single work directory for branch folders containing worktrees."""
108
+ if not work_dir.exists():
109
+ return []
110
+
111
+ folders: List[BranchFolder] = []
112
+ seen_paths: set[Path] = set()
113
+
114
+ for entry in sorted(work_dir.iterdir()):
115
+ if not entry.is_dir():
116
+ continue
117
+ resolved = entry.resolve()
118
+ if resolved in seen_paths:
119
+ continue
120
+ seen_paths.add(resolved)
121
+
122
+ bf = BranchFolder(path=entry, name=entry.name)
123
+
124
+ git_file = entry / ".git"
125
+ if git_file.is_file():
126
+ # Single-repo worktree
127
+ wt = scan_worktree(entry)
128
+ if wt:
129
+ bf.worktrees.append(wt)
130
+ else:
131
+ # Multi-repo: check subdirectories
132
+ for sub in sorted(entry.iterdir()):
133
+ if sub.is_dir() and (sub / ".git").is_file():
134
+ wt = scan_worktree(sub)
135
+ if wt:
136
+ bf.worktrees.append(wt)
137
+
138
+ if bf.worktrees:
139
+ folders.append(bf)
140
+
141
+ return folders
142
+
143
+
144
+ def scan_branch_folders() -> List[BranchFolder]:
145
+ """Scan all WORK_DIRS for branch folders containing worktrees."""
146
+ all_folders: List[BranchFolder] = []
147
+ seen_paths: set[Path] = set()
148
+
149
+ for work_dir in WORK_DIRS:
150
+ for bf in scan_single_dir(work_dir):
151
+ resolved = bf.path.resolve()
152
+ if resolved in seen_paths:
153
+ continue
154
+ seen_paths.add(resolved)
155
+ all_folders.append(bf)
156
+
157
+ return all_folders
158
+
159
+
160
+ def format_age(dt: datetime) -> str:
161
+ delta = datetime.now(tz=timezone.utc) - dt
162
+ days = delta.days
163
+ if days == 0:
164
+ hours = delta.seconds // 3600
165
+ return f"{hours}h ago" if hours > 0 else "just now"
166
+ if days < 30:
167
+ return f"{days}d ago"
168
+ return f"{days // 30}mo ago"
169
+
170
+
171
+ def format_choice(bf: BranchFolder) -> str:
172
+ repo_count = len(bf.worktrees)
173
+ dirty_str = "dirty" if bf.any_dirty else "clean"
174
+ age = format_age(bf.oldest_modified)
175
+ repo_label = "repo" if repo_count == 1 else "repos"
176
+ parent_dir = bf.path.parent.name
177
+ return f"{bf.name} ({parent_dir}/, {repo_count} {repo_label}, {dirty_str}, {age})"
178
+
179
+
180
+ def remove_worktree(wt: WorktreeInfo, delete_branch: bool):
181
+ """Remove a single worktree and optionally its branch."""
182
+ parent = wt.parent_repo_path
183
+
184
+ if parent is None or not parent.exists():
185
+ console.print(f" [yellow]Parent repo gone, removing directory: {wt.worktree_path}[/yellow]")
186
+ shutil.rmtree(wt.worktree_path, ignore_errors=True)
187
+ return
188
+
189
+ # git worktree remove
190
+ cmd = ["git", "worktree", "remove", str(wt.worktree_path)]
191
+ if wt.is_dirty:
192
+ cmd.append("--force")
193
+ console.print(f" [yellow]Force-removing dirty worktree:[/yellow] {wt.worktree_path.name}")
194
+ else:
195
+ console.print(f" Removing worktree: {wt.worktree_path.name}")
196
+
197
+ result = subprocess.run(cmd, cwd=parent, capture_output=True, text=True)
198
+ if result.returncode != 0:
199
+ err = result.stderr.strip()
200
+ console.print(f" [red]worktree remove failed: {err}[/red]")
201
+ # Fallback: remove directory
202
+ if wt.worktree_path.exists():
203
+ console.print(f" [yellow]Falling back to rmtree[/yellow]")
204
+ shutil.rmtree(wt.worktree_path, ignore_errors=True)
205
+
206
+ # Delete branch if requested
207
+ if delete_branch and wt.branch_name not in ("unknown", "HEAD"):
208
+ result = subprocess.run(
209
+ ["git", "branch", "-d", wt.branch_name],
210
+ cwd=parent, capture_output=True, text=True,
211
+ )
212
+ if result.returncode != 0:
213
+ err = result.stderr.strip()
214
+ if "not fully merged" in err:
215
+ console.print(f" [yellow]Branch '{wt.branch_name}' not merged, force-deleting[/yellow]")
216
+ subprocess.run(
217
+ ["git", "branch", "-D", wt.branch_name],
218
+ cwd=parent, capture_output=True, text=True,
219
+ )
220
+ elif "not found" not in err:
221
+ console.print(f" [dim]Branch delete skipped: {err}[/dim]")
222
+
223
+
224
+ @app.command()
225
+ def main():
226
+ """Remove worktree workspaces created by the setup script."""
227
+ existing_dirs = [d for d in WORK_DIRS if d.exists()]
228
+ if not existing_dirs:
229
+ console.print(f"[yellow]No worktree directories found[/yellow]")
230
+ raise typer.Exit()
231
+
232
+ dirs_str = ", ".join(str(d) for d in existing_dirs)
233
+ console.print(f"[bold blue]Scanning for worktree workspaces...[/bold blue]")
234
+ console.print(f"[dim]Directories: {dirs_str}[/dim]")
235
+ folders = scan_branch_folders()
236
+
237
+ if not folders:
238
+ console.print("[yellow]No worktree workspaces found.[/yellow]")
239
+ raise typer.Exit()
240
+
241
+ # Sort oldest-first
242
+ folders.sort(key=lambda bf: bf.oldest_modified)
243
+
244
+ # Build lookup
245
+ folder_map = {format_choice(bf): bf for bf in folders}
246
+
247
+ selected_labels = inquirer.fuzzy(
248
+ message="Select workspaces to remove (Type to search):",
249
+ choices=list(folder_map.keys()),
250
+ multiselect=True,
251
+ ).execute()
252
+
253
+ if not selected_labels:
254
+ console.print("No workspaces selected. Exiting.")
255
+ raise typer.Exit()
256
+
257
+ selected = [folder_map[label] for label in selected_labels]
258
+
259
+ # Branch deletion preference
260
+ branch_pref = inquirer.select(
261
+ message="Delete branches from parent repos?",
262
+ choices=["Delete all branches", "Keep branches", "Ask per branch"],
263
+ ).execute()
264
+
265
+ # Show plan
266
+ console.print(f"\n[bold]Plan:[/bold]")
267
+ for bf in selected:
268
+ console.print(f"\n [bold]{bf.name}/[/bold]")
269
+ for wt in bf.worktrees:
270
+ dirty_tag = " [yellow](dirty — will force)[/yellow]" if wt.is_dirty else ""
271
+ parent_label = wt.parent_repo_path.name if wt.parent_repo_path else "unknown"
272
+ console.print(f" {parent_label}: git worktree remove{dirty_tag}")
273
+ if branch_pref == "Delete all branches":
274
+ console.print(f" {parent_label}: git branch -d {wt.branch_name}")
275
+ elif branch_pref == "Ask per branch":
276
+ console.print(f" {parent_label}: [dim]will ask about branch '{wt.branch_name}'[/dim]")
277
+
278
+ console.print("")
279
+ if not inquirer.confirm(message="Proceed with cleanup?").execute():
280
+ console.print("Aborted.")
281
+ raise typer.Exit()
282
+
283
+ # Execute
284
+ parent_repos_seen: set[Path] = set()
285
+
286
+ for bf in track(selected, description="Cleaning up..."):
287
+ # Per-branch "ask" decisions
288
+ branch_decisions: dict[str, bool] = {}
289
+
290
+ for wt in bf.worktrees:
291
+ delete_branch = False
292
+ if branch_pref == "Delete all branches":
293
+ delete_branch = True
294
+ elif branch_pref == "Ask per branch":
295
+ key = f"{wt.parent_repo_path}:{wt.branch_name}"
296
+ if key not in branch_decisions:
297
+ branch_decisions[key] = inquirer.confirm(
298
+ message=f"Delete branch '{wt.branch_name}' from {wt.parent_repo_path.name}?",
299
+ default=True,
300
+ ).execute()
301
+ delete_branch = branch_decisions[key]
302
+
303
+ remove_worktree(wt, delete_branch)
304
+
305
+ if wt.parent_repo_path and wt.parent_repo_path.exists():
306
+ parent_repos_seen.add(wt.parent_repo_path)
307
+
308
+ # Remove any leftover non-worktree dirs and the branch folder itself
309
+ if bf.path.exists():
310
+ shutil.rmtree(bf.path, ignore_errors=True)
311
+
312
+ # Prune all affected parent repos
313
+ for repo in parent_repos_seen:
314
+ subprocess.run(
315
+ ["git", "worktree", "prune"],
316
+ cwd=repo, capture_output=True,
317
+ )
318
+
319
+ console.print("\n[bold green]Done![/bold green]")
320
+
321
+
322
+ if __name__ == "__main__":
323
+ app()
@@ -0,0 +1,12 @@
1
+ 2026-02-26 00:01:20.082 [info] Starting Excalidraw MCP server...
2
+ 2026-02-26 00:01:20.085 [debug] Connecting to stdio transport...
3
+ 2026-02-26 00:01:20.086 [info] Excalidraw MCP server running on stdio
4
+ 2026-02-26 00:01:20.250 [info] Listing available tools
5
+ 2026-02-27 23:50:12.254 [info] Starting Excalidraw MCP server...
6
+ 2026-02-27 23:50:12.255 [debug] Connecting to stdio transport...
7
+ 2026-02-27 23:50:12.256 [info] Excalidraw MCP server running on stdio
8
+ 2026-02-27 23:50:12.569 [info] Listing available tools
9
+ 2026-02-28 00:06:17.621 [info] Starting Excalidraw MCP server...
10
+ 2026-02-28 00:06:17.623 [debug] Connecting to stdio transport...
11
+ 2026-02-28 00:06:17.623 [info] Excalidraw MCP server running on stdio
12
+ 2026-02-28 00:06:17.754 [info] Listing available tools
@@ -0,0 +1,88 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # Spawnpoint installer
5
+ # Usage: curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh
6
+
7
+ log() { printf "\033[0;32m=>\033[0m %s\n" "$1" >&2; }
8
+ warn() { printf "\033[1;33mwarning:\033[0m %s\n" "$1" >&2; }
9
+ err() { printf "\033[0;31merror:\033[0m %s\n" "$1" >&2; exit 1; }
10
+
11
+ PYTHON=""
12
+ INSTALLER=""
13
+
14
+ check_python() {
15
+ for cmd in python3 python; do
16
+ if command -v "$cmd" >/dev/null 2>&1; then
17
+ ver=$("$cmd" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
18
+ major=$(echo "$ver" | cut -d. -f1)
19
+ minor=$(echo "$ver" | cut -d. -f2)
20
+ if [ "$major" -ge 3 ] && [ "$minor" -ge 10 ]; then
21
+ PYTHON="$cmd"
22
+ log "Found $cmd ($ver)"
23
+ return 0
24
+ fi
25
+ fi
26
+ done
27
+ err "Python 3.10+ is required. Install from https://python.org"
28
+ }
29
+
30
+ ensure_pipx() {
31
+ if command -v pipx >/dev/null 2>&1; then
32
+ INSTALLER="pipx"
33
+ return 0
34
+ fi
35
+ log "pipx not found. Installing pipx..."
36
+ "$PYTHON" -m pip install --user pipx 2>/dev/null && {
37
+ # Ensure pipx is on PATH
38
+ "$PYTHON" -m pipx ensurepath 2>/dev/null || true
39
+ INSTALLER="pipx"
40
+ return 0
41
+ }
42
+ warn "Could not install pipx. Falling back to pip."
43
+ INSTALLER="pip"
44
+ }
45
+
46
+ install_spawnpoint() {
47
+ check_python
48
+ ensure_pipx
49
+
50
+ if [ "$INSTALLER" = "pipx" ]; then
51
+ log "Installing spawnpoint via pipx..."
52
+ if command -v pipx >/dev/null 2>&1; then
53
+ pipx install spawnpoint
54
+ else
55
+ "$PYTHON" -m pipx install spawnpoint
56
+ fi
57
+ else
58
+ log "Installing spawnpoint via pip..."
59
+ "$PYTHON" -m pip install --user spawnpoint
60
+ fi
61
+ }
62
+
63
+ verify() {
64
+ echo ""
65
+ if command -v spawnpoint >/dev/null 2>&1; then
66
+ log "spawnpoint $(spawnpoint --version 2>/dev/null || echo '') installed successfully!"
67
+ else
68
+ warn "spawnpoint installed but not found in PATH."
69
+ warn "You may need to add ~/.local/bin to your PATH:"
70
+ echo ""
71
+ case "$SHELL" in
72
+ */zsh) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;;
73
+ */bash) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;;
74
+ */fish) echo " fish_add_path ~/.local/bin" ;;
75
+ *) echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
76
+ esac
77
+ echo ""
78
+ fi
79
+
80
+ echo " Next steps:"
81
+ echo " spawnpoint create # spawn worktree workspaces"
82
+ echo " spawnpoint cleanup # remove worktree workspaces"
83
+ echo " spawnpoint --help # see all commands"
84
+ echo ""
85
+ }
86
+
87
+ install_spawnpoint
88
+ verify