bet-cli 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -42,6 +42,7 @@ If your `~/code` folder is chaos, **bet turns it into a map**.
42
42
  - `bet update` scans those roots and builds an index.
43
43
  - Projects are detected using simple signals (today: folders containing **`.git`** and/or a **`README.md`**, with common build dirs ignored).
44
44
  - Commands like `list`, `search`, `info`, `path`, and `go` read that index.
45
+ - Commands like `list`, `search`, `info`, `path`, `go`, and `edit` read that index.
45
46
 
46
47
  ## Install
47
48
 
@@ -123,6 +124,9 @@ Then Tab-complete the slug argument after `bet go `, `bet path `, or `bet info `
123
124
  - **`bet go <slug>`**: Jump to a project.
124
125
  - **`--print`**: print selected path only (no shell `cd`)
125
126
  - **`--no-enter`**: do not run the project’s `onEnter` hook (if configured)
127
+ - **`bet edit <slug>`**: Open a project in your editor.
128
+ - Uses `editor` from `config.json` when set.
129
+ - Falls back to the system default app opener when `editor` is not set.
126
130
  - **`bet shell`**: Print the shell integration snippet (see above).
127
131
  - **`bet completion [bash|zsh]`**: Print shell completion script for project name autocompletion (see above).
128
132
 
@@ -132,6 +136,7 @@ bet stores its data in:
132
136
 
133
137
  - **Config dir**: `~/.config/bet/` (or `$XDG_CONFIG_HOME/bet/`)
134
138
  - **Roots**: `config.json` — each root is `{ "path": "/absolute/path", "name": "display-name" }`. The name defaults to the top folder name and is used when listing/grouping projects.
139
+ - **editor** (optional): In `config.json`, a command string used by `bet edit`, for example `"code -n"` or `"cursor"`.
135
140
  - **slugParentFolders** (optional): In `config.json`, an array of folder names. When a discovered project path ends in one of these (e.g. `src` or `app`), the project slug is taken from the parent directory name instead. Default in code is `["src", "app"]` when the key is not set.
136
141
  - **Project index**: `projects.json`
137
142
 
@@ -0,0 +1,48 @@
1
+ import { readConfig } from "../lib/config.js";
2
+ import { openProjectInEditor } from "../lib/editor.js";
3
+ import { findBySlug, listProjects, projectLabel } from "../lib/projects.js";
4
+ import { promptSelect } from "../ui/prompt.js";
5
+ export function registerEdit(program) {
6
+ program
7
+ .command("edit <slug>")
8
+ .description("Open a project in your editor")
9
+ .action(async (slug) => {
10
+ try {
11
+ const config = await readConfig();
12
+ const projects = listProjects(config);
13
+ const matches = findBySlug(projects, slug);
14
+ if (matches.length === 0) {
15
+ process.stderr.write(`No project found for slug "${slug}".\n`);
16
+ process.exitCode = 1;
17
+ return;
18
+ }
19
+ let project = matches[0];
20
+ if (matches.length > 1) {
21
+ if (!process.stdin.isTTY) {
22
+ process.stderr.write(`Slug "${slug}" is ambiguous. Matches:\n`);
23
+ for (const item of matches) {
24
+ process.stderr.write(` ${projectLabel(item)} ${item.path}\n`);
25
+ }
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+ const items = matches.map((item) => ({
30
+ label: projectLabel(item),
31
+ hint: item.path,
32
+ value: item,
33
+ type: "item",
34
+ }));
35
+ const selected = await promptSelect(items, { title: `Select ${slug}` });
36
+ if (!selected)
37
+ return;
38
+ project = selected.value;
39
+ }
40
+ await openProjectInEditor(project.path, config.editor);
41
+ }
42
+ catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ process.stderr.write(`Error: ${message}\n`);
45
+ process.exitCode = 1;
46
+ }
47
+ });
48
+ }
@@ -40,7 +40,9 @@ export function registerInfo(program) {
40
40
  value: item,
41
41
  type: "item",
42
42
  }));
43
- const selected = await promptSelect(items, { title: `Select ${slug}` });
43
+ const selected = await promptSelect(items, {
44
+ title: `Select ${slug}`,
45
+ });
44
46
  if (!selected)
45
47
  return;
46
48
  project = selected.value;
@@ -59,7 +61,7 @@ export function registerInfo(program) {
59
61
  ? await readReadmeContent(project.path, { full: true })
60
62
  : null;
61
63
  const markdown = readme ?? description;
62
- const view = (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { width: "100%", borderStyle: "single", borderColor: "green", paddingX: 1, paddingY: 1, marginBottom: 1, children: [_jsx(Text, { color: "green", bold: true, children: project.slug }), _jsx(Text, { color: "cyan", children: project.path })] }), _jsxs(Box, { borderStyle: "round", borderColor: "cyan", padding: 1, flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "magenta", children: "Details" }) }), _jsxs(Box, { flexDirection: "column", children: [_jsx(MetaRow, { label: "Root", value: project.rootName }), _jsx(MetaRow, { label: "Root path", value: project.root }), _jsx(MetaRow, { label: "Git", value: hasGit ? "yes" : "no", valueColor: hasGit ? "green" : "yellow" }), _jsx(MetaRow, { label: "README", value: project.hasReadme ? "yes" : "no", valueColor: project.hasReadme ? "green" : "yellow" }), _jsx(MetaRow, { label: "Started", value: formatDate(project.auto.startedAt) }), _jsx(MetaRow, { label: "Last modified", value: formatDate(project.auto.lastModifiedAt) }), _jsx(MetaRow, { label: "Last indexed", value: formatDate(project.auto.lastIndexedAt) }), _jsx(MetaRow, { label: "Dirty", value: dirty === undefined ? "unknown" : dirty ? "yes" : "no", valueColor: dirty === undefined ? "yellow" : dirty ? "red" : "green" }), project.user?.tags?.length ? (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "gray", children: `Tags: ` }), _jsx(Text, { color: "magenta", children: project.user.tags.join(", ") })] })) : null, project.user?.onEnter ? (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "gray", children: `On enter: ` }), _jsx(Text, { color: "blue", children: project.user.onEnter })] })) : null] })] }), _jsxs(Box, { borderStyle: "round", borderColor: "magenta", padding: 1, flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "magenta", children: "Description" }) }), _jsx(Markdown, { children: markdown })] })] }));
64
+ const view = (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { width: "100%", borderStyle: "single", borderColor: "green", paddingX: 1, paddingY: 1, marginBottom: 1, flexDirection: "column", children: [_jsx(Box, { width: "100%", paddingBottom: 1, children: _jsx(Text, { color: "green", bold: true, children: project.slug }) }), _jsx(Box, { width: "100%", children: _jsx(Text, { color: "cyan", children: project.path }) })] }), _jsxs(Box, { borderStyle: "round", borderColor: "cyan", padding: 1, flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "magenta", children: "Details" }) }), _jsxs(Box, { flexDirection: "column", children: [_jsx(MetaRow, { label: "Git", value: hasGit ? "yes" : "no", valueColor: hasGit ? "green" : "yellow" }), _jsx(MetaRow, { label: "Git dirty", value: dirty === undefined ? "unknown" : dirty ? "yes" : "no", valueColor: dirty === undefined ? "yellow" : dirty ? "red" : "green" }), _jsx(MetaRow, { label: "README", value: project.hasReadme ? "yes" : "no", valueColor: project.hasReadme ? "green" : "yellow" }), _jsx(MetaRow, { label: "Started", value: formatDate(project.auto.startedAt) }), _jsx(MetaRow, { label: "Last modified", value: formatDate(project.auto.lastModifiedAt) }), _jsx(MetaRow, { label: "Last indexed", value: formatDate(project.auto.lastIndexedAt) }), _jsx(MetaRow, { label: "Root", value: project.rootName }), _jsx(MetaRow, { label: "Root path", value: project.root }), project.user?.tags?.length ? (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "gray", children: `Tags: ` }), _jsx(Text, { color: "magenta", children: project.user.tags.join(", ") })] })) : null, project.user?.onEnter ? (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "gray", children: `On enter: ` }), _jsx(Text, { color: "blue", children: project.user.onEnter })] })) : null] })] }), _jsxs(Box, { borderStyle: "round", borderColor: "magenta", padding: 1, flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "magenta", children: "Description" }) }), _jsx(Markdown, { children: markdown })] }), !options.full && project.hasReadme ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["Tip: Run ", _jsxs(Text, { bold: true, children: ["bet info ", project.slug, " --full"] }), " to read the full README."] }) })) : null] }));
63
65
  const { unmount } = render(view, { stdout: process.stdout });
64
66
  await new Promise((resolve) => setTimeout(resolve, 0));
65
67
  unmount();
@@ -72,7 +74,8 @@ export function registerInfo(program) {
72
74
  process.stdout.write(`${chalk.bold("Git:")} ${hasGit ? "yes" : "no"}\n`);
73
75
  process.stdout.write(`${chalk.bold("README:")} ${project.hasReadme ? "yes" : "no"}\n\n`);
74
76
  const descToShow = options.full && project.hasReadme
75
- ? (await readReadmeContent(project.path, { full: true })) ?? description
77
+ ? ((await readReadmeContent(project.path, { full: true })) ??
78
+ description)
76
79
  : description;
77
80
  process.stdout.write(`${chalk.bold("Description:")} ${descToShow}\n`);
78
81
  process.stdout.write(`${chalk.bold("Started:")} ${formatDate(project.auto.startedAt)}\n`);
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import readline from "node:readline";
3
3
  import { readConfig, resolveRoots, writeConfig } from "../lib/config.js";
4
4
  import { normalizeAbsolute } from "../utils/paths.js";
5
- import { installUpdateCron, uninstallUpdateCron, parseCronSchedule, formatScheduleLabel } from "../lib/cron.js";
5
+ import { installUpdateCron, uninstallUpdateCron, parseCronSchedule, formatScheduleLabel, } from "../lib/cron.js";
6
6
  import { scanRoots } from "../lib/scan.js";
7
7
  import { computeMetadata } from "../lib/metadata.js";
8
8
  import { getEffectiveIgnores, isPathIgnored } from "../lib/ignore.js";
@@ -23,8 +23,7 @@ function pathsToRootConfigs(paths) {
23
23
  });
24
24
  }
25
25
  export function willOverrideRoots(providedRootConfigs, configRoots) {
26
- return !!(providedRootConfigs !== undefined &&
27
- configRoots.length > 0);
26
+ return !!(providedRootConfigs !== undefined && configRoots.length > 0);
28
27
  }
29
28
  const DEFAULT_SLUG_PARENT_FOLDERS = ["src", "app"];
30
29
  export { DEFAULT_SLUG_PARENT_FOLDERS };
@@ -36,7 +35,10 @@ export function projectSlug(pathName, slugParentFolders) {
36
35
  return folderName;
37
36
  }
38
37
  async function promptYesNo(question, defaultNo = true) {
39
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
38
+ const rl = readline.createInterface({
39
+ input: process.stdin,
40
+ output: process.stderr,
41
+ });
40
42
  return new Promise((resolve) => {
41
43
  const defaultHint = defaultNo ? "y/N" : "Y/n";
42
44
  rl.question(question + " [" + defaultHint + "] ", (answer) => {
@@ -136,8 +138,12 @@ export function registerUpdate(program) {
136
138
  projects,
137
139
  updatedAt: new Date().toISOString(),
138
140
  ...(config.ignores !== undefined && { ignores: config.ignores }),
139
- ...(config.ignoredPaths !== undefined && { ignoredPaths: config.ignoredPaths }),
140
- ...(config.slugParentFolders !== undefined && { slugParentFolders: config.slugParentFolders }),
141
+ ...(config.ignoredPaths !== undefined && {
142
+ ignoredPaths: config.ignoredPaths,
143
+ }),
144
+ ...(config.slugParentFolders !== undefined && {
145
+ slugParentFolders: config.slugParentFolders,
146
+ }),
141
147
  };
142
148
  await writeConfig(nextConfig);
143
149
  const projectCount = Object.keys(projects).length;
@@ -60,11 +60,18 @@ function normalizeIgnoredPaths(parsed) {
60
60
  const list = parsed.filter((x) => typeof x === "string").map((x) => normalizeAbsolute(x));
61
61
  return list.length === 0 ? undefined : list;
62
62
  }
63
+ function normalizeEditor(parsed) {
64
+ if (typeof parsed !== "string")
65
+ return undefined;
66
+ const trimmed = parsed.trim();
67
+ return trimmed.length === 0 ? undefined : trimmed;
68
+ }
63
69
  async function readAppConfig() {
64
70
  try {
65
71
  const raw = await fs.readFile(CONFIG_PATH, "utf8");
66
72
  const parsed = JSON.parse(raw);
67
73
  const roots = normalizeRoots(parsed.roots ?? []);
74
+ const editor = normalizeEditor(parsed.editor);
68
75
  const ignores = normalizeIgnores(parsed.ignores);
69
76
  const ignoredPaths = normalizeIgnoredPaths(parsed.ignoredPaths);
70
77
  const slugParentFolders = normalizeSlugParentFolders(parsed.slugParentFolders);
@@ -72,6 +79,7 @@ async function readAppConfig() {
72
79
  ...DEFAULT_APP_CONFIG,
73
80
  version: parsed.version ?? 1,
74
81
  roots,
82
+ ...(editor !== undefined && { editor }),
75
83
  ...(ignores !== undefined && { ignores }),
76
84
  ...(ignoredPaths !== undefined && { ignoredPaths }),
77
85
  ...(slugParentFolders !== undefined && { slugParentFolders }),
@@ -134,6 +142,7 @@ export async function writeConfig(config) {
134
142
  const appConfig = {
135
143
  version: config.version,
136
144
  roots: config.roots,
145
+ ...(config.editor !== undefined && { editor: config.editor }),
137
146
  ...(config.ignores !== undefined && { ignores: config.ignores }),
138
147
  ...(config.ignoredPaths !== undefined && { ignoredPaths: config.ignoredPaths }),
139
148
  ...(config.slugParentFolders !== undefined && { slugParentFolders: config.slugParentFolders }),
@@ -0,0 +1,97 @@
1
+ import { spawn } from "node:child_process";
2
+ function tokenizeCommand(input) {
3
+ const tokens = [];
4
+ let current = "";
5
+ let inSingle = false;
6
+ let inDouble = false;
7
+ let escaped = false;
8
+ for (const char of input) {
9
+ if (escaped) {
10
+ current += char;
11
+ escaped = false;
12
+ continue;
13
+ }
14
+ if (char === "\\") {
15
+ escaped = true;
16
+ continue;
17
+ }
18
+ if (char === "'" && !inDouble) {
19
+ inSingle = !inSingle;
20
+ continue;
21
+ }
22
+ if (char === '"' && !inSingle) {
23
+ inDouble = !inDouble;
24
+ continue;
25
+ }
26
+ if (!inSingle && !inDouble && /\s/.test(char)) {
27
+ if (current.length > 0) {
28
+ tokens.push(current);
29
+ current = "";
30
+ }
31
+ continue;
32
+ }
33
+ current += char;
34
+ }
35
+ if (escaped || inSingle || inDouble) {
36
+ throw new Error("Invalid editor command in config.");
37
+ }
38
+ if (current.length > 0) {
39
+ tokens.push(current);
40
+ }
41
+ return tokens;
42
+ }
43
+ export function parseEditorCommand(editor) {
44
+ const trimmed = editor.trim();
45
+ if (!trimmed) {
46
+ throw new Error("Config editor must not be empty.");
47
+ }
48
+ const tokens = tokenizeCommand(trimmed);
49
+ if (tokens.length === 0) {
50
+ throw new Error("Config editor must not be empty.");
51
+ }
52
+ const [command, ...args] = tokens;
53
+ return { command, args };
54
+ }
55
+ export function getSystemOpenCommand(targetPath, platform = process.platform) {
56
+ if (platform === "darwin") {
57
+ return { command: "open", args: [targetPath] };
58
+ }
59
+ if (platform === "win32") {
60
+ return { command: "cmd", args: ["/c", "start", "", targetPath] };
61
+ }
62
+ return { command: "xdg-open", args: [targetPath] };
63
+ }
64
+ function getEnvEditor(env) {
65
+ const visual = env.VISUAL?.trim();
66
+ if (visual)
67
+ return visual;
68
+ const editor = env.EDITOR?.trim();
69
+ if (editor)
70
+ return editor;
71
+ return undefined;
72
+ }
73
+ function spawnDetached(command, args) {
74
+ return new Promise((resolve, reject) => {
75
+ const child = spawn(command, args, {
76
+ detached: true,
77
+ stdio: "ignore",
78
+ });
79
+ child.once("error", (error) => {
80
+ reject(error);
81
+ });
82
+ child.once("spawn", () => {
83
+ child.unref();
84
+ resolve();
85
+ });
86
+ });
87
+ }
88
+ export async function openProjectInEditor(projectPath, configuredEditor, env = process.env) {
89
+ const preferredEditor = configuredEditor?.trim() || getEnvEditor(env);
90
+ if (preferredEditor) {
91
+ const parsed = parseEditorCommand(preferredEditor);
92
+ await spawnDetached(parsed.command, [...parsed.args, projectPath]);
93
+ return;
94
+ }
95
+ const fallback = getSystemOpenCommand(projectPath);
96
+ await spawnDetached(fallback.command, fallback.args);
97
+ }
package/dist/lib/git.js CHANGED
@@ -1,10 +1,10 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
4
  async function runGit(cwd, args) {
5
5
  try {
6
- const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], {
7
- encoding: 'utf8',
6
+ const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
7
+ encoding: "utf8",
8
8
  });
9
9
  return stdout.trim();
10
10
  }
@@ -13,16 +13,22 @@ async function runGit(cwd, args) {
13
13
  }
14
14
  }
15
15
  export async function getFirstCommitDate(cwd) {
16
- const output = await runGit(cwd, ['log', '--reverse', '--format=%cI', '-n', '1']);
16
+ const output = await runGit(cwd, [
17
+ "log",
18
+ "--max-parents=0",
19
+ "--format=%cd",
20
+ "--date=iso-strict",
21
+ "HEAD",
22
+ ]);
17
23
  return output || undefined;
18
24
  }
19
25
  export async function getDirtyStatus(cwd) {
20
- const output = await runGit(cwd, ['status', '--porcelain']);
26
+ const output = await runGit(cwd, ["status", "--porcelain"]);
21
27
  if (output === null)
22
28
  return undefined;
23
29
  return output.length > 0;
24
30
  }
25
31
  export async function isInsideGitRepo(cwd) {
26
- const output = await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
27
- return output === 'true';
32
+ const output = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
33
+ return output === "true";
28
34
  }
package/dist/lib/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Help } from "commander";
2
- const GROUP_1 = ["list", "search", "info", "go", "path"];
2
+ const GROUP_1 = ["list", "search", "info", "go", "edit", "path"];
3
3
  const GROUP_2 = ["shell", "completion"];
4
4
  const GROUP_3 = ["update", "ignore", "help"];
5
5
  const GROUPS = [
@@ -40,12 +40,16 @@ export class GroupedHelp extends Help {
40
40
  output.push(helper.wrap(commandDescription, helpWidth, 0), "");
41
41
  }
42
42
  // Arguments
43
- const argumentList = helper.visibleArguments(cmd).map((argument) => formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument)));
43
+ const argumentList = helper
44
+ .visibleArguments(cmd)
45
+ .map((argument) => formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument)));
44
46
  if (argumentList.length > 0) {
45
47
  output.push("Arguments:", formatList(argumentList), "");
46
48
  }
47
49
  // Options
48
- const optionList = helper.visibleOptions(cmd).map((option) => formatItem(helper.optionTerm(option), helper.optionDescription(option)));
50
+ const optionList = helper
51
+ .visibleOptions(cmd)
52
+ .map((option) => formatItem(helper.optionTerm(option), helper.optionDescription(option)));
49
53
  if (optionList.length > 0) {
50
54
  output.push("Options:", formatList(optionList), "");
51
55
  }
package/dist/main.js CHANGED
@@ -9,23 +9,24 @@ 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
  const ASCII_HEADER = `
13
14
  ░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░
14
15
  ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░
15
- ░░░░░░░░░░░░░ ░░░░░░ ░░░░░░
16
- ░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░
17
- ░░░░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░
18
- ░░░░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░
19
- ░░░░░░░░░░░░ ░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
20
- ░░░░░░░░░░░░░░░░ ░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
21
- ░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
22
- ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
23
- ░░░░░░░░░ ░░░░░░ ░░░░░░
24
- ░░░░░ ░░░░░░ ░░░░░░
25
- ░░░░░ ░░░░░░ ░░░░░░
26
- ░░░░░ ░░░░░░ ░░░░░░
27
- ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
28
- ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
16
+ ░░░░░░░░░░░░░ ░░░░░░ ░░░░░░
17
+ ░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░
18
+ ░░░░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░
19
+ ░░░░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░
20
+ ░░░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░
21
+ ░░░░░░░░░░░░░░░░ ░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
22
+ ░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
23
+ ░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░
24
+ ░░░░░░░░░ ░░░░░░ ░░░░░░
25
+ ░░░░░ ░░░░░░ ░░░░░░
26
+ ░░░░░ ░░░░░░ ░░░░░░
27
+ ░░░░░ ░░░░░░ ░░░░░░
28
+ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
29
+ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
29
30
  `;
30
31
  const program = new Command();
31
32
  program.createHelp = function createHelp() {
@@ -40,6 +41,7 @@ registerList(program);
40
41
  registerSearch(program);
41
42
  registerInfo(program);
42
43
  registerGo(program);
44
+ registerEdit(program);
43
45
  registerPath(program);
44
46
  registerShell(program);
45
47
  registerCompletion(program);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bet-cli",
3
3
  "description": "Explore and jump between local projects.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "author": "Chris Mckenzie",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -0,0 +1,56 @@
1
+ import { Command } from "commander";
2
+ import { readConfig } from "../lib/config.js";
3
+ import { openProjectInEditor } from "../lib/editor.js";
4
+ import { findBySlug, listProjects, projectLabel } from "../lib/projects.js";
5
+ import { promptSelect } from "../ui/prompt.js";
6
+ import { SelectEntry } from "../ui/select.js";
7
+
8
+ export function registerEdit(program: Command): void {
9
+ program
10
+ .command("edit <slug>")
11
+ .description("Open a project in your editor")
12
+ .action(async (slug: string) => {
13
+ try {
14
+ const config = await readConfig();
15
+ const projects = listProjects(config);
16
+ const matches = findBySlug(projects, slug);
17
+
18
+ if (matches.length === 0) {
19
+ process.stderr.write(`No project found for slug "${slug}".\n`);
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+
24
+ let project = matches[0];
25
+ if (matches.length > 1) {
26
+ if (!process.stdin.isTTY) {
27
+ process.stderr.write(`Slug "${slug}" is ambiguous. Matches:\n`);
28
+ for (const item of matches) {
29
+ process.stderr.write(` ${projectLabel(item)} ${item.path}\n`);
30
+ }
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+
35
+ const items: SelectEntry<(typeof matches)[number]>[] = matches.map(
36
+ (item) => ({
37
+ label: projectLabel(item),
38
+ hint: item.path,
39
+ value: item,
40
+ type: "item",
41
+ }),
42
+ );
43
+
44
+ const selected = await promptSelect(items, { title: `Select ${slug}` });
45
+ if (!selected) return;
46
+ project = selected.value;
47
+ }
48
+
49
+ await openProjectInEditor(project.path, config.editor);
50
+ } catch (error) {
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ process.stderr.write(`Error: ${message}\n`);
53
+ process.exitCode = 1;
54
+ }
55
+ });
56
+ }