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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/index.js +577 -0
  4. 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
+ ![Chapa Badge](https://chapa.thecreativetoken.com/u/juan294/badge.svg)
4
+ [![CI](https://github.com/juan294/summon/actions/workflows/ci.yml/badge.svg)](https://github.com/juan294/summon/actions/workflows/ci.yml)
5
+ [![CodeQL](https://github.com/juan294/summon/actions/workflows/codeql.yml/badge.svg)](https://github.com/juan294/summon/actions/workflows/codeql.yml)
6
+ [![npm version](https://img.shields.io/npm/v/summon-ws)](https://www.npmjs.com/package/summon-ws)
7
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
8
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue)](https://www.typescriptlang.org)
9
+ [![license](https://img.shields.io/npm/l/summon-ws)](./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
+ }