bet-cli 0.1.4 → 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
@@ -1,13 +1,3 @@
1
- ```
2
- ██
3
- ██
4
- ████████████
5
- ██
6
- ████████
7
- ╲╲
8
- ╲╲
9
- ```
10
-
11
1
  # bet
12
2
 
13
3
  Keep your house in order. Explore and jump between local projects.
@@ -16,6 +6,27 @@ Keep your house in order. Explore and jump between local projects.
16
6
 
17
7
  If your `~/code` folder is chaos, **bet turns it into a map**.
18
8
 
9
+ ```
10
+
11
+ ░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░
12
+ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░
13
+ ░░░░░░░░░░░░░ ░░░░░░ ░░░░░░
14
+ ░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░
15
+ ░░░░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░
16
+ ░░░░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░
17
+ ░░░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░
18
+ ░░░░░░░░░░░░░░░░ ░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
19
+ ░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
20
+ ░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░
21
+ ░░░░░░░░░ ░░░░░░ ░░░░░░
22
+ ░░░░░ ░░░░░░ ░░░░░░
23
+ ░░░░░ ░░░░░░ ░░░░░░
24
+ ░░░░░ ░░░░░░ ░░░░░░
25
+ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
26
+ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░
27
+
28
+ ```
29
+
19
30
  ## Why bet?
20
31
 
21
32
  - **No scrolling through 300 folders**
@@ -31,6 +42,7 @@ If your `~/code` folder is chaos, **bet turns it into a map**.
31
42
  - `bet update` scans those roots and builds an index.
32
43
  - Projects are detected using simple signals (today: folders containing **`.git`** and/or a **`README.md`**, with common build dirs ignored).
33
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.
34
46
 
35
47
  ## Install
36
48
 
@@ -112,6 +124,9 @@ Then Tab-complete the slug argument after `bet go `, `bet path `, or `bet info `
112
124
  - **`bet go <slug>`**: Jump to a project.
113
125
  - **`--print`**: print selected path only (no shell `cd`)
114
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.
115
130
  - **`bet shell`**: Print the shell integration snippet (see above).
116
131
  - **`bet completion [bash|zsh]`**: Print shell completion script for project name autocompletion (see above).
117
132
 
@@ -121,6 +136,7 @@ bet stores its data in:
121
136
 
122
137
  - **Config dir**: `~/.config/bet/` (or `$XDG_CONFIG_HOME/bet/`)
123
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"`.
124
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.
125
141
  - **Project index**: `projects.json`
126
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
+ }
@@ -30,7 +30,7 @@ export function registerIgnore(program) {
30
30
  }
31
31
  const rootPaths = config.roots.map((r) => r.path);
32
32
  if (!isPathUnderAnyRoot(normalized, rootPaths)) {
33
- process.stderr.write(`Error: Path must be under a configured root.\n Path: ${normalized}\n Roots: ${rootPaths.join(", ")}\n`);
33
+ process.stderr.write(`Error: Path must be under a configured root. Use an absolute path (e.g. /path/to/project), not a project slug.\n Path: ${normalized}\n Roots: ${rootPaths.join(", ")}\n`);
34
34
  process.exitCode = 1;
35
35
  return;
36
36
  }
@@ -1,19 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import chalk from "chalk";
3
- import { readConfig } from "../lib/config.js";
4
3
  import { render, Box, Text } from "ink";
4
+ import { readConfig } from "../lib/config.js";
5
5
  import { findBySlug, listProjects, projectLabel } from "../lib/projects.js";
6
6
  import { getDirtyStatus, isInsideGitRepo } from "../lib/git.js";
7
7
  import { formatDate } from "../utils/format.js";
8
8
  import { promptSelect } from "../ui/prompt.js";
9
9
  import { readReadmeContent } from "../lib/readme.js";
10
- import Table from "../ui/table.js";
11
- const data = [];
10
+ import Markdown from "../ui/markdown.js";
11
+ const MetaRow = ({ label, value, valueColor }) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "gray", children: `${label}: ` }), _jsx(Text, { color: valueColor, bold: !!valueColor, children: value })] }));
12
12
  export function registerInfo(program) {
13
13
  program
14
14
  .command("info <slug>")
15
15
  .description("Show project details")
16
16
  .option("--json", "Print JSON output")
17
+ .option("--full", "Show full README content")
17
18
  .action(async (slug, options) => {
18
19
  const config = await readConfig();
19
20
  const projects = listProjects(config);
@@ -39,7 +40,9 @@ export function registerInfo(program) {
39
40
  value: item,
40
41
  type: "item",
41
42
  }));
42
- const selected = await promptSelect(items, { title: `Select ${slug}` });
43
+ const selected = await promptSelect(items, {
44
+ title: `Select ${slug}`,
45
+ });
43
46
  if (!selected)
44
47
  return;
45
48
  project = selected.value;
@@ -54,18 +57,11 @@ export function registerInfo(program) {
54
57
  const hasGit = await isInsideGitRepo(project.path);
55
58
  const dirty = hasGit ? await getDirtyStatus(project.path) : undefined;
56
59
  if (process.stdin.isTTY) {
57
- const readme = await readReadmeContent(project.path);
60
+ const readme = options.full
61
+ ? await readReadmeContent(project.path, { full: true })
62
+ : null;
58
63
  const markdown = readme ?? description;
59
- let Markdown = null;
60
- try {
61
- const markdownModule = await import("ink-markdown");
62
- Markdown = (markdownModule.default ??
63
- markdownModule);
64
- }
65
- catch {
66
- Markdown = null;
67
- }
68
- const view = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Table, { data: data }), _jsx(Text, { color: "green", bold: true, children: project.slug }), _jsx(Text, { dimColor: true, children: project.path }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: `Root: ${project.rootName}` }), _jsx(Text, { bold: true, children: `Root path: ${project.root}` }), _jsx(Text, { bold: true, children: `Git: ${hasGit ? "yes" : "no"}` }), _jsx(Text, { bold: true, children: `README: ${project.hasReadme ? "yes" : "no"}` }), _jsx(Text, { bold: true, children: `Started: ${formatDate(project.auto.startedAt)}` }), _jsx(Text, { bold: true, children: `Last modified: ${formatDate(project.auto.lastModifiedAt)}` }), _jsx(Text, { bold: true, children: `Last indexed: ${formatDate(project.auto.lastIndexedAt)}` }), _jsx(Text, { bold: true, children: `Dirty: ${dirty === undefined ? "unknown" : dirty ? "yes" : "no"}` }), project.user?.tags?.length ? (_jsx(Text, { children: `Tags: ${project.user.tags.join(", ")}` })) : null, project.user?.onEnter ? (_jsx(Text, { children: `On enter: ${project.user.onEnter}` })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { children: chalk.bold("Description") }), Markdown ? (_jsx(Markdown, { children: markdown })) : (_jsx(Text, { 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] }));
69
65
  const { unmount } = render(view, { stdout: process.stdout });
70
66
  await new Promise((resolve) => setTimeout(resolve, 0));
71
67
  unmount();
@@ -77,7 +73,11 @@ export function registerInfo(program) {
77
73
  process.stdout.write(`${chalk.bold("Root path:")} ${project.root}\n`);
78
74
  process.stdout.write(`${chalk.bold("Git:")} ${hasGit ? "yes" : "no"}\n`);
79
75
  process.stdout.write(`${chalk.bold("README:")} ${project.hasReadme ? "yes" : "no"}\n\n`);
80
- process.stdout.write(`${chalk.bold("Description:")} ${description}\n`);
76
+ const descToShow = options.full && project.hasReadme
77
+ ? ((await readReadmeContent(project.path, { full: true })) ??
78
+ description)
79
+ : description;
80
+ process.stdout.write(`${chalk.bold("Description:")} ${descToShow}\n`);
81
81
  process.stdout.write(`${chalk.bold("Started:")} ${formatDate(project.auto.startedAt)}\n`);
82
82
  process.stdout.write(`${chalk.bold("Last modified:")} ${formatDate(project.auto.lastModifiedAt)}\n`);
83
83
  process.stdout.write(`${chalk.bold("Last indexed:")} ${formatDate(project.auto.lastIndexedAt)}\n`);
@@ -1,11 +1,18 @@
1
- import chalk from 'chalk';
2
- import { readConfig } from '../lib/config.js';
3
- import path from 'node:path';
4
- import { listProjects } from '../lib/projects.js';
5
- import { emitSelection } from '../utils/output.js';
6
- import { promptSelect } from '../ui/prompt.js';
1
+ import chalk from "chalk";
2
+ import { readConfig } from "../lib/config.js";
3
+ import path from "node:path";
4
+ import { listProjects } from "../lib/projects.js";
5
+ import { emitSelection } from "../utils/output.js";
6
+ import { promptSelect } from "../ui/prompt.js";
7
7
  const MAX_ROWS = 18;
8
- const GROUP_COLORS = ['#22d3ee', '#34d399', '#facc15', '#c084fc', '#60a5fa', '#f87171'];
8
+ const GROUP_COLORS = [
9
+ "#22d3ee",
10
+ "#34d399",
11
+ "#facc15",
12
+ "#c084fc",
13
+ "#60a5fa",
14
+ "#f87171",
15
+ ];
9
16
  function groupColor(index) {
10
17
  return GROUP_COLORS[index % GROUP_COLORS.length];
11
18
  }
@@ -44,22 +51,23 @@ function orderedGroupsByConfigRoots(projects, roots) {
44
51
  }
45
52
  export function registerList(program) {
46
53
  program
47
- .command('list')
48
- .description('List projects')
49
- .option('--plain', 'Print a non-interactive list')
50
- .option('--json', 'Print JSON output')
51
- .option('--print', 'Print selected path only')
54
+ .command("list")
55
+ .alias("ls")
56
+ .description("List projects")
57
+ .option("--plain", "Print a non-interactive list")
58
+ .option("--json", "Print JSON output")
59
+ .option("--print", "Print selected path only")
52
60
  .action(async (options) => {
53
61
  const config = await readConfig();
54
62
  const projects = listProjects(config);
55
63
  if (options.json) {
56
64
  process.stdout.write(JSON.stringify(projects, null, 2));
57
- process.stdout.write('\n');
65
+ process.stdout.write("\n");
58
66
  return;
59
67
  }
60
68
  if (!process.stdin.isTTY || options.plain) {
61
69
  if (projects.length === 0) {
62
- process.stdout.write('No projects indexed. Run bet update.\n');
70
+ process.stdout.write("No projects indexed. Run bet update.\n");
63
71
  return;
64
72
  }
65
73
  const orderedGroups = orderedGroupsByConfigRoots(projects, config.roots);
@@ -79,17 +87,24 @@ export function registerList(program) {
79
87
  const rows = [];
80
88
  let groupIndex = 0;
81
89
  for (const { rootName, items } of orderedGroups) {
82
- rows.push({ type: 'group', label: rootName, color: groupColor(groupIndex++) });
90
+ rows.push({
91
+ type: "group",
92
+ label: rootName,
93
+ color: groupColor(groupIndex++),
94
+ });
83
95
  for (const project of items) {
84
96
  const rel = relativePath(project.path, project.root);
85
97
  rows.push({
86
- type: 'item',
98
+ type: "item",
87
99
  label: formatLabel(project.slug, project.rootName, rel),
88
100
  value: project,
89
101
  });
90
102
  }
91
103
  }
92
- const selected = await promptSelect(rows, { title: 'Projects', maxRows: MAX_ROWS });
104
+ const selected = await promptSelect(rows, {
105
+ title: "Projects",
106
+ maxRows: MAX_ROWS,
107
+ });
93
108
  if (!selected)
94
109
  return;
95
110
  emitSelection(selected.value, { printOnly: options.print });
@@ -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) => {
@@ -134,9 +136,14 @@ export function registerUpdate(program) {
134
136
  version: config.version ?? 1,
135
137
  roots: rootsResolved,
136
138
  projects,
139
+ updatedAt: new Date().toISOString(),
137
140
  ...(config.ignores !== undefined && { ignores: config.ignores }),
138
- ...(config.ignoredPaths !== undefined && { ignoredPaths: config.ignoredPaths }),
139
- ...(config.slugParentFolders !== undefined && { slugParentFolders: config.slugParentFolders }),
141
+ ...(config.ignoredPaths !== undefined && {
142
+ ignoredPaths: config.ignoredPaths,
143
+ }),
144
+ ...(config.slugParentFolders !== undefined && {
145
+ slugParentFolders: config.slugParentFolders,
146
+ }),
140
147
  };
141
148
  await writeConfig(nextConfig);
142
149
  const projectCount = Object.keys(projects).length;
package/dist/index.js CHANGED
@@ -1,30 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
3
- import { GroupedHelp } from "./lib/help.js";
4
- import { registerUpdate } from "./commands/update.js";
5
- import { registerList } from "./commands/list.js";
6
- import { registerSearch } from "./commands/search.js";
7
- import { registerInfo } from "./commands/info.js";
8
- import { registerGo } from "./commands/go.js";
9
- import { registerPath } from "./commands/path.js";
10
- import { registerShell } from "./commands/shell.js";
11
- import { registerCompletion } from "./commands/completion.js";
12
- import { registerIgnore } from "./commands/ignore.js";
13
- const program = new Command();
14
- program.createHelp = function createHelp() {
15
- return Object.assign(new GroupedHelp(), this.configureHelp());
16
- };
17
- program
18
- .name("bet")
19
- .description("Explore and jump between local projects.")
20
- .version("0.1.3");
21
- registerUpdate(program);
22
- registerList(program);
23
- registerSearch(program);
24
- registerInfo(program);
25
- registerGo(program);
26
- registerPath(program);
27
- registerShell(program);
28
- registerCompletion(program);
29
- registerIgnore(program);
30
- program.parseAsync(process.argv);
2
+ /**
3
+ * Set FORCE_COLOR before any module (including Ink/chalk) is loaded so that
4
+ * list/search/info render with colors. ESM hoists static imports, so we must
5
+ * use a dynamic import here.
6
+ */
7
+ process.env.FORCE_COLOR = "1";
8
+ await import("./main.js");
9
+ export {};
@@ -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 }),
@@ -87,6 +95,7 @@ async function readProjectsConfig() {
87
95
  const parsed = JSON.parse(raw);
88
96
  return {
89
97
  projects: parsed.projects ?? {},
98
+ ...(parsed.updatedAt != null && typeof parsed.updatedAt === "string" && { updatedAt: parsed.updatedAt }),
90
99
  };
91
100
  }
92
101
  catch (error) {
@@ -116,6 +125,7 @@ export async function readConfig() {
116
125
  return {
117
126
  ...appConfig,
118
127
  projects: normalizedProjects,
128
+ ...(projectsConfig.updatedAt != null && { updatedAt: projectsConfig.updatedAt }),
119
129
  };
120
130
  }
121
131
  async function writeAppConfig(appConfig) {
@@ -132,12 +142,14 @@ export async function writeConfig(config) {
132
142
  const appConfig = {
133
143
  version: config.version,
134
144
  roots: config.roots,
145
+ ...(config.editor !== undefined && { editor: config.editor }),
135
146
  ...(config.ignores !== undefined && { ignores: config.ignores }),
136
147
  ...(config.ignoredPaths !== undefined && { ignoredPaths: config.ignoredPaths }),
137
148
  ...(config.slugParentFolders !== undefined && { slugParentFolders: config.slugParentFolders }),
138
149
  };
139
150
  const projectsConfig = {
140
151
  projects: config.projects,
152
+ ...(config.updatedAt !== undefined && { updatedAt: config.updatedAt }),
141
153
  };
142
154
  await Promise.all([
143
155
  writeAppConfig(appConfig),
@@ -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
  }
@@ -14,14 +14,17 @@ export async function readReadmePath(projectPath) {
14
14
  }
15
15
  return undefined;
16
16
  }
17
- export async function readReadmeContent(projectPath) {
17
+ export async function readReadmeContent(projectPath, options) {
18
18
  const readmePath = await readReadmePath(projectPath);
19
19
  if (!readmePath)
20
20
  return undefined;
21
21
  let content = await fs.readFile(readmePath, "utf8");
22
22
  // remove html tags
23
23
  content = content.replace(/<[^>]*>?/g, "");
24
- // only graph the first 300 characters, and add ... if there are more
24
+ if (options?.full) {
25
+ return content;
26
+ }
27
+ // only grab the first 500 characters, and add ... if there are more
25
28
  const truncated = content.slice(0, 500);
26
29
  if (content.length > 500) {
27
30
  return `${truncated}...`;