@longshot/cli 0.0.1

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,79 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { userInfo, hostname } from "node:os";
5
+ let cachedPrefix = null;
6
+ let dataDir = null;
7
+ /** Set the data directory (for testing) */
8
+ export function setProfileDataDir(dir) {
9
+ dataDir = dir;
10
+ cachedPrefix = null;
11
+ }
12
+ /** Generate a 4-char hex prefix from user@hostname */
13
+ export function generatePrefix(identity) {
14
+ const input = identity || `${userInfo().username}@${hostname()}`;
15
+ const hash = createHash("sha256").update(input).digest("hex");
16
+ return hash.slice(0, 4);
17
+ }
18
+ function getProfilePath() {
19
+ if (dataDir)
20
+ return join(dataDir, "profile.json");
21
+ return join(import.meta.dirname, "..", ".longshot", "profile.json");
22
+ }
23
+ /** Get or create the user prefix. Cached after first call. */
24
+ export function getPrefix() {
25
+ if (cachedPrefix)
26
+ return cachedPrefix;
27
+ const path = getProfilePath();
28
+ if (existsSync(path)) {
29
+ try {
30
+ const profile = JSON.parse(readFileSync(path, "utf-8"));
31
+ cachedPrefix = profile.prefix;
32
+ return cachedPrefix;
33
+ }
34
+ catch {
35
+ // Corrupt file — regenerate
36
+ }
37
+ }
38
+ const prefix = generatePrefix();
39
+ writeFileSync(path, JSON.stringify({ prefix }, null, 2), "utf-8");
40
+ cachedPrefix = prefix;
41
+ return prefix;
42
+ }
43
+ /** Extract the sequence number from a prefixed task ID (e.g. "a3f2-5" → 5) */
44
+ export function getSeqNumber(id) {
45
+ const parts = id.split("-");
46
+ if (parts.length >= 2) {
47
+ const seq = parseInt(parts[parts.length - 1], 10);
48
+ if (!isNaN(seq))
49
+ return seq;
50
+ }
51
+ // Legacy numeric ID
52
+ const num = parseInt(id, 10);
53
+ return isNaN(num) ? 0 : num;
54
+ }
55
+ /** Check if a task ID is a legacy numeric-only ID */
56
+ export function isLegacyId(id) {
57
+ return /^\d+$/.test(id);
58
+ }
59
+ /** Get the prefix part from a prefixed task ID (e.g. "a3f2-5" → "a3f2") */
60
+ export function getIdPrefix(id) {
61
+ if (isLegacyId(id))
62
+ return null;
63
+ const lastDash = id.lastIndexOf("-");
64
+ if (lastDash <= 0)
65
+ return null;
66
+ return id.slice(0, lastDash);
67
+ }
68
+ /** Format a task ID for file naming (e.g. "a3f2-5" → "a3f2-005") */
69
+ export function formatIdForFile(id) {
70
+ if (isLegacyId(id)) {
71
+ return String(parseInt(id, 10)).padStart(3, "0");
72
+ }
73
+ const prefix = getIdPrefix(id);
74
+ const seq = getSeqNumber(id);
75
+ if (prefix) {
76
+ return `${prefix}-${String(seq).padStart(3, "0")}`;
77
+ }
78
+ return String(seq).padStart(3, "0");
79
+ }
@@ -0,0 +1,337 @@
1
+ import { existsSync, readdirSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import * as store from "./store.js";
5
+ // --- Root directory (where longshot was launched) ---
6
+ const ROOT_DIR = process.env.PROJECT_ROOT || process.cwd();
7
+ // --- Multi-project state ---
8
+ let multiProject = false;
9
+ let currentProjectName = null;
10
+ let currentProjectRoot = ROOT_DIR;
11
+ // --- Exports ---
12
+ export function isMultiProject() {
13
+ return multiProject;
14
+ }
15
+ export function getProjectRoot() {
16
+ return currentProjectRoot;
17
+ }
18
+ export function getRootDir() {
19
+ return ROOT_DIR;
20
+ }
21
+ export function getCurrentProjectName() {
22
+ return currentProjectName;
23
+ }
24
+ // --- Project registry (parent data/projects.json) ---
25
+ function parentDataDir() {
26
+ return join(ROOT_DIR, ".longshot");
27
+ }
28
+ function projectsJsonPath() {
29
+ return join(parentDataDir(), "projects.json");
30
+ }
31
+ function ensureParentDataDir() {
32
+ const dir = parentDataDir();
33
+ if (!existsSync(dir)) {
34
+ mkdirSync(dir, { recursive: true });
35
+ }
36
+ }
37
+ export function readProjects() {
38
+ const path = projectsJsonPath();
39
+ if (!existsSync(path))
40
+ return [];
41
+ try {
42
+ return JSON.parse(readFileSync(path, "utf-8"));
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ function saveProjects(projects) {
49
+ ensureParentDataDir();
50
+ writeFileSync(projectsJsonPath(), JSON.stringify(projects, null, 2), "utf-8");
51
+ }
52
+ // --- Detection on startup ---
53
+ export function detectMode() {
54
+ // Auto-migrate legacy data/ → .longshot/
55
+ const legacyDir = join(ROOT_DIR, "data");
56
+ const newDir = join(ROOT_DIR, ".longshot");
57
+ if (!existsSync(newDir) && existsSync(legacyDir) && (existsSync(join(legacyDir, "tasks.json")) || existsSync(join(legacyDir, "spec.md")))) {
58
+ renameSync(legacyDir, newDir);
59
+ console.log("Migrated data/ → .longshot/");
60
+ }
61
+ // Auto-migrate .rem-c/ → .longshot/
62
+ const remcDir = join(ROOT_DIR, ".rem-c");
63
+ if (!existsSync(newDir) && existsSync(remcDir)) {
64
+ renameSync(remcDir, newDir);
65
+ console.log("Migrated .rem-c/ → .longshot/");
66
+ }
67
+ const hasLocalData = existsSync(join(ROOT_DIR, ".longshot"));
68
+ if (hasLocalData) {
69
+ // Project mode — single project, current behavior
70
+ multiProject = false;
71
+ currentProjectRoot = ROOT_DIR;
72
+ currentProjectName = null;
73
+ return;
74
+ }
75
+ // Parent mode — look for child projects
76
+ multiProject = true;
77
+ autoDiscover();
78
+ // Switch to the first project if any exist
79
+ const projects = readProjects();
80
+ if (projects.length > 0) {
81
+ switchProject(projects[0].name);
82
+ }
83
+ }
84
+ // --- Auto-discovery ---
85
+ function autoDiscover() {
86
+ const projects = readProjects();
87
+ const knownNames = new Set(projects.map((p) => p.name));
88
+ try {
89
+ const entries = readdirSync(ROOT_DIR, { withFileTypes: true });
90
+ for (const entry of entries) {
91
+ if (!entry.isDirectory() || entry.name === ".longshot" || entry.name.startsWith("."))
92
+ continue;
93
+ if (knownNames.has(entry.name))
94
+ continue;
95
+ const childDataDir = join(ROOT_DIR, entry.name, ".longshot");
96
+ if (existsSync(childDataDir)) {
97
+ projects.push({ name: entry.name, path: `./${entry.name}` });
98
+ }
99
+ }
100
+ }
101
+ catch { }
102
+ saveProjects(projects);
103
+ }
104
+ // --- Switch project ---
105
+ export function switchProject(name) {
106
+ if (!multiProject)
107
+ return false;
108
+ const projects = readProjects();
109
+ const project = projects.find((p) => p.name === name);
110
+ if (!project)
111
+ return false;
112
+ const projectPath = resolve(ROOT_DIR, project.path);
113
+ if (!existsSync(join(projectPath, ".longshot")))
114
+ return false;
115
+ currentProjectName = name;
116
+ currentProjectRoot = projectPath;
117
+ store.setDataDir(join(projectPath, ".longshot"));
118
+ return true;
119
+ }
120
+ // --- List available (unregistered) subdirectories ---
121
+ export function listAvailableSubdirs() {
122
+ const projects = readProjects();
123
+ const knownNames = new Set(projects.map((p) => p.name));
124
+ const available = [];
125
+ try {
126
+ const entries = readdirSync(ROOT_DIR, { withFileTypes: true });
127
+ for (const entry of entries) {
128
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules")
129
+ continue;
130
+ if (knownNames.has(entry.name))
131
+ continue;
132
+ available.push(entry.name);
133
+ }
134
+ }
135
+ catch { }
136
+ return available;
137
+ }
138
+ // --- Gitignore entries for transient state ---
139
+ const GITIGNORE_ENTRIES = [
140
+ "# longshot transient state",
141
+ ".longshot/status.json",
142
+ ".longshot/pending-diffs.json",
143
+ ".longshot/queue.json",
144
+ "CLAUDE.md",
145
+ ];
146
+ function setupGitignore(projectPath, isNew) {
147
+ const gitignorePath = join(projectPath, ".gitignore");
148
+ if (isNew) {
149
+ const lines = [
150
+ "node_modules/",
151
+ "dist/",
152
+ "",
153
+ ...GITIGNORE_ENTRIES,
154
+ "",
155
+ ];
156
+ writeFileSync(gitignorePath, lines.join("\n"), "utf-8");
157
+ return;
158
+ }
159
+ // Adopt flow — append to existing .gitignore
160
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
161
+ const existingLines = existing.split("\n");
162
+ const toAdd = [];
163
+ for (const entry of GITIGNORE_ENTRIES) {
164
+ if (entry.startsWith("#")) {
165
+ // Always add the header if we're adding anything
166
+ continue;
167
+ }
168
+ if (!existingLines.includes(entry)) {
169
+ toAdd.push(entry);
170
+ }
171
+ }
172
+ if (toAdd.length > 0) {
173
+ const block = "\n\n# longshot transient state\n" + toAdd.join("\n") + "\n";
174
+ appendFileSync(gitignorePath, block, "utf-8");
175
+ }
176
+ }
177
+ // --- Scaffold data dir ---
178
+ function scaffoldDataDir(projectPath, projectName) {
179
+ const dataDir = join(projectPath, ".longshot");
180
+ mkdirSync(dataDir, { recursive: true });
181
+ writeFileSync(join(dataDir, "spec.md"), `# ${projectName}\n\n`, "utf-8");
182
+ writeFileSync(join(dataDir, "tasks.json"), "[]", "utf-8");
183
+ writeFileSync(join(dataDir, "conversation.json"), "[]", "utf-8");
184
+ writeFileSync(join(dataDir, "history.jsonl"), "", "utf-8");
185
+ writeFileSync(join(dataDir, "queue.json"), "[]", "utf-8");
186
+ }
187
+ // --- Create new project ---
188
+ export function createProject(name) {
189
+ if (!multiProject)
190
+ return { ok: false, error: "Not in multi-project mode" };
191
+ if (!name || /[^a-zA-Z0-9_-]/.test(name)) {
192
+ return { ok: false, error: "Invalid project name (use letters, numbers, hyphens, underscores)" };
193
+ }
194
+ const projects = readProjects();
195
+ if (projects.find((p) => p.name === name)) {
196
+ return { ok: false, error: "Project already exists" };
197
+ }
198
+ const projectPath = join(ROOT_DIR, name);
199
+ if (existsSync(projectPath)) {
200
+ return { ok: false, error: "Directory already exists" };
201
+ }
202
+ // Create directory and git init
203
+ mkdirSync(projectPath, { recursive: true });
204
+ execFileSync("git", ["init"], { cwd: projectPath, stdio: "ignore" });
205
+ // Scaffold data dir
206
+ scaffoldDataDir(projectPath, name);
207
+ // Set up .gitignore
208
+ setupGitignore(projectPath, true);
209
+ // Initial commit
210
+ execFileSync("git", ["add", "."], { cwd: projectPath, stdio: "ignore" });
211
+ execFileSync("git", ["commit", "-m", "Initial longshot scaffold"], { cwd: projectPath, stdio: "ignore" });
212
+ // Register
213
+ projects.push({ name, path: `./${name}` });
214
+ saveProjects(projects);
215
+ return { ok: true };
216
+ }
217
+ // --- Adopt existing subfolder ---
218
+ export function adoptProject(name) {
219
+ if (!multiProject)
220
+ return { ok: false, error: "Not in multi-project mode" };
221
+ const projectPath = join(ROOT_DIR, name);
222
+ if (!existsSync(projectPath)) {
223
+ return { ok: false, error: "Directory not found" };
224
+ }
225
+ // Check it's a git repo
226
+ if (!existsSync(join(projectPath, ".git"))) {
227
+ return { ok: false, error: "Not a git repo — run `git init` first" };
228
+ }
229
+ const projects = readProjects();
230
+ // Already has .longshot/ — just register, don't re-scaffold
231
+ if (existsSync(join(projectPath, ".longshot"))) {
232
+ if (projects.find((p) => p.name === name)) {
233
+ return { ok: false, error: "Already registered as a project" };
234
+ }
235
+ projects.push({ name, path: `./${name}` });
236
+ saveProjects(projects);
237
+ switchProject(name);
238
+ return { ok: true };
239
+ }
240
+ if (projects.find((p) => p.name === name)) {
241
+ return { ok: false, error: "Already registered as a project" };
242
+ }
243
+ // Scaffold data dir
244
+ scaffoldDataDir(projectPath, name);
245
+ // Update .gitignore
246
+ setupGitignore(projectPath, false);
247
+ // Commit the scaffold
248
+ try {
249
+ execFileSync("git", ["add", ".longshot/", ".gitignore"], { cwd: projectPath, stdio: "ignore" });
250
+ execFileSync("git", ["commit", "-m", "Add longshot data scaffold"], { cwd: projectPath, stdio: "ignore" });
251
+ }
252
+ catch {
253
+ // May fail if nothing to commit
254
+ }
255
+ // Register
256
+ projects.push({ name, path: `./${name}` });
257
+ saveProjects(projects);
258
+ // Switch to it
259
+ switchProject(name);
260
+ return { ok: true };
261
+ }
262
+ // --- CLI onboarding helpers ---
263
+ /**
264
+ * Initialize a directory as a single longshot project.
265
+ * Scaffolds .longshot/ and sets up .gitignore.
266
+ * Commits if the directory is a git repo.
267
+ */
268
+ export function initSingleProject(dir) {
269
+ const name = dir.split("/").pop() || "project";
270
+ scaffoldDataDir(dir, name);
271
+ setupGitignore(dir, !existsSync(join(dir, ".gitignore")));
272
+ // Commit if it's a git repo
273
+ if (existsSync(join(dir, ".git"))) {
274
+ try {
275
+ execFileSync("git", ["add", ".longshot/", ".gitignore"], { cwd: dir, stdio: "ignore" });
276
+ execFileSync("git", ["commit", "-m", "Initialize longshot project"], { cwd: dir, stdio: "ignore" });
277
+ }
278
+ catch {
279
+ // May fail if nothing to commit
280
+ }
281
+ }
282
+ }
283
+ /**
284
+ * Initialize multi-project mode in a parent directory.
285
+ * Adopts selected subdirectories as projects.
286
+ * Self-contained — reads/writes projects.json directly in parentDir/.longshot/
287
+ * so it works regardless of the module-level ROOT_DIR.
288
+ */
289
+ export function initMultiProject(parentDir, selectedDirs) {
290
+ const parentDataPath = join(parentDir, ".longshot");
291
+ const projectsPath = join(parentDataPath, "projects.json");
292
+ // Ensure parent .longshot dir exists
293
+ if (!existsSync(parentDataPath)) {
294
+ mkdirSync(parentDataPath, { recursive: true });
295
+ }
296
+ // Read existing projects.json or start fresh
297
+ let projects = [];
298
+ if (existsSync(projectsPath)) {
299
+ try {
300
+ projects = JSON.parse(readFileSync(projectsPath, "utf-8"));
301
+ }
302
+ catch {
303
+ projects = [];
304
+ }
305
+ }
306
+ for (const dirName of selectedDirs) {
307
+ const projectPath = join(parentDir, dirName);
308
+ if (!existsSync(projectPath))
309
+ continue;
310
+ // Already has .longshot/ — just register
311
+ if (existsSync(join(projectPath, ".longshot"))) {
312
+ if (!projects.find((p) => p.name === dirName)) {
313
+ projects.push({ name: dirName, path: `./${dirName}` });
314
+ }
315
+ continue;
316
+ }
317
+ // Scaffold the subdirectory
318
+ scaffoldDataDir(projectPath, dirName);
319
+ setupGitignore(projectPath, !existsSync(join(projectPath, ".gitignore")));
320
+ // Commit if it's a git repo
321
+ if (existsSync(join(projectPath, ".git"))) {
322
+ try {
323
+ execFileSync("git", ["add", ".longshot/", ".gitignore"], { cwd: projectPath, stdio: "ignore" });
324
+ execFileSync("git", ["commit", "-m", "Add longshot data scaffold"], { cwd: projectPath, stdio: "ignore" });
325
+ }
326
+ catch {
327
+ // May fail if nothing to commit
328
+ }
329
+ }
330
+ // Register
331
+ if (!projects.find((p) => p.name === dirName)) {
332
+ projects.push({ name: dirName, path: `./${dirName}` });
333
+ }
334
+ }
335
+ // Save projects.json
336
+ writeFileSync(projectsPath, JSON.stringify(projects, null, 2), "utf-8");
337
+ }