Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
agentx
dist/
checksums.txt
vendor/
12 changes: 12 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
dist/
cmd/
docs/
internal/
registry/
ui/
Formula/
*.png
*.gz
*.zip
*.exe
agentx
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ It provides both a command-line interface and an interactive terminal UI (TUI) f

## Installation

### 1. Homebrew (macOS/Linux)
### 1. npm (global install)

```bash
npm install -g @agentsdance/agentx
```

### 2. Homebrew (macOS/Linux)

```bash
# Add the tap (points to this repo)
Expand All @@ -54,17 +60,17 @@ brew install agentx
brew upgrade agentx
```

### 2. Download Binary
### 3. Download Binary

Download the latest release from [GitHub Releases](https://github.com/agentsdance/agentx/releases).

### 3. Go Install
### 4. Go Install

```bash
go install github.com/agentsdance/agentx@latest
```

### 4. Build from Source
### 5. Build from Source

```bash
# Clone the repository
Expand Down
14 changes: 10 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ AgentX 简化了在主流 AI 编程工具中安装、管理与监控 MCP 服务

## 安装

### 1. Homebrew(macOS/Linux)
### 1. npm(全局安装)

```bash
npm install -g @agentsdance/agentx
```

### 2. Homebrew(macOS/Linux)

```bash
# 添加 tap(指向本仓库)
Expand All @@ -54,17 +60,17 @@ brew install agentx
brew upgrade agentx
```

### 2. 下载二进制
### 3. 下载二进制

从 [GitHub Releases](https://github.com/agentsdance/agentx/releases) 下载最新版本。

### 3. Go Install
### 4. Go Install

```bash
go install github.com/agentsdance/agentx@latest
```

### 4. 从源码构建
### 5. 从源码构建

```bash
# 克隆仓库
Expand Down
21 changes: 21 additions & 0 deletions bin/agentx.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node
"use strict";

const path = require("path");
const { spawn } = require("child_process");

const BIN_NAME = process.platform === "win32" ? "agentx.exe" : "agentx";
const BIN_PATH = path.resolve(__dirname, "..", "vendor", BIN_NAME);

const child = spawn(BIN_PATH, process.argv.slice(2), {
stdio: "inherit",
});

child.on("exit", (code) => {
process.exit(code ?? 1);
});

child.on("error", (err) => {
console.error(`Failed to run AgentX: ${err.message}`);
process.exit(1);
});
32 changes: 32 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@agentsdance/agentx",
"version": "0.0.8",
"description": "AgentX CLI installer (downloads the native AgentX binary on install)",
"license": "MIT",
"bin": {
"agentx": "bin/agentx.js"
},
"files": [
"bin/",
"scripts/",
"vendor/",
"README.md",
"LICENSE"
],
"scripts": {
"postinstall": "node scripts/install.js"
},
"dependencies": {},
"engines": {
"node": ">=18"
},
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"x64",
"arm64"
]
}
254 changes: 254 additions & 0 deletions scripts/install.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
#!/usr/bin/env node
/* eslint-disable no-console */
"use strict";

const fs = require("fs");
const os = require("os");
const path = require("path");
const https = require("https");
const { pipeline } = require("stream");
const { promisify } = require("util");
const { spawn } = require("child_process");

const streamPipeline = promisify(pipeline);

const PKG_ROOT = path.resolve(__dirname, "..");
const BIN_DIR = path.join(PKG_ROOT, "vendor");
const BIN_NAME = os.platform() === "win32" ? "agentx.exe" : "agentx";
const BIN_PATH = path.join(BIN_DIR, BIN_NAME);

const VERSION = require(path.join(PKG_ROOT, "package.json")).version;
const REPO = "agentsdance/agentx";

function getPlatform() {
const platform = os.platform();
if (platform === "darwin" || platform === "linux" || platform === "win32") {
return platform;
}
throw new Error(`Unsupported platform: ${platform}`);
}

function getArch() {
const arch = os.arch();
if (arch === "x64") return "amd64";
if (arch === "arm64") return "arm64";
throw new Error(`Unsupported architecture: ${arch}`);
}

function getAssetInfo() {
const platform = getPlatform();
const arch = getArch();
const ext = platform === "win32" ? "zip" : "tar.gz";
const osName = platform === "win32" ? "windows" : platform;
const filename = `agentx_${VERSION}_${osName}_${arch}.${ext}`;
return { platform, arch, ext, filename, osName };
}

async function ensureDir(dir) {
await fs.promises.mkdir(dir, { recursive: true });
}

async function downloadTo(url, dest, redirects = 0) {
await new Promise((resolve, reject) => {
https
.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (redirects >= 5) {
reject(new Error("Too many redirects while downloading."));
res.resume();
return;
}
res.resume();
const nextUrl = new URL(res.headers.location, url).toString();
downloadTo(nextUrl, dest, redirects + 1).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
const err = new Error(
`Failed to download ${url} (status ${res.statusCode})`
);
err.statusCode = res.statusCode;
reject(err);
res.resume();
return;
}
const file = fs.createWriteStream(dest);
streamPipeline(res, file).then(resolve).catch(reject);
})
.on("error", reject);
});
}

async function extractArchive(archivePath, ext) {
if (ext === "zip") {
if (os.platform() === "win32") {
const escapedArchive = archivePath.replace(/'/g, "''");
const escapedDest = BIN_DIR.replace(/'/g, "''");
await runCommand("powershell", [
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${escapedArchive}' -DestinationPath '${escapedDest}' -Force`,
]);
return;
}
await runCommand("unzip", ["-o", archivePath, "-d", BIN_DIR]);
return;
}
await runCommand("tar", ["-xzf", archivePath, "-C", BIN_DIR]);
}

async function findInstalledBinary() {
const directPath = path.join(BIN_DIR, BIN_NAME);
try {
await fs.promises.access(directPath, fs.constants.X_OK);
return directPath;
} catch (_) {
// continue
}
// Look for nested path like vendor/agentx_<ver>_<os>_<arch>/agentx
const entries = await fs.promises.readdir(BIN_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const candidate = path.join(BIN_DIR, entry.name, BIN_NAME);
try {
await fs.promises.access(candidate, fs.constants.X_OK);
return candidate;
} catch (_) {
// continue
}
}
return null;
}

async function promoteBinary(foundPath) {
if (foundPath === BIN_PATH) return;
await fs.promises.copyFile(foundPath, BIN_PATH);
}

async function makeExecutable(filePath) {
if (os.platform() === "win32") return;
await fs.promises.chmod(filePath, 0o755);
}

function runCommand(cmd, args) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: "inherit" });
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${cmd} exited with code ${code}`));
}
});
});
}

async function main() {
const { filename, ext, osName, arch } = getAssetInfo();
await ensureDir(BIN_DIR);

if (fs.existsSync(BIN_PATH)) {
return;
}

const archivePath = path.join(BIN_DIR, filename);
const release = await resolveRelease(filename, osName, arch, ext);
console.log(
`Downloading AgentX ${release.tag} for ${os.platform()} ${os.arch()}...`
);
await downloadTo(release.url, archivePath);
await extractArchive(archivePath, ext);

const found = await findInstalledBinary();
if (!found) {
throw new Error("Downloaded archive but could not find agentx binary.");
}
await promoteBinary(found);
await makeExecutable(BIN_PATH);
}

async function resolveRelease(expectedFilename, osName, arch, ext) {
const tag = `v${VERSION}`;
const byTag = await fetchRelease(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`);
if (byTag) {
const asset = pickAsset(byTag, expectedFilename);
if (asset) return { url: asset.browser_download_url, tag };
}

const latest = await fetchRelease(
`https://api.github.com/repos/${REPO}/releases/latest`
);
if (latest) {
const fallbackName = `agentx_${latest.tag_name.replace(/^v/, "")}_${osName}_${arch}.${ext}`;
const asset = pickAsset(latest, fallbackName);
if (asset) return { url: asset.browser_download_url, tag: latest.tag_name };
}
throw new Error("Could not locate a matching release asset on GitHub.");
}

function pickAsset(release, filename) {
if (!release || !Array.isArray(release.assets)) return null;
return release.assets.find((asset) => asset.name === filename) || null;
}

async function fetchRelease(url) {
try {
return await fetchJson(url);
} catch (err) {
if (err.statusCode === 404) return null;
throw err;
}
}

async function fetchJson(url, redirects = 0) {
return await new Promise((resolve, reject) => {
const options = {
headers: {
"User-Agent": "agentx-npm-installer",
Accept: "application/vnd.github+json",
},
};
https
.get(url, options, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (redirects >= 5) {
const err = new Error("Too many redirects while fetching JSON.");
err.statusCode = res.statusCode;
reject(err);
res.resume();
return;
}
res.resume();
const nextUrl = new URL(res.headers.location, url).toString();
fetchJson(nextUrl, redirects + 1).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
const err = new Error(`Failed to fetch ${url} (status ${res.statusCode})`);
err.statusCode = res.statusCode;
reject(err);
res.resume();
return;
}
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (parseErr) {
reject(parseErr);
}
});
})
.on("error", reject);
});
}

main().catch((err) => {
console.error(`AgentX install failed: ${err.message}`);
process.exit(1);
});