@longshot/cli 0.0.1 → 0.0.2

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/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { parseArgs } from "node:util";
3
3
  import { resolve, join, dirname } from "node:path";
4
4
  import { existsSync, readFileSync, readdirSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { homedir } from "node:os";
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  function getVersion() {
8
9
  // Walk up from src/ or dist/ to find package.json
@@ -23,15 +24,19 @@ function printHelp() {
23
24
  Usage: longshot [options]
24
25
 
25
26
  Options:
26
- -p, --port <number> Port for the web server (default: 3333)
27
- -d, --dir <path> Project root directory (default: current directory)
28
- -h, --help Show this help message
29
- -v, --version Show version number
27
+ -p, --port <number> Port for the web server (default: 3333)
28
+ -d, --dir <path> Project root directory (default: current directory)
29
+ -c, --connect Connect to remote proxy tunnel
30
+ --proxy-url <url> Proxy WebSocket URL (overrides auth.json / env)
31
+ --token <token> Auth token (overrides auth.json / env)
32
+ -h, --help Show this help message
33
+ -v, --version Show version number
30
34
 
31
35
  Examples:
32
36
  longshot Start in current directory on port 3333
33
37
  longshot -p 8080 Start on port 8080
34
38
  longshot -d ~/my-project Start with a specific project directory
39
+ longshot --connect Start server and connect to proxy tunnel
35
40
 
36
41
  First run:
37
42
  If the target directory isn't a longshot project yet, you'll be guided
@@ -45,6 +50,9 @@ export function parseCliArgs(args) {
45
50
  dir: { type: "string", short: "d" },
46
51
  help: { type: "boolean", short: "h", default: false },
47
52
  version: { type: "boolean", short: "v", default: false },
53
+ connect: { type: "boolean", short: "c", default: false },
54
+ "proxy-url": { type: "string" },
55
+ token: { type: "string" },
48
56
  },
49
57
  strict: true,
50
58
  allowPositionals: false,
@@ -54,23 +62,44 @@ export function parseCliArgs(args) {
54
62
  dir: values.dir ? resolve(values.dir) : process.cwd(),
55
63
  help: values.help ?? false,
56
64
  version: values.version ?? false,
65
+ connect: values.connect ?? false,
66
+ proxyUrl: values["proxy-url"],
67
+ token: values.token,
57
68
  };
58
69
  }
70
+ function loadAuthConfig() {
71
+ const authPath = join(homedir(), ".longshot", "auth.json");
72
+ try {
73
+ if (existsSync(authPath)) {
74
+ return JSON.parse(readFileSync(authPath, "utf-8"));
75
+ }
76
+ }
77
+ catch {
78
+ // Ignore malformed auth.json
79
+ }
80
+ return {};
81
+ }
82
+ export function resolveTunnelConfig(parsed) {
83
+ const auth = loadAuthConfig();
84
+ const proxyUrl = parsed.proxyUrl
85
+ || process.env.LONGSHOT_PROXY_URL
86
+ || auth.proxyUrl;
87
+ const token = parsed.token
88
+ || process.env.LONGSHOT_TOKEN
89
+ || auth.token;
90
+ return { proxyUrl, token };
91
+ }
59
92
  class InquirerPrompter {
60
93
  async selectMode() {
61
- const inquirer = await import("inquirer");
94
+ const { select } = await import("@inquirer/prompts");
62
95
  try {
63
- const { mode } = await inquirer.default.prompt([
64
- {
65
- type: "list",
66
- name: "mode",
67
- message: "How would you like to use longshot?",
68
- choices: [
69
- { name: "Single project — initialize this directory", value: "single" },
70
- { name: "Multi-project — manage multiple subdirectories", value: "multi" },
71
- ],
72
- },
73
- ]);
96
+ const mode = await select({
97
+ message: "How would you like to use longshot?",
98
+ choices: [
99
+ { name: "Single project — initialize this directory", value: "single" },
100
+ { name: "Multi-project manage multiple subdirectories", value: "multi" },
101
+ ],
102
+ });
74
103
  return mode;
75
104
  }
76
105
  catch {
@@ -78,16 +107,12 @@ class InquirerPrompter {
78
107
  }
79
108
  }
80
109
  async selectFolders(folders) {
81
- const inquirer = await import("inquirer");
110
+ const { checkbox } = await import("@inquirer/prompts");
82
111
  try {
83
- const { selected } = await inquirer.default.prompt([
84
- {
85
- type: "checkbox",
86
- name: "selected",
87
- message: "Select project directories:",
88
- choices: folders.map((f) => ({ name: f, value: f })),
89
- },
90
- ]);
112
+ const selected = await checkbox({
113
+ message: "Select project directories:",
114
+ choices: folders.map((f) => ({ name: f, value: f })),
115
+ });
91
116
  return selected;
92
117
  }
93
118
  catch {
@@ -162,9 +187,41 @@ async function main() {
162
187
  // Set environment for the server
163
188
  process.env.PORT = String(parsed.port);
164
189
  process.env.PROJECT_ROOT = parsed.dir;
190
+ process.env.LONGSHOT_CLI = "1";
165
191
  // Start the server
166
192
  const { startServer } = await import("./index.js");
167
193
  await startServer();
194
+ // Start tunnel if --connect flag is used
195
+ if (parsed.connect) {
196
+ const { proxyUrl, token } = resolveTunnelConfig(parsed);
197
+ if (!proxyUrl || !token) {
198
+ console.error("Tunnel requires --proxy-url and --token (or ~/.longshot/auth.json or LONGSHOT_PROXY_URL/LONGSHOT_TOKEN env vars)");
199
+ process.exit(1);
200
+ }
201
+ const { TunnelClient } = await import("./tunnel.js");
202
+ const tunnel = new TunnelClient({
203
+ proxyUrl,
204
+ authToken: token,
205
+ localPort: parsed.port,
206
+ onConnected: () => console.log("Connected to proxy"),
207
+ onDisconnected: () => console.log("Disconnected from proxy, reconnecting..."),
208
+ onError: (err) => console.error("Tunnel error:", err.message),
209
+ });
210
+ const shutdown = () => {
211
+ console.log("Disconnecting tunnel...");
212
+ tunnel.disconnect();
213
+ process.exit(0);
214
+ };
215
+ process.on("SIGINT", shutdown);
216
+ process.on("SIGTERM", shutdown);
217
+ try {
218
+ await tunnel.connect();
219
+ }
220
+ catch (err) {
221
+ console.error("Failed to connect tunnel:", err.message);
222
+ process.exit(1);
223
+ }
224
+ }
168
225
  }
169
226
  main().catch((err) => {
170
227
  console.error("Fatal error:", err);
package/dist/index.js CHANGED
@@ -1244,7 +1244,7 @@ export async function startServer() {
1244
1244
  }
1245
1245
  });
1246
1246
  }
1247
- // Only start server when not imported as a module for testing
1248
- if (!process.env.TESTING) {
1247
+ // Only start server when run directly (not imported by CLI or tests)
1248
+ if (!process.env.TESTING && !process.env.LONGSHOT_CLI) {
1249
1249
  startServer();
1250
1250
  }
package/dist/projects.js CHANGED
@@ -64,17 +64,25 @@ export function detectMode() {
64
64
  renameSync(remcDir, newDir);
65
65
  console.log("Migrated .rem-c/ → .longshot/");
66
66
  }
67
+ const hasProjectsJson = existsSync(join(ROOT_DIR, ".longshot", "projects.json"));
67
68
  const hasLocalData = existsSync(join(ROOT_DIR, ".longshot"));
68
- if (hasLocalData) {
69
- // Project mode — single project, current behavior
69
+ if (hasProjectsJson) {
70
+ // Multi-project mode — parent dir has projects.json
71
+ multiProject = true;
72
+ autoDiscover();
73
+ }
74
+ else if (hasLocalData) {
75
+ // Single project mode — has .longshot/ but no projects.json
70
76
  multiProject = false;
71
77
  currentProjectRoot = ROOT_DIR;
72
78
  currentProjectName = null;
73
79
  return;
74
80
  }
75
- // Parent mode — look for child projects
76
- multiProject = true;
77
- autoDiscover();
81
+ else {
82
+ // No data at all — look for child projects
83
+ multiProject = true;
84
+ autoDiscover();
85
+ }
78
86
  // Switch to the first project if any exist
79
87
  const projects = readProjects();
80
88
  if (projects.length > 0) {
package/dist/queue.js CHANGED
@@ -415,7 +415,6 @@ Context available if needed:
415
415
  break;
416
416
  }
417
417
  case "start-work": {
418
- await store.updateTaskStatus(item.params.taskId, "in_progress");
419
418
  const task = await store.getTask(item.params.taskId);
420
419
  if (!task) {
421
420
  await store.updateQueueItem(item.id, {
@@ -425,6 +424,15 @@ Context available if needed:
425
424
  });
426
425
  return;
427
426
  }
427
+ // Skip duplicate start-work for tasks already complete/rejected
428
+ if (task.meta.status === "complete" || task.meta.status === "rejected" || task.meta.completed || task.meta.checkpoint) {
429
+ await store.updateQueueItem(item.id, {
430
+ status: "done",
431
+ completedAt: new Date().toISOString(),
432
+ });
433
+ return;
434
+ }
435
+ await store.updateTaskStatus(item.params.taskId, "in_progress");
428
436
  const prompt = `Implement task #${item.params.taskId}: ${task.meta.title}\n\nTask spec:\n${task.spec}`;
429
437
  result = await runAgent(prompt, `Task #${item.params.taskId}: ${task.meta.title}`, task, item.params.taskId);
430
438
  break;
@@ -440,6 +448,14 @@ Context available if needed:
440
448
  });
441
449
  return;
442
450
  }
451
+ // Skip duplicate spec-update for tasks already complete
452
+ if (task.meta.status === "complete" || task.meta.status === "rejected") {
453
+ await store.updateQueueItem(item.id, {
454
+ status: "done",
455
+ completedAt: new Date().toISOString(),
456
+ });
457
+ return;
458
+ }
443
459
  const padId = String(task.meta.id).padStart(3, "0");
444
460
  const taskDir = `.longshot/tasks/${padId}-${task.meta.slug}`;
445
461
  const specPrompt = `Task #${taskIdForUpdate} "${task.meta.title}" has just been implemented and approved.
@@ -598,9 +614,18 @@ async function revertTaskStatusOnFailure(item) {
598
614
  if (!taskId)
599
615
  return;
600
616
  // Don't revert terminal statuses — a completed/rejected task should stay that way
617
+ // Also check for completed/checkpoint fields, since a duplicate start-work may have
618
+ // overwritten the status to in_progress even though the task was already done
601
619
  const task = await store.getTask(taskId);
602
- if (task && (task.meta.status === "complete" || task.meta.status === "rejected"))
620
+ if (!task)
621
+ return;
622
+ if (task.meta.status === "complete" || task.meta.status === "rejected")
623
+ return;
624
+ if (task.meta.completed || task.meta.checkpoint) {
625
+ // Task was completed but status got overwritten — restore it
626
+ await store.updateTaskStatus(taskId, "complete");
603
627
  return;
628
+ }
604
629
  switch (item.type) {
605
630
  case "start-work": {
606
631
  // If there's a pending diff from a previous run, stay in_progress
@@ -666,18 +691,23 @@ export async function enqueue(type, params) {
666
691
  params.taskSlug = task.slug;
667
692
  }
668
693
  const item = await store.addQueueItem({ type, params });
669
- // Set transitional task status immediately on enqueue
670
- if (type === "start-work" && params.taskId) {
671
- await store.updateTaskStatus(params.taskId, "queued");
672
- }
673
- else if (type === "refine-task" && params.taskId) {
674
- await store.updateTaskStatus(params.taskId, "refining");
675
- }
676
- else if (type === "spec-update" && params.taskId) {
677
- await store.updateTaskStatus(params.taskId, "approved");
678
- }
679
- else if (type === "fix-task" && params.taskId) {
680
- await store.updateTaskStatus(params.taskId, "fixing");
694
+ // Set transitional task status immediately on enqueue — but only if the task
695
+ // is in the expected state. Never overwrite a "further along" or terminal status.
696
+ if (params.taskId) {
697
+ const task = await store.getTask(params.taskId);
698
+ const status = task?.meta.status;
699
+ if (type === "start-work" && status === "ready") {
700
+ await store.updateTaskStatus(params.taskId, "queued");
701
+ }
702
+ else if (type === "refine-task" && status === "drafting") {
703
+ await store.updateTaskStatus(params.taskId, "refining");
704
+ }
705
+ else if (type === "spec-update" && status === "in_progress") {
706
+ await store.updateTaskStatus(params.taskId, "approved");
707
+ }
708
+ else if (type === "fix-task" && status === "in_progress") {
709
+ await store.updateTaskStatus(params.taskId, "fixing");
710
+ }
681
711
  }
682
712
  // Start processing if not already running
683
713
  processNext().catch((err) => console.error("Queue processor error:", err));
package/dist/tunnel.js ADDED
@@ -0,0 +1,152 @@
1
+ import WebSocket from "ws";
2
+ const HOP_BY_HOP_HEADERS = new Set([
3
+ "connection",
4
+ "keep-alive",
5
+ "proxy-authenticate",
6
+ "proxy-authorization",
7
+ "te",
8
+ "trailers",
9
+ "transfer-encoding",
10
+ "upgrade",
11
+ ]);
12
+ export class TunnelClient {
13
+ ws = null;
14
+ opts;
15
+ reconnectDelay = 1000;
16
+ reconnectTimer = null;
17
+ intentionalClose = false;
18
+ _connected = false;
19
+ constructor(opts) {
20
+ this.opts = opts;
21
+ }
22
+ connect() {
23
+ this.intentionalClose = false;
24
+ return new Promise((resolve, reject) => {
25
+ this.openConnection(resolve, reject);
26
+ });
27
+ }
28
+ disconnect() {
29
+ this.intentionalClose = true;
30
+ if (this.reconnectTimer) {
31
+ clearTimeout(this.reconnectTimer);
32
+ this.reconnectTimer = null;
33
+ }
34
+ if (this.ws) {
35
+ this.ws.close();
36
+ this.ws = null;
37
+ }
38
+ this._connected = false;
39
+ }
40
+ isConnected() {
41
+ return this._connected;
42
+ }
43
+ openConnection(onFirstConnect, onFirstError) {
44
+ const ws = new WebSocket(this.opts.proxyUrl);
45
+ this.ws = ws;
46
+ let registered = false;
47
+ ws.on("open", () => {
48
+ ws.send(JSON.stringify({ type: "register", token: this.opts.authToken }));
49
+ });
50
+ ws.on("message", (data) => {
51
+ let msg;
52
+ try {
53
+ msg = JSON.parse(data.toString());
54
+ }
55
+ catch {
56
+ return;
57
+ }
58
+ if (msg.type === "registered") {
59
+ registered = true;
60
+ this._connected = true;
61
+ this.reconnectDelay = 1000;
62
+ this.opts.onConnected?.();
63
+ onFirstConnect?.();
64
+ onFirstConnect = undefined;
65
+ onFirstError = undefined;
66
+ }
67
+ else if (msg.type === "error") {
68
+ const err = new Error(msg.message);
69
+ this.opts.onError?.(err);
70
+ if (!registered && onFirstError) {
71
+ onFirstError(err);
72
+ onFirstConnect = undefined;
73
+ onFirstError = undefined;
74
+ }
75
+ }
76
+ else if (msg.type === "ping") {
77
+ ws.send(JSON.stringify({ type: "pong" }));
78
+ }
79
+ else if (msg.type === "request") {
80
+ this.handleRequest(msg);
81
+ }
82
+ });
83
+ ws.on("close", () => {
84
+ this._connected = false;
85
+ this.opts.onDisconnected?.();
86
+ if (!this.intentionalClose) {
87
+ this.scheduleReconnect();
88
+ }
89
+ });
90
+ ws.on("error", (err) => {
91
+ this.opts.onError?.(err);
92
+ if (!registered && onFirstError) {
93
+ onFirstError(err);
94
+ onFirstConnect = undefined;
95
+ onFirstError = undefined;
96
+ }
97
+ });
98
+ }
99
+ scheduleReconnect() {
100
+ this.reconnectTimer = setTimeout(() => {
101
+ this.reconnectTimer = null;
102
+ this.openConnection();
103
+ }, this.reconnectDelay);
104
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
105
+ }
106
+ async handleRequest(req) {
107
+ const url = `http://localhost:${this.opts.localPort}${req.path}`;
108
+ const headers = {};
109
+ if (req.headers) {
110
+ for (const [k, v] of Object.entries(req.headers)) {
111
+ if (!HOP_BY_HOP_HEADERS.has(k.toLowerCase())) {
112
+ headers[k] = v;
113
+ }
114
+ }
115
+ }
116
+ try {
117
+ const resp = await fetch(url, {
118
+ method: req.method,
119
+ headers,
120
+ body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
121
+ });
122
+ const respHeaders = {};
123
+ resp.headers.forEach((v, k) => {
124
+ if (!HOP_BY_HOP_HEADERS.has(k.toLowerCase())) {
125
+ respHeaders[k] = v;
126
+ }
127
+ });
128
+ const body = await resp.text();
129
+ this.send({
130
+ type: "response",
131
+ id: req.id,
132
+ status: resp.status,
133
+ headers: respHeaders,
134
+ body,
135
+ });
136
+ }
137
+ catch {
138
+ this.send({
139
+ type: "response",
140
+ id: req.id,
141
+ status: 502,
142
+ headers: {},
143
+ body: "Local server unavailable",
144
+ });
145
+ }
146
+ }
147
+ send(msg) {
148
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
149
+ this.ws.send(JSON.stringify(msg));
150
+ }
151
+ }
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@longshot/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Mobile-first Claude orchestrator with spec-driven workflow",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,12 +24,13 @@
24
24
  "@hono/node-server": "^1.19.9",
25
25
  "diff2html": "^3.4.56",
26
26
  "hono": "^4.11.9",
27
- "inquirer": "^13.2.4",
28
- "marked": "^17.0.2"
27
+ "@inquirer/prompts": "^7.0.0",
28
+ "marked": "^17.0.2",
29
+ "ws": "^8.19.0"
29
30
  },
30
31
  "devDependencies": {
31
- "@types/inquirer": "^9.0.9",
32
32
  "@types/node": "^25.2.3",
33
+ "@types/ws": "^8.18.1",
33
34
  "tsx": "^4.21.0",
34
35
  "typescript": "^5.9.3"
35
36
  }