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/README.md +24 -2
- package/dist/commands/edit.js +48 -0
- package/dist/commands/info.js +6 -3
- package/dist/commands/update.js +12 -6
- package/dist/lib/config.js +9 -0
- package/dist/lib/editor.js +97 -0
- package/dist/lib/git.js +14 -8
- package/dist/lib/help.js +15 -3
- package/dist/main.js +17 -15
- package/landing-page/index.html +996 -0
- package/landing-page/vercel.json +25 -0
- package/package.json +1 -1
- package/skills/bet/SKILL.md +129 -0
- package/src/commands/edit.ts +56 -0
- package/src/commands/info.tsx +200 -175
- package/src/commands/update.ts +203 -149
- package/src/lib/config.ts +17 -1
- package/src/lib/editor.ts +131 -0
- package/src/lib/git.ts +20 -10
- package/src/lib/help.ts +28 -15
- package/src/lib/types.ts +2 -0
- package/src/main.ts +3 -1
- package/tests/config.test.ts +71 -0
- package/tests/editor.test.ts +167 -0
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
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);
|
package/tests/config.test.ts
CHANGED
|
@@ -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
|
+
});
|