summon-ws 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/index.js +577 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 juan294
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# summon — Native Ghostty workspace launcher
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
[](https://github.com/juan294/summon/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/juan294/summon/actions/workflows/codeql.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/summon-ws)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
[](https://www.typescriptlang.org)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
|
|
11
|
+
Summon your Ghostty workspace with one command. Native splits, no tmux.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm i -g summon-ws
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Node >= 18, macOS, and [Ghostty](https://ghostty.org) 1.3.0+.
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
summon . # launch workspace in current directory
|
|
27
|
+
summon add myapp ~/code/myapp # register a project
|
|
28
|
+
summon myapp # launch by project name
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## How It Works
|
|
32
|
+
|
|
33
|
+
Summon generates and executes AppleScript that drives Ghostty's native split system. No terminal multiplexer -- just native Ghostty panes with commands running in each one.
|
|
34
|
+
|
|
35
|
+
## Default Layout
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
summon . (panes=2, editor=claude, sidebar=lazygit, server=true)
|
|
39
|
+
|
|
40
|
+
+-------------------- 75% ---------------------+------ 25% ------+
|
|
41
|
+
| | | |
|
|
42
|
+
| | claude (2) | |
|
|
43
|
+
| claude (1) | | lazygit |
|
|
44
|
+
| +--------------------------+ |
|
|
45
|
+
| | | |
|
|
46
|
+
| | server (shell) | |
|
|
47
|
+
| | | |
|
|
48
|
+
+--------------------+--------------------------+-----------------+
|
|
49
|
+
left col right col sidebar
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Layout Presets
|
|
53
|
+
|
|
54
|
+
| Preset | Panes | Server | Use case |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| `full` | 3 | yes | Multi-agent coding + dev server |
|
|
57
|
+
| `pair` | 2 | yes | Two editors + dev server |
|
|
58
|
+
| `minimal` | 1 | no | Simple editor + sidebar only |
|
|
59
|
+
| `cli` | 1 | yes | CLI tool development -- editor + server |
|
|
60
|
+
| `mtop` | 2 | yes | System monitoring -- editor + mtop + server |
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
summon . --layout minimal # 1 editor pane, no server
|
|
64
|
+
summon . -l pair # 2 editors + server
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Per-project Config
|
|
68
|
+
|
|
69
|
+
Drop a `.summon` file in your project root to override machine-level config:
|
|
70
|
+
|
|
71
|
+
```ini
|
|
72
|
+
# .summon
|
|
73
|
+
layout=minimal
|
|
74
|
+
editor=vim
|
|
75
|
+
server=npm run dev
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Config resolution order: **CLI flags > .summon > machine config > preset > defaults**
|
|
79
|
+
|
|
80
|
+
## Commands
|
|
81
|
+
|
|
82
|
+
| Command | Description |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `summon <target>` | Launch workspace (project name, path, or `.`) |
|
|
85
|
+
| `summon add <name> <path>` | Register a project name to a directory |
|
|
86
|
+
| `summon remove <name>` | Remove a registered project |
|
|
87
|
+
| `summon list` | List all registered projects |
|
|
88
|
+
| `summon set <key> [value]` | Set a machine-level config value |
|
|
89
|
+
| `summon config` | Show current machine configuration |
|
|
90
|
+
|
|
91
|
+
## CLI Flags
|
|
92
|
+
|
|
93
|
+
| Flag | Description |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `-l, --layout <preset>` | Use a layout preset (`minimal`, `full`, `pair`, `cli`, `mtop`) |
|
|
96
|
+
| `--editor <cmd>` | Override editor command |
|
|
97
|
+
| `--panes <n>` | Override number of editor panes |
|
|
98
|
+
| `--editor-size <n>` | Override editor width percentage |
|
|
99
|
+
| `--sidebar <cmd>` | Override sidebar command |
|
|
100
|
+
| `--server <value>` | Server pane: `true`, `false`, or a command |
|
|
101
|
+
| `-n, --dry-run` | Print generated AppleScript without executing |
|
|
102
|
+
| `-h, --help` | Show help message |
|
|
103
|
+
| `-v, --version` | Show version number |
|
|
104
|
+
|
|
105
|
+
## Config Keys
|
|
106
|
+
|
|
107
|
+
| Key | Default | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `editor` | `claude` | Command launched in editor panes |
|
|
110
|
+
| `sidebar` | `lazygit` | Command launched in the sidebar pane |
|
|
111
|
+
| `panes` | `2` | Number of editor panes |
|
|
112
|
+
| `editor-size` | `75` | Width percentage for the editor grid |
|
|
113
|
+
| `server` | `true` | Server pane: `true` (shell), `false` (none), or a command |
|
|
114
|
+
| `layout` | | Default layout preset |
|
|
115
|
+
|
|
116
|
+
Machine config is stored at `~/.config/summon/config`:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
summon set editor vim # use vim as the editor
|
|
120
|
+
summon set server "npm run dev" # run dev server automatically
|
|
121
|
+
summon set layout minimal # default to minimal preset
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Docs
|
|
125
|
+
|
|
126
|
+
- [Architecture](docs/architecture.md) -- module map, AppleScript generation, layout algorithm
|
|
127
|
+
- [User Manual](docs/user-manual.md) -- full command reference, walkthrough, troubleshooting
|
|
128
|
+
- [Changelog](CHANGELOG.md) -- release history
|
|
129
|
+
- [Publishing](docs/publishing.md) -- npm publish checklist
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
Contributions are welcome! Please read the [Contributing Guide](CONTRIBUTING.md) for details on the development workflow, commit conventions, and PR guidelines.
|
|
134
|
+
|
|
135
|
+
## Code of Conduct
|
|
136
|
+
|
|
137
|
+
This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
|
|
138
|
+
|
|
139
|
+
## Security
|
|
140
|
+
|
|
141
|
+
To report a vulnerability, please follow the [Security Policy](SECURITY.md). Do not open a public issue.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
[MIT](./LICENSE)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
var CONFIG_DIR = join(homedir(), ".config", "summon");
|
|
12
|
+
var PROJECTS_FILE = join(CONFIG_DIR, "projects");
|
|
13
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config");
|
|
14
|
+
function ensureConfig() {
|
|
15
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
16
|
+
if (!existsSync(PROJECTS_FILE)) writeFileSync(PROJECTS_FILE, "");
|
|
17
|
+
if (!existsSync(CONFIG_FILE)) writeFileSync(CONFIG_FILE, "editor=claude\n");
|
|
18
|
+
}
|
|
19
|
+
function readKVFile(path) {
|
|
20
|
+
const map = /* @__PURE__ */ new Map();
|
|
21
|
+
if (!existsSync(path)) return map;
|
|
22
|
+
const content = readFileSync(path, "utf-8").trim();
|
|
23
|
+
if (!content) return map;
|
|
24
|
+
for (const line of content.split("\n")) {
|
|
25
|
+
const idx = line.indexOf("=");
|
|
26
|
+
if (idx === -1) continue;
|
|
27
|
+
map.set(line.slice(0, idx), line.slice(idx + 1));
|
|
28
|
+
}
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
function readKV(file) {
|
|
32
|
+
ensureConfig();
|
|
33
|
+
return readKVFile(file);
|
|
34
|
+
}
|
|
35
|
+
function writeKV(file, map) {
|
|
36
|
+
const lines = [...map.entries()].map(([k, v]) => `${k}=${v}`);
|
|
37
|
+
writeFileSync(file, lines.join("\n") + "\n");
|
|
38
|
+
}
|
|
39
|
+
function addProject(name, path) {
|
|
40
|
+
const projects = readKV(PROJECTS_FILE);
|
|
41
|
+
projects.set(name, path);
|
|
42
|
+
writeKV(PROJECTS_FILE, projects);
|
|
43
|
+
}
|
|
44
|
+
function removeProject(name) {
|
|
45
|
+
const projects = readKV(PROJECTS_FILE);
|
|
46
|
+
const existed = projects.delete(name);
|
|
47
|
+
writeKV(PROJECTS_FILE, projects);
|
|
48
|
+
return existed;
|
|
49
|
+
}
|
|
50
|
+
function getProject(name) {
|
|
51
|
+
return readKV(PROJECTS_FILE).get(name);
|
|
52
|
+
}
|
|
53
|
+
function listProjects() {
|
|
54
|
+
return readKV(PROJECTS_FILE);
|
|
55
|
+
}
|
|
56
|
+
function setConfig(key, value) {
|
|
57
|
+
const config = readKV(CONFIG_FILE);
|
|
58
|
+
config.set(key, value);
|
|
59
|
+
writeKV(CONFIG_FILE, config);
|
|
60
|
+
}
|
|
61
|
+
function getConfig(key) {
|
|
62
|
+
return readKV(CONFIG_FILE).get(key);
|
|
63
|
+
}
|
|
64
|
+
function listConfig() {
|
|
65
|
+
return readKV(CONFIG_FILE);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/launcher.ts
|
|
69
|
+
import { existsSync as existsSync2 } from "fs";
|
|
70
|
+
import { join as join2 } from "path";
|
|
71
|
+
import { createInterface } from "readline";
|
|
72
|
+
import { execSync, execFileSync } from "child_process";
|
|
73
|
+
|
|
74
|
+
// src/layout.ts
|
|
75
|
+
var DEFAULT_OPTIONS = {
|
|
76
|
+
editor: "claude",
|
|
77
|
+
editorPanes: 2,
|
|
78
|
+
editorSize: 75,
|
|
79
|
+
sidebarCommand: "lazygit",
|
|
80
|
+
server: "true",
|
|
81
|
+
secondaryEditor: ""
|
|
82
|
+
};
|
|
83
|
+
function parseServer(value) {
|
|
84
|
+
if (value === "false" || value === "") {
|
|
85
|
+
return { hasServer: false, serverCommand: null };
|
|
86
|
+
}
|
|
87
|
+
if (value === "true") {
|
|
88
|
+
return { hasServer: true, serverCommand: null };
|
|
89
|
+
}
|
|
90
|
+
return { hasServer: true, serverCommand: value };
|
|
91
|
+
}
|
|
92
|
+
var PRESETS = {
|
|
93
|
+
minimal: { editorPanes: 1, server: "false" },
|
|
94
|
+
full: { editorPanes: 3, server: "true" },
|
|
95
|
+
pair: { editorPanes: 2, server: "true" },
|
|
96
|
+
cli: { editorPanes: 1, server: "true" },
|
|
97
|
+
mtop: { editorPanes: 2, server: "true", secondaryEditor: "mtop" }
|
|
98
|
+
};
|
|
99
|
+
function isPresetName(value) {
|
|
100
|
+
return value in PRESETS;
|
|
101
|
+
}
|
|
102
|
+
function getPreset(name) {
|
|
103
|
+
return PRESETS[name];
|
|
104
|
+
}
|
|
105
|
+
function planLayout(partial) {
|
|
106
|
+
const opts = { ...DEFAULT_OPTIONS, ...partial };
|
|
107
|
+
const leftColumnCount = Math.ceil(opts.editorPanes / 2);
|
|
108
|
+
const { hasServer, serverCommand } = parseServer(opts.server);
|
|
109
|
+
return {
|
|
110
|
+
editorSize: opts.editorSize,
|
|
111
|
+
sidebarSize: 100 - opts.editorSize,
|
|
112
|
+
leftColumnCount,
|
|
113
|
+
rightColumnEditorCount: opts.editorPanes - leftColumnCount,
|
|
114
|
+
editor: opts.editor,
|
|
115
|
+
sidebarCommand: opts.sidebarCommand,
|
|
116
|
+
hasServer,
|
|
117
|
+
serverCommand,
|
|
118
|
+
secondaryEditor: opts.secondaryEditor || null
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/script.ts
|
|
123
|
+
function escapeAppleScript(s) {
|
|
124
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
|
|
125
|
+
}
|
|
126
|
+
function generateAppleScript(plan, targetDir) {
|
|
127
|
+
const lines = [];
|
|
128
|
+
const add = (indent, line) => {
|
|
129
|
+
lines.push(" ".repeat(indent) + line);
|
|
130
|
+
};
|
|
131
|
+
const blank = () => lines.push("");
|
|
132
|
+
const sendCommand = (pane, cmd) => {
|
|
133
|
+
add(1, `input text "${escapeAppleScript(cmd)}" to ${pane}`);
|
|
134
|
+
add(1, `send key "enter" to ${pane}`);
|
|
135
|
+
};
|
|
136
|
+
const setConfigCommand = (cmd) => {
|
|
137
|
+
add(1, `set command of cfg to "${escapeAppleScript(cmd)}"`);
|
|
138
|
+
};
|
|
139
|
+
const clearConfigCommand = () => {
|
|
140
|
+
add(1, `set command of cfg to ""`);
|
|
141
|
+
};
|
|
142
|
+
add(0, 'tell application "Ghostty"');
|
|
143
|
+
add(1, "activate");
|
|
144
|
+
blank();
|
|
145
|
+
add(1, "set cfg to new surface configuration");
|
|
146
|
+
add(1, `set initial working directory of cfg to "${escapeAppleScript(targetDir)}"`);
|
|
147
|
+
blank();
|
|
148
|
+
add(1, "set win to front window");
|
|
149
|
+
add(1, "set paneRoot to terminal 1 of selected tab of win");
|
|
150
|
+
blank();
|
|
151
|
+
const editorCmd = plan.editor;
|
|
152
|
+
const secondaryCmd = plan.secondaryEditor ?? editorCmd;
|
|
153
|
+
if (plan.sidebarCommand) {
|
|
154
|
+
setConfigCommand(plan.sidebarCommand);
|
|
155
|
+
}
|
|
156
|
+
add(1, "set paneSidebar to split paneRoot direction right with configuration cfg");
|
|
157
|
+
const needsRightColumn = plan.rightColumnEditorCount > 0 || plan.hasServer;
|
|
158
|
+
if (needsRightColumn) {
|
|
159
|
+
blank();
|
|
160
|
+
if (plan.rightColumnEditorCount > 0) {
|
|
161
|
+
if (secondaryCmd) {
|
|
162
|
+
setConfigCommand(secondaryCmd);
|
|
163
|
+
} else {
|
|
164
|
+
clearConfigCommand();
|
|
165
|
+
}
|
|
166
|
+
add(1, "set paneRightCol to split paneRoot direction right with configuration cfg");
|
|
167
|
+
} else {
|
|
168
|
+
if (plan.serverCommand) {
|
|
169
|
+
setConfigCommand(plan.serverCommand);
|
|
170
|
+
} else {
|
|
171
|
+
clearConfigCommand();
|
|
172
|
+
}
|
|
173
|
+
add(1, "set paneRightCol to split paneRoot direction right with configuration cfg");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
let lastLeftPane = "paneRoot";
|
|
177
|
+
if (plan.leftColumnCount > 1 && editorCmd) {
|
|
178
|
+
setConfigCommand(editorCmd);
|
|
179
|
+
}
|
|
180
|
+
for (let i = 2; i <= plan.leftColumnCount; i++) {
|
|
181
|
+
const name = `paneLeft${i}`;
|
|
182
|
+
blank();
|
|
183
|
+
add(1, `set ${name} to split ${lastLeftPane} direction down with configuration cfg`);
|
|
184
|
+
lastLeftPane = name;
|
|
185
|
+
}
|
|
186
|
+
if (needsRightColumn && plan.rightColumnEditorCount > 0) {
|
|
187
|
+
let lastRightPane = "paneRightCol";
|
|
188
|
+
let nextRight = 2;
|
|
189
|
+
if (plan.rightColumnEditorCount > 1 && secondaryCmd) {
|
|
190
|
+
setConfigCommand(secondaryCmd);
|
|
191
|
+
}
|
|
192
|
+
for (let i = 2; i <= plan.rightColumnEditorCount; i++) {
|
|
193
|
+
const name = `paneRight${nextRight}`;
|
|
194
|
+
blank();
|
|
195
|
+
add(1, `set ${name} to split ${lastRightPane} direction down with configuration cfg`);
|
|
196
|
+
lastRightPane = name;
|
|
197
|
+
nextRight++;
|
|
198
|
+
}
|
|
199
|
+
if (plan.hasServer) {
|
|
200
|
+
const name = `paneRight${nextRight}`;
|
|
201
|
+
blank();
|
|
202
|
+
if (plan.serverCommand) {
|
|
203
|
+
setConfigCommand(plan.serverCommand);
|
|
204
|
+
} else {
|
|
205
|
+
clearConfigCommand();
|
|
206
|
+
}
|
|
207
|
+
add(1, `set ${name} to split ${lastRightPane} direction down with configuration cfg`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
blank();
|
|
211
|
+
if (editorCmd) {
|
|
212
|
+
sendCommand("paneRoot", editorCmd);
|
|
213
|
+
}
|
|
214
|
+
blank();
|
|
215
|
+
add(1, "focus paneRoot");
|
|
216
|
+
add(0, "end tell");
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/launcher.ts
|
|
221
|
+
var SAFE_COMMAND_RE = /^[a-zA-Z0-9_][a-zA-Z0-9_.+-]*$/;
|
|
222
|
+
function ensureGhostty() {
|
|
223
|
+
if (!existsSync2("/Applications/Ghostty.app")) {
|
|
224
|
+
console.error(
|
|
225
|
+
"Ghostty.app not found. Please install Ghostty 1.3.0+ from https://ghostty.org"
|
|
226
|
+
);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function executeScript(script) {
|
|
231
|
+
execSync("osascript", { input: script, encoding: "utf-8" });
|
|
232
|
+
}
|
|
233
|
+
function resolveCommand(cmd) {
|
|
234
|
+
if (!SAFE_COMMAND_RE.test(cmd)) {
|
|
235
|
+
console.error(`Invalid command name: "${cmd}". Command names may only contain letters, digits, hyphens, dots, underscores, and plus signs.`);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return execSync(`command -v ${cmd}`, { encoding: "utf-8" }).trim();
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function resolveFullPath(cmdString) {
|
|
245
|
+
const parts = cmdString.split(" ");
|
|
246
|
+
const bin = parts[0];
|
|
247
|
+
const fullPath = resolveCommand(bin);
|
|
248
|
+
if (!fullPath) return cmdString;
|
|
249
|
+
parts[0] = fullPath;
|
|
250
|
+
return parts.join(" ");
|
|
251
|
+
}
|
|
252
|
+
function prompt(question) {
|
|
253
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
254
|
+
return new Promise((resolve2) => {
|
|
255
|
+
rl.question(question, (answer) => {
|
|
256
|
+
rl.close();
|
|
257
|
+
resolve2(answer.trim().toLowerCase());
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
var KNOWN_INSTALL_COMMANDS = {
|
|
262
|
+
claude: () => ["npm", ["install", "-g", "@anthropic-ai/claude-code"]],
|
|
263
|
+
lazygit: () => {
|
|
264
|
+
try {
|
|
265
|
+
execSync("command -v brew", { stdio: "ignore" });
|
|
266
|
+
return ["brew", ["install", "lazygit"]];
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
async function ensureCommand(cmd) {
|
|
273
|
+
if (resolveCommand(cmd)) return;
|
|
274
|
+
const getInstall = KNOWN_INSTALL_COMMANDS[cmd];
|
|
275
|
+
const installCmd = getInstall ? getInstall() : null;
|
|
276
|
+
if (!installCmd) {
|
|
277
|
+
console.error(
|
|
278
|
+
`\`${cmd}\` is required but not installed, and no known install method was found.`
|
|
279
|
+
);
|
|
280
|
+
console.error(
|
|
281
|
+
`Please install \`${cmd}\` manually or change your config with: summon set editor <command>`
|
|
282
|
+
);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
const [installBin, installArgs] = installCmd;
|
|
286
|
+
const installDisplay = [installBin, ...installArgs].join(" ");
|
|
287
|
+
console.log(`\`${cmd}\` is required but not installed on this machine.`);
|
|
288
|
+
const answer = await prompt(`Install it now with \`${installDisplay}\`? [Y/n] `);
|
|
289
|
+
if (answer && answer !== "y" && answer !== "yes") {
|
|
290
|
+
console.log(`\`${cmd}\` is required for this workspace layout. Exiting.`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
console.log(`Running: ${installDisplay}`);
|
|
294
|
+
try {
|
|
295
|
+
execFileSync(installBin, installArgs, { stdio: "inherit" });
|
|
296
|
+
} catch {
|
|
297
|
+
console.error(
|
|
298
|
+
`Failed to install \`${cmd}\`. Please install it manually and try again.`
|
|
299
|
+
);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
if (!resolveCommand(cmd)) {
|
|
303
|
+
console.error(`\`${cmd}\` still not found after install. Please check your PATH.`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
console.log(`\`${cmd}\` installed successfully!
|
|
307
|
+
`);
|
|
308
|
+
}
|
|
309
|
+
function resolveConfig(targetDir, cliOverrides) {
|
|
310
|
+
const project = readKVFile(join2(targetDir, ".summon"));
|
|
311
|
+
const layoutKey = cliOverrides.layout ?? project.get("layout") ?? getConfig("layout");
|
|
312
|
+
let base = {};
|
|
313
|
+
if (layoutKey) {
|
|
314
|
+
if (isPresetName(layoutKey)) {
|
|
315
|
+
base = getPreset(layoutKey);
|
|
316
|
+
} else {
|
|
317
|
+
console.warn(
|
|
318
|
+
`Unknown layout preset: "${layoutKey}". Valid presets: minimal, full, pair, cli, mtop. Using defaults.`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const pick = (cli, projKey) => cli ?? project.get(projKey) ?? getConfig(projKey);
|
|
323
|
+
const editor = pick(cliOverrides.editor, "editor");
|
|
324
|
+
const sidebar = pick(cliOverrides.sidebar, "sidebar");
|
|
325
|
+
const panes = pick(cliOverrides.panes, "panes");
|
|
326
|
+
const editorSize = pick(cliOverrides["editor-size"], "editor-size");
|
|
327
|
+
const server = pick(cliOverrides.server, "server");
|
|
328
|
+
const result = { ...base };
|
|
329
|
+
if (editor !== void 0) result.editor = editor;
|
|
330
|
+
if (sidebar !== void 0) result.sidebarCommand = sidebar;
|
|
331
|
+
if (panes !== void 0) {
|
|
332
|
+
const parsed = parseInt(panes, 10);
|
|
333
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
334
|
+
console.warn(
|
|
335
|
+
`Invalid panes value: "${panes}". Must be a positive integer. Using default (2).`
|
|
336
|
+
);
|
|
337
|
+
result.editorPanes = 2;
|
|
338
|
+
} else {
|
|
339
|
+
result.editorPanes = parsed;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (editorSize !== void 0) {
|
|
343
|
+
const parsed = parseInt(editorSize, 10);
|
|
344
|
+
if (Number.isNaN(parsed) || parsed < 1 || parsed > 99) {
|
|
345
|
+
console.warn(
|
|
346
|
+
`Invalid editor-size value: "${editorSize}". Must be 1-99. Using default (75).`
|
|
347
|
+
);
|
|
348
|
+
result.editorSize = 75;
|
|
349
|
+
} else {
|
|
350
|
+
result.editorSize = parsed;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (server !== void 0) result.server = server;
|
|
354
|
+
return { opts: result };
|
|
355
|
+
}
|
|
356
|
+
async function launch(targetDir, cliOverrides) {
|
|
357
|
+
if (!existsSync2(targetDir)) {
|
|
358
|
+
console.error(`Directory not found: ${targetDir}`);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
ensureGhostty();
|
|
362
|
+
const { opts } = resolveConfig(targetDir, cliOverrides ?? {});
|
|
363
|
+
const plan = planLayout(opts);
|
|
364
|
+
if (plan.editor) await ensureCommand(plan.editor);
|
|
365
|
+
if (plan.sidebarCommand) await ensureCommand(plan.sidebarCommand);
|
|
366
|
+
if (plan.secondaryEditor) {
|
|
367
|
+
const secondaryBin = plan.secondaryEditor.split(" ")[0];
|
|
368
|
+
await ensureCommand(secondaryBin);
|
|
369
|
+
}
|
|
370
|
+
if (plan.serverCommand) {
|
|
371
|
+
const serverBin = plan.serverCommand.split(" ")[0];
|
|
372
|
+
await ensureCommand(serverBin);
|
|
373
|
+
}
|
|
374
|
+
if (plan.editor) plan.editor = resolveFullPath(plan.editor);
|
|
375
|
+
if (plan.sidebarCommand) plan.sidebarCommand = resolveFullPath(plan.sidebarCommand);
|
|
376
|
+
if (plan.secondaryEditor) plan.secondaryEditor = resolveFullPath(plan.secondaryEditor);
|
|
377
|
+
if (plan.serverCommand) plan.serverCommand = resolveFullPath(plan.serverCommand);
|
|
378
|
+
const script = generateAppleScript(plan, targetDir);
|
|
379
|
+
if (cliOverrides?.dryRun) {
|
|
380
|
+
console.log(script);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
executeScript(script);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/index.ts
|
|
387
|
+
var HELP = `
|
|
388
|
+
summon -- Launch multi-pane Ghostty workspaces
|
|
389
|
+
|
|
390
|
+
Usage:
|
|
391
|
+
summon <target> Launch workspace (project name, path, or '.')
|
|
392
|
+
summon add <name> <path> Register a project name -> path mapping
|
|
393
|
+
summon remove <name> Remove a registered project
|
|
394
|
+
summon list List all registered projects
|
|
395
|
+
summon set <key> [value] Set a machine-level config value
|
|
396
|
+
summon config Show current machine configuration
|
|
397
|
+
|
|
398
|
+
Options:
|
|
399
|
+
-h, --help Show this help message
|
|
400
|
+
-v, --version Show version number
|
|
401
|
+
-l, --layout <preset> Use a layout preset (minimal, full, pair, cli, mtop)
|
|
402
|
+
--editor <cmd> Override editor command
|
|
403
|
+
--panes <n> Override number of editor panes
|
|
404
|
+
--editor-size <n> Override editor width %
|
|
405
|
+
--sidebar <cmd> Override sidebar command
|
|
406
|
+
--server <value> Server pane: true, false, or a command
|
|
407
|
+
-n, --dry-run Print generated AppleScript without executing
|
|
408
|
+
|
|
409
|
+
Config keys:
|
|
410
|
+
editor Command for coding panes (default: claude)
|
|
411
|
+
sidebar Command for sidebar pane (default: lazygit)
|
|
412
|
+
panes Number of editor panes (default: 2)
|
|
413
|
+
editor-size Width % for editor grid (default: 75)
|
|
414
|
+
server Server pane toggle (default: true)
|
|
415
|
+
layout Default layout preset
|
|
416
|
+
|
|
417
|
+
Layout presets:
|
|
418
|
+
minimal 1 editor pane, no server
|
|
419
|
+
full 3 editor panes + server
|
|
420
|
+
pair 2 editor panes + server
|
|
421
|
+
cli 1 editor pane + server
|
|
422
|
+
mtop editor + mtop + server + lazygit sidebar
|
|
423
|
+
|
|
424
|
+
Per-project config:
|
|
425
|
+
Place a .summon file in your project root with key=value pairs.
|
|
426
|
+
Project config overrides machine config; CLI flags override both.
|
|
427
|
+
|
|
428
|
+
Requires: macOS, Ghostty 1.3.0+
|
|
429
|
+
|
|
430
|
+
Examples:
|
|
431
|
+
summon . Launch workspace in current directory
|
|
432
|
+
summon myapp Launch workspace for registered project
|
|
433
|
+
summon add myapp ~/code/app Register a project
|
|
434
|
+
summon set editor claude Set the editor command
|
|
435
|
+
summon . --layout minimal Launch with minimal preset
|
|
436
|
+
summon . --server "npm run dev" Launch with custom server command
|
|
437
|
+
`.trim();
|
|
438
|
+
function showHelp() {
|
|
439
|
+
console.log(HELP);
|
|
440
|
+
}
|
|
441
|
+
function expandHome(p) {
|
|
442
|
+
return resolve(p.replace(/^~/, process.env.HOME ?? ""));
|
|
443
|
+
}
|
|
444
|
+
var parseOpts = {
|
|
445
|
+
allowPositionals: true,
|
|
446
|
+
options: {
|
|
447
|
+
help: { type: "boolean", short: "h" },
|
|
448
|
+
version: { type: "boolean", short: "v" },
|
|
449
|
+
layout: { type: "string", short: "l" },
|
|
450
|
+
editor: { type: "string" },
|
|
451
|
+
panes: { type: "string" },
|
|
452
|
+
"editor-size": { type: "string" },
|
|
453
|
+
sidebar: { type: "string" },
|
|
454
|
+
server: { type: "string" },
|
|
455
|
+
"dry-run": { type: "boolean", short: "n" }
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
function safeParse() {
|
|
459
|
+
try {
|
|
460
|
+
return parseArgs(parseOpts);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
463
|
+
console.error(`Error: ${msg}`);
|
|
464
|
+
console.error(`Run 'summon --help' for usage information.`);
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
var { values, positionals } = safeParse();
|
|
469
|
+
if (values.version) {
|
|
470
|
+
console.log("0.1.0");
|
|
471
|
+
process.exit(0);
|
|
472
|
+
}
|
|
473
|
+
if (values.help) {
|
|
474
|
+
showHelp();
|
|
475
|
+
process.exit(0);
|
|
476
|
+
}
|
|
477
|
+
var [subcommand, ...args] = positionals;
|
|
478
|
+
if (!subcommand) {
|
|
479
|
+
console.error(HELP);
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
switch (subcommand) {
|
|
483
|
+
case "add": {
|
|
484
|
+
const [name, path] = args;
|
|
485
|
+
if (!name || !path) {
|
|
486
|
+
console.error("Usage: summon add <name> <path>");
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
const resolved = expandHome(path);
|
|
490
|
+
addProject(name, resolved);
|
|
491
|
+
console.log(`Registered: ${name} \u2192 ${resolved}`);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case "remove": {
|
|
495
|
+
const [name] = args;
|
|
496
|
+
if (!name) {
|
|
497
|
+
console.error("Usage: summon remove <name>");
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
const existed = removeProject(name);
|
|
501
|
+
if (existed) {
|
|
502
|
+
console.log(`Removed: ${name}`);
|
|
503
|
+
} else {
|
|
504
|
+
console.error(`Project not found: ${name}`);
|
|
505
|
+
console.error("Run 'summon list' to see registered projects.");
|
|
506
|
+
process.exit(1);
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case "list": {
|
|
511
|
+
const projects = listProjects();
|
|
512
|
+
if (projects.size === 0) {
|
|
513
|
+
console.log("No projects registered. Use: summon add <name> <path>");
|
|
514
|
+
} else {
|
|
515
|
+
console.log("Registered projects:");
|
|
516
|
+
for (const [name, path] of projects) {
|
|
517
|
+
console.log(` ${name} \u2192 ${path}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
case "set": {
|
|
523
|
+
const [key, value] = args;
|
|
524
|
+
if (!key) {
|
|
525
|
+
console.error("Usage: summon set <key> [value]");
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
const VALID_KEYS = ["editor", "sidebar", "panes", "editor-size", "server", "layout"];
|
|
529
|
+
if (!VALID_KEYS.includes(key)) {
|
|
530
|
+
console.warn(`Warning: unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
|
531
|
+
}
|
|
532
|
+
setConfig(key, value ?? "");
|
|
533
|
+
if (value) {
|
|
534
|
+
console.log(`Set ${key} \u2192 ${value}`);
|
|
535
|
+
} else {
|
|
536
|
+
console.log(`Set ${key} \u2192 (empty, will open plain shell)`);
|
|
537
|
+
}
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case "config": {
|
|
541
|
+
const config = listConfig();
|
|
542
|
+
console.log("Machine config:");
|
|
543
|
+
for (const [key, value] of config) {
|
|
544
|
+
console.log(` ${key} \u2192 ${value || "(plain shell)"}`);
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
default: {
|
|
549
|
+
const target = subcommand;
|
|
550
|
+
let targetDir;
|
|
551
|
+
if (target === ".") {
|
|
552
|
+
targetDir = process.cwd();
|
|
553
|
+
} else if (target.startsWith("/") || target.startsWith("~")) {
|
|
554
|
+
targetDir = expandHome(target);
|
|
555
|
+
} else {
|
|
556
|
+
const path = getProject(target);
|
|
557
|
+
if (!path) {
|
|
558
|
+
console.error(`Unknown project: ${target}`);
|
|
559
|
+
console.error(
|
|
560
|
+
`Register it with: summon add ${target} /path/to/project`
|
|
561
|
+
);
|
|
562
|
+
console.error(`Or see available: summon list`);
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
targetDir = path;
|
|
566
|
+
}
|
|
567
|
+
const overrides = {};
|
|
568
|
+
if (values.layout) overrides.layout = values.layout;
|
|
569
|
+
if (values.editor) overrides.editor = values.editor;
|
|
570
|
+
if (values.panes) overrides.panes = values.panes;
|
|
571
|
+
if (values["editor-size"]) overrides["editor-size"] = values["editor-size"];
|
|
572
|
+
if (values.sidebar) overrides.sidebar = values.sidebar;
|
|
573
|
+
if (values.server) overrides.server = values.server;
|
|
574
|
+
if (values["dry-run"]) overrides.dryRun = true;
|
|
575
|
+
await launch(targetDir, overrides);
|
|
576
|
+
}
|
|
577
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "summon-ws",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Launch configurable multi-pane Ghostty workspaces with one command",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@10.29.2",
|
|
7
|
+
"bin": {
|
|
8
|
+
"summon": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup",
|
|
12
|
+
"dev": "tsup --watch",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"lint": "eslint src/",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest",
|
|
17
|
+
"test:coverage": "vitest run --coverage",
|
|
18
|
+
"prepublishOnly": "pnpm run build",
|
|
19
|
+
"prepare": "husky"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"ghostty",
|
|
23
|
+
"terminal",
|
|
24
|
+
"workspace",
|
|
25
|
+
"splits",
|
|
26
|
+
"applescript",
|
|
27
|
+
"developer-tools",
|
|
28
|
+
"cli",
|
|
29
|
+
"macos"
|
|
30
|
+
],
|
|
31
|
+
"author": "juan294",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/juan294/summon.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/juan294/summon#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/juan294/summon/issues"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist"
|
|
43
|
+
],
|
|
44
|
+
"os": [
|
|
45
|
+
"darwin"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18"
|
|
49
|
+
},
|
|
50
|
+
"pnpm": {
|
|
51
|
+
"onlyBuiltDependencies": [
|
|
52
|
+
"esbuild"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@eslint/js": "^10.0.1",
|
|
57
|
+
"@types/node": "^25.2.3",
|
|
58
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
59
|
+
"eslint": "^10.0.2",
|
|
60
|
+
"husky": "^9.1.7",
|
|
61
|
+
"tsup": "^8.0.0",
|
|
62
|
+
"typescript": "^5.7.0",
|
|
63
|
+
"typescript-eslint": "^8.56.1",
|
|
64
|
+
"vitest": "^4.0.18"
|
|
65
|
+
}
|
|
66
|
+
}
|