bet-cli 0.2.0 → 0.3.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.
package/src/lib/help.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Help } from "commander";
2
2
  import type { Command } from "commander";
3
3
 
4
- const GROUP_1: string[] = ["list", "search", "info", "go", "path"];
4
+ const GROUP_1: string[] = ["list", "search", "info", "go", "edit", "path"];
5
5
  const GROUP_2: string[] = ["shell", "completion"];
6
6
  const GROUP_3: string[] = ["update", "ignore", "help"];
7
7
 
@@ -55,23 +55,24 @@ export class GroupedHelp extends Help {
55
55
  }
56
56
 
57
57
  // Arguments
58
- const argumentList = helper.visibleArguments(cmd).map((argument) =>
59
- formatItem(
60
- helper.argumentTerm(argument),
61
- helper.argumentDescription(argument),
62
- ),
63
- );
58
+ const argumentList = helper
59
+ .visibleArguments(cmd)
60
+ .map((argument) =>
61
+ formatItem(
62
+ helper.argumentTerm(argument),
63
+ helper.argumentDescription(argument),
64
+ ),
65
+ );
64
66
  if (argumentList.length > 0) {
65
67
  output.push("Arguments:", formatList(argumentList), "");
66
68
  }
67
69
 
68
70
  // Options
69
- const optionList = helper.visibleOptions(cmd).map((option) =>
70
- formatItem(
71
- helper.optionTerm(option),
72
- helper.optionDescription(option),
73
- ),
74
- );
71
+ const optionList = helper
72
+ .visibleOptions(cmd)
73
+ .map((option) =>
74
+ formatItem(helper.optionTerm(option), helper.optionDescription(option)),
75
+ );
75
76
  if (optionList.length > 0) {
76
77
  output.push("Options:", formatList(optionList), "");
77
78
  }
@@ -105,8 +106,7 @@ export class GroupedHelp extends Help {
105
106
  const groupIndices = [...byGroup.keys()].sort((a, b) => a - b);
106
107
  for (const idx of groupIndices) {
107
108
  const commands = byGroup.get(idx)!;
108
- const heading =
109
- idx < GROUPS.length ? GROUPS[idx].heading : "Commands";
109
+ const heading = idx < GROUPS.length ? GROUPS[idx].heading : "Commands";
110
110
  const commandList = commands.map((sub) =>
111
111
  formatItem(
112
112
  helper.subcommandTerm(sub),
@@ -117,6 +117,19 @@ export class GroupedHelp extends Help {
117
117
  }
118
118
  }
119
119
 
120
+ output.push(
121
+ "AI agent skill:",
122
+ formatList([
123
+ "bet ships an agent skill that teaches AI coding agents (Claude Code,",
124
+ "Cursor, etc.) how to drive this CLI from natural-language requests.",
125
+ "Install it to supercharge your agent — drop the skills/bet/ folder",
126
+ "into your harness's skills directory (e.g. ~/.claude/skills/bet/).",
127
+ "",
128
+ " https://github.com/kenzic/bet-cli/tree/main/skills/bet",
129
+ ]),
130
+ "",
131
+ );
132
+
120
133
  return output.join("\n");
121
134
  }
122
135
  }
package/src/lib/types.ts CHANGED
@@ -33,6 +33,7 @@ export type RootConfig = {
33
33
  export type AppConfig = {
34
34
  version: number;
35
35
  roots: RootConfig[];
36
+ editor?: string;
36
37
  ignores?: string[];
37
38
  ignoredPaths?: string[];
38
39
  slugParentFolders?: string[];
@@ -46,6 +47,7 @@ export type ProjectsConfig = {
46
47
  export type Config = {
47
48
  version: number;
48
49
  roots: RootConfig[];
50
+ editor?: string;
49
51
  ignores?: string[];
50
52
  ignoredPaths?: string[];
51
53
  slugParentFolders?: string[];
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import { registerPath } from "./commands/path.js";
9
9
  import { registerShell } from "./commands/shell.js";
10
10
  import { registerCompletion } from "./commands/completion.js";
11
11
  import { registerIgnore } from "./commands/ignore.js";
12
+ import { registerEdit } from "./commands/edit.js";
12
13
 
13
14
  const ASCII_HEADER = `
14
15
  ░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░
@@ -38,13 +39,14 @@ program.createHelp = function createHelp(this: Command) {
38
39
  program
39
40
  .name("bet")
40
41
  .description("Explore and jump between local projects.")
41
- .version("0.2.0");
42
+ .version("0.3.1");
42
43
 
43
44
  registerUpdate(program);
44
45
  registerList(program);
45
46
  registerSearch(program);
46
47
  registerInfo(program);
47
48
  registerGo(program);
49
+ registerEdit(program);
48
50
  registerPath(program);
49
51
  registerShell(program);
50
52
  registerCompletion(program);
@@ -114,6 +114,54 @@ describe("config", () => {
114
114
  expect(config.ignores).toEqual(["**/foo/**", "**/bar"]);
115
115
  });
116
116
 
117
+ it("returns editor when set in config", async () => {
118
+ const configPath = getConfigPath();
119
+ const projectsPath = getProjectsPath();
120
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
121
+ if (p === configPath) {
122
+ return Promise.resolve(
123
+ JSON.stringify({
124
+ version: 1,
125
+ roots: [],
126
+ editor: "code -n",
127
+ }),
128
+ );
129
+ }
130
+ if (p === projectsPath) {
131
+ return Promise.resolve(JSON.stringify({ projects: {} }));
132
+ }
133
+ return Promise.reject(new Error("ENOENT"));
134
+ });
135
+
136
+ const config = await readConfig();
137
+
138
+ expect(config.editor).toBe("code -n");
139
+ });
140
+
141
+ it("normalizes blank editor to undefined", async () => {
142
+ const configPath = getConfigPath();
143
+ const projectsPath = getProjectsPath();
144
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
145
+ if (p === configPath) {
146
+ return Promise.resolve(
147
+ JSON.stringify({
148
+ version: 1,
149
+ roots: [],
150
+ editor: " ",
151
+ }),
152
+ );
153
+ }
154
+ if (p === projectsPath) {
155
+ return Promise.resolve(JSON.stringify({ projects: {} }));
156
+ }
157
+ return Promise.reject(new Error("ENOENT"));
158
+ });
159
+
160
+ const config = await readConfig();
161
+
162
+ expect(config.editor).toBeUndefined();
163
+ });
164
+
117
165
  it("leaves ignores undefined when not in config", async () => {
118
166
  const configPath = getConfigPath();
119
167
  const projectsPath = getProjectsPath();
@@ -276,5 +324,28 @@ describe("config", () => {
276
324
  );
277
325
  expect(written.ignoredPaths).toEqual([path.resolve("/code/foo"), path.resolve("/code/bar")]);
278
326
  });
327
+
328
+ it("writes editor to app config when present", async () => {
329
+ const configPath = getConfigPath();
330
+ const projectsPath = getProjectsPath();
331
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
332
+ if (p === configPath) {
333
+ return Promise.resolve(JSON.stringify({ version: 1, roots: [] }));
334
+ }
335
+ if (p === projectsPath) {
336
+ return Promise.resolve(JSON.stringify({ projects: {} }));
337
+ }
338
+ return Promise.reject(new Error("ENOENT"));
339
+ });
340
+ const config = await readConfig();
341
+
342
+ await writeConfig({ ...config, editor: "cursor" });
343
+
344
+ expect(fs.writeFile).toHaveBeenCalledWith(
345
+ configPath,
346
+ expect.stringContaining('"editor": "cursor"'),
347
+ "utf8",
348
+ );
349
+ });
279
350
  });
280
351
  });
@@ -0,0 +1,167 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { getSystemOpenCommand, openProjectInEditor, parseEditorCommand } from "../src/lib/editor.js";
4
+
5
+ vi.mock("node:child_process", () => ({
6
+ spawn: vi.fn(),
7
+ }));
8
+
9
+ import { spawn } from "node:child_process";
10
+
11
+ type SpawnedProcess = EventEmitter & {
12
+ unref: ReturnType<typeof vi.fn>;
13
+ };
14
+
15
+ function createSpawnedProcess(): SpawnedProcess {
16
+ const child = new EventEmitter() as SpawnedProcess;
17
+ child.unref = vi.fn();
18
+ return child;
19
+ }
20
+
21
+ describe("editor", () => {
22
+ beforeEach(() => {
23
+ vi.mocked(spawn).mockReset();
24
+ });
25
+
26
+ describe("parseEditorCommand", () => {
27
+ it("parses command and args", () => {
28
+ expect(parseEditorCommand("code -n")).toEqual({
29
+ command: "code",
30
+ args: ["-n"],
31
+ });
32
+ });
33
+
34
+ it("handles quoted args", () => {
35
+ expect(parseEditorCommand('cursor --profile "Work Profile"')).toEqual({
36
+ command: "cursor",
37
+ args: ["--profile", "Work Profile"],
38
+ });
39
+ });
40
+
41
+ it("throws for malformed command", () => {
42
+ expect(() => parseEditorCommand('code "unterminated')).toThrow(
43
+ "Invalid editor command in config.",
44
+ );
45
+ });
46
+ });
47
+
48
+ describe("getSystemOpenCommand", () => {
49
+ it("returns open on macOS", () => {
50
+ expect(getSystemOpenCommand("/tmp/project", "darwin")).toEqual({
51
+ command: "open",
52
+ args: ["/tmp/project"],
53
+ });
54
+ });
55
+
56
+ it("returns cmd start on windows", () => {
57
+ expect(getSystemOpenCommand("C:\\tmp\\project", "win32")).toEqual({
58
+ command: "cmd",
59
+ args: ["/c", "start", "", "C:\\tmp\\project"],
60
+ });
61
+ });
62
+
63
+ it("returns xdg-open on linux", () => {
64
+ expect(getSystemOpenCommand("/tmp/project", "linux")).toEqual({
65
+ command: "xdg-open",
66
+ args: ["/tmp/project"],
67
+ });
68
+ });
69
+ });
70
+
71
+ describe("openProjectInEditor", () => {
72
+ it("uses configured editor command with project path", async () => {
73
+ const child = createSpawnedProcess();
74
+ vi.mocked(spawn).mockReturnValue(child as never);
75
+
76
+ const openPromise = openProjectInEditor("/tmp/project", "code -n");
77
+ child.emit("spawn");
78
+ await openPromise;
79
+
80
+ expect(spawn).toHaveBeenCalledWith("code", ["-n", "/tmp/project"], {
81
+ detached: true,
82
+ stdio: "ignore",
83
+ });
84
+ expect(child.unref).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it("falls back to system opener when editor is not configured", async () => {
88
+ const child = createSpawnedProcess();
89
+ vi.mocked(spawn).mockReturnValue(child as never);
90
+
91
+ const openPromise = openProjectInEditor("/tmp/project", undefined, {});
92
+ child.emit("spawn");
93
+ await openPromise;
94
+
95
+ expect(spawn).toHaveBeenCalledTimes(1);
96
+ expect(child.unref).toHaveBeenCalledTimes(1);
97
+ });
98
+
99
+ it("uses VISUAL when config editor is not set", async () => {
100
+ const child = createSpawnedProcess();
101
+ vi.mocked(spawn).mockReturnValue(child as never);
102
+
103
+ const openPromise = openProjectInEditor(
104
+ "/tmp/project",
105
+ undefined,
106
+ { VISUAL: "nvim" },
107
+ );
108
+ child.emit("spawn");
109
+ await openPromise;
110
+
111
+ expect(spawn).toHaveBeenCalledWith("nvim", ["/tmp/project"], {
112
+ detached: true,
113
+ stdio: "ignore",
114
+ });
115
+ expect(child.unref).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ it("uses EDITOR when VISUAL is not set", async () => {
119
+ const child = createSpawnedProcess();
120
+ vi.mocked(spawn).mockReturnValue(child as never);
121
+
122
+ const openPromise = openProjectInEditor(
123
+ "/tmp/project",
124
+ undefined,
125
+ { EDITOR: "vim -p" },
126
+ );
127
+ child.emit("spawn");
128
+ await openPromise;
129
+
130
+ expect(spawn).toHaveBeenCalledWith("vim", ["-p", "/tmp/project"], {
131
+ detached: true,
132
+ stdio: "ignore",
133
+ });
134
+ expect(child.unref).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ it("prefers VISUAL over EDITOR", async () => {
138
+ const child = createSpawnedProcess();
139
+ vi.mocked(spawn).mockReturnValue(child as never);
140
+
141
+ const openPromise = openProjectInEditor(
142
+ "/tmp/project",
143
+ undefined,
144
+ { VISUAL: "nvim", EDITOR: "vim" },
145
+ );
146
+ child.emit("spawn");
147
+ await openPromise;
148
+
149
+ expect(spawn).toHaveBeenCalledWith("nvim", ["/tmp/project"], {
150
+ detached: true,
151
+ stdio: "ignore",
152
+ });
153
+ expect(child.unref).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ it("throws when spawn fails", async () => {
157
+ const child = createSpawnedProcess();
158
+ vi.mocked(spawn).mockReturnValue(child as never);
159
+
160
+ const openPromise = openProjectInEditor("/tmp/project", "code");
161
+ const error = new Error("missing executable");
162
+ child.emit("error", error);
163
+
164
+ await expect(openPromise).rejects.toThrow("missing executable");
165
+ });
166
+ });
167
+ });