@reeledge/agent-tools 0.1.2
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/dist/apps/claude-code.js +40 -0
- package/dist/apps/codex.js +122 -0
- package/dist/apps/droid.js +81 -0
- package/dist/apps/index.js +21 -0
- package/dist/apps/types.js +44 -0
- package/dist/config.js +32 -0
- package/dist/connect.js +26 -0
- package/dist/index.js +143 -0
- package/dist/install.js +233 -0
- package/dist/mcp.js +33 -0
- package/dist/options.js +79 -0
- package/dist/skills.js +143 -0
- package/package.json +35 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { claudeMcpAddArgs } from '../mcp.js';
|
|
3
|
+
import { writeSkillTree } from '../skills.js';
|
|
4
|
+
import { APP_LABELS, CONNECTOR_NAME, } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Claude Code adapter. Skills are per-skill `SKILL.md` directories under
|
|
7
|
+
* `~/.claude/skills/` (overridable via `--skills-dir`); the MCP connector is
|
|
8
|
+
* registered by spawning `claude mcp add --transport http …` (the documented
|
|
9
|
+
* Claude Code path), which embeds the bearer token in an `Authorization` header.
|
|
10
|
+
*/
|
|
11
|
+
/** Resolve the skills dir: explicit override, else `~/.claude/skills`. */
|
|
12
|
+
function claudeSkillsDir(env) {
|
|
13
|
+
return env.skillsDirOverride ?? path.join(env.homeDir, '.claude', 'skills');
|
|
14
|
+
}
|
|
15
|
+
/** Construct the Claude Code adapter for the given environment. */
|
|
16
|
+
export function makeClaudeCodeAdapter(env) {
|
|
17
|
+
const skillsDir = () => claudeSkillsDir(env);
|
|
18
|
+
return {
|
|
19
|
+
id: 'claude-code',
|
|
20
|
+
label: APP_LABELS['claude-code'],
|
|
21
|
+
skillsDir,
|
|
22
|
+
writeSkills(skills) {
|
|
23
|
+
return skills.flatMap((s) => writeSkillTree(skillsDir(), s.slug, s.files));
|
|
24
|
+
},
|
|
25
|
+
async configureMcp(serverUrl, token) {
|
|
26
|
+
await env.exec('claude', claudeMcpAddArgs(CONNECTOR_NAME, serverUrl, token));
|
|
27
|
+
},
|
|
28
|
+
dryRunPlan(serverUrl, token, skills) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (const s of skills) {
|
|
31
|
+
for (const file of s.files) {
|
|
32
|
+
lines.push(`would write ${path.join(skillsDir(), s.slug, file.path)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const args = claudeMcpAddArgs(CONNECTOR_NAME, serverUrl, token);
|
|
36
|
+
lines.push(`would run: claude ${args.join(' ')}`);
|
|
37
|
+
return lines;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mcpEndpoint } from '../mcp.js';
|
|
4
|
+
import { writeSkillTree } from '../skills.js';
|
|
5
|
+
import { APP_LABELS, CONNECTOR_NAME, } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* OpenAI Codex CLI adapter. Codex reads `~/.codex/config.toml` and registers
|
|
8
|
+
* MCP servers under `[mcp_servers.<name>]`; a remote Streamable-HTTP server is
|
|
9
|
+
* indicated by a `url` key (no `transport`), and a literal `Authorization`
|
|
10
|
+
* header goes in a static `http_headers` inline table. Codex also has a skills
|
|
11
|
+
* concept — per-skill `SKILL.md` directories under `~/.agents/skills/`.
|
|
12
|
+
*
|
|
13
|
+
* Verified against https://developers.openai.com/codex/mcp,
|
|
14
|
+
* https://developers.openai.com/codex/config-reference and
|
|
15
|
+
* https://developers.openai.com/codex/skills (Jun 2026).
|
|
16
|
+
*/
|
|
17
|
+
/** Absolute path to the Codex skills tree. */
|
|
18
|
+
function codexSkillsDir(env) {
|
|
19
|
+
return path.join(env.homeDir, '.agents', 'skills');
|
|
20
|
+
}
|
|
21
|
+
/** Absolute path to the Codex config file. */
|
|
22
|
+
function codexConfigPath(env) {
|
|
23
|
+
return path.join(env.homeDir, '.codex', 'config.toml');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The `[mcp_servers.<name>]` TOML block for a remote HTTP server with a static
|
|
27
|
+
* bearer header. Self-contained (token embedded) so it works without extra env
|
|
28
|
+
* setup — matching how the Claude Code connector embeds the token in its header.
|
|
29
|
+
*/
|
|
30
|
+
export function codexConfigBlock(name, endpoint, token) {
|
|
31
|
+
return [
|
|
32
|
+
`[mcp_servers.${name}]`,
|
|
33
|
+
`url = "${endpoint}"`,
|
|
34
|
+
`http_headers = { "Authorization" = "Bearer ${token}" }`,
|
|
35
|
+
'',
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
/** Split TOML into a preamble (top-level keys) + ordered `[table]` sections. */
|
|
39
|
+
function splitSections(content) {
|
|
40
|
+
const preamble = [];
|
|
41
|
+
const sections = [];
|
|
42
|
+
let cur = null;
|
|
43
|
+
for (const line of content.split('\n')) {
|
|
44
|
+
if (line.trim().startsWith('[')) {
|
|
45
|
+
cur = { header: line.trim(), body: [] };
|
|
46
|
+
sections.push(cur);
|
|
47
|
+
}
|
|
48
|
+
else if (cur) {
|
|
49
|
+
cur.body.push(line);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
preamble.push(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { preamble, sections };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Merge a generated `[mcp_servers.<name>]` block into an existing `config.toml`:
|
|
59
|
+
* replace the same-named section in place (preserving order + other servers) or
|
|
60
|
+
* append it. Output is normalised — sections separated by a blank line, single
|
|
61
|
+
* trailing newline. This is a targeted edit, not a full TOML parser, but it is
|
|
62
|
+
* exact for the simple blocks this installer writes.
|
|
63
|
+
*/
|
|
64
|
+
export function mergeCodexToml(existing, name, block) {
|
|
65
|
+
const header = `[mcp_servers.${name}]`;
|
|
66
|
+
const blockText = block.trim();
|
|
67
|
+
if (existing.trim() === '')
|
|
68
|
+
return `${blockText}\n`;
|
|
69
|
+
const { preamble, sections } = splitSections(existing);
|
|
70
|
+
const rendered = [];
|
|
71
|
+
let replaced = false;
|
|
72
|
+
for (const s of sections) {
|
|
73
|
+
if (s.header === header) {
|
|
74
|
+
rendered.push(blockText);
|
|
75
|
+
replaced = true;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
rendered.push(`${s.header}\n${s.body.join('\n')}`.trim());
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!replaced)
|
|
82
|
+
rendered.push(blockText);
|
|
83
|
+
const blocks = [];
|
|
84
|
+
const pre = preamble.join('\n').trim();
|
|
85
|
+
if (pre !== '')
|
|
86
|
+
blocks.push(pre);
|
|
87
|
+
blocks.push(...rendered);
|
|
88
|
+
return `${blocks.join('\n\n')}\n`;
|
|
89
|
+
}
|
|
90
|
+
/** Construct the Codex adapter for the given environment. */
|
|
91
|
+
export function makeCodexAdapter(env) {
|
|
92
|
+
const skillsDir = () => codexSkillsDir(env);
|
|
93
|
+
return {
|
|
94
|
+
id: 'codex',
|
|
95
|
+
label: APP_LABELS.codex,
|
|
96
|
+
skillsDir,
|
|
97
|
+
writeSkills(skills) {
|
|
98
|
+
return skills.flatMap((s) => writeSkillTree(skillsDir(), s.slug, s.files));
|
|
99
|
+
},
|
|
100
|
+
async configureMcp(serverUrl, token) {
|
|
101
|
+
const block = codexConfigBlock(CONNECTOR_NAME, mcpEndpoint(serverUrl), token);
|
|
102
|
+
const cfgPath = codexConfigPath(env);
|
|
103
|
+
const existing = fs.existsSync(cfgPath) ? fs.readFileSync(cfgPath, 'utf8') : '';
|
|
104
|
+
const merged = mergeCodexToml(existing, CONNECTOR_NAME, block);
|
|
105
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
106
|
+
fs.writeFileSync(cfgPath, merged, 'utf8');
|
|
107
|
+
},
|
|
108
|
+
dryRunPlan(serverUrl, token, skills) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
for (const s of skills) {
|
|
111
|
+
for (const file of s.files) {
|
|
112
|
+
lines.push(`would write ${path.join(skillsDir(), s.slug, file.path)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
lines.push(`would write ${codexConfigPath(env)} with section [mcp_servers.${CONNECTOR_NAME}]:`);
|
|
116
|
+
for (const l of codexConfigBlock(CONNECTOR_NAME, mcpEndpoint(serverUrl), token).trimEnd().split('\n')) {
|
|
117
|
+
lines.push(` ${l}`);
|
|
118
|
+
}
|
|
119
|
+
return lines;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mcpEndpoint } from '../mcp.js';
|
|
4
|
+
import { writeSkillTree } from '../skills.js';
|
|
5
|
+
import { APP_LABELS, CONNECTOR_NAME, } from './types.js';
|
|
6
|
+
/** Absolute path to the Droid skills tree. */
|
|
7
|
+
function droidSkillsDir(env) {
|
|
8
|
+
return path.join(env.homeDir, '.factory', 'skills');
|
|
9
|
+
}
|
|
10
|
+
/** Absolute path to the Droid MCP config file. */
|
|
11
|
+
function droidConfigPath(env) {
|
|
12
|
+
return path.join(env.homeDir, '.factory', 'mcp.json');
|
|
13
|
+
}
|
|
14
|
+
/** The `mcpServers.<name>` entry for a remote HTTP server with a bearer header. */
|
|
15
|
+
export function droidServerEntry(endpoint, token) {
|
|
16
|
+
return {
|
|
17
|
+
type: 'http',
|
|
18
|
+
url: endpoint,
|
|
19
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Merge a server entry into an existing `mcp.json` body (or `undefined` when no
|
|
24
|
+
* file exists). Malformed JSON is replaced with a fresh file (mirroring how the
|
|
25
|
+
* CLI's own config loader tolerates corruption). Other servers + top-level keys
|
|
26
|
+
* are preserved. Returns pretty-printed JSON with a trailing newline.
|
|
27
|
+
*/
|
|
28
|
+
export function mergeDroidConfig(existing, name, entry) {
|
|
29
|
+
let root = {};
|
|
30
|
+
if (existing && existing.trim() !== '') {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(existing);
|
|
33
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
34
|
+
root = parsed;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
root = {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const servers = root.mcpServers && typeof root.mcpServers === 'object' && !Array.isArray(root.mcpServers)
|
|
42
|
+
? root.mcpServers
|
|
43
|
+
: {};
|
|
44
|
+
servers[name] = entry;
|
|
45
|
+
root.mcpServers = servers;
|
|
46
|
+
return `${JSON.stringify(root, null, 2)}\n`;
|
|
47
|
+
}
|
|
48
|
+
/** Construct the Droid adapter for the given environment. */
|
|
49
|
+
export function makeDroidAdapter(env) {
|
|
50
|
+
const skillsDir = () => droidSkillsDir(env);
|
|
51
|
+
return {
|
|
52
|
+
id: 'droid',
|
|
53
|
+
label: APP_LABELS.droid,
|
|
54
|
+
skillsDir,
|
|
55
|
+
writeSkills(skills) {
|
|
56
|
+
return skills.flatMap((s) => writeSkillTree(skillsDir(), s.slug, s.files));
|
|
57
|
+
},
|
|
58
|
+
async configureMcp(serverUrl, token) {
|
|
59
|
+
const entry = droidServerEntry(mcpEndpoint(serverUrl), token);
|
|
60
|
+
const cfgPath = droidConfigPath(env);
|
|
61
|
+
const existing = fs.existsSync(cfgPath) ? fs.readFileSync(cfgPath, 'utf8') : undefined;
|
|
62
|
+
const merged = mergeDroidConfig(existing, CONNECTOR_NAME, entry);
|
|
63
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(cfgPath, merged, 'utf8');
|
|
65
|
+
},
|
|
66
|
+
dryRunPlan(serverUrl, token, skills) {
|
|
67
|
+
const lines = [];
|
|
68
|
+
for (const s of skills) {
|
|
69
|
+
for (const file of s.files) {
|
|
70
|
+
lines.push(`would write ${path.join(skillsDir(), s.slug, file.path)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const entry = droidServerEntry(mcpEndpoint(serverUrl), token);
|
|
74
|
+
lines.push(`would write ${droidConfigPath(env)} with mcpServers.${CONNECTOR_NAME}:`);
|
|
75
|
+
for (const l of JSON.stringify(entry, null, 2).split('\n')) {
|
|
76
|
+
lines.push(` ${l}`);
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { makeClaudeCodeAdapter } from './claude-code.js';
|
|
2
|
+
import { makeCodexAdapter } from './codex.js';
|
|
3
|
+
import { makeDroidAdapter } from './droid.js';
|
|
4
|
+
export { ALL_APP_IDS, APP_LABELS, CONNECTOR_NAME, parseAppSpec } from './types.js';
|
|
5
|
+
/** Build the adapter for a single app id (throws on an unknown id). */
|
|
6
|
+
export function makeAdapter(id, env) {
|
|
7
|
+
switch (id) {
|
|
8
|
+
case 'claude-code':
|
|
9
|
+
return makeClaudeCodeAdapter(env);
|
|
10
|
+
case 'codex':
|
|
11
|
+
return makeCodexAdapter(env);
|
|
12
|
+
case 'droid':
|
|
13
|
+
return makeDroidAdapter(env);
|
|
14
|
+
default:
|
|
15
|
+
throw new Error(`Unknown app: ${id}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Build adapters for a list of app ids, preserving order. */
|
|
19
|
+
export function makeAdapters(ids, env) {
|
|
20
|
+
return ids.map((id) => makeAdapter(id, env));
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-target model: each agent the installer can configure (Claude Code, OpenAI
|
|
3
|
+
* Codex, Factory AI Droid) is described by a small {@link AppAdapter} so the
|
|
4
|
+
* orchestrator can write skills + wire the MCP connector uniformly. The pure
|
|
5
|
+
* `parseAppSpec` turns a `--app` flag (or interactive answer) into a list of
|
|
6
|
+
* ids; the side-effecting adapters live in the sibling files.
|
|
7
|
+
*/
|
|
8
|
+
/** Every supported app, in the canonical display/selection order. */
|
|
9
|
+
export const ALL_APP_IDS = ['claude-code', 'codex', 'droid'];
|
|
10
|
+
/** Human-readable label per app id. */
|
|
11
|
+
export const APP_LABELS = {
|
|
12
|
+
'claude-code': 'Claude Code',
|
|
13
|
+
codex: 'Codex',
|
|
14
|
+
droid: 'Droid (Factory AI)',
|
|
15
|
+
};
|
|
16
|
+
/** The MCP connector name registered in every app. */
|
|
17
|
+
export const CONNECTOR_NAME = 'reel-edge';
|
|
18
|
+
/**
|
|
19
|
+
* Parse a `--app` value (or interactive answer) into a de-duplicated list of
|
|
20
|
+
* app ids. Accepts `all`, a single id, or a comma list. Case-insensitive;
|
|
21
|
+
* whitespace-tolerant. Throws on an unknown id or an empty spec.
|
|
22
|
+
*/
|
|
23
|
+
export function parseAppSpec(spec) {
|
|
24
|
+
const tokens = spec
|
|
25
|
+
.split(',')
|
|
26
|
+
.map((t) => t.trim().toLowerCase())
|
|
27
|
+
.filter((t) => t.length > 0);
|
|
28
|
+
if (tokens.length === 0) {
|
|
29
|
+
throw new Error('No app specified. Use --app claude-code|codex|droid|all (or a comma list).');
|
|
30
|
+
}
|
|
31
|
+
if (tokens.includes('all'))
|
|
32
|
+
return [...ALL_APP_IDS];
|
|
33
|
+
const known = new Set(ALL_APP_IDS);
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const t of tokens) {
|
|
36
|
+
if (!known.has(t)) {
|
|
37
|
+
throw new Error(`Unknown app: ${t}. Valid: ${ALL_APP_IDS.join(', ')}, all.`);
|
|
38
|
+
}
|
|
39
|
+
const id = t;
|
|
40
|
+
if (!out.includes(id))
|
|
41
|
+
out.push(id);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/** Config home — `REELEDGE_CONFIG_DIR` override, else `~/.reeledge`. */
|
|
5
|
+
export function configDir() {
|
|
6
|
+
const override = process.env.REELEDGE_CONFIG_DIR;
|
|
7
|
+
if (override && override.trim() !== '')
|
|
8
|
+
return override;
|
|
9
|
+
return path.join(os.homedir(), '.reeledge');
|
|
10
|
+
}
|
|
11
|
+
/** Absolute path to `config.json`. */
|
|
12
|
+
export function configPath() {
|
|
13
|
+
return path.join(configDir(), 'config.json');
|
|
14
|
+
}
|
|
15
|
+
/** Load the stored config, or `{}` if missing/unreadable/malformed. */
|
|
16
|
+
export function loadConfig() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(configPath(), 'utf8');
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (parsed && typeof parsed === 'object')
|
|
21
|
+
return parsed;
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Persist the config (creating the dir), pretty-printed for hand-editing. */
|
|
29
|
+
export function saveConfig(cfg) {
|
|
30
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
31
|
+
fs.writeFileSync(configPath(), `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
32
|
+
}
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { httpErrorMessage } from './skills.js';
|
|
2
|
+
function resolveFetch(deps) {
|
|
3
|
+
const f = deps?.fetch ?? globalThis.fetch;
|
|
4
|
+
if (!f)
|
|
5
|
+
throw new Error('No fetch implementation available (Node >=20 required).');
|
|
6
|
+
return f;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Hit `GET <serverUrl>/skills` with `Authorization: Bearer <token>` and classify
|
|
10
|
+
* the result. Never throws — failures come back as `{ ok: false, … }` so the
|
|
11
|
+
* caller can keep going (the check is non-fatal).
|
|
12
|
+
*/
|
|
13
|
+
export async function verifyConnection(serverUrl, token, deps) {
|
|
14
|
+
const url = `${serverUrl.replace(/\/+$/, '')}/skills`;
|
|
15
|
+
try {
|
|
16
|
+
const f = resolveFetch(deps);
|
|
17
|
+
const res = await f(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
return { ok: false, status: res.status, error: httpErrorMessage(res.status) };
|
|
20
|
+
const body = (await res.json());
|
|
21
|
+
return { ok: true, count: Array.isArray(body) ? body.length : 0 };
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return { ok: false, error: err.message };
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `@reeledge/agent-tools` — the `npx agent-tools install` / `update` CLI. Wires
|
|
4
|
+
* the Reel Edge MCP connector + skills into one or more local agents (Claude
|
|
5
|
+
* Code, OpenAI Codex, Factory AI Droid) using shared-token auth.
|
|
6
|
+
*
|
|
7
|
+
* Layout: `parseArgs` (command discriminant) + `parseOptions` (flags) feed the
|
|
8
|
+
* orchestrators `runInstall` / `runUpdate` (in install.ts), which compose the
|
|
9
|
+
* pure, separately-tested helpers in config.ts / skills.ts / mcp.ts via injected
|
|
10
|
+
* side-effecting `deps` (fetch / exec / prompt / log).
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { realpathSync } from 'node:fs';
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import checkbox from '@inquirer/checkbox';
|
|
17
|
+
import { parseOptions } from './options.js';
|
|
18
|
+
import { runInstall, runUpdate } from './install.js';
|
|
19
|
+
export const USAGE = [
|
|
20
|
+
'Usage: agent-tools <command> [options]',
|
|
21
|
+
'',
|
|
22
|
+
'Commands:',
|
|
23
|
+
' install Install the Reel Edge MCP connector + skills into your agent(s)',
|
|
24
|
+
' update Re-fetch skills and re-configure an existing install',
|
|
25
|
+
' help Show this help',
|
|
26
|
+
'',
|
|
27
|
+
'Options (install / update):',
|
|
28
|
+
' --server-url <url> MCP server base URL (else stored config / prompt)',
|
|
29
|
+
' --token <tok> Shared bearer token (else stored config / prompt)',
|
|
30
|
+
' --app <targets> Agent(s) to configure: claude-code, codex, droid,',
|
|
31
|
+
' a comma list, or all (default: prompt, or all with --yes)',
|
|
32
|
+
' --skills a,b Install only these skill slugs',
|
|
33
|
+
' --all Install every skill the server offers',
|
|
34
|
+
' --skills-dir <path> Claude Code skills dir (default ~/.claude/skills)',
|
|
35
|
+
' --reauth Ignore saved credentials and re-prompt for URL + token',
|
|
36
|
+
' --dry-run Print the per-app plan; write nothing, exec nothing',
|
|
37
|
+
' --yes Non-interactive; never prompt',
|
|
38
|
+
].join('\n');
|
|
39
|
+
/** Parse argv (without node/script) into a discriminated command. */
|
|
40
|
+
export function parseArgs(argv) {
|
|
41
|
+
const first = argv[0];
|
|
42
|
+
if (first === undefined || first === 'help' || first === '-h' || first === '--help') {
|
|
43
|
+
return { command: 'help' };
|
|
44
|
+
}
|
|
45
|
+
if (first === 'install')
|
|
46
|
+
return { command: 'install' };
|
|
47
|
+
if (first === 'update')
|
|
48
|
+
return { command: 'update' };
|
|
49
|
+
return { command: 'unknown', name: first };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Synchronous handler for the non-network commands (help / unknown). `install`
|
|
53
|
+
* and `update` are async and go through `main`; calling `run` for them just
|
|
54
|
+
* points the user at `main`/the usage (kept so existing callers/tests of the
|
|
55
|
+
* pure `run` keep working).
|
|
56
|
+
*/
|
|
57
|
+
export function run(argv, out = console.log) {
|
|
58
|
+
const parsed = parseArgs(argv);
|
|
59
|
+
switch (parsed.command) {
|
|
60
|
+
case 'install':
|
|
61
|
+
case 'update':
|
|
62
|
+
out(`Run \`agent-tools ${parsed.command}\` from a terminal.`);
|
|
63
|
+
out('');
|
|
64
|
+
out(USAGE);
|
|
65
|
+
return 0;
|
|
66
|
+
case 'help':
|
|
67
|
+
out(USAGE);
|
|
68
|
+
return 0;
|
|
69
|
+
case 'unknown':
|
|
70
|
+
out(`Unknown command: ${parsed.name}`);
|
|
71
|
+
out('');
|
|
72
|
+
out(USAGE);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Default production deps: real fetch, spawn `claude`, readline prompt, stdout. */
|
|
77
|
+
export function defaultDeps() {
|
|
78
|
+
return {
|
|
79
|
+
fetch: globalThis.fetch,
|
|
80
|
+
exec: (cmd, args) => new Promise((resolve, reject) => {
|
|
81
|
+
const child = spawn(cmd, args, { stdio: 'inherit' });
|
|
82
|
+
child.on('error', reject);
|
|
83
|
+
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`)));
|
|
84
|
+
}),
|
|
85
|
+
prompt: (question) => new Promise((resolve) => {
|
|
86
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
87
|
+
rl.question(question, (answer) => {
|
|
88
|
+
rl.close();
|
|
89
|
+
resolve(answer);
|
|
90
|
+
});
|
|
91
|
+
}),
|
|
92
|
+
multiSelect: ({ message, choices }) => checkbox({
|
|
93
|
+
message,
|
|
94
|
+
choices: choices.map((c) => ({ name: c.name, value: c.value, checked: c.checked })),
|
|
95
|
+
}),
|
|
96
|
+
log: (line) => console.log(line),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Async entrypoint: dispatches install/update to the orchestrators with the
|
|
101
|
+
* given deps (production by default; tests inject fakes). Returns an exit code.
|
|
102
|
+
*/
|
|
103
|
+
export async function main(argv, deps = defaultDeps()) {
|
|
104
|
+
const parsed = parseArgs(argv);
|
|
105
|
+
if (parsed.command === 'install' || parsed.command === 'update') {
|
|
106
|
+
let opts;
|
|
107
|
+
try {
|
|
108
|
+
opts = parseOptions(argv.slice(1));
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
deps.log(err.message);
|
|
112
|
+
deps.log('');
|
|
113
|
+
deps.log(USAGE);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
// Non-interactive automatically when stdin is not a TTY.
|
|
117
|
+
if (!process.stdin.isTTY)
|
|
118
|
+
opts.yes = opts.yes || true;
|
|
119
|
+
return parsed.command === 'install' ? runInstall(opts, deps) : runUpdate(opts, deps);
|
|
120
|
+
}
|
|
121
|
+
return run(argv, deps.log);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* True when this module is the process entry point. Resolves symlinks (realpath)
|
|
125
|
+
* before comparing, so it works when launched through the `bin` symlink
|
|
126
|
+
* (`…/node_modules/.bin/agent-tools` → `dist/index.js`) under npx / global install.
|
|
127
|
+
* A plain string compare of `import.meta.url` to `argv[1]` is false there — argv[1]
|
|
128
|
+
* is the symlink — which silently skipped main() (ran but printed nothing).
|
|
129
|
+
*/
|
|
130
|
+
export function isMainModule(argv1, moduleUrl) {
|
|
131
|
+
if (!argv1)
|
|
132
|
+
return false;
|
|
133
|
+
try {
|
|
134
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Run only when invoked as a script (not when imported by tests).
|
|
141
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
142
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
143
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
3
|
+
import { ALL_APP_IDS, APP_LABELS, CONNECTOR_NAME, makeAdapters as defaultMakeAdapters, } from './apps/index.js';
|
|
4
|
+
import { verifyConnection } from './connect.js';
|
|
5
|
+
import { checkHealth, fetchSkillFiles, fetchSkillList, selectSkills } from './skills.js';
|
|
6
|
+
/**
|
|
7
|
+
* Hardcoded production server. The CLI never prompts for the server URL: it
|
|
8
|
+
* resolves `--server-url` flag → stored config → this default, so a normal
|
|
9
|
+
* install needs only a token. `--server-url` still overrides (e.g. local dev,
|
|
10
|
+
* `http://localhost:3000`).
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_SERVER_URL = 'https://reeledge-mcp.onrender.com';
|
|
13
|
+
/**
|
|
14
|
+
* Resolve `{ serverUrl, token }`. The server URL is resolved silently —
|
|
15
|
+
* `--server-url` flag → stored config → `DEFAULT_SERVER_URL` — so it is NEVER
|
|
16
|
+
* prompted (a normal install needs only a token). The token still resolves
|
|
17
|
+
* flag → stored config → interactive prompt. Returns null (after logging what's
|
|
18
|
+
* missing) when the token can't be resolved without prompting (`--yes` /
|
|
19
|
+
* non-TTY).
|
|
20
|
+
*
|
|
21
|
+
* NOTE: this no longer persists anything. The credentials are saved only AFTER
|
|
22
|
+
* the first successful authenticated call (in `run`), so a token that later
|
|
23
|
+
* 401s never gets written to `~/.reeledge/config.json` and locks the user out.
|
|
24
|
+
*
|
|
25
|
+
* `fromStore` is true only when BOTH values came silently from the stored
|
|
26
|
+
* config (no flag, no fresh prompt, no default) — the caller uses it to surface
|
|
27
|
+
* a "Using saved connection…" line so reuse is never invisible.
|
|
28
|
+
*
|
|
29
|
+
* `--reauth` ignores the stored config entirely and forces a fresh token prompt
|
|
30
|
+
* (a `--token` flag still wins; the URL falls back to the default). It is a
|
|
31
|
+
* contradiction with `--yes` / non-TTY (no way to prompt) → abort with a clear
|
|
32
|
+
* message.
|
|
33
|
+
*/
|
|
34
|
+
async function resolveCredentials(opts, deps) {
|
|
35
|
+
if (opts.reauth && opts.yes) {
|
|
36
|
+
deps.log('Cannot --reauth without an interactive prompt (running with --yes or a non-TTY). ' +
|
|
37
|
+
'Re-run in a terminal.');
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const stored = opts.reauth ? {} : loadConfig();
|
|
41
|
+
const serverFromStore = opts.serverUrl === undefined && stored.serverUrl !== undefined;
|
|
42
|
+
const tokenFromStore = opts.token === undefined && stored.token !== undefined;
|
|
43
|
+
const serverUrl = opts.serverUrl ?? stored.serverUrl ?? DEFAULT_SERVER_URL;
|
|
44
|
+
let token = opts.token ?? stored.token;
|
|
45
|
+
if (!token) {
|
|
46
|
+
if (opts.yes) {
|
|
47
|
+
deps.log('Missing required value: --token (and none stored). Aborting.');
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
token = (await deps.prompt('Reel Edge bearer token: ')).trim();
|
|
51
|
+
if (!token) {
|
|
52
|
+
deps.log('No token provided. Aborting.');
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { serverUrl, token, fromStore: serverFromStore && tokenFromStore };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve which apps to configure: `--app` if given, else `all` under `--yes`
|
|
60
|
+
* (non-interactive), else an interactive checkbox multi-select (all apps
|
|
61
|
+
* pre-checked). Confirming with nothing checked logs a clear message and
|
|
62
|
+
* returns `[]`, which the caller treats as an abort.
|
|
63
|
+
*/
|
|
64
|
+
async function resolveApps(opts, deps) {
|
|
65
|
+
if (opts.apps && opts.apps.length > 0)
|
|
66
|
+
return opts.apps;
|
|
67
|
+
if (opts.yes)
|
|
68
|
+
return [...ALL_APP_IDS];
|
|
69
|
+
const selected = await deps.multiSelect({
|
|
70
|
+
message: 'Which app(s) to configure? (↑/↓ move, SPACE toggle, ENTER confirm)',
|
|
71
|
+
choices: ALL_APP_IDS.map((id) => ({ name: APP_LABELS[id], value: id, checked: true })),
|
|
72
|
+
});
|
|
73
|
+
if (selected.length === 0) {
|
|
74
|
+
deps.log('No apps selected. Aborting.');
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return selected;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Shared flow for both `install` and `update`: resolve creds + target apps,
|
|
81
|
+
* fetch the skill list (clamped to `allowedSkills` if provided), fetch each
|
|
82
|
+
* body once, then per app write its skills + wire the MCP connector (or print
|
|
83
|
+
* the plan under --dry-run), and finally run an authenticated connection test.
|
|
84
|
+
*/
|
|
85
|
+
async function run(opts, deps, mode, allowedSkills) {
|
|
86
|
+
const creds = await resolveCredentials(opts, deps);
|
|
87
|
+
if (!creds)
|
|
88
|
+
return 1;
|
|
89
|
+
const { serverUrl, token } = creds;
|
|
90
|
+
// Make silent reuse of stored credentials visible (never print the token).
|
|
91
|
+
if (creds.fromStore) {
|
|
92
|
+
deps.log(`Using saved connection to ${serverUrl} (token ✓).`);
|
|
93
|
+
}
|
|
94
|
+
const appIds = await resolveApps(opts, deps);
|
|
95
|
+
if (appIds.length === 0)
|
|
96
|
+
return 1;
|
|
97
|
+
const env = {
|
|
98
|
+
homeDir: deps.homeDir ?? os.homedir(),
|
|
99
|
+
exec: deps.exec,
|
|
100
|
+
skillsDirOverride: opts.skillsDir,
|
|
101
|
+
};
|
|
102
|
+
const adapters = (deps.makeAdapters ?? defaultMakeAdapters)(appIds, env);
|
|
103
|
+
// 1. Fetch the catalog. This is the first authenticated call — only once it
|
|
104
|
+
// succeeds do we persist the credentials (so a token that 401s is never
|
|
105
|
+
// written and can't silently lock the user out on the next run).
|
|
106
|
+
let list;
|
|
107
|
+
try {
|
|
108
|
+
list = await fetchSkillList(serverUrl, token, deps);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
deps.log(`Could not fetch the skill list: ${err.message}`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
saveConfig({ serverUrl, token });
|
|
115
|
+
// 2. Select which skills to install. Interactive `install` (no flags, on a
|
|
116
|
+
// TTY) gets a checkbox of every offered skill (all pre-checked); otherwise
|
|
117
|
+
// flags decide. `update` defaults to "all" so an existing install is
|
|
118
|
+
// refreshed wholesale even without --all.
|
|
119
|
+
let slugs;
|
|
120
|
+
const interactiveSkills = mode === 'install' &&
|
|
121
|
+
!opts.yes &&
|
|
122
|
+
!opts.all &&
|
|
123
|
+
!(opts.skills && opts.skills.length > 0);
|
|
124
|
+
if (interactiveSkills) {
|
|
125
|
+
const allowed = allowedSkills !== undefined ? new Set(allowedSkills) : null;
|
|
126
|
+
const candidates = allowed ? list.filter((s) => allowed.has(s.slug)) : list;
|
|
127
|
+
slugs = await deps.multiSelect({
|
|
128
|
+
message: 'Which skill(s) to install? (↑/↓ move, SPACE toggle, ENTER confirm)',
|
|
129
|
+
choices: candidates.map((s) => ({
|
|
130
|
+
name: `${s.name} (${s.slug})`,
|
|
131
|
+
value: s.slug,
|
|
132
|
+
checked: true,
|
|
133
|
+
})),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const selectOpts = mode === 'update' && !opts.all && !(opts.skills && opts.skills.length > 0)
|
|
138
|
+
? { all: true, allowedSkills }
|
|
139
|
+
: { all: opts.all, skills: opts.skills, allowedSkills };
|
|
140
|
+
slugs = selectSkills(list, selectOpts);
|
|
141
|
+
}
|
|
142
|
+
if (slugs.length === 0) {
|
|
143
|
+
deps.log('No skills selected. Use --all or --skills <a,b>. Available:');
|
|
144
|
+
for (const s of list)
|
|
145
|
+
deps.log(` - ${s.slug}: ${s.name}`);
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
// 3. Fetch every selected skill's full file tree once (SKILL.md + references/**;
|
|
149
|
+
// shared across apps). Fetched even under --dry-run so the plan can enumerate
|
|
150
|
+
// every file. Skipped only when no selected app has a skills concept. An older
|
|
151
|
+
// server without the /files route is handled by fetchSkillFiles' single-body
|
|
152
|
+
// fallback.
|
|
153
|
+
const anySkills = adapters.some((a) => a.skillsDir() !== null);
|
|
154
|
+
let skills = slugs.map((slug) => ({ slug, files: [] }));
|
|
155
|
+
if (anySkills) {
|
|
156
|
+
skills = [];
|
|
157
|
+
for (const slug of slugs) {
|
|
158
|
+
let files;
|
|
159
|
+
try {
|
|
160
|
+
files = await fetchSkillFiles(serverUrl, token, slug, deps);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
// err.message already reads `Couldn't fetch skill "<slug>": <reason>`
|
|
164
|
+
// (slug kept, no URL) — log it as-is to avoid a double "fetch…fetch".
|
|
165
|
+
deps.log(` ${err.message}`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
skills.push({ slug, files });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const verb = mode === 'install' ? 'Installing' : 'Updating';
|
|
172
|
+
deps.log(`${verb} ${slugs.length} skill(s) for ${adapters.length} app(s): ${slugs.join(', ')}`);
|
|
173
|
+
// 4. Per app: write skills + configure the MCP connector (or print the plan).
|
|
174
|
+
for (const app of adapters) {
|
|
175
|
+
deps.log('');
|
|
176
|
+
deps.log(`== ${app.label} ==`);
|
|
177
|
+
if (opts.dryRun) {
|
|
178
|
+
for (const line of app.dryRunPlan(serverUrl, token, skills))
|
|
179
|
+
deps.log(` [dry-run] ${line}`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (app.skillsDir() === null) {
|
|
183
|
+
deps.log(' skills: N/A for this app (MCP server only)');
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
for (const file of app.writeSkills(skills))
|
|
187
|
+
deps.log(` wrote ${file}`);
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
await app.configureMcp(serverUrl, token);
|
|
191
|
+
deps.log(` configured MCP connector "${CONNECTOR_NAME}"`);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
deps.log(` Warning: configuring the MCP connector for ${app.label} failed ` +
|
|
195
|
+
`(${err.message}). Re-run with --dry-run to see the exact ` +
|
|
196
|
+
`command/config to apply manually.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// 5. Liveness ping (unauthenticated) — warn, never fail.
|
|
200
|
+
const healthy = await checkHealth(serverUrl, deps);
|
|
201
|
+
if (!healthy) {
|
|
202
|
+
deps.log('');
|
|
203
|
+
deps.log('Warning: the Reel Edge server health check failed — the server may be down.');
|
|
204
|
+
}
|
|
205
|
+
// 6. Authenticated connection test (PRD step 7) — non-fatal.
|
|
206
|
+
deps.log('');
|
|
207
|
+
if (opts.dryRun) {
|
|
208
|
+
deps.log(`[dry-run] would verify the connection: GET ${serverUrl.replace(/\/+$/, '')}/skills with the bearer token.`);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const result = await verifyConnection(serverUrl, token, deps);
|
|
212
|
+
if (result.ok) {
|
|
213
|
+
deps.log(`Connected — ${result.count} skill(s) available.`);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const why = result.status ? `HTTP ${result.status}` : (result.error ?? 'unknown error');
|
|
217
|
+
deps.log(`Could not connect (${why}). Double-check the server URL and token.`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// 7. Summary.
|
|
221
|
+
deps.log('');
|
|
222
|
+
deps.log(`Done. ${mode === 'install' ? 'Installed' : 'Updated'} ${slugs.length} skill(s) ` +
|
|
223
|
+
`for ${adapters.map((a) => a.label).join(', ')}: ${slugs.join(', ')}.`);
|
|
224
|
+
if (opts.dryRun)
|
|
225
|
+
deps.log('(dry-run — nothing was written or executed.)');
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
export function runInstall(opts, deps, allowedSkills) {
|
|
229
|
+
return run(opts, deps, 'install', allowedSkills);
|
|
230
|
+
}
|
|
231
|
+
export function runUpdate(opts, deps, allowedSkills) {
|
|
232
|
+
return run(opts, deps, 'update', allowedSkills);
|
|
233
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for wiring the Reel Edge MCP connector into Claude Code via the
|
|
3
|
+
* `claude mcp add` subcommand. Kept side-effect-free so they're exhaustively
|
|
4
|
+
* unit-tested; the actual `child_process` exec is a thin wrapper in install.ts.
|
|
5
|
+
*/
|
|
6
|
+
/** Normalise a server URL to its Streamable-HTTP `/mcp` endpoint. */
|
|
7
|
+
export function mcpEndpoint(serverUrl) {
|
|
8
|
+
const trimmed = serverUrl.replace(/\/+$/, '');
|
|
9
|
+
return trimmed.endsWith('/mcp') ? trimmed : `${trimmed}/mcp`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* argv (after the `claude` binary) for:
|
|
13
|
+
* claude mcp add --scope user --transport http <name> <serverUrl>/mcp \
|
|
14
|
+
* --header "Authorization: Bearer <token>"
|
|
15
|
+
*
|
|
16
|
+
* `--scope user` registers the connector GLOBALLY (user scope) rather than the
|
|
17
|
+
* `claude mcp add` default of `local` (per-project), so the connector matches
|
|
18
|
+
* the globally-installed skills instead of being scoped to one project.
|
|
19
|
+
*/
|
|
20
|
+
export function claudeMcpAddArgs(name, serverUrl, token) {
|
|
21
|
+
return [
|
|
22
|
+
'mcp',
|
|
23
|
+
'add',
|
|
24
|
+
'--scope',
|
|
25
|
+
'user',
|
|
26
|
+
'--transport',
|
|
27
|
+
'http',
|
|
28
|
+
name,
|
|
29
|
+
mcpEndpoint(serverUrl),
|
|
30
|
+
'--header',
|
|
31
|
+
`Authorization: Bearer ${token}`,
|
|
32
|
+
];
|
|
33
|
+
}
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseAppSpec } from './apps/types.js';
|
|
4
|
+
/** Default skills directory for Claude Code: `~/.claude/skills`. */
|
|
5
|
+
export function defaultSkillsDir() {
|
|
6
|
+
return path.join(os.homedir(), '.claude', 'skills');
|
|
7
|
+
}
|
|
8
|
+
const VALUE_FLAGS = new Set(['--server-url', '--token', '--app', '--skills', '--skills-dir']);
|
|
9
|
+
const BOOL_FLAGS = new Set(['--all', '--dry-run', '--yes', '--reauth']);
|
|
10
|
+
/**
|
|
11
|
+
* Parse the flag portion of argv (i.e. argv WITHOUT the leading command token).
|
|
12
|
+
* Supports `--flag value` and `--flag=value`. Throws on an unknown flag so a
|
|
13
|
+
* typo fails loudly rather than being silently ignored.
|
|
14
|
+
*/
|
|
15
|
+
export function parseOptions(argv) {
|
|
16
|
+
const opts = {
|
|
17
|
+
all: false,
|
|
18
|
+
skillsDir: defaultSkillsDir(),
|
|
19
|
+
dryRun: false,
|
|
20
|
+
yes: false,
|
|
21
|
+
reauth: false,
|
|
22
|
+
};
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const arg = argv[i];
|
|
25
|
+
let flag = arg;
|
|
26
|
+
let inlineValue;
|
|
27
|
+
const eq = arg.indexOf('=');
|
|
28
|
+
if (arg.startsWith('--') && eq !== -1) {
|
|
29
|
+
flag = arg.slice(0, eq);
|
|
30
|
+
inlineValue = arg.slice(eq + 1);
|
|
31
|
+
}
|
|
32
|
+
const readValue = () => {
|
|
33
|
+
if (inlineValue !== undefined)
|
|
34
|
+
return inlineValue;
|
|
35
|
+
const next = argv[i + 1];
|
|
36
|
+
if (next === undefined)
|
|
37
|
+
throw new Error(`Flag ${flag} requires a value.`);
|
|
38
|
+
i++;
|
|
39
|
+
return next;
|
|
40
|
+
};
|
|
41
|
+
if (BOOL_FLAGS.has(flag)) {
|
|
42
|
+
if (flag === '--all')
|
|
43
|
+
opts.all = true;
|
|
44
|
+
else if (flag === '--dry-run')
|
|
45
|
+
opts.dryRun = true;
|
|
46
|
+
else if (flag === '--yes')
|
|
47
|
+
opts.yes = true;
|
|
48
|
+
else if (flag === '--reauth')
|
|
49
|
+
opts.reauth = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (VALUE_FLAGS.has(flag)) {
|
|
53
|
+
const value = readValue();
|
|
54
|
+
switch (flag) {
|
|
55
|
+
case '--server-url':
|
|
56
|
+
opts.serverUrl = value;
|
|
57
|
+
break;
|
|
58
|
+
case '--token':
|
|
59
|
+
opts.token = value;
|
|
60
|
+
break;
|
|
61
|
+
case '--app':
|
|
62
|
+
opts.apps = parseAppSpec(value);
|
|
63
|
+
break;
|
|
64
|
+
case '--skills':
|
|
65
|
+
opts.skills = value
|
|
66
|
+
.split(',')
|
|
67
|
+
.map((s) => s.trim())
|
|
68
|
+
.filter((s) => s.length > 0);
|
|
69
|
+
break;
|
|
70
|
+
case '--skills-dir':
|
|
71
|
+
opts.skillsDir = value;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Unknown flag: ${flag}`);
|
|
77
|
+
}
|
|
78
|
+
return opts;
|
|
79
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function resolveFetch(deps) {
|
|
4
|
+
const f = deps?.fetch ?? globalThis.fetch;
|
|
5
|
+
if (!f)
|
|
6
|
+
throw new Error('No fetch implementation available (Node >=20 required).');
|
|
7
|
+
return f;
|
|
8
|
+
}
|
|
9
|
+
function baseUrl(serverUrl) {
|
|
10
|
+
return serverUrl.replace(/\/+$/, '');
|
|
11
|
+
}
|
|
12
|
+
function authHeaders(token) {
|
|
13
|
+
return { Authorization: `Bearer ${token}` };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Map an HTTP status to a user-facing, non-leaky message. Deliberately omits the
|
|
17
|
+
* request URL/endpoint so server internals never surface in CLI output: a 401/403
|
|
18
|
+
* points the user at re-authing; anything else is a generic "couldn't reach"
|
|
19
|
+
* with just the status code.
|
|
20
|
+
*/
|
|
21
|
+
export function httpErrorMessage(status) {
|
|
22
|
+
if (status === 401 || status === 403) {
|
|
23
|
+
return 'Unauthorized — invalid or missing authorization credentials. Re-run and provide a valid token (or --token).';
|
|
24
|
+
}
|
|
25
|
+
return `Couldn't reach the Reel Edge server (HTTP ${status}).`;
|
|
26
|
+
}
|
|
27
|
+
/** `GET /skills` → the skill catalog. Throws on a non-2xx response. */
|
|
28
|
+
export async function fetchSkillList(serverUrl, token, deps) {
|
|
29
|
+
const f = resolveFetch(deps);
|
|
30
|
+
const url = `${baseUrl(serverUrl)}/skills`;
|
|
31
|
+
const res = await f(url, { headers: authHeaders(token) });
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
throw new Error(httpErrorMessage(res.status));
|
|
34
|
+
}
|
|
35
|
+
return (await res.json());
|
|
36
|
+
}
|
|
37
|
+
/** `GET /skills/:slug` → the markdown body. Throws on a non-2xx response. */
|
|
38
|
+
export async function fetchSkillBody(serverUrl, token, slug, deps) {
|
|
39
|
+
const f = resolveFetch(deps);
|
|
40
|
+
const url = `${baseUrl(serverUrl)}/skills/${slug}`;
|
|
41
|
+
const res = await f(url, { headers: authHeaders(token) });
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
throw new Error(`Couldn't fetch skill "${slug}": ${httpErrorMessage(res.status)}`);
|
|
44
|
+
}
|
|
45
|
+
return res.text();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `GET /skills/:slug/files` → the full file tree (`SKILL.md` + `references/**`).
|
|
49
|
+
*
|
|
50
|
+
* On a 404 (an older server without the `/files` route, OR a genuinely unknown
|
|
51
|
+
* slug) we fall back to `fetchSkillBody`: that succeeds on an old server (→ a
|
|
52
|
+
* single-file tree) and 404s again for an unknown slug (→ a clean throw). Any
|
|
53
|
+
* other non-2xx throws a non-leaky `Couldn't fetch skill "<slug>": …`.
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchSkillFiles(serverUrl, token, slug, deps) {
|
|
56
|
+
const f = resolveFetch(deps);
|
|
57
|
+
const url = `${baseUrl(serverUrl)}/skills/${slug}/files`;
|
|
58
|
+
const res = await f(url, { headers: authHeaders(token) });
|
|
59
|
+
if (res.status === 404) {
|
|
60
|
+
const body = await fetchSkillBody(serverUrl, token, slug, deps);
|
|
61
|
+
return [{ path: 'SKILL.md', content: body }];
|
|
62
|
+
}
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
throw new Error(`Couldn't fetch skill "${slug}": ${httpErrorMessage(res.status)}`);
|
|
65
|
+
}
|
|
66
|
+
const data = (await res.json());
|
|
67
|
+
return data.files;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a skill-relative file path to an absolute path INSIDE the skill dir,
|
|
71
|
+
* rejecting anything that would escape it (absolute paths or `..` traversal) so a
|
|
72
|
+
* malicious manifest can never write outside `<skillsDir>/<slug>/`.
|
|
73
|
+
*/
|
|
74
|
+
function resolveSkillFilePath(skillDir, relPath) {
|
|
75
|
+
if (path.isAbsolute(relPath)) {
|
|
76
|
+
throw new Error(`Unsafe skill file path (absolute path not allowed): ${relPath}`);
|
|
77
|
+
}
|
|
78
|
+
const dest = path.resolve(skillDir, relPath);
|
|
79
|
+
const rel = path.relative(skillDir, dest);
|
|
80
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
81
|
+
throw new Error(`Unsafe skill file path (escapes the skill directory): ${relPath}`);
|
|
82
|
+
}
|
|
83
|
+
return dest;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Write a skill's full file tree under `<skillsDir>/<slug>/`, preserving each
|
|
87
|
+
* file's relative path (e.g. `references/x.md`) and creating directories. Every
|
|
88
|
+
* path is validated up-front, so a malicious manifest aborts before any write.
|
|
89
|
+
* Returns the written absolute file paths.
|
|
90
|
+
*/
|
|
91
|
+
export function writeSkillTree(skillsDir, slug, files) {
|
|
92
|
+
const skillDir = path.join(skillsDir, slug);
|
|
93
|
+
// Validate the WHOLE tree before touching disk (atomic-ish: reject as a unit).
|
|
94
|
+
const planned = files.map((file) => ({
|
|
95
|
+
dest: resolveSkillFilePath(skillDir, file.path),
|
|
96
|
+
content: file.content,
|
|
97
|
+
}));
|
|
98
|
+
const written = [];
|
|
99
|
+
for (const { dest, content } of planned) {
|
|
100
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
101
|
+
fs.writeFileSync(dest, content, 'utf8');
|
|
102
|
+
written.push(dest);
|
|
103
|
+
}
|
|
104
|
+
return written;
|
|
105
|
+
}
|
|
106
|
+
/** Write `<skillsDir>/<slug>/SKILL.md`, creating dirs. Returns the file path. */
|
|
107
|
+
export function writeSkill(skillsDir, slug, body) {
|
|
108
|
+
return writeSkillTree(skillsDir, slug, [{ path: 'SKILL.md', content: body }])[0];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Resolve which slugs to install from the available catalog. `all` wins; else
|
|
112
|
+
* an explicit `skills` list filtered to (and ordered by) the catalog; else [].
|
|
113
|
+
*
|
|
114
|
+
* `allowedSkills` is a forward-compat seam: when a future login step restricts a
|
|
115
|
+
* user to a subset of skills, pass it here and selection is clamped to that set
|
|
116
|
+
* before `all`/`skills` apply. `undefined` (the default) means every skill the
|
|
117
|
+
* server returned is allowed; `[]` means none are.
|
|
118
|
+
*/
|
|
119
|
+
export function selectSkills(available, opts) {
|
|
120
|
+
let slugs = available.map((s) => s.slug);
|
|
121
|
+
if (opts.allowedSkills !== undefined) {
|
|
122
|
+
const allowed = new Set(opts.allowedSkills);
|
|
123
|
+
slugs = slugs.filter((s) => allowed.has(s));
|
|
124
|
+
}
|
|
125
|
+
if (opts.all)
|
|
126
|
+
return slugs;
|
|
127
|
+
if (opts.skills && opts.skills.length > 0) {
|
|
128
|
+
const wanted = new Set(opts.skills);
|
|
129
|
+
return slugs.filter((s) => wanted.has(s));
|
|
130
|
+
}
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
/** `GET /healthz` → true if reachable and 2xx, false otherwise (never throws). */
|
|
134
|
+
export async function checkHealth(serverUrl, deps) {
|
|
135
|
+
try {
|
|
136
|
+
const f = resolveFetch(deps);
|
|
137
|
+
const res = await f(`${baseUrl(serverUrl)}/healthz`);
|
|
138
|
+
return res.ok;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reeledge/agent-tools",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Reel Edge agent-tools installer CLI — installs/updates the Reel Edge MCP connector + skills for Claude Code / Codex / Droid.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"agent-tools": "dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/**/*.js"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@inquirer/checkbox": "^5.2.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^26.0.0",
|
|
31
|
+
"tsx": "^4.22.4",
|
|
32
|
+
"typescript": "^6.0.3",
|
|
33
|
+
"vitest": "^4.1.9"
|
|
34
|
+
}
|
|
35
|
+
}
|