botholomew 0.1.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/package.json +42 -0
- package/src/cli.ts +45 -0
- package/src/commands/chat.ts +11 -0
- package/src/commands/check-update.ts +62 -0
- package/src/commands/context.ts +27 -0
- package/src/commands/daemon.ts +61 -0
- package/src/commands/init.ts +19 -0
- package/src/commands/mcpx.ts +29 -0
- package/src/commands/task.ts +126 -0
- package/src/commands/tools.ts +257 -0
- package/src/commands/upgrade.ts +185 -0
- package/src/config/loader.ts +31 -0
- package/src/config/schemas.ts +15 -0
- package/src/constants.ts +44 -0
- package/src/daemon/index.ts +39 -0
- package/src/daemon/llm.ts +186 -0
- package/src/daemon/prompt.ts +55 -0
- package/src/daemon/run.ts +14 -0
- package/src/daemon/spawn.ts +38 -0
- package/src/daemon/tick.ts +70 -0
- package/src/db/connection.ts +32 -0
- package/src/db/context.ts +415 -0
- package/src/db/embeddings.ts +22 -0
- package/src/db/schedules.ts +17 -0
- package/src/db/schema.ts +66 -0
- package/src/db/sql/1-core_tables.sql +53 -0
- package/src/db/sql/2-logging_tables.sql +24 -0
- package/src/db/sql/3-daemon_state.sql +5 -0
- package/src/db/tasks.ts +194 -0
- package/src/db/threads.ts +202 -0
- package/src/db/uuid.ts +1 -0
- package/src/init/index.ts +84 -0
- package/src/init/templates.ts +48 -0
- package/src/tools/dir/create.ts +39 -0
- package/src/tools/dir/list.ts +87 -0
- package/src/tools/dir/size.ts +45 -0
- package/src/tools/dir/tree.ts +91 -0
- package/src/tools/file/copy.ts +30 -0
- package/src/tools/file/count-lines.ts +26 -0
- package/src/tools/file/delete.ts +43 -0
- package/src/tools/file/edit.ts +40 -0
- package/src/tools/file/exists.ts +23 -0
- package/src/tools/file/info.ts +50 -0
- package/src/tools/file/move.ts +29 -0
- package/src/tools/file/read.ts +40 -0
- package/src/tools/file/write.ts +90 -0
- package/src/tools/registry.ts +53 -0
- package/src/tools/search/grep.ts +94 -0
- package/src/tools/search/semantic.ts +40 -0
- package/src/tools/task/complete.ts +23 -0
- package/src/tools/task/create.ts +42 -0
- package/src/tools/task/fail.ts +22 -0
- package/src/tools/task/wait.ts +23 -0
- package/src/tools/tool.ts +73 -0
- package/src/tui/App.tsx +14 -0
- package/src/types/istextorbinary.d.ts +10 -0
- package/src/update/background.ts +89 -0
- package/src/update/cache.ts +40 -0
- package/src/update/checker.ts +133 -0
- package/src/utils/frontmatter.ts +24 -0
- package/src/utils/logger.ts +29 -0
- package/src/utils/pid.ts +55 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "botholomew",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An AI agent for knowledge work",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"botholomew": "./src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/evantahler/botholomew.git"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "bun run src/cli.ts",
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"build": "bun build --compile --minify --sourcemap ./src/cli.ts --outfile dist/botholomew",
|
|
22
|
+
"lint": "tsc --noEmit && biome check ."
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@anthropic-ai/sdk": "^0.88.0",
|
|
26
|
+
"ansis": "^4.2.0",
|
|
27
|
+
"commander": "^14.0.0",
|
|
28
|
+
"gray-matter": "^4.0.3",
|
|
29
|
+
"ink": "^6.0.0",
|
|
30
|
+
"istextorbinary": "^9.5.0",
|
|
31
|
+
"react": "^19.1.0",
|
|
32
|
+
"uuid": "^13.0.0",
|
|
33
|
+
"zod": "^4.3.6"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "^2.4.11",
|
|
37
|
+
"@types/bun": "latest",
|
|
38
|
+
"@types/react": "^19.1.0",
|
|
39
|
+
"@types/uuid": "^11.0.0",
|
|
40
|
+
"typescript": "^6.0.2"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import { registerChatCommand } from "./commands/chat.ts";
|
|
5
|
+
import { registerCheckUpdateCommand } from "./commands/check-update.ts";
|
|
6
|
+
import { registerContextCommand } from "./commands/context.ts";
|
|
7
|
+
import { registerDaemonCommand } from "./commands/daemon.ts";
|
|
8
|
+
import { registerInitCommand } from "./commands/init.ts";
|
|
9
|
+
import { registerMcpxCommand } from "./commands/mcpx.ts";
|
|
10
|
+
import { registerTaskCommand } from "./commands/task.ts";
|
|
11
|
+
import { registerToolCommands } from "./commands/tools.ts";
|
|
12
|
+
import { registerUpgradeCommand } from "./commands/upgrade.ts";
|
|
13
|
+
import { maybeCheckForUpdate } from "./update/background.ts";
|
|
14
|
+
|
|
15
|
+
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name("botholomew")
|
|
19
|
+
.description("An AI agent for knowledge work")
|
|
20
|
+
.version(pkg.version)
|
|
21
|
+
.option("-d, --dir <path>", "project directory", process.cwd());
|
|
22
|
+
|
|
23
|
+
registerInitCommand(program);
|
|
24
|
+
registerDaemonCommand(program);
|
|
25
|
+
registerTaskCommand(program);
|
|
26
|
+
registerChatCommand(program);
|
|
27
|
+
registerContextCommand(program);
|
|
28
|
+
registerMcpxCommand(program);
|
|
29
|
+
registerToolCommands(program);
|
|
30
|
+
registerCheckUpdateCommand(program);
|
|
31
|
+
registerUpgradeCommand(program);
|
|
32
|
+
|
|
33
|
+
program.action(() => {
|
|
34
|
+
program.help();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Start background update check before parsing (non-blocking)
|
|
38
|
+
const updateNotice = maybeCheckForUpdate();
|
|
39
|
+
|
|
40
|
+
program.parse();
|
|
41
|
+
|
|
42
|
+
// Print update notice to stderr after command completes
|
|
43
|
+
updateNotice.then((notice) => {
|
|
44
|
+
if (notice) process.stderr.write(notice);
|
|
45
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { logger } from "../utils/logger.ts";
|
|
3
|
+
|
|
4
|
+
export function registerChatCommand(program: Command) {
|
|
5
|
+
program
|
|
6
|
+
.command("chat")
|
|
7
|
+
.description("Open the interactive chat TUI")
|
|
8
|
+
.action(async () => {
|
|
9
|
+
logger.warn("Chat TUI not yet implemented. Coming soon.");
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cyan, dim, green, yellow } from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { saveUpdateCache } from "../update/cache.ts";
|
|
4
|
+
import type { UpdateCache } from "../update/checker.ts";
|
|
5
|
+
import { checkForUpdate } from "../update/checker.ts";
|
|
6
|
+
|
|
7
|
+
const pkg = await Bun.file(
|
|
8
|
+
new URL("../../package.json", import.meta.url),
|
|
9
|
+
).json();
|
|
10
|
+
|
|
11
|
+
export function registerCheckUpdateCommand(program: Command) {
|
|
12
|
+
program
|
|
13
|
+
.command("check-update")
|
|
14
|
+
.description("Check for a newer version of botholomew")
|
|
15
|
+
.action(async () => {
|
|
16
|
+
try {
|
|
17
|
+
const info = await checkForUpdate(pkg.version);
|
|
18
|
+
|
|
19
|
+
// Save to cache
|
|
20
|
+
const cache: UpdateCache = {
|
|
21
|
+
lastCheckAt: new Date().toISOString(),
|
|
22
|
+
latestVersion: info.latestVersion,
|
|
23
|
+
hasUpdate: info.hasUpdate,
|
|
24
|
+
changelog: info.changelog,
|
|
25
|
+
};
|
|
26
|
+
await saveUpdateCache(cache);
|
|
27
|
+
|
|
28
|
+
if (!info.hasUpdate) {
|
|
29
|
+
if (info.aheadOfLatest) {
|
|
30
|
+
console.log(
|
|
31
|
+
yellow(
|
|
32
|
+
`botholomew v${info.currentVersion} is ahead of latest published release (v${info.latestVersion})`,
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
console.log(
|
|
37
|
+
green(`botholomew is up to date (v${info.currentVersion})`),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(
|
|
44
|
+
yellow(
|
|
45
|
+
`Update available: ${info.currentVersion} → ${info.latestVersion}`,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (info.changelog) {
|
|
50
|
+
console.log("");
|
|
51
|
+
console.log(dim(info.changelog));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log("");
|
|
55
|
+
console.log(cyan("Run `botholomew upgrade` to update"));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error("Failed to check for updates");
|
|
58
|
+
console.error(String(err));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { logger } from "../utils/logger.ts";
|
|
3
|
+
|
|
4
|
+
export function registerContextCommand(program: Command) {
|
|
5
|
+
const ctx = program.command("context").description("Manage context items");
|
|
6
|
+
|
|
7
|
+
ctx
|
|
8
|
+
.command("list")
|
|
9
|
+
.description("List context items")
|
|
10
|
+
.action(async () => {
|
|
11
|
+
logger.warn("Not yet implemented. Coming soon.");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
ctx
|
|
15
|
+
.command("add <path>")
|
|
16
|
+
.description("Add a file or directory to context")
|
|
17
|
+
.action(async () => {
|
|
18
|
+
logger.warn("Not yet implemented. Coming soon.");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
ctx
|
|
22
|
+
.command("search <query>")
|
|
23
|
+
.description("Search context items")
|
|
24
|
+
.action(async () => {
|
|
25
|
+
logger.warn("Not yet implemented. Coming soon.");
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { logger } from "../utils/logger.ts";
|
|
3
|
+
|
|
4
|
+
export function registerDaemonCommand(program: Command) {
|
|
5
|
+
const daemon = program
|
|
6
|
+
.command("daemon")
|
|
7
|
+
.description("Manage the Botholomew daemon");
|
|
8
|
+
|
|
9
|
+
daemon
|
|
10
|
+
.command("start")
|
|
11
|
+
.description("Start the daemon for this project")
|
|
12
|
+
.option("--foreground", "run in the foreground (don't detach)")
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const dir = program.opts().dir;
|
|
15
|
+
|
|
16
|
+
if (opts.foreground) {
|
|
17
|
+
// Import dynamically to avoid loading daemon code for other commands
|
|
18
|
+
const { startDaemon } = await import("../daemon/index.ts");
|
|
19
|
+
await startDaemon(dir);
|
|
20
|
+
} else {
|
|
21
|
+
// Spawn detached child process
|
|
22
|
+
const { spawnDaemon } = await import("../daemon/spawn.ts");
|
|
23
|
+
await spawnDaemon(dir);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
daemon
|
|
28
|
+
.command("stop")
|
|
29
|
+
.description("Stop the daemon for this project")
|
|
30
|
+
.action(async () => {
|
|
31
|
+
const dir = program.opts().dir;
|
|
32
|
+
const { stopDaemon } = await import("../utils/pid.ts");
|
|
33
|
+
const stopped = await stopDaemon(dir);
|
|
34
|
+
if (stopped) {
|
|
35
|
+
logger.success("Daemon stopped.");
|
|
36
|
+
} else {
|
|
37
|
+
logger.warn("No running daemon found.");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
daemon
|
|
42
|
+
.command("status")
|
|
43
|
+
.description("Check if the daemon is running")
|
|
44
|
+
.action(async () => {
|
|
45
|
+
const dir = program.opts().dir;
|
|
46
|
+
const { getDaemonStatus } = await import("../utils/pid.ts");
|
|
47
|
+
const status = await getDaemonStatus(dir);
|
|
48
|
+
if (status) {
|
|
49
|
+
logger.success(`Daemon running (PID ${status.pid})`);
|
|
50
|
+
} else {
|
|
51
|
+
logger.dim("Daemon is not running.");
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
daemon
|
|
56
|
+
.command("install")
|
|
57
|
+
.description("Install OS-level watchdog (launchd/systemd)")
|
|
58
|
+
.action(async () => {
|
|
59
|
+
logger.warn("Not yet implemented. Coming soon.");
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { initProject } from "../init/index.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
|
|
5
|
+
export function registerInitCommand(program: Command) {
|
|
6
|
+
program
|
|
7
|
+
.command("init")
|
|
8
|
+
.description("Initialize a new Botholomew project in the current directory")
|
|
9
|
+
.option("--force", "overwrite existing .botholomew directory")
|
|
10
|
+
.action(async (opts) => {
|
|
11
|
+
const dir = program.opts().dir;
|
|
12
|
+
try {
|
|
13
|
+
await initProject(dir, { force: opts.force });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
logger.error(String(err instanceof Error ? err.message : err));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { logger } from "../utils/logger.ts";
|
|
3
|
+
|
|
4
|
+
export function registerMcpxCommand(program: Command) {
|
|
5
|
+
const mcpx = program
|
|
6
|
+
.command("mcpx")
|
|
7
|
+
.description("Manage MCP servers via MCPX");
|
|
8
|
+
|
|
9
|
+
mcpx
|
|
10
|
+
.command("list")
|
|
11
|
+
.description("List configured MCP servers")
|
|
12
|
+
.action(async () => {
|
|
13
|
+
logger.warn("Not yet implemented. Coming soon.");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
mcpx
|
|
17
|
+
.command("add <server>")
|
|
18
|
+
.description("Add an MCP server")
|
|
19
|
+
.action(async () => {
|
|
20
|
+
logger.warn("Not yet implemented. Coming soon.");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
mcpx
|
|
24
|
+
.command("test <tool>")
|
|
25
|
+
.description("Test a tool call")
|
|
26
|
+
.action(async () => {
|
|
27
|
+
logger.warn("Not yet implemented. Coming soon.");
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import ansis from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { getDbPath } from "../constants.ts";
|
|
4
|
+
import { getConnection } from "../db/connection.ts";
|
|
5
|
+
import { migrate } from "../db/schema.ts";
|
|
6
|
+
import type { Task } from "../db/tasks.ts";
|
|
7
|
+
import { createTask, getTask, listTasks } from "../db/tasks.ts";
|
|
8
|
+
import { logger } from "../utils/logger.ts";
|
|
9
|
+
|
|
10
|
+
export function registerTaskCommand(program: Command) {
|
|
11
|
+
const task = program.command("task").description("Manage tasks");
|
|
12
|
+
|
|
13
|
+
task
|
|
14
|
+
.command("list")
|
|
15
|
+
.description("List all tasks")
|
|
16
|
+
.option("-s, --status <status>", "filter by status")
|
|
17
|
+
.option("-p, --priority <priority>", "filter by priority")
|
|
18
|
+
.option("-l, --limit <n>", "max number of tasks", parseInt)
|
|
19
|
+
.action(async (opts) => {
|
|
20
|
+
const dir = program.opts().dir;
|
|
21
|
+
const conn = getConnection(getDbPath(dir));
|
|
22
|
+
migrate(conn);
|
|
23
|
+
|
|
24
|
+
const tasks = await listTasks(conn, {
|
|
25
|
+
status: opts.status,
|
|
26
|
+
priority: opts.priority,
|
|
27
|
+
limit: opts.limit,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (tasks.length === 0) {
|
|
31
|
+
logger.dim("No tasks found.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const t of tasks) {
|
|
36
|
+
printTask(t);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
conn.close();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
task
|
|
43
|
+
.command("add <name>")
|
|
44
|
+
.description("Create a new task")
|
|
45
|
+
.option("--description <text>", "task description", "")
|
|
46
|
+
.option("-p, --priority <priority>", "low, medium, or high", "medium")
|
|
47
|
+
.action(async (name, opts) => {
|
|
48
|
+
const dir = program.opts().dir;
|
|
49
|
+
const conn = getConnection(getDbPath(dir));
|
|
50
|
+
migrate(conn);
|
|
51
|
+
|
|
52
|
+
const t = await createTask(conn, {
|
|
53
|
+
name,
|
|
54
|
+
description: opts.description,
|
|
55
|
+
priority: opts.priority,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
logger.success(`Created task: ${t.name} (${t.id})`);
|
|
59
|
+
conn.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
task
|
|
63
|
+
.command("view <id>")
|
|
64
|
+
.description("View task details")
|
|
65
|
+
.action(async (id) => {
|
|
66
|
+
const dir = program.opts().dir;
|
|
67
|
+
const conn = getConnection(getDbPath(dir));
|
|
68
|
+
migrate(conn);
|
|
69
|
+
|
|
70
|
+
const t = await getTask(conn, id);
|
|
71
|
+
if (!t) {
|
|
72
|
+
logger.error(`Task not found: ${id}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
printTaskDetail(t);
|
|
77
|
+
conn.close();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function statusColor(status: Task["status"]): string {
|
|
82
|
+
switch (status) {
|
|
83
|
+
case "pending":
|
|
84
|
+
return ansis.yellow(status);
|
|
85
|
+
case "in_progress":
|
|
86
|
+
return ansis.blue(status);
|
|
87
|
+
case "complete":
|
|
88
|
+
return ansis.green(status);
|
|
89
|
+
case "failed":
|
|
90
|
+
return ansis.red(status);
|
|
91
|
+
case "waiting":
|
|
92
|
+
return ansis.magenta(status);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function priorityColor(priority: Task["priority"]): string {
|
|
97
|
+
switch (priority) {
|
|
98
|
+
case "high":
|
|
99
|
+
return ansis.red(priority);
|
|
100
|
+
case "medium":
|
|
101
|
+
return ansis.yellow(priority);
|
|
102
|
+
case "low":
|
|
103
|
+
return ansis.dim(priority);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function printTask(t: Task) {
|
|
108
|
+
const id = ansis.dim(t.id.slice(0, 8));
|
|
109
|
+
console.log(
|
|
110
|
+
` ${id} ${statusColor(t.status)} ${priorityColor(t.priority)} ${t.name}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function printTaskDetail(t: Task) {
|
|
115
|
+
console.log(ansis.bold(t.name));
|
|
116
|
+
console.log(` ID: ${t.id}`);
|
|
117
|
+
console.log(` Status: ${statusColor(t.status)}`);
|
|
118
|
+
console.log(` Priority: ${priorityColor(t.priority)}`);
|
|
119
|
+
if (t.description) console.log(` Description: ${t.description}`);
|
|
120
|
+
if (t.waiting_reason) console.log(` Waiting: ${t.waiting_reason}`);
|
|
121
|
+
if (t.claimed_by) console.log(` Claimed by: ${t.claimed_by}`);
|
|
122
|
+
if (t.blocked_by.length > 0)
|
|
123
|
+
console.log(` Blocked by: ${t.blocked_by.join(", ")}`);
|
|
124
|
+
console.log(` Created: ${t.created_at.toISOString()}`);
|
|
125
|
+
console.log(` Updated: ${t.updated_at.toISOString()}`);
|
|
126
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
4
|
+
import { DEFAULT_CONFIG } from "../config/schemas.ts";
|
|
5
|
+
import { getDbPath } from "../constants.ts";
|
|
6
|
+
import { getConnection } from "../db/connection.ts";
|
|
7
|
+
import { migrate } from "../db/schema.ts";
|
|
8
|
+
import { registerAllTools } from "../tools/registry.ts";
|
|
9
|
+
import {
|
|
10
|
+
type AnyToolDefinition,
|
|
11
|
+
getToolsByGroup,
|
|
12
|
+
type ToolContext,
|
|
13
|
+
} from "../tools/tool.ts";
|
|
14
|
+
import { logger } from "../utils/logger.ts";
|
|
15
|
+
|
|
16
|
+
registerAllTools();
|
|
17
|
+
|
|
18
|
+
const GROUP_DESCRIPTIONS: Record<string, string> = {
|
|
19
|
+
dir: "Directory operations on the virtual filesystem",
|
|
20
|
+
file: "File operations on the virtual filesystem",
|
|
21
|
+
search: "Search the virtual filesystem",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function registerToolCommands(program: Command) {
|
|
25
|
+
for (const group of ["dir", "file", "search"]) {
|
|
26
|
+
const groupCmd = program
|
|
27
|
+
.command(group)
|
|
28
|
+
.description(GROUP_DESCRIPTIONS[group] ?? `${group} tools`);
|
|
29
|
+
|
|
30
|
+
for (const tool of getToolsByGroup(group)) {
|
|
31
|
+
registerToolAsCLI(groupCmd, tool, program);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function registerToolAsCLI(
|
|
37
|
+
parent: Command,
|
|
38
|
+
tool: AnyToolDefinition,
|
|
39
|
+
program: Command,
|
|
40
|
+
) {
|
|
41
|
+
// Derive subcommand name: "file_read" → "read", "file_count_lines" → "count-lines"
|
|
42
|
+
const subName = tool.name.replace(/^[^_]+_/, "").replace(/_/g, "-");
|
|
43
|
+
|
|
44
|
+
// Inspect zod schema to determine positional args and options
|
|
45
|
+
const shape = tool.inputSchema.shape as Record<string, z.ZodType>;
|
|
46
|
+
const positionals: string[] = [];
|
|
47
|
+
const options: {
|
|
48
|
+
key: string;
|
|
49
|
+
flag: string;
|
|
50
|
+
description: string;
|
|
51
|
+
isArray: boolean;
|
|
52
|
+
}[] = [];
|
|
53
|
+
|
|
54
|
+
for (const [key, schema] of Object.entries(shape)) {
|
|
55
|
+
const desc = schema.description ?? key;
|
|
56
|
+
const isOptional = schema.isOptional();
|
|
57
|
+
const unwrapped = unwrapOptional(schema);
|
|
58
|
+
|
|
59
|
+
if (isPositionalArg(key, tool.name)) {
|
|
60
|
+
positionals.push(isOptional ? `[${key}]` : `<${key}>`);
|
|
61
|
+
} else if (unwrapped instanceof z.ZodBoolean) {
|
|
62
|
+
options.push({
|
|
63
|
+
key,
|
|
64
|
+
flag: `--${key.replace(/_/g, "-")}`,
|
|
65
|
+
description: desc,
|
|
66
|
+
isArray: false,
|
|
67
|
+
});
|
|
68
|
+
} else if (unwrapped instanceof z.ZodArray) {
|
|
69
|
+
options.push({
|
|
70
|
+
key,
|
|
71
|
+
flag: `--${key.replace(/_/g, "-")} <json>`,
|
|
72
|
+
description: desc,
|
|
73
|
+
isArray: true,
|
|
74
|
+
});
|
|
75
|
+
} else {
|
|
76
|
+
options.push({
|
|
77
|
+
key,
|
|
78
|
+
flag: `--${key.replace(/_/g, "-")} <value>`,
|
|
79
|
+
description: desc,
|
|
80
|
+
isArray: false,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cmd = parent
|
|
86
|
+
.command(`${subName} ${positionals.join(" ")}`.trim())
|
|
87
|
+
.description(tool.description);
|
|
88
|
+
|
|
89
|
+
for (const opt of options) {
|
|
90
|
+
if (opt.isArray) {
|
|
91
|
+
cmd.option(opt.flag, opt.description);
|
|
92
|
+
} else {
|
|
93
|
+
cmd.option(opt.flag, opt.description);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
cmd.action(async (...args: unknown[]) => {
|
|
98
|
+
const dir = program.opts().dir;
|
|
99
|
+
const conn = getConnection(getDbPath(dir));
|
|
100
|
+
migrate(conn);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const input = buildInput(tool, positionals, options, shape, args);
|
|
104
|
+
|
|
105
|
+
const ctx: ToolContext = {
|
|
106
|
+
conn,
|
|
107
|
+
projectDir: dir,
|
|
108
|
+
config: DEFAULT_CONFIG as Required<BotholomewConfig>,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = await tool.execute(input, ctx);
|
|
112
|
+
formatOutput(result, tool.name);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger.error(String(err));
|
|
115
|
+
process.exit(1);
|
|
116
|
+
} finally {
|
|
117
|
+
conn.close();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildInput(
|
|
123
|
+
tool: AnyToolDefinition,
|
|
124
|
+
positionals: string[],
|
|
125
|
+
options: {
|
|
126
|
+
key: string;
|
|
127
|
+
flag: string;
|
|
128
|
+
description: string;
|
|
129
|
+
isArray: boolean;
|
|
130
|
+
}[],
|
|
131
|
+
shape: Record<string, z.ZodType>,
|
|
132
|
+
args: unknown[],
|
|
133
|
+
): Record<string, unknown> {
|
|
134
|
+
const input: Record<string, unknown> = {};
|
|
135
|
+
|
|
136
|
+
// Positional args come first in Commander's action callback
|
|
137
|
+
for (let i = 0; i < positionals.length; i++) {
|
|
138
|
+
const key = positionals[i]?.replace(/[<>[\]]/g, "");
|
|
139
|
+
const value = args[i];
|
|
140
|
+
if (key !== undefined && value !== undefined) input[key] = value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Options object is the last argument before the Command object
|
|
144
|
+
const optsObj = (args[positionals.length] ?? {}) as Record<string, unknown>;
|
|
145
|
+
|
|
146
|
+
for (const opt of options) {
|
|
147
|
+
const cliKey = opt.key.replace(/_/g, "-");
|
|
148
|
+
// Commander converts --foo-bar to fooBar
|
|
149
|
+
const camelKey = cliKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
150
|
+
let value = optsObj[camelKey] ?? optsObj[opt.key];
|
|
151
|
+
|
|
152
|
+
if (value === undefined) continue;
|
|
153
|
+
|
|
154
|
+
const schemaForKey = shape[opt.key];
|
|
155
|
+
if (!schemaForKey) continue;
|
|
156
|
+
const unwrapped = unwrapOptional(schemaForKey);
|
|
157
|
+
|
|
158
|
+
// Parse JSON for array types
|
|
159
|
+
if (opt.isArray && typeof value === "string") {
|
|
160
|
+
value = JSON.parse(value);
|
|
161
|
+
}
|
|
162
|
+
// Parse numbers
|
|
163
|
+
else if (unwrapped instanceof z.ZodNumber && typeof value === "string") {
|
|
164
|
+
value = Number(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
input[opt.key] = value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Validate with zod
|
|
171
|
+
const parsed = tool.inputSchema.safeParse(input);
|
|
172
|
+
if (!parsed.success) {
|
|
173
|
+
throw new Error(`Invalid arguments: ${JSON.stringify(parsed.error)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return parsed.data;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function formatOutput(result: unknown, _toolName: string) {
|
|
180
|
+
if (result == null) return;
|
|
181
|
+
|
|
182
|
+
if (typeof result === "object") {
|
|
183
|
+
const obj = result as Record<string, unknown>;
|
|
184
|
+
|
|
185
|
+
// Special formatting for known output shapes
|
|
186
|
+
if ("tree" in obj && typeof obj.tree === "string") {
|
|
187
|
+
console.log(obj.tree);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if ("content" in obj && typeof obj.content === "string") {
|
|
192
|
+
console.log(obj.content);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if ("exists" in obj && typeof obj.exists === "boolean") {
|
|
197
|
+
if (!obj.exists) process.exit(1);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if ("entries" in obj && Array.isArray(obj.entries)) {
|
|
202
|
+
for (const entry of obj.entries) {
|
|
203
|
+
const e = entry as { name: string; type: string; size: number };
|
|
204
|
+
const suffix = e.type === "directory" ? "/" : "";
|
|
205
|
+
console.log(` ${e.name}${suffix}`);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if ("matches" in obj && Array.isArray(obj.matches)) {
|
|
211
|
+
for (const match of obj.matches) {
|
|
212
|
+
if (typeof match === "string") {
|
|
213
|
+
console.log(match);
|
|
214
|
+
} else {
|
|
215
|
+
const m = match as { path: string; line: number; content: string };
|
|
216
|
+
console.log(`${m.path}:${m.line}: ${m.content}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Default: print as JSON
|
|
223
|
+
console.log(JSON.stringify(obj, null, 2));
|
|
224
|
+
} else {
|
|
225
|
+
console.log(result);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isPositionalArg(key: string, toolName: string): boolean {
|
|
230
|
+
// These keys are treated as positional arguments
|
|
231
|
+
const positionalKeys: Record<string, string[]> = {
|
|
232
|
+
dir_create: ["path"],
|
|
233
|
+
dir_list: ["path"],
|
|
234
|
+
dir_tree: ["path"],
|
|
235
|
+
dir_size: ["path"],
|
|
236
|
+
file_read: ["path"],
|
|
237
|
+
file_write: ["path"],
|
|
238
|
+
file_edit: ["path"],
|
|
239
|
+
file_delete: ["path"],
|
|
240
|
+
file_copy: ["src", "dst"],
|
|
241
|
+
file_move: ["src", "dst"],
|
|
242
|
+
file_info: ["path"],
|
|
243
|
+
file_exists: ["path"],
|
|
244
|
+
file_count_lines: ["path"],
|
|
245
|
+
search_find: ["pattern"],
|
|
246
|
+
search_grep: ["pattern"],
|
|
247
|
+
search_semantic: ["query"],
|
|
248
|
+
};
|
|
249
|
+
return positionalKeys[toolName]?.includes(key) ?? false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function unwrapOptional(schema: z.ZodType): z.ZodType {
|
|
253
|
+
if (schema instanceof z.ZodOptional) {
|
|
254
|
+
return schema.unwrap() as z.ZodType;
|
|
255
|
+
}
|
|
256
|
+
return schema;
|
|
257
|
+
}
|