agent-hub-cli 1.0.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/LICENSE +21 -0
- package/README.md +121 -0
- package/package.json +40 -0
- package/src/index.js +11 -0
- package/src/lib/cli.js +179 -0
- package/src/lib/config.js +107 -0
- package/src/lib/detect.js +156 -0
- package/src/lib/registry.js +74 -0
- package/src/lib/terminal.js +607 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# AGENT HUB
|
|
2
|
+
|
|
3
|
+
Terminal hub to discover and launch all your AI coding CLIs from one place.
|
|
4
|
+
|
|
5
|
+
[](./AgentHub.mp4)
|
|
6
|
+
|
|
7
|
+
Full demo video: [AgentHub.mp4](./AgentHub.mp4)
|
|
8
|
+
|
|
9
|
+
## What it is
|
|
10
|
+
|
|
11
|
+
AGENT HUB is an open-source Node.js CLI that scans your machine for installed AI coding tools and gives you one terminal entry point to launch them.
|
|
12
|
+
|
|
13
|
+
Instead of remembering separate commands for tools like `codex`, `claude`, `gemini`, `opencode`, `aider`, and custom wrappers, you open one interface and jump into the tool you want.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
- Scans the local machine for supported AI CLIs.
|
|
18
|
+
- Shows only tools that are actually installed.
|
|
19
|
+
- Lets you launch a tool by menu, number, or id.
|
|
20
|
+
- Supports custom tools through `.agent-hub.json`.
|
|
21
|
+
- Works as a lightweight terminal switchboard, not as a chatbot.
|
|
22
|
+
|
|
23
|
+
## Demo
|
|
24
|
+
|
|
25
|
+
The GIF above is a preview. Click it to open the full video.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Local project usage:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install
|
|
33
|
+
npm start
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Global installation:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g .
|
|
40
|
+
agent-hub
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
agent-hub
|
|
47
|
+
agent-hub scan
|
|
48
|
+
agent-hub scan --json
|
|
49
|
+
agent-hub scan --all
|
|
50
|
+
agent-hub list
|
|
51
|
+
agent-hub run codex
|
|
52
|
+
agent-hub --theme neon-light
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Supported tools
|
|
56
|
+
|
|
57
|
+
Built-in detection currently includes:
|
|
58
|
+
|
|
59
|
+
- `claude`
|
|
60
|
+
- `codex`
|
|
61
|
+
- `gemini`
|
|
62
|
+
- `opencode`
|
|
63
|
+
- `aider`
|
|
64
|
+
- `goose`
|
|
65
|
+
|
|
66
|
+
## Custom tools
|
|
67
|
+
|
|
68
|
+
Create a `.agent-hub.json` in the project directory or in your home directory:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"tools": [
|
|
73
|
+
{
|
|
74
|
+
"id": "cursor-agent",
|
|
75
|
+
"name": "Cursor Agent",
|
|
76
|
+
"command": "cursor-agent",
|
|
77
|
+
"description": "Cursor terminal agent"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "local-wrapper",
|
|
81
|
+
"name": "My Wrapper",
|
|
82
|
+
"command": ".\\scripts\\my-wrapper.cmd",
|
|
83
|
+
"args": ["--fast"]
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Supported fields:
|
|
90
|
+
|
|
91
|
+
- `id`
|
|
92
|
+
- `name`
|
|
93
|
+
- `command`
|
|
94
|
+
- `args`
|
|
95
|
+
- `description`
|
|
96
|
+
- `binaryNames`
|
|
97
|
+
- `knownPaths`
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
Run checks:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm run check
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Run a local scan:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm start -- scan
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Notes
|
|
114
|
+
|
|
115
|
+
- By default, the dashboard shows only installed tools.
|
|
116
|
+
- Use `agent-hub scan --all` to inspect missing known tools too.
|
|
117
|
+
- This version launches one interactive CLI at a time and returns to the dashboard when the process exits.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-hub-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Terminal hub to discover and launch all your AI coding CLIs from one place.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-hub": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js",
|
|
11
|
+
"check": "node --test"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cli",
|
|
20
|
+
"ai",
|
|
21
|
+
"terminal",
|
|
22
|
+
"claude",
|
|
23
|
+
"codex",
|
|
24
|
+
"developer-tools",
|
|
25
|
+
"nodejs",
|
|
26
|
+
"open-source"
|
|
27
|
+
],
|
|
28
|
+
"homepage": "https://github.com/schenoneedoardo/AiFacilityCli#readme",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/schenoneedoardo/AiFacilityCli.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/schenoneedoardo/AiFacilityCli/issues"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
import { runCli } from "./lib/cli.js";
|
|
6
|
+
|
|
7
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
8
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9
|
+
console.error(message);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
});
|
package/src/lib/cli.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
import { detectTools, getAvailableTools, serializeTools } from "./detect.js";
|
|
4
|
+
import {
|
|
5
|
+
installTool,
|
|
6
|
+
launchTool,
|
|
7
|
+
normalizeThemeName,
|
|
8
|
+
printScanTable,
|
|
9
|
+
promptForToolSelection,
|
|
10
|
+
} from "./terminal.js";
|
|
11
|
+
|
|
12
|
+
export async function runCli(argv) {
|
|
13
|
+
const { command, rest, theme } = parseArguments(argv);
|
|
14
|
+
|
|
15
|
+
if (!command || command === "menu") {
|
|
16
|
+
await openInteractiveMenu({ theme });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (command === "scan") {
|
|
21
|
+
const includeMissing = rest.includes("--all");
|
|
22
|
+
const tools = selectDisplayedTools(detectTools(), { includeMissing });
|
|
23
|
+
if (rest.includes("--json")) {
|
|
24
|
+
console.log(JSON.stringify(serializeTools(tools), null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
printScanTable(tools, { showActions: false, theme });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (command === "run") {
|
|
33
|
+
const toolId = rest[0];
|
|
34
|
+
if (!toolId) {
|
|
35
|
+
throw new Error("Missing tool id. Use `agent-hub scan` to list available ids.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tools = getAvailableTools(detectTools());
|
|
39
|
+
const tool = tools.find((entry) => entry.id === toolId);
|
|
40
|
+
if (!tool) {
|
|
41
|
+
throw new Error(`Tool "${toolId}" is not installed on this machine. Use \`agent-hub scan\` first.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await launchTool(tool, { theme });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (command === "list") {
|
|
49
|
+
printScanTable(getAvailableTools(detectTools()), { showActions: false, theme });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (command === "install") {
|
|
54
|
+
const toolId = rest[0];
|
|
55
|
+
if (!toolId) {
|
|
56
|
+
throw new Error("Missing tool id. Use `agent-hub scan --all` to see installable tools.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const allTools = detectTools();
|
|
60
|
+
const tool = allTools.find((entry) => entry.id === toolId);
|
|
61
|
+
if (!tool) {
|
|
62
|
+
throw new Error(`Unknown tool "${toolId}".`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (tool.available) {
|
|
66
|
+
console.log(`${tool.name} is already installed.`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!tool.installCommand) {
|
|
71
|
+
throw new Error(`No install command defined for "${toolId}".`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await installTool(tool, { theme });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
79
|
+
printHelp();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Unknown command "${command}". Use \`agent-hub help\` to see the supported commands.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function openInteractiveMenu(options = {}) {
|
|
89
|
+
// Use ALL tools (installed + installable) so the user can install from the menu
|
|
90
|
+
let tools = detectTools();
|
|
91
|
+
|
|
92
|
+
while (true) {
|
|
93
|
+
printScanTable(tools, { theme: options.theme });
|
|
94
|
+
|
|
95
|
+
const selected = await promptForToolSelection(tools, { theme: options.theme });
|
|
96
|
+
if (selected === null) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (selected === "rescan") {
|
|
101
|
+
tools = detectTools();
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (selected.available) {
|
|
106
|
+
await launchTool(selected, { theme: options.theme });
|
|
107
|
+
} else {
|
|
108
|
+
await installTool(selected, { theme: options.theme });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Rescan after launch or install so status is up to date
|
|
112
|
+
tools = detectTools();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function selectDisplayedTools(tools, options = {}) {
|
|
117
|
+
if (options.includeMissing) {
|
|
118
|
+
return tools;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return getAvailableTools(tools);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function printHelp() {
|
|
125
|
+
const lines = [
|
|
126
|
+
"agent-hub",
|
|
127
|
+
"",
|
|
128
|
+
"Usage:",
|
|
129
|
+
" agent-hub Open the interactive menu (installed + installable tools)",
|
|
130
|
+
" agent-hub scan Scan and print only the installed tools",
|
|
131
|
+
" agent-hub scan --json Scan and print only the installed tools as JSON",
|
|
132
|
+
" agent-hub scan --all Include missing known tools",
|
|
133
|
+
" agent-hub list Alias of scan",
|
|
134
|
+
" agent-hub run <id> Launch a specific installed tool directly",
|
|
135
|
+
" agent-hub install <id> Install a specific tool by id",
|
|
136
|
+
" agent-hub --theme vscode",
|
|
137
|
+
" agent-hub --theme cyber",
|
|
138
|
+
" agent-hub --theme neon-dark",
|
|
139
|
+
" agent-hub --theme neon-light",
|
|
140
|
+
"",
|
|
141
|
+
"The menu shows installed tools (launch) and installable tools (install on select).",
|
|
142
|
+
"After launch or install the menu rescans automatically.",
|
|
143
|
+
"Configuration files supported:",
|
|
144
|
+
" .agent-hub.json in the current directory",
|
|
145
|
+
` .agent-hub.json in ${process.env.USERPROFILE ?? process.env.HOME ?? "~"}`,
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
console.log(lines.join("\n"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseArguments(argv) {
|
|
152
|
+
const positional = [];
|
|
153
|
+
let theme = normalizeThemeName();
|
|
154
|
+
|
|
155
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
156
|
+
const value = argv[index];
|
|
157
|
+
|
|
158
|
+
if (value === "--theme") {
|
|
159
|
+
const next = argv[index + 1];
|
|
160
|
+
if (!next) {
|
|
161
|
+
throw new Error("Missing value for --theme. Use `vscode`, `cyber`, `neon-dark` or `neon-light`.");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
theme = normalizeThemeName(next);
|
|
165
|
+
index += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (value.startsWith("--theme=")) {
|
|
170
|
+
theme = normalizeThemeName(value.slice("--theme=".length));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
positional.push(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const [command, ...rest] = positional;
|
|
178
|
+
return { command, rest, theme };
|
|
179
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const CONFIG_NAME = ".agent-hub.json";
|
|
6
|
+
|
|
7
|
+
export function loadUserTools() {
|
|
8
|
+
const configFiles = [
|
|
9
|
+
path.join(process.cwd(), CONFIG_NAME),
|
|
10
|
+
path.join(os.homedir(), CONFIG_NAME),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const tools = [];
|
|
14
|
+
|
|
15
|
+
for (const filePath of configFiles) {
|
|
16
|
+
if (!fs.existsSync(filePath)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const parsed = readJson(filePath);
|
|
21
|
+
if (!parsed || !Array.isArray(parsed.tools)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const entry of parsed.tools) {
|
|
26
|
+
const tool = normalizeToolEntry(entry, path.dirname(filePath));
|
|
27
|
+
if (tool) {
|
|
28
|
+
tools.push(tool);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return tools;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readJson(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
39
|
+
return JSON.parse(raw);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeToolEntry(entry, baseDir) {
|
|
46
|
+
if (!entry || typeof entry !== "object") {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const id = sanitizeString(entry.id);
|
|
51
|
+
const name = sanitizeString(entry.name);
|
|
52
|
+
const command = sanitizeString(entry.command);
|
|
53
|
+
const description = sanitizeString(entry.description) ?? "User-defined tool";
|
|
54
|
+
const args = Array.isArray(entry.args)
|
|
55
|
+
? entry.args.filter((value) => typeof value === "string")
|
|
56
|
+
: [];
|
|
57
|
+
|
|
58
|
+
if (!id || !name || !command) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
name,
|
|
65
|
+
command,
|
|
66
|
+
args,
|
|
67
|
+
description,
|
|
68
|
+
binaryNames: normalizeBinaryNames(entry.binaryNames, command),
|
|
69
|
+
knownPaths: normalizeKnownPaths(entry.knownPaths, baseDir),
|
|
70
|
+
baseDir,
|
|
71
|
+
source: "config",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeBinaryNames(binaryNames, command) {
|
|
76
|
+
if (!Array.isArray(binaryNames)) {
|
|
77
|
+
return [path.basename(command)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const names = binaryNames.filter((value) => typeof value === "string" && value.trim());
|
|
81
|
+
return names.length > 0 ? names : [path.basename(command)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeKnownPaths(knownPaths, baseDir) {
|
|
85
|
+
if (!Array.isArray(knownPaths)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return knownPaths
|
|
90
|
+
.filter((value) => typeof value === "string" && value.trim())
|
|
91
|
+
.map((value) => {
|
|
92
|
+
if (path.isAbsolute(value)) {
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return path.resolve(baseDir, value);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sanitizeString(value) {
|
|
101
|
+
if (typeof value !== "string") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const trimmed = value.trim();
|
|
106
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
107
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
import { loadUserTools } from "./config.js";
|
|
6
|
+
import { KNOWN_TOOLS } from "./registry.js";
|
|
7
|
+
|
|
8
|
+
export function detectTools(options = {}) {
|
|
9
|
+
const platform = options.platform ?? process.platform;
|
|
10
|
+
const env = options.env ?? process.env;
|
|
11
|
+
const searchDirs = buildSearchDirectories(env, platform);
|
|
12
|
+
const definitions = mergeDefinitions(KNOWN_TOOLS, loadUserTools());
|
|
13
|
+
|
|
14
|
+
return definitions.map((tool) => detectTool(tool, { platform, searchDirs }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getAvailableTools(tools) {
|
|
18
|
+
return tools.filter((tool) => tool.available);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function serializeTools(tools) {
|
|
22
|
+
return tools.map((tool) => ({
|
|
23
|
+
id: tool.id,
|
|
24
|
+
name: tool.name,
|
|
25
|
+
description: tool.description,
|
|
26
|
+
available: tool.available,
|
|
27
|
+
resolvedPath: tool.resolvedPath,
|
|
28
|
+
command: tool.command,
|
|
29
|
+
args: tool.args,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildSearchDirectories(env, platform) {
|
|
34
|
+
const pathValue = env.PATH ?? "";
|
|
35
|
+
const pathDirs = pathValue
|
|
36
|
+
.split(path.delimiter)
|
|
37
|
+
.map((entry) => entry.trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
|
|
40
|
+
const platformDirs =
|
|
41
|
+
platform === "win32" ? buildWindowsDirectories(env) : buildUnixDirectories();
|
|
42
|
+
|
|
43
|
+
return uniqueStrings([...pathDirs, ...platformDirs]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveBinary(binaryNames, searchDirs, options = {}) {
|
|
47
|
+
const exists = options.exists ?? fs.existsSync;
|
|
48
|
+
|
|
49
|
+
for (const binaryName of uniqueStrings(binaryNames)) {
|
|
50
|
+
if (path.isAbsolute(binaryName) && exists(binaryName)) {
|
|
51
|
+
return binaryName;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const directory of searchDirs) {
|
|
56
|
+
for (const binaryName of uniqueStrings(binaryNames)) {
|
|
57
|
+
const candidate = path.join(directory, binaryName);
|
|
58
|
+
if (exists(candidate)) {
|
|
59
|
+
return candidate;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function detectTool(tool, context) {
|
|
68
|
+
const binaryNames = buildBinaryNames(tool, context.platform);
|
|
69
|
+
const directCommand = resolveDirectCommand(tool.command, tool.baseDir);
|
|
70
|
+
const absoluteCandidates = uniqueStrings([
|
|
71
|
+
...tool.knownPaths,
|
|
72
|
+
...(directCommand ? [directCommand] : []),
|
|
73
|
+
]);
|
|
74
|
+
const resolvedPath =
|
|
75
|
+
resolveBinary(absoluteCandidates, [], { exists: fs.existsSync }) ??
|
|
76
|
+
resolveBinary(binaryNames, context.searchDirs, { exists: fs.existsSync });
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
...tool,
|
|
80
|
+
available: Boolean(resolvedPath),
|
|
81
|
+
resolvedPath,
|
|
82
|
+
launchCommand: resolvedPath ?? tool.command,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mergeDefinitions(builtInTools, userTools) {
|
|
87
|
+
const definitions = new Map();
|
|
88
|
+
|
|
89
|
+
for (const tool of [...builtInTools, ...userTools]) {
|
|
90
|
+
definitions.set(tool.id, tool);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [...definitions.values()].sort((left, right) =>
|
|
94
|
+
left.name.localeCompare(right.name),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildBinaryNames(tool, platform) {
|
|
99
|
+
const orderedNames = [];
|
|
100
|
+
|
|
101
|
+
for (const entry of tool.binaryNames ?? [tool.command]) {
|
|
102
|
+
if (platform === "win32" && !path.extname(entry)) {
|
|
103
|
+
orderedNames.push(`${entry}.cmd`, `${entry}.exe`, `${entry}.bat`, entry);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
orderedNames.push(entry);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return uniqueStrings(orderedNames);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveDirectCommand(command, baseDir = process.cwd()) {
|
|
114
|
+
if (path.isAbsolute(command)) {
|
|
115
|
+
return command;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
119
|
+
return path.resolve(baseDir, command);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildWindowsDirectories(env) {
|
|
126
|
+
return [
|
|
127
|
+
joinIfDefined(env.APPDATA, "npm"),
|
|
128
|
+
joinIfDefined(env.LOCALAPPDATA, "Microsoft", "WindowsApps"),
|
|
129
|
+
joinIfDefined(env.LOCALAPPDATA, "Programs"),
|
|
130
|
+
joinIfDefined(env.ProgramFiles, "nodejs"),
|
|
131
|
+
joinIfDefined(env["ProgramFiles(x86)"], "nodejs"),
|
|
132
|
+
joinIfDefined(env.USERPROFILE, "scoop", "shims"),
|
|
133
|
+
joinIfDefined(env.ChocolateyInstall, "bin"),
|
|
134
|
+
].filter(Boolean);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildUnixDirectories() {
|
|
138
|
+
return [
|
|
139
|
+
"/usr/local/bin",
|
|
140
|
+
"/usr/bin",
|
|
141
|
+
"/opt/homebrew/bin",
|
|
142
|
+
"/home/linuxbrew/.linuxbrew/bin",
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function joinIfDefined(...parts) {
|
|
147
|
+
if (parts.some((value) => !value)) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return path.join(...parts);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function uniqueStrings(values) {
|
|
155
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.trim()))];
|
|
156
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export const KNOWN_TOOLS = [
|
|
2
|
+
{
|
|
3
|
+
id: "aider",
|
|
4
|
+
name: "Aider",
|
|
5
|
+
command: "aider",
|
|
6
|
+
args: [],
|
|
7
|
+
description: "AI pair programming in your terminal",
|
|
8
|
+
binaryNames: ["aider"],
|
|
9
|
+
knownPaths: [],
|
|
10
|
+
source: "built-in",
|
|
11
|
+
installCommand: "pip install aider-chat",
|
|
12
|
+
installMethod: "pip",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "claude",
|
|
16
|
+
name: "Claude Code",
|
|
17
|
+
command: "claude",
|
|
18
|
+
args: [],
|
|
19
|
+
description: "Anthropic Claude Code CLI",
|
|
20
|
+
binaryNames: ["claude"],
|
|
21
|
+
knownPaths: [],
|
|
22
|
+
source: "built-in",
|
|
23
|
+
installCommand: "npm install -g @anthropic-ai/claude-code",
|
|
24
|
+
installMethod: "npm",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "codex",
|
|
28
|
+
name: "Codex",
|
|
29
|
+
command: "codex",
|
|
30
|
+
args: [],
|
|
31
|
+
description: "OpenAI Codex CLI",
|
|
32
|
+
binaryNames: ["codex"],
|
|
33
|
+
knownPaths: [],
|
|
34
|
+
source: "built-in",
|
|
35
|
+
installCommand: "npm install -g @openai/codex",
|
|
36
|
+
installMethod: "npm",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "gemini",
|
|
40
|
+
name: "Gemini CLI",
|
|
41
|
+
command: "gemini",
|
|
42
|
+
args: [],
|
|
43
|
+
description: "Google Gemini CLI",
|
|
44
|
+
binaryNames: ["gemini"],
|
|
45
|
+
knownPaths: [],
|
|
46
|
+
source: "built-in",
|
|
47
|
+
installCommand: "npm install -g @google/gemini-cli",
|
|
48
|
+
installMethod: "npm",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "goose",
|
|
52
|
+
name: "Goose",
|
|
53
|
+
command: "goose",
|
|
54
|
+
args: [],
|
|
55
|
+
description: "Block Goose — open source AI agent",
|
|
56
|
+
binaryNames: ["goose"],
|
|
57
|
+
knownPaths: [],
|
|
58
|
+
source: "built-in",
|
|
59
|
+
installCommand: "winget install Block.Goose",
|
|
60
|
+
installMethod: "winget",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "opencode",
|
|
64
|
+
name: "OpenCode",
|
|
65
|
+
command: "opencode",
|
|
66
|
+
args: [],
|
|
67
|
+
description: "OpenCode — open source AI coding agent",
|
|
68
|
+
binaryNames: ["opencode"],
|
|
69
|
+
knownPaths: [],
|
|
70
|
+
source: "built-in",
|
|
71
|
+
installCommand: "npm install -g opencode",
|
|
72
|
+
installMethod: "npm",
|
|
73
|
+
},
|
|
74
|
+
];
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const { version: APP_VERSION } = require("../../package.json");
|
|
9
|
+
|
|
10
|
+
const APP_BRAND = "agent-hub";
|
|
11
|
+
const APP_WORDMARK = "AGENT HUB";
|
|
12
|
+
const APP_COMMAND = "agent-hub";
|
|
13
|
+
|
|
14
|
+
const ANSI = {
|
|
15
|
+
reset: "\x1b[0m",
|
|
16
|
+
bold: "\x1b[1m",
|
|
17
|
+
dim: "\x1b[2m",
|
|
18
|
+
black: "\x1b[30m",
|
|
19
|
+
gray: "\x1b[90m",
|
|
20
|
+
white: "\x1b[97m",
|
|
21
|
+
green: "\x1b[92m",
|
|
22
|
+
cyan: "\x1b[96m",
|
|
23
|
+
blue: "\x1b[94m",
|
|
24
|
+
magenta: "\x1b[95m",
|
|
25
|
+
yellow: "\x1b[93m",
|
|
26
|
+
red: "\x1b[91m",
|
|
27
|
+
bgBlack: "\x1b[40m",
|
|
28
|
+
bgWhite: "\x1b[107m",
|
|
29
|
+
bgGreen: "\x1b[102m",
|
|
30
|
+
bgCyan: "\x1b[106m",
|
|
31
|
+
bgBlue: "\x1b[44m",
|
|
32
|
+
bgYellow: "\x1b[43m",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Unicode box-drawing characters
|
|
36
|
+
const BOX = {
|
|
37
|
+
tl: "╔", tr: "╗", bl: "╚", br: "╝",
|
|
38
|
+
h: "═", v: "║",
|
|
39
|
+
ml: "╠", mr: "╣",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const THEMES = {
|
|
43
|
+
"vscode": {
|
|
44
|
+
name: "vscode",
|
|
45
|
+
logoTop: [ANSI.blue],
|
|
46
|
+
logoBottom: [ANSI.bold, ANSI.blue],
|
|
47
|
+
wordmark: [ANSI.bold, ANSI.blue],
|
|
48
|
+
headline: [ANSI.bold, ANSI.white],
|
|
49
|
+
subtext: [ANSI.white],
|
|
50
|
+
border: [ANSI.blue],
|
|
51
|
+
sep: [ANSI.blue],
|
|
52
|
+
chip: [ANSI.bold, ANSI.white, ANSI.bgBlue],
|
|
53
|
+
token: [ANSI.bold, ANSI.blue],
|
|
54
|
+
prompt: [ANSI.bold, ANSI.blue],
|
|
55
|
+
footer: [ANSI.white],
|
|
56
|
+
tip: [ANSI.bold, ANSI.cyan],
|
|
57
|
+
accent: [ANSI.bold, ANSI.blue],
|
|
58
|
+
key: [ANSI.bold, ANSI.white, ANSI.bgBlue],
|
|
59
|
+
missing: [ANSI.dim, ANSI.white],
|
|
60
|
+
installBadge: [ANSI.bold, ANSI.black, ANSI.bgYellow],
|
|
61
|
+
},
|
|
62
|
+
"cyber": {
|
|
63
|
+
name: "cyber",
|
|
64
|
+
logoTop: [ANSI.bold, ANSI.blue],
|
|
65
|
+
logoBottom: [ANSI.bold, ANSI.cyan],
|
|
66
|
+
wordmark: [ANSI.bold, ANSI.cyan],
|
|
67
|
+
headline: [ANSI.bold, ANSI.white],
|
|
68
|
+
subtext: [ANSI.dim, ANSI.gray],
|
|
69
|
+
border: [ANSI.bold, ANSI.cyan],
|
|
70
|
+
sep: [ANSI.dim, ANSI.cyan],
|
|
71
|
+
chip: [ANSI.bold, ANSI.black, ANSI.bgCyan],
|
|
72
|
+
token: [ANSI.bold, ANSI.cyan],
|
|
73
|
+
prompt: [ANSI.bold, ANSI.cyan],
|
|
74
|
+
footer: [ANSI.dim, ANSI.gray],
|
|
75
|
+
tip: [ANSI.bold, ANSI.yellow],
|
|
76
|
+
accent: [ANSI.bold, ANSI.blue],
|
|
77
|
+
key: [ANSI.bold, ANSI.black, ANSI.bgCyan],
|
|
78
|
+
missing: [ANSI.dim, ANSI.gray],
|
|
79
|
+
installBadge: [ANSI.bold, ANSI.black, ANSI.bgYellow],
|
|
80
|
+
},
|
|
81
|
+
"neon-dark": {
|
|
82
|
+
name: "neon-dark",
|
|
83
|
+
logoTop: [ANSI.bold, ANSI.gray],
|
|
84
|
+
logoBottom: [ANSI.bold, ANSI.green],
|
|
85
|
+
wordmark: [ANSI.bold, ANSI.green],
|
|
86
|
+
headline: [ANSI.bold, ANSI.white],
|
|
87
|
+
subtext: [ANSI.dim, ANSI.gray],
|
|
88
|
+
border: [ANSI.bold, ANSI.green],
|
|
89
|
+
sep: [ANSI.dim, ANSI.green],
|
|
90
|
+
chip: [ANSI.bold, ANSI.black, ANSI.bgGreen],
|
|
91
|
+
token: [ANSI.bold, ANSI.green],
|
|
92
|
+
prompt: [ANSI.bold, ANSI.green],
|
|
93
|
+
footer: [ANSI.dim, ANSI.gray],
|
|
94
|
+
tip: [ANSI.bold, ANSI.yellow],
|
|
95
|
+
accent: [ANSI.bold, ANSI.green],
|
|
96
|
+
key: [ANSI.bold, ANSI.black, ANSI.bgGreen],
|
|
97
|
+
missing: [ANSI.dim, ANSI.gray],
|
|
98
|
+
installBadge: [ANSI.bold, ANSI.black, ANSI.bgYellow],
|
|
99
|
+
},
|
|
100
|
+
"neon-light": {
|
|
101
|
+
name: "neon-light",
|
|
102
|
+
logoTop: [ANSI.bold, ANSI.white],
|
|
103
|
+
logoBottom: [ANSI.bold, ANSI.green],
|
|
104
|
+
wordmark: [ANSI.bold, ANSI.white],
|
|
105
|
+
headline: [ANSI.bold, ANSI.black, ANSI.bgWhite],
|
|
106
|
+
subtext: [ANSI.bold, ANSI.white],
|
|
107
|
+
border: [ANSI.bold, ANSI.white],
|
|
108
|
+
sep: [ANSI.dim, ANSI.white],
|
|
109
|
+
chip: [ANSI.bold, ANSI.black, ANSI.bgGreen],
|
|
110
|
+
token: [ANSI.bold, ANSI.white],
|
|
111
|
+
prompt: [ANSI.bold, ANSI.green],
|
|
112
|
+
footer: [ANSI.bold, ANSI.white],
|
|
113
|
+
tip: [ANSI.bold, ANSI.green],
|
|
114
|
+
accent: [ANSI.bold, ANSI.green],
|
|
115
|
+
key: [ANSI.bold, ANSI.black, ANSI.bgGreen],
|
|
116
|
+
missing: [ANSI.dim, ANSI.white],
|
|
117
|
+
installBadge: [ANSI.bold, ANSI.black, ANSI.bgYellow],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const TOOL_COLORS = {
|
|
122
|
+
aider: [ANSI.bold, ANSI.green],
|
|
123
|
+
claude: [ANSI.bold, ANSI.magenta],
|
|
124
|
+
codex: [ANSI.bold, ANSI.blue],
|
|
125
|
+
gemini: [ANSI.bold, ANSI.cyan],
|
|
126
|
+
goose: [ANSI.bold, ANSI.yellow],
|
|
127
|
+
opencode: [ANSI.bold, ANSI.white],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const LOGO_LINES = [
|
|
131
|
+
" _ ____ _____ _ _ _____ _ _ _ _ ____ ",
|
|
132
|
+
" / \\ / ___| ____| \\ | |_ _| | | | | | | | __ ) ",
|
|
133
|
+
" / _ \\| | _| _| | \\| | | | | |_| | | | | _ \\ ",
|
|
134
|
+
" / ___ \\ |_| | |___| |\\ | | | | _ | |_| | |_) |",
|
|
135
|
+
"/_/ \\_\\____|_____|_| \\_| |_| |_| |_|\\___/|____/ ",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
export function normalizeThemeName(value = "vscode") {
|
|
139
|
+
return Object.hasOwn(THEMES, value) ? value : "vscode";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function printScanTable(tools, options = {}) {
|
|
143
|
+
const showActions = options.showActions ?? true;
|
|
144
|
+
const theme = getTheme(options.theme);
|
|
145
|
+
|
|
146
|
+
if (showActions && process.stdout.isTTY) {
|
|
147
|
+
process.stdout.write("\x1Bc");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(buildDashboard(tools, { showActions, theme }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function promptForToolSelection(tools, options = {}) {
|
|
154
|
+
const theme = getTheme(options.theme);
|
|
155
|
+
const rl = readline.createInterface({
|
|
156
|
+
input: process.stdin,
|
|
157
|
+
output: process.stdout,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
while (true) {
|
|
162
|
+
const answer = (await rl.question(` ${paint("›", ...theme.prompt)} `))
|
|
163
|
+
.trim()
|
|
164
|
+
.toLowerCase();
|
|
165
|
+
|
|
166
|
+
if (!answer || answer === "q" || answer === "quit") {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (answer === "r" || answer === "rescan") {
|
|
171
|
+
return "rescan";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (tools.length === 0) {
|
|
175
|
+
console.log(` ${paint("!", ...theme.tip)} ${paint("No tools found. Press r to rescan or q to quit.", ...theme.subtext)}`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const byIndex = Number.parseInt(answer, 10);
|
|
180
|
+
if (Number.isInteger(byIndex) && byIndex >= 1 && byIndex <= tools.length) {
|
|
181
|
+
return tools[byIndex - 1];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const byId = tools.find((tool) => tool.id.toLowerCase() === answer);
|
|
185
|
+
if (byId) {
|
|
186
|
+
return byId;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(` ${paint("?", ...theme.subtext)} ${paint("Unknown. Enter a number, tool id, r or q.", ...theme.subtext)}`);
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
rl.close();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function launchTool(tool, options = {}) {
|
|
197
|
+
const theme = getTheme(options.theme);
|
|
198
|
+
|
|
199
|
+
if (process.stdout.isTTY) {
|
|
200
|
+
process.stdout.write("\x1Bc");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const panelWidth = Math.min(Math.max((process.stdout.columns ?? 100) - 8, 54), 76);
|
|
204
|
+
console.log(buildLaunchPanel(tool, panelWidth, theme));
|
|
205
|
+
console.log("");
|
|
206
|
+
|
|
207
|
+
await new Promise((resolve, reject) => {
|
|
208
|
+
const child = spawn(tool.launchCommand, tool.args, {
|
|
209
|
+
cwd: process.cwd(),
|
|
210
|
+
env: process.env,
|
|
211
|
+
stdio: "inherit",
|
|
212
|
+
shell: shouldUseShell(tool.launchCommand),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
child.on("error", reject);
|
|
216
|
+
child.on("exit", (code, signal) => {
|
|
217
|
+
if (signal) {
|
|
218
|
+
console.log(`\n ${paint("◆", ...theme.accent)} ${paint(`${tool.name} terminated with signal ${signal}.`, ...theme.subtext)}`);
|
|
219
|
+
} else if (typeof code === "number" && code !== 0) {
|
|
220
|
+
console.log(`\n ${paint("◆", ...theme.accent)} ${paint(`${tool.name} exited with code ${code}.`, ...theme.subtext)}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
resolve();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
console.log(` ${paint("◆", ...theme.accent)} ${paint("Returned to AGENT HUB.", ...theme.subtext)}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function installTool(tool, options = {}) {
|
|
231
|
+
const theme = getTheme(options.theme);
|
|
232
|
+
|
|
233
|
+
if (process.stdout.isTTY) {
|
|
234
|
+
process.stdout.write("\x1Bc");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const panelWidth = Math.min(Math.max((process.stdout.columns ?? 100) - 8, 54), 76);
|
|
238
|
+
console.log(buildInstallPanel(tool, panelWidth, theme));
|
|
239
|
+
console.log("");
|
|
240
|
+
|
|
241
|
+
const rl = readline.createInterface({
|
|
242
|
+
input: process.stdin,
|
|
243
|
+
output: process.stdout,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
let confirmed = false;
|
|
247
|
+
try {
|
|
248
|
+
const answer = (
|
|
249
|
+
await rl.question(
|
|
250
|
+
` ${paint("Install now?", ...theme.prompt)} ${paint("[y/n]", ...theme.subtext)} `,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
.trim()
|
|
254
|
+
.toLowerCase();
|
|
255
|
+
confirmed = answer === "y" || answer === "yes";
|
|
256
|
+
} finally {
|
|
257
|
+
rl.close();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!confirmed) {
|
|
261
|
+
console.log(` ${paint("◆", ...theme.accent)} ${paint("Installation cancelled.", ...theme.subtext)}`);
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
console.log(`\n ${paint("◆", ...theme.accent)} ${paint(`Running: ${tool.installCommand}`, ...theme.subtext)}`);
|
|
266
|
+
console.log("");
|
|
267
|
+
|
|
268
|
+
const parts = tool.installCommand.split(" ");
|
|
269
|
+
const cmd = parts[0];
|
|
270
|
+
const args = parts.slice(1);
|
|
271
|
+
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
const child = spawn(cmd, args, {
|
|
274
|
+
cwd: process.cwd(),
|
|
275
|
+
env: process.env,
|
|
276
|
+
stdio: "inherit",
|
|
277
|
+
shell: true,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
child.on("error", (err) => {
|
|
281
|
+
console.log(`\n ${paint("✗", ...theme.tip)} ${paint(`Install failed: ${err.message}`, ...theme.subtext)}`);
|
|
282
|
+
resolve(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
child.on("exit", (code) => {
|
|
286
|
+
if (code === 0) {
|
|
287
|
+
console.log(`\n ${paint("✓", ...theme.accent)} ${paint(`${tool.name} installed successfully.`, ...theme.subtext)}`);
|
|
288
|
+
} else {
|
|
289
|
+
console.log(`\n ${paint("✗", ...theme.tip)} ${paint(`Install exited with code ${code}.`, ...theme.subtext)}`);
|
|
290
|
+
}
|
|
291
|
+
resolve(code === 0);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function getTheme(themeName) {
|
|
297
|
+
return THEMES[normalizeThemeName(themeName)];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildDashboard(tools, options) {
|
|
301
|
+
const terminalWidth = process.stdout.columns ?? 100;
|
|
302
|
+
const panelWidth = Math.min(Math.max(terminalWidth - 10, 54), 80);
|
|
303
|
+
const lines = [];
|
|
304
|
+
|
|
305
|
+
lines.push("");
|
|
306
|
+
lines.push(...centerLogo(terminalWidth, options.theme));
|
|
307
|
+
lines.push("");
|
|
308
|
+
lines.push(...centerBlock(buildPromptPanel(tools, panelWidth, options.theme, options.showActions), terminalWidth));
|
|
309
|
+
lines.push("");
|
|
310
|
+
|
|
311
|
+
if (options.showActions) {
|
|
312
|
+
lines.push(
|
|
313
|
+
centerText(
|
|
314
|
+
`${paint("◆", ...options.theme.tip)} ${paint("Tip:", ...options.theme.tip)} ${paint(
|
|
315
|
+
"add custom tools via .agent-hub.json",
|
|
316
|
+
...options.theme.subtext,
|
|
317
|
+
)}`,
|
|
318
|
+
terminalWidth,
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
lines.push("");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
lines.push(buildFooter(terminalWidth, options.theme));
|
|
325
|
+
|
|
326
|
+
return lines.join("\n");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function buildPromptPanel(allTools, panelWidth, theme, showActions) {
|
|
330
|
+
const innerWidth = panelWidth - 4; // 2 border chars + 2 padding spaces
|
|
331
|
+
const borderH = BOX.h.repeat(panelWidth - 2);
|
|
332
|
+
const sepH = BOX.h.repeat(panelWidth - 2);
|
|
333
|
+
|
|
334
|
+
const V = paint(BOX.v, ...theme.border);
|
|
335
|
+
const row = (content) => `${V} ${content} ${V}`;
|
|
336
|
+
const innerSep = `${paint(BOX.ml, ...theme.border)}${paint(sepH, ...theme.sep)}${paint(BOX.mr, ...theme.border)}`;
|
|
337
|
+
|
|
338
|
+
// Header: AGENT HUB + version
|
|
339
|
+
const titleText = paint(APP_WORDMARK, ...theme.wordmark);
|
|
340
|
+
const versionText = paint(`v${APP_VERSION}`, ...theme.subtext);
|
|
341
|
+
const titleGap = Math.max(1, innerWidth - visibleLength(titleText) - visibleLength(versionText));
|
|
342
|
+
const headerLine = row(`${titleText}${" ".repeat(titleGap)}${versionText}`);
|
|
343
|
+
const subtitleLine = row(padVisible(` ${paint("terminal switchboard for AI coding CLIs", ...theme.subtext)}`, innerWidth));
|
|
344
|
+
|
|
345
|
+
// Split tools into installed / installable
|
|
346
|
+
const installedTools = allTools.filter((t) => t.available);
|
|
347
|
+
const missingTools = allTools.filter((t) => !t.available && t.installCommand);
|
|
348
|
+
|
|
349
|
+
// Installed section
|
|
350
|
+
const installedCount = installedTools.length;
|
|
351
|
+
const installedLabel =
|
|
352
|
+
installedCount === 0
|
|
353
|
+
? paint("NO TOOLS INSTALLED", ...theme.tip)
|
|
354
|
+
: `${paint("INSTALLED", ...theme.headline)} ${paint(`${installedCount} tool${installedCount === 1 ? "" : "s"} ready`, ...theme.subtext)}`;
|
|
355
|
+
const installedSectionLine = row(padVisible(` ${installedLabel}`, innerWidth));
|
|
356
|
+
|
|
357
|
+
const installedBodyLines = [];
|
|
358
|
+
if (installedTools.length === 0) {
|
|
359
|
+
installedBodyLines.push(row(" ".repeat(innerWidth)));
|
|
360
|
+
installedBodyLines.push(
|
|
361
|
+
row(
|
|
362
|
+
padVisible(
|
|
363
|
+
` ${paint("No AI CLIs detected. Enter a number below to install one.", ...theme.subtext)}`,
|
|
364
|
+
innerWidth,
|
|
365
|
+
),
|
|
366
|
+
),
|
|
367
|
+
);
|
|
368
|
+
installedBodyLines.push(row(" ".repeat(innerWidth)));
|
|
369
|
+
} else {
|
|
370
|
+
installedBodyLines.push(row(" ".repeat(innerWidth)));
|
|
371
|
+
for (const gridRow of buildNumberedGrid(installedTools, allTools, theme, innerWidth - 1, true)) {
|
|
372
|
+
installedBodyLines.push(row(padVisible(` ${gridRow}`, innerWidth)));
|
|
373
|
+
}
|
|
374
|
+
installedBodyLines.push(row(" ".repeat(innerWidth)));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Installable section (only when there are missing tools)
|
|
378
|
+
const installableLines = [];
|
|
379
|
+
if (missingTools.length > 0) {
|
|
380
|
+
const missingCount = missingTools.length;
|
|
381
|
+
const missingLabel = `${paint("INSTALLABLE", ...theme.installBadge)} ${paint(`${missingCount} tool${missingCount === 1 ? "" : "s"} not installed`, ...theme.missing)}`;
|
|
382
|
+
installableLines.push(innerSep);
|
|
383
|
+
installableLines.push(row(padVisible(` ${missingLabel}`, innerWidth)));
|
|
384
|
+
installableLines.push(row(" ".repeat(innerWidth)));
|
|
385
|
+
for (const gridRow of buildNumberedGrid(missingTools, allTools, theme, innerWidth - 1, false)) {
|
|
386
|
+
installableLines.push(row(padVisible(` ${gridRow}`, innerWidth)));
|
|
387
|
+
}
|
|
388
|
+
installableLines.push(row(" ".repeat(innerWidth)));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Action bar
|
|
392
|
+
const actionLines = [];
|
|
393
|
+
if (showActions) {
|
|
394
|
+
const keyHint = (key, label) =>
|
|
395
|
+
`${paint(` ${key} `, ...theme.key)} ${paint(label, ...theme.subtext)}`;
|
|
396
|
+
const dots = paint(" · ", ...theme.subtext);
|
|
397
|
+
const hintParts = [
|
|
398
|
+
keyHint("n", "select"),
|
|
399
|
+
keyHint("r", "rescan"),
|
|
400
|
+
keyHint("q", "quit"),
|
|
401
|
+
];
|
|
402
|
+
actionLines.push(row(padVisible(` ${hintParts.join(dots)}`, innerWidth)));
|
|
403
|
+
} else {
|
|
404
|
+
actionLines.push(row(padVisible(` ${paint(`preview · ${theme.name}`, ...theme.subtext)}`, innerWidth)));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return [
|
|
408
|
+
`${paint(BOX.tl, ...theme.border)}${paint(borderH, ...theme.border)}${paint(BOX.tr, ...theme.border)}`,
|
|
409
|
+
headerLine,
|
|
410
|
+
subtitleLine,
|
|
411
|
+
innerSep,
|
|
412
|
+
installedSectionLine,
|
|
413
|
+
...installedBodyLines,
|
|
414
|
+
...installableLines,
|
|
415
|
+
innerSep,
|
|
416
|
+
...actionLines,
|
|
417
|
+
`${paint(BOX.bl, ...theme.border)}${paint(borderH, ...theme.border)}${paint(BOX.br, ...theme.border)}`,
|
|
418
|
+
];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Builds a 2-column grid for a subset of tools, using global index for numbering.
|
|
422
|
+
function buildNumberedGrid(subTools, allTools, theme, maxWidth, isInstalled) {
|
|
423
|
+
const colWidth = Math.floor(maxWidth / 2);
|
|
424
|
+
const tokens = subTools.map((tool) => {
|
|
425
|
+
const globalIndex = allTools.findIndex((t) => t.id === tool.id);
|
|
426
|
+
const num = String(globalIndex + 1).padStart(2, "0");
|
|
427
|
+
|
|
428
|
+
if (isInstalled) {
|
|
429
|
+
const dot = paint("●", ...getToolColor(tool));
|
|
430
|
+
const numBadge = paint(` ${num} `, ...theme.chip);
|
|
431
|
+
const nameStr = paint(tool.id, ...getToolColor(tool));
|
|
432
|
+
return `${dot} ${numBadge} ${nameStr}`;
|
|
433
|
+
} else {
|
|
434
|
+
const dot = paint("○", ...theme.missing);
|
|
435
|
+
const numBadge = paint(` ${num} `, ...theme.installBadge);
|
|
436
|
+
const nameStr = paint(tool.id, ...theme.missing);
|
|
437
|
+
return `${dot} ${numBadge} ${nameStr}`;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const rows = [];
|
|
442
|
+
for (let i = 0; i < tokens.length; i += 2) {
|
|
443
|
+
const left = tokens[i];
|
|
444
|
+
const right = tokens[i + 1];
|
|
445
|
+
if (right) {
|
|
446
|
+
rows.push(`${padVisible(left, colWidth)} ${right}`);
|
|
447
|
+
} else {
|
|
448
|
+
rows.push(left);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return rows;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function buildLaunchPanel(tool, panelWidth, theme) {
|
|
456
|
+
const innerWidth = panelWidth - 4;
|
|
457
|
+
const borderH = BOX.h.repeat(panelWidth - 2);
|
|
458
|
+
const sepH = BOX.h.repeat(panelWidth - 2);
|
|
459
|
+
|
|
460
|
+
const V = paint(BOX.v, ...theme.border);
|
|
461
|
+
const row = (content) => `${V} ${content} ${V}`;
|
|
462
|
+
const innerSep = `${paint(BOX.ml, ...theme.border)}${paint(sepH, ...theme.sep)}${paint(BOX.mr, ...theme.border)}`;
|
|
463
|
+
|
|
464
|
+
const commandPath = truncateMiddle(tool.resolvedPath ?? tool.launchCommand ?? tool.command, innerWidth - 8);
|
|
465
|
+
const toolColor = getToolColor(tool);
|
|
466
|
+
|
|
467
|
+
const nameText = paint(tool.name, ...toolColor);
|
|
468
|
+
const idText = paint(` ${tool.id}`, ...theme.subtext);
|
|
469
|
+
const headerLine = row(padVisible(` ${nameText}${idText}`, innerWidth));
|
|
470
|
+
|
|
471
|
+
const pathLine = row(padVisible(` ${paint("path", ...theme.prompt)} ${paint(commandPath, ...theme.headline)}`, innerWidth));
|
|
472
|
+
const hintLine = row(padVisible(` ${paint("Exit the CLI to return to AGENT HUB", ...theme.subtext)}`, innerWidth));
|
|
473
|
+
|
|
474
|
+
const lines = [
|
|
475
|
+
`${paint(BOX.tl, ...theme.border)}${paint(borderH, ...theme.border)}${paint(BOX.tr, ...theme.border)}`,
|
|
476
|
+
headerLine,
|
|
477
|
+
innerSep,
|
|
478
|
+
pathLine,
|
|
479
|
+
hintLine,
|
|
480
|
+
`${paint(BOX.bl, ...theme.border)}${paint(borderH, ...theme.border)}${paint(BOX.br, ...theme.border)}`,
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
return centerBlock(lines, process.stdout.columns ?? 100).join("\n");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function buildInstallPanel(tool, panelWidth, theme) {
|
|
487
|
+
const innerWidth = panelWidth - 4;
|
|
488
|
+
const borderH = BOX.h.repeat(panelWidth - 2);
|
|
489
|
+
const sepH = BOX.h.repeat(panelWidth - 2);
|
|
490
|
+
|
|
491
|
+
const V = paint(BOX.v, ...theme.border);
|
|
492
|
+
const row = (content) => `${V} ${content} ${V}`;
|
|
493
|
+
const innerSep = `${paint(BOX.ml, ...theme.border)}${paint(sepH, ...theme.sep)}${paint(BOX.mr, ...theme.border)}`;
|
|
494
|
+
|
|
495
|
+
const nameText = paint(tool.name, ...theme.wordmark);
|
|
496
|
+
const notInstalledText = paint("not installed", ...theme.missing);
|
|
497
|
+
const headerLine = row(padVisible(` ${nameText} ${notInstalledText}`, innerWidth));
|
|
498
|
+
|
|
499
|
+
const cmdText = paint(tool.installCommand, ...theme.headline);
|
|
500
|
+
const cmdLine = row(padVisible(` ${paint("command", ...theme.prompt)} ${cmdText}`, innerWidth));
|
|
501
|
+
|
|
502
|
+
const descLine = row(padVisible(` ${paint(tool.description, ...theme.subtext)}`, innerWidth));
|
|
503
|
+
|
|
504
|
+
const methodText = paint(`via ${tool.installMethod ?? "system"}`, ...theme.subtext);
|
|
505
|
+
const methodLine = row(padVisible(` ${methodText}`, innerWidth));
|
|
506
|
+
|
|
507
|
+
const lines = [
|
|
508
|
+
`${paint(BOX.tl, ...theme.border)}${paint(borderH, ...theme.border)}${paint(BOX.tr, ...theme.border)}`,
|
|
509
|
+
headerLine,
|
|
510
|
+
innerSep,
|
|
511
|
+
cmdLine,
|
|
512
|
+
descLine,
|
|
513
|
+
methodLine,
|
|
514
|
+
`${paint(BOX.bl, ...theme.border)}${paint(borderH, ...theme.border)}${paint(BOX.br, ...theme.border)}`,
|
|
515
|
+
];
|
|
516
|
+
|
|
517
|
+
return centerBlock(lines, process.stdout.columns ?? 100).join("\n");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function centerLogo(terminalWidth, theme) {
|
|
521
|
+
return LOGO_LINES.map((line, index) => {
|
|
522
|
+
const style = index < 2 ? theme.logoTop : theme.logoBottom;
|
|
523
|
+
return centerText(paint(line, ...style), terminalWidth);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function centerBlock(lines, terminalWidth) {
|
|
528
|
+
return lines.map((line) => centerText(line, terminalWidth));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function centerText(text, terminalWidth) {
|
|
532
|
+
const padding = Math.max(0, Math.floor((terminalWidth - visibleLength(text)) / 2));
|
|
533
|
+
return `${" ".repeat(padding)}${text}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function buildFooter(terminalWidth, theme) {
|
|
537
|
+
const left = `${paint("◆", ...theme.accent)} ${paint(shrinkHomePath(process.cwd()), ...theme.footer)}`;
|
|
538
|
+
const right = `${paint(APP_COMMAND, ...theme.footer)} ${paint(`v${APP_VERSION}`, ...theme.subtext)}`;
|
|
539
|
+
const gap = Math.max(2, terminalWidth - visibleLength(left) - visibleLength(right));
|
|
540
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function visibleLength(text) {
|
|
544
|
+
return stripAnsi(text).length;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function padVisible(text, width) {
|
|
548
|
+
const padding = Math.max(0, width - visibleLength(text));
|
|
549
|
+
return `${text}${" ".repeat(padding)}`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function stripAnsi(text) {
|
|
553
|
+
return text.replace(/\x1B\[[0-9;]*m/g, "");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function getToolColor(tool) {
|
|
557
|
+
return TOOL_COLORS[tool.id] ?? [ANSI.bold, ANSI.cyan];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function truncateMiddle(value, maxLength) {
|
|
561
|
+
if (value.length <= maxLength) {
|
|
562
|
+
return value;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const usable = maxLength - 3;
|
|
566
|
+
const left = Math.ceil(usable / 2);
|
|
567
|
+
const right = Math.floor(usable / 2);
|
|
568
|
+
return `${value.slice(0, left)}...${value.slice(value.length - right)}`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function shrinkHomePath(value) {
|
|
572
|
+
const home = process.env.USERPROFILE ?? process.env.HOME;
|
|
573
|
+
if (home && value.startsWith(home)) {
|
|
574
|
+
return `~${value.slice(home.length)}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function paint(text, ...codes) {
|
|
581
|
+
if (!supportsColor()) {
|
|
582
|
+
return text;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return `${codes.join("")}${text}${ANSI.reset}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function supportsColor() {
|
|
589
|
+
if ("NO_COLOR" in process.env) {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return Boolean(process.stdout.isTTY);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function shouldUseShell(command) {
|
|
601
|
+
if (process.platform !== "win32") {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const extension = path.extname(command).toLowerCase();
|
|
606
|
+
return extension === ".cmd" || extension === ".bat";
|
|
607
|
+
}
|