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.
@@ -0,0 +1,25 @@
1
+ {
2
+ "headers": [
3
+ {
4
+ "source": "/(.*)",
5
+ "headers": [
6
+ {
7
+ "key": "Referrer-Policy",
8
+ "value": "strict-origin-when-cross-origin"
9
+ },
10
+ {
11
+ "key": "X-Content-Type-Options",
12
+ "value": "nosniff"
13
+ },
14
+ {
15
+ "key": "X-Frame-Options",
16
+ "value": "DENY"
17
+ },
18
+ {
19
+ "key": "Permissions-Policy",
20
+ "value": "camera=(), microphone=(), geolocation=()"
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
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.1",
5
5
  "author": "Chris Mckenzie",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: bet
3
+ description: Use the bet CLI to find, inspect, and jump between local projects from natural-language requests. Triggers on requests like "jump to my X project", "open the api repo in my editor", "what's the path to X", "find projects matching Y", "which of my projects have uncommitted changes", "list projects I haven't touched in N months", or any task where the user refers to a local project by name/topic instead of by path. Also use when the user types /bet.
4
+ ---
5
+
6
+ # bet
7
+
8
+ `bet` is a local project index. It scans configured root folders, detects projects by `.git`/`README.md`, and exposes them through fast lookup commands. Treat it as the canonical way to resolve a project name → an absolute path on this machine.
9
+
10
+ ## Quick command map
11
+
12
+ Map the user's intent to a command. Always use the slug (kebab-case folder name), not a fuzzy display name.
13
+
14
+ | User asks for… | Run |
15
+ |---|---|
16
+ | The path to project `X` | `bet path X` |
17
+ | Details / metadata about `X` (description, root, git state) | `bet info X --json` |
18
+ | The full README of `X` | `bet info X --full` |
19
+ | A list of all projects | `bet list --json` |
20
+ | Search by keyword `Y` | `bet search Y --json` |
21
+ | Open `X` in the user's editor | `bet edit X` |
22
+ | Re-scan disk for new projects | `bet update` |
23
+ | Add/remove/list ignored paths | `bet ignore add <path>` / `bet ignore rm <path>` / `bet ignore list` |
24
+
25
+ ## Non-interactive flags are mandatory
26
+
27
+ `bet list` and `bet search` launch an interactive TUI by default. **Never** run them without `--plain` or `--json` from an agent context — they will hang waiting for keystrokes.
28
+
29
+ - `--json` — machine-readable, the right default for filtering/parsing
30
+ - `--plain` — line-per-project text, fine when only a name is needed
31
+ - `--print` — emit the selected absolute path only
32
+
33
+ ## Resolving a path before `cd`
34
+
35
+ `bet go <slug>` only changes the shell's directory when the user has the shell integration (`eval "$(bet shell)"`) active. From a Bash tool call, that integration is **not** loaded, so `bet go` will not actually `cd`.
36
+
37
+ When you need to enter a project directory inside a Bash tool call, resolve the path first:
38
+
39
+ ```sh
40
+ cd "$(bet path X)" && <command>
41
+ ```
42
+
43
+ This works without any shell integration and is the correct pattern for agent-driven workflows.
44
+
45
+ ## The `--json` schema
46
+
47
+ `bet list --json` and `bet info <slug> --json` return objects with these fields:
48
+
49
+ ```jsonc
50
+ {
51
+ "id": "/abs/path", // unique id (= path)
52
+ "slug": "my-project", // use this with all commands
53
+ "name": "my-project",
54
+ "path": "/abs/path",
55
+ "root": "/abs/root", // which configured root it lives under
56
+ "rootName": "code", // friendly root label
57
+ "hasGit": true,
58
+ "hasReadme": true,
59
+ "auto": {
60
+ "description": "...", // first paragraph of README
61
+ "startedAt": "ISO-8601", // first git commit date
62
+ "lastModifiedAt": "ISO-8601",// most recent file mtime
63
+ "lastIndexedAt": "ISO-8601",
64
+ "dirty": true // uncommitted changes
65
+ }
66
+ }
67
+ ```
68
+
69
+ `bet list --json` returns an array of these.
70
+
71
+ ## `--json | jq` recipes
72
+
73
+ The single highest-leverage feature for an agent. Reach for these when the user asks anything analytical about their projects:
74
+
75
+ ```sh
76
+ # Projects with uncommitted work
77
+ bet list --json | jq 'map(select(.auto.dirty)) | .[].slug'
78
+
79
+ # Projects modified in the last 30 days, newest first
80
+ bet list --json | jq 'sort_by(.auto.lastModifiedAt) | reverse | map(select(.auto.lastModifiedAt > (now - 86400*30 | todateiso8601)))'
81
+
82
+ # Stale projects (untouched > 6 months)
83
+ bet list --json | jq --arg cutoff "$(date -u -v-6m +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '6 months ago' +%Y-%m-%dT%H:%M:%SZ)" \
84
+ 'map(select(.auto.lastModifiedAt < $cutoff)) | .[].slug'
85
+
86
+ # Group projects by root
87
+ bet list --json | jq 'group_by(.rootName) | map({root: .[0].rootName, count: length, slugs: map(.slug)})'
88
+
89
+ # Projects whose description mentions a keyword
90
+ bet list --json | jq --arg q "rust" 'map(select(.auto.description | test($q; "i"))) | .[].slug'
91
+ ```
92
+
93
+ Compose `jq` filters from `auto.*` fields rather than re-implementing logic in shell. The user's README at `bet --help` calls out this pattern explicitly.
94
+
95
+ ## When `bet` returns nothing
96
+
97
+ If `bet list --json` is empty or `bet path X` errors with an unknown slug, the user has not indexed yet (or the index is stale). Run:
98
+
99
+ ```sh
100
+ bet update
101
+ ```
102
+
103
+ If this is their first run, `bet update` will fail without roots. Tell the user to run:
104
+
105
+ ```sh
106
+ bet update --roots "$HOME/code,$HOME/work"
107
+ ```
108
+
109
+ …with whatever roots make sense for their machine. Do not pick roots for them.
110
+
111
+ ## Slug rules — what to actually pass
112
+
113
+ - The slug is the project folder's basename, kebab-cased.
114
+ - If the project sits inside a wrapper folder named in `slugParentFolders` (defaults: `src`, `app`), the slug is the **parent's** name. Example: `~/code/foo/src` → slug `foo`, not `src`.
115
+ - When unsure, run `bet search <fuzzy>` first and read back the slug from JSON.
116
+
117
+ ## Common pitfalls
118
+
119
+ - **TUI hang** — `bet list` / `bet search` without `--plain`/`--json` will block waiting for keystrokes. Always pass one.
120
+ - **`bet go` in Bash** — won't actually change directory; use `cd "$(bet path X)"` instead.
121
+ - **Treating the name as the slug** — `bet info "My Project"` will fail; use the slug from `bet list --json`.
122
+ - **Stale index after creating a new project** — run `bet update` before assuming the new project is searchable. Suggest `bet update --cron 1h` if the user creates projects often.
123
+ - **Editing config by hand** — config lives at `~/.config/bet/config.json` (or `$XDG_CONFIG_HOME/bet/config.json`). Prefer commands (`bet update --roots`, `bet ignore add`) over hand-editing.
124
+
125
+ ## File locations (for debugging only)
126
+
127
+ - Config + ignore list: `~/.config/bet/config.json`
128
+ - Project index: `~/.config/bet/projects.json`
129
+ - Logs: `~/Library/Logs/bet/bet.log` (macOS) or `~/.local/state/bet/bet.log` (Linux). Set `BET_LOG_LEVEL=debug` for verbose output.
@@ -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
+ }
@@ -39,194 +39,219 @@ export function registerInfo(program: Command): void {
39
39
  .description("Show project details")
40
40
  .option("--json", "Print JSON output")
41
41
  .option("--full", "Show full README content")
42
- .action(async (slug: string, options: { json?: boolean; full?: boolean }) => {
43
- const config = await readConfig();
44
- const projects = listProjects(config);
45
- const matches = findBySlug(projects, slug);
46
-
47
- if (matches.length === 0) {
48
- process.stderr.write(`No project found for slug "${slug}".\n`);
49
- process.exitCode = 1;
50
- return;
51
- }
52
-
53
- let project = matches[0];
54
-
55
- if (matches.length > 1) {
56
- if (!process.stdin.isTTY) {
57
- process.stderr.write(`Slug "${slug}" is ambiguous. Matches:\n`);
58
- for (const item of matches) {
59
- process.stderr.write(` ${projectLabel(item)} ${item.path}\n`);
60
- }
42
+ .action(
43
+ async (slug: string, options: { json?: boolean; full?: boolean }) => {
44
+ const config = await readConfig();
45
+ const projects = listProjects(config);
46
+ const matches = findBySlug(projects, slug);
47
+
48
+ if (matches.length === 0) {
49
+ process.stderr.write(`No project found for slug "${slug}".\n`);
61
50
  process.exitCode = 1;
62
51
  return;
63
52
  }
64
53
 
65
- const items: SelectEntry<(typeof matches)[number]>[] = matches.map(
66
- (item) => ({
67
- label: projectLabel(item),
68
- hint: item.path,
69
- value: item,
70
- type: "item",
71
- }),
72
- );
54
+ let project = matches[0];
73
55
 
74
- const selected = await promptSelect(items, { title: `Select ${slug}` });
75
- if (!selected) return;
76
- project = selected.value;
77
- }
78
-
79
- if (options.json) {
80
- process.stdout.write(JSON.stringify(project, null, 2));
81
- process.stdout.write("\n");
82
- return;
83
- }
84
-
85
- const description =
86
- project.user?.description ?? project.auto.description ?? "—";
87
- // Compute git status live
88
- const hasGit = await isInsideGitRepo(project.path);
89
- const dirty = hasGit ? await getDirtyStatus(project.path) : undefined;
90
-
91
- if (process.stdin.isTTY) {
92
- const readme = options.full
93
- ? await readReadmeContent(project.path, { full: true })
94
- : null;
95
- const markdown = readme ?? description;
96
-
97
- const view = (
98
- <Box flexDirection="column" width="100%">
99
- <Box
100
- width="100%"
101
- borderStyle="single"
102
- borderColor="green"
103
- paddingX={1}
104
- paddingY={1}
105
- marginBottom={1}
106
- >
107
- <Text color="green" bold>
108
- {project.slug}
109
- </Text>
110
- <Text color="cyan">{project.path}</Text>
111
- </Box>
112
- <Box
113
- borderStyle="round"
114
- borderColor="cyan"
115
- padding={1}
116
- flexDirection="column"
117
- marginBottom={1}
118
- >
119
- <Box marginBottom={1}>
120
- <Text bold color="magenta">
121
- Details
122
- </Text>
56
+ if (matches.length > 1) {
57
+ if (!process.stdin.isTTY) {
58
+ process.stderr.write(`Slug "${slug}" is ambiguous. Matches:\n`);
59
+ for (const item of matches) {
60
+ process.stderr.write(` ${projectLabel(item)} ${item.path}\n`);
61
+ }
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ const items: SelectEntry<(typeof matches)[number]>[] = matches.map(
67
+ (item) => ({
68
+ label: projectLabel(item),
69
+ hint: item.path,
70
+ value: item,
71
+ type: "item",
72
+ }),
73
+ );
74
+
75
+ const selected = await promptSelect(items, {
76
+ title: `Select ${slug}`,
77
+ });
78
+ if (!selected) return;
79
+ project = selected.value;
80
+ }
81
+
82
+ if (options.json) {
83
+ process.stdout.write(JSON.stringify(project, null, 2));
84
+ process.stdout.write("\n");
85
+ return;
86
+ }
87
+
88
+ const description =
89
+ project.user?.description ?? project.auto.description ?? "";
90
+ // Compute git status live
91
+ const hasGit = await isInsideGitRepo(project.path);
92
+ const dirty = hasGit ? await getDirtyStatus(project.path) : undefined;
93
+
94
+ if (process.stdin.isTTY) {
95
+ const readme = options.full
96
+ ? await readReadmeContent(project.path, { full: true })
97
+ : null;
98
+ const markdown = readme ?? description;
99
+
100
+ const view = (
101
+ <Box flexDirection="column" width="100%">
102
+ <Box
103
+ width="100%"
104
+ borderStyle="single"
105
+ borderColor="green"
106
+ paddingX={1}
107
+ paddingY={1}
108
+ marginBottom={1}
109
+ flexDirection="column"
110
+ >
111
+ <Box width="100%" paddingBottom={1}>
112
+ <Text color="green" bold>
113
+ {project.slug}
114
+ </Text>
115
+ </Box>
116
+ <Box width="100%">
117
+ <Text color="cyan">{project.path}</Text>
118
+ </Box>
123
119
  </Box>
124
- <Box flexDirection="column">
125
- <MetaRow label="Root" value={project.rootName} />
126
- <MetaRow label="Root path" value={project.root} />
127
- <MetaRow
128
- label="Git"
129
- value={hasGit ? "yes" : "no"}
130
- valueColor={hasGit ? "green" : "yellow"}
131
- />
132
- <MetaRow
133
- label="README"
134
- value={project.hasReadme ? "yes" : "no"}
135
- valueColor={project.hasReadme ? "green" : "yellow"}
136
- />
137
- <MetaRow
138
- label="Started"
139
- value={formatDate(project.auto.startedAt)}
140
- />
141
- <MetaRow
142
- label="Last modified"
143
- value={formatDate(project.auto.lastModifiedAt)}
144
- />
145
- <MetaRow
146
- label="Last indexed"
147
- value={formatDate(project.auto.lastIndexedAt)}
148
- />
149
- <MetaRow
150
- label="Dirty"
151
- value={dirty === undefined ? "unknown" : dirty ? "yes" : "no"}
152
- valueColor={
153
- dirty === undefined ? "yellow" : dirty ? "red" : "green"
154
- }
155
- />
156
- {project.user?.tags?.length ? (
157
- <Box>
158
- <Text bold color="gray">{`Tags: `}</Text>
159
- <Text color="magenta">{project.user.tags.join(", ")}</Text>
160
- </Box>
161
- ) : null}
162
- {project.user?.onEnter ? (
163
- <Box>
164
- <Text bold color="gray">{`On enter: `}</Text>
165
- <Text color="blue">{project.user.onEnter}</Text>
166
- </Box>
167
- ) : null}
120
+ <Box
121
+ borderStyle="round"
122
+ borderColor="cyan"
123
+ padding={1}
124
+ flexDirection="column"
125
+ marginBottom={1}
126
+ >
127
+ <Box marginBottom={1}>
128
+ <Text bold color="magenta">
129
+ Details
130
+ </Text>
131
+ </Box>
132
+ <Box flexDirection="column">
133
+ <MetaRow
134
+ label="Git"
135
+ value={hasGit ? "yes" : "no"}
136
+ valueColor={hasGit ? "green" : "yellow"}
137
+ />
138
+ <MetaRow
139
+ label="Git dirty"
140
+ value={
141
+ dirty === undefined ? "unknown" : dirty ? "yes" : "no"
142
+ }
143
+ valueColor={
144
+ dirty === undefined ? "yellow" : dirty ? "red" : "green"
145
+ }
146
+ />
147
+ <MetaRow
148
+ label="README"
149
+ value={project.hasReadme ? "yes" : "no"}
150
+ valueColor={project.hasReadme ? "green" : "yellow"}
151
+ />
152
+ <MetaRow
153
+ label="Started"
154
+ value={formatDate(project.auto.startedAt)}
155
+ />
156
+ <MetaRow
157
+ label="Last modified"
158
+ value={formatDate(project.auto.lastModifiedAt)}
159
+ />
160
+ <MetaRow
161
+ label="Last indexed"
162
+ value={formatDate(project.auto.lastIndexedAt)}
163
+ />
164
+
165
+ <MetaRow label="Root" value={project.rootName} />
166
+ <MetaRow label="Root path" value={project.root} />
167
+ {project.user?.tags?.length ? (
168
+ <Box>
169
+ <Text bold color="gray">{`Tags: `}</Text>
170
+ <Text color="magenta">
171
+ {project.user.tags.join(", ")}
172
+ </Text>
173
+ </Box>
174
+ ) : null}
175
+ {project.user?.onEnter ? (
176
+ <Box>
177
+ <Text bold color="gray">{`On enter: `}</Text>
178
+ <Text color="blue">{project.user.onEnter}</Text>
179
+ </Box>
180
+ ) : null}
181
+ </Box>
168
182
  </Box>
169
- </Box>
170
- <Box
171
- borderStyle="round"
172
- borderColor="magenta"
173
- padding={1}
174
- flexDirection="column"
175
- >
176
- <Box marginBottom={1}>
177
- <Text bold color="magenta">
178
- Description
179
- </Text>
183
+ <Box
184
+ borderStyle="round"
185
+ borderColor="magenta"
186
+ padding={1}
187
+ flexDirection="column"
188
+ >
189
+ <Box marginBottom={1}>
190
+ <Text bold color="magenta">
191
+ Description
192
+ </Text>
193
+ </Box>
194
+ <Markdown>{markdown}</Markdown>
180
195
  </Box>
181
- <Markdown>{markdown}</Markdown>
196
+ {!options.full && project.hasReadme ? (
197
+ <Box marginTop={1}>
198
+ <Text color="yellow">
199
+ Tip: Run <Text bold>bet info {project.slug} --full</Text> to
200
+ read the full README.
201
+ </Text>
202
+ </Box>
203
+ ) : null}
182
204
  </Box>
183
- </Box>
184
- );
205
+ );
185
206
 
186
- const { unmount } = render(view, { stdout: process.stdout });
187
- await new Promise((resolve) => setTimeout(resolve, 0));
188
- unmount();
189
- return;
190
- }
191
-
192
- process.stdout.write(`${chalk.bold(project.slug)}\n`);
193
- process.stdout.write(`${chalk.dim(project.path)}\n\n`);
194
-
195
- process.stdout.write(`${chalk.bold("Root:")} ${project.rootName}\n`);
196
- process.stdout.write(`${chalk.bold("Root path:")} ${project.root}\n`);
197
- process.stdout.write(`${chalk.bold("Git:")} ${hasGit ? "yes" : "no"}\n`);
198
- process.stdout.write(
199
- `${chalk.bold("README:")} ${project.hasReadme ? "yes" : "no"}\n\n`,
200
- );
201
-
202
- const descToShow =
203
- options.full && project.hasReadme
204
- ? (await readReadmeContent(project.path, { full: true })) ?? description
205
- : description;
206
- process.stdout.write(`${chalk.bold("Description:")} ${descToShow}\n`);
207
- process.stdout.write(
208
- `${chalk.bold("Started:")} ${formatDate(project.auto.startedAt)}\n`,
209
- );
210
- process.stdout.write(
211
- `${chalk.bold("Last modified:")} ${formatDate(project.auto.lastModifiedAt)}\n`,
212
- );
213
- process.stdout.write(
214
- `${chalk.bold("Last indexed:")} ${formatDate(project.auto.lastIndexedAt)}\n`,
215
- );
216
- process.stdout.write(
217
- `${chalk.bold("Dirty:")} ${dirty === undefined ? "unknown" : dirty ? "yes" : "no"}\n`,
218
- );
219
-
220
- if (project.user?.tags?.length) {
207
+ const { unmount } = render(view, { stdout: process.stdout });
208
+ await new Promise((resolve) => setTimeout(resolve, 0));
209
+ unmount();
210
+ return;
211
+ }
212
+
213
+ process.stdout.write(`${chalk.bold(project.slug)}\n`);
214
+ process.stdout.write(`${chalk.dim(project.path)}\n\n`);
215
+
216
+ process.stdout.write(`${chalk.bold("Root:")} ${project.rootName}\n`);
217
+ process.stdout.write(`${chalk.bold("Root path:")} ${project.root}\n`);
221
218
  process.stdout.write(
222
- `${chalk.bold("Tags:")} ${project.user.tags.join(", ")}\n`,
219
+ `${chalk.bold("Git:")} ${hasGit ? "yes" : "no"}\n`,
220
+ );
221
+ process.stdout.write(
222
+ `${chalk.bold("README:")} ${project.hasReadme ? "yes" : "no"}\n\n`,
223
223
  );
224
- }
225
224
 
226
- if (project.user?.onEnter) {
225
+ const descToShow =
226
+ options.full && project.hasReadme
227
+ ? ((await readReadmeContent(project.path, { full: true })) ??
228
+ description)
229
+ : description;
230
+ process.stdout.write(`${chalk.bold("Description:")} ${descToShow}\n`);
231
+ process.stdout.write(
232
+ `${chalk.bold("Started:")} ${formatDate(project.auto.startedAt)}\n`,
233
+ );
227
234
  process.stdout.write(
228
- `${chalk.bold("On enter:")} ${project.user.onEnter}\n`,
235
+ `${chalk.bold("Last modified:")} ${formatDate(project.auto.lastModifiedAt)}\n`,
229
236
  );
230
- }
231
- });
237
+ process.stdout.write(
238
+ `${chalk.bold("Last indexed:")} ${formatDate(project.auto.lastIndexedAt)}\n`,
239
+ );
240
+ process.stdout.write(
241
+ `${chalk.bold("Dirty:")} ${dirty === undefined ? "unknown" : dirty ? "yes" : "no"}\n`,
242
+ );
243
+
244
+ if (project.user?.tags?.length) {
245
+ process.stdout.write(
246
+ `${chalk.bold("Tags:")} ${project.user.tags.join(", ")}\n`,
247
+ );
248
+ }
249
+
250
+ if (project.user?.onEnter) {
251
+ process.stdout.write(
252
+ `${chalk.bold("On enter:")} ${project.user.onEnter}\n`,
253
+ );
254
+ }
255
+ },
256
+ );
232
257
  }