impel-cli 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +695 -0
- package/bin/impel.js +7 -0
- package/package.json +29 -0
- package/src/apps.js +1263 -0
- package/src/args.js +36 -0
- package/src/claudeSetup.js +207 -0
- package/src/cli.js +184 -0
- package/src/cliProfiles.js +216 -0
- package/src/codexSecurity.js +184 -0
- package/src/codexSetup.js +224 -0
- package/src/commands/apps.js +538 -0
- package/src/commands/auth.js +89 -0
- package/src/commands/doctor.js +215 -0
- package/src/commands/experimental.js +60 -0
- package/src/commands/launch.js +161 -0
- package/src/commands/mcp.js +94 -0
- package/src/commands/setup.js +350 -0
- package/src/commands/skills.js +108 -0
- package/src/commands/status.js +95 -0
- package/src/commands/tasks.js +359 -0
- package/src/commands/tenant.js +77 -0
- package/src/commands/token.js +25 -0
- package/src/commands/update.js +217 -0
- package/src/commands/use.js +208 -0
- package/src/config.js +98 -0
- package/src/doctor.js +546 -0
- package/src/nativeProcess.js +192 -0
- package/src/prompt.js +51 -0
- package/src/selfInvocation.js +21 -0
- package/src/skills.js +314 -0
- package/src/tenants.js +194 -0
- package/src/updates.js +181 -0
- package/src/windowsApps.js +439 -0
- package/src/windowsSetup.js +122 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const WINDOWS_DEFAULT_PATH_EXTENSIONS = [".COM", ".EXE", ".BAT", ".CMD"];
|
|
6
|
+
const WINDOWS_BATCH_EXTENSION_RE = /\.(?:bat|cmd)$/iu;
|
|
7
|
+
const WINDOWS_BARE_COMMAND_RE = /^[A-Za-z0-9_.-]+$/u;
|
|
8
|
+
const WINDOWS_SHELL_META_RE = /([()\][%!^"`<>&|;, *?])/gu;
|
|
9
|
+
const WINDOWS_UNSAFE_LINE_RE = /[\u0000\r\n]/u;
|
|
10
|
+
|
|
11
|
+
function pathApi(platform) {
|
|
12
|
+
return platform === "win32" ? path.win32 : path;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pathDelimiter(platform) {
|
|
16
|
+
return platform === "win32" ? ";" : path.delimiter;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function environmentValue(environment, name) {
|
|
20
|
+
if (environment[name] !== undefined) return environment[name];
|
|
21
|
+
const key = Object.keys(environment).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
22
|
+
return key ? environment[key] : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isExecutable(filePath, platform = process.platform) {
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.statSync(filePath).isFile()) return false;
|
|
28
|
+
if (platform !== "win32") fs.accessSync(filePath, fs.constants.X_OK);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function windowsPathExtensions(environment) {
|
|
36
|
+
const configured = String(environmentValue(environment, "PATHEXT") || "")
|
|
37
|
+
.split(";")
|
|
38
|
+
.map((extension) => extension.trim())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
|
|
41
|
+
return configured.length ? configured : WINDOWS_DEFAULT_PATH_EXTENSIONS;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function binaryCandidates(directory, tool, environment, platform) {
|
|
45
|
+
const paths = pathApi(platform);
|
|
46
|
+
const base = paths.join(directory, tool);
|
|
47
|
+
if (platform !== "win32" || paths.extname(tool)) return [base];
|
|
48
|
+
// Windows npm distributions include extensionless Unix companion scripts
|
|
49
|
+
// beside the executable shims. They are regular files but CreateProcess
|
|
50
|
+
// cannot run them, so only PATHEXT candidates are valid on Windows.
|
|
51
|
+
return windowsPathExtensions(environment).map((extension) => `${base}${extension}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function overrideName(tool) {
|
|
55
|
+
if (tool === "claude") return "IMPEL_CLAUDE_BIN";
|
|
56
|
+
if (tool === "codex") return "IMPEL_CODEX_BIN";
|
|
57
|
+
if (tool === "npm") return "IMPEL_NPM_BIN";
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripPathEntryQuotes(entry) {
|
|
62
|
+
const trimmed = entry.trim();
|
|
63
|
+
return trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')
|
|
64
|
+
? trimmed.slice(1, -1)
|
|
65
|
+
: trimmed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function expandWindowsEnvironmentVariables(value, environment) {
|
|
69
|
+
return value.replace(/%([^%]+)%/gu, (match, name) => environmentValue(environment, name) ?? match);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function commonCandidates(tool, environment, platform) {
|
|
73
|
+
const paths = pathApi(platform);
|
|
74
|
+
const home = environmentValue(environment, "USERPROFILE") || environmentValue(environment, "HOME") || os.homedir();
|
|
75
|
+
const locations = [];
|
|
76
|
+
|
|
77
|
+
if (tool === "claude") {
|
|
78
|
+
locations.push(
|
|
79
|
+
[paths.join(home, ".local", "bin"), "claude"],
|
|
80
|
+
[paths.join(home, ".claude", "local"), "claude"],
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (platform === "win32") {
|
|
85
|
+
const appData = environmentValue(environment, "APPDATA");
|
|
86
|
+
const localAppData = environmentValue(environment, "LOCALAPPDATA");
|
|
87
|
+
const nvmSymlink = environmentValue(environment, "NVM_SYMLINK");
|
|
88
|
+
const programFiles = environmentValue(environment, "ProgramFiles");
|
|
89
|
+
const nodeDirectory = path.win32.isAbsolute(process.execPath)
|
|
90
|
+
? path.win32.dirname(process.execPath)
|
|
91
|
+
: null;
|
|
92
|
+
|
|
93
|
+
// npm's default global prefix on Windows. This also finds the vendor CLIs
|
|
94
|
+
// immediately after installation when the parent PowerShell process has a
|
|
95
|
+
// stale PATH value.
|
|
96
|
+
if ((tool === "claude" || tool === "codex") && appData) {
|
|
97
|
+
locations.push([paths.join(appData, "npm"), tool]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Official standalone Codex installs live here. Claude's official native
|
|
101
|
+
// installer uses ~/.local/bin, which is covered above.
|
|
102
|
+
if (tool === "codex" && localAppData) {
|
|
103
|
+
locations.push([paths.join(localAppData, "Programs", "OpenAI", "Codex", "bin"), "codex"]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// npm itself normally sits beside node.exe. Include the common Node and
|
|
107
|
+
// NVM locations so setup/update do not depend on PATH normalization.
|
|
108
|
+
if (tool === "npm") {
|
|
109
|
+
if (nodeDirectory) locations.push([nodeDirectory, "npm"]);
|
|
110
|
+
if (nvmSymlink) locations.push([nvmSymlink, "npm"]);
|
|
111
|
+
if (programFiles) locations.push([paths.join(programFiles, "nodejs"), "npm"]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return locations.flatMap(([directory, name]) => binaryCandidates(directory, name, environment, platform));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Resolve a real executable/shim if one is installed, otherwise return null. */
|
|
119
|
+
export function findNativeBinary(tool, environment = process.env, platform = process.platform) {
|
|
120
|
+
const override = overrideName(tool);
|
|
121
|
+
const overriddenBinary = override ? environmentValue(environment, override) : null;
|
|
122
|
+
if (overriddenBinary) return isExecutable(overriddenBinary, platform) ? overriddenBinary : null;
|
|
123
|
+
|
|
124
|
+
for (const rawDirectory of String(environmentValue(environment, "PATH") || "").split(pathDelimiter(platform))) {
|
|
125
|
+
const expandedDirectory = platform === "win32"
|
|
126
|
+
? expandWindowsEnvironmentVariables(rawDirectory, environment)
|
|
127
|
+
: rawDirectory;
|
|
128
|
+
const directory = stripPathEntryQuotes(expandedDirectory);
|
|
129
|
+
if (!directory) continue;
|
|
130
|
+
for (const candidate of binaryCandidates(directory, tool, environment, platform)) {
|
|
131
|
+
if (isExecutable(candidate, platform)) return candidate;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return commonCandidates(tool, environment, platform).find((candidate) => isExecutable(candidate, platform)) || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Resolve an executable for launch, preserving explicit overrides and useful ENOENT errors. */
|
|
139
|
+
export function resolveNativeBinary(tool, environment = process.env, platform = process.platform) {
|
|
140
|
+
const override = overrideName(tool);
|
|
141
|
+
const overriddenBinary = override ? environmentValue(environment, override) : null;
|
|
142
|
+
return overriddenBinary || findNativeBinary(tool, environment, platform) || tool;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Windows cannot execute npm's .cmd/.bat shims directly. This is the escaping
|
|
146
|
+
// strategy used by cross-spawn, inlined here to keep the CLI dependency-free.
|
|
147
|
+
// Arguments are quoted individually and command metacharacters are escaped
|
|
148
|
+
// twice because npm shims forward them through `%*`, causing a second parse.
|
|
149
|
+
function escapeWindowsBatchCommand(value) {
|
|
150
|
+
return String(value).replace(WINDOWS_SHELL_META_RE, "^$1");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function escapeWindowsBatchArgument(value) {
|
|
154
|
+
let escaped = String(value);
|
|
155
|
+
if (WINDOWS_UNSAFE_LINE_RE.test(escaped)) {
|
|
156
|
+
throw new Error("cannot safely forward an argument containing a NUL or line break through a Windows batch shim");
|
|
157
|
+
}
|
|
158
|
+
escaped = escaped.replace(/(?=(\\+?)?)\1"/gu, "$1$1\\\"");
|
|
159
|
+
escaped = escaped.replace(/(?=(\\+?)?)\1$/gu, "$1$1");
|
|
160
|
+
escaped = `"${escaped}"`;
|
|
161
|
+
escaped = escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
|
|
162
|
+
return escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function nativeSpawnInvocation(binary, argv, environment = process.env, platform = process.platform) {
|
|
166
|
+
const bareWindowsCommand = (
|
|
167
|
+
platform === "win32"
|
|
168
|
+
&& WINDOWS_BARE_COMMAND_RE.test(binary)
|
|
169
|
+
&& path.win32.basename(binary) === binary
|
|
170
|
+
);
|
|
171
|
+
if (platform !== "win32" || (!WINDOWS_BATCH_EXTENSION_RE.test(binary) && !bareWindowsCommand)) {
|
|
172
|
+
return { command: binary, args: argv, windowsVerbatimArguments: false };
|
|
173
|
+
}
|
|
174
|
+
if (WINDOWS_UNSAFE_LINE_RE.test(binary)) {
|
|
175
|
+
throw new Error("cannot safely launch a Windows batch shim whose path contains a NUL or line break");
|
|
176
|
+
}
|
|
177
|
+
const shellCommand = [
|
|
178
|
+
escapeWindowsBatchCommand(pathApi(platform).normalize(binary)),
|
|
179
|
+
...argv.map(escapeWindowsBatchArgument),
|
|
180
|
+
].join(" ");
|
|
181
|
+
return {
|
|
182
|
+
command: environmentValue(environment, "ComSpec") || "cmd.exe",
|
|
183
|
+
args: ["/d", "/s", "/v:off", "/c", `"${shellCommand}"`],
|
|
184
|
+
windowsVerbatimArguments: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Resolve a PATH command and produce a spawn-safe invocation for this platform. */
|
|
189
|
+
export function nativeCommandInvocation(tool, argv, environment = process.env, platform = process.platform) {
|
|
190
|
+
const binary = resolveNativeBinary(tool, environment, platform);
|
|
191
|
+
return { binary, ...nativeSpawnInvocation(binary, argv, environment, platform) };
|
|
192
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Minimal stdlib-only prompt helpers (no inquirer/prompts dependency).
|
|
2
|
+
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prompts on the TTY with the typed characters masked as `*`.
|
|
7
|
+
* Falls back to a plain (unmasked) prompt when stdin isn't a TTY
|
|
8
|
+
* (e.g. piped input in CI), since there's nothing to mask there anyway.
|
|
9
|
+
*/
|
|
10
|
+
export function promptSecret(question) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const rl = readline.createInterface({
|
|
13
|
+
input: process.stdin,
|
|
14
|
+
output: process.stdout,
|
|
15
|
+
terminal: process.stdin.isTTY,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (process.stdin.isTTY) {
|
|
19
|
+
// Intercept the internal write so keystrokes render as `*` instead of
|
|
20
|
+
// the real character. This is the standard trick for masked prompts
|
|
21
|
+
// with plain `readline` (no external deps).
|
|
22
|
+
const rlInternal = /** @type {any} */ (rl);
|
|
23
|
+
const originalWrite = rlInternal._writeToOutput?.bind(rlInternal);
|
|
24
|
+
if (originalWrite) {
|
|
25
|
+
rlInternal._writeToOutput = (stringToWrite) => {
|
|
26
|
+
if (stringToWrite === question) {
|
|
27
|
+
originalWrite(stringToWrite);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
originalWrite("*".repeat(stringToWrite.length));
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
rl.question(question, (answer) => {
|
|
36
|
+
rl.close();
|
|
37
|
+
process.stdout.write("\n");
|
|
38
|
+
resolve(answer.trim());
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function promptText(question) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
46
|
+
rl.question(question, (answer) => {
|
|
47
|
+
rl.close();
|
|
48
|
+
resolve(answer.trim());
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
/** Stable entry point used by managed subprocess configs, including on Windows. */
|
|
4
|
+
export const IMPEL_CLI_ENTRYPOINT = fileURLToPath(new URL("../bin/impel.js", import.meta.url));
|
|
5
|
+
|
|
6
|
+
export function impelCliInvocation(args = []) {
|
|
7
|
+
return {
|
|
8
|
+
command: process.execPath,
|
|
9
|
+
args: [IMPEL_CLI_ENTRYPOINT, ...args],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const IMPEL_MANAGED_MCP_ENV = "IMPEL_MANAGED_MCP";
|
|
14
|
+
|
|
15
|
+
export function impelMcpInvocation(args = []) {
|
|
16
|
+
return {
|
|
17
|
+
type: "stdio",
|
|
18
|
+
...impelCliInvocation(["mcp", ...args]),
|
|
19
|
+
env: { [IMPEL_MANAGED_MCP_ENV]: "1" },
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Syncs Impel's Bifrost shared-skills marketplace into the managed Claude Code
|
|
2
|
+
// and Codex clients. The Bifrost gateway exposes a PUBLIC (unauthenticated)
|
|
3
|
+
// skills registry that bundles every served skill into a single plugin named
|
|
4
|
+
// `bifrost-all-skills`, published through per-client marketplaces:
|
|
5
|
+
//
|
|
6
|
+
// Claude Code: <gateway>/api/skills/serve/claude-code/.claude-plugin/marketplace.json
|
|
7
|
+
// Codex source: <gateway>/api/skills/serve/codex
|
|
8
|
+
// manifest: <source>/.agents/plugins/marketplace.json
|
|
9
|
+
//
|
|
10
|
+
// `syncSkills` is idempotent: it registers the marketplace, ensures
|
|
11
|
+
// `bifrost-all-skills` is installed, then runs the refresh commands so an
|
|
12
|
+
// already-installed plugin picks up a newer bundle. It NEVER throws — every
|
|
13
|
+
// failure degrades to a warning so the install/update path that called it keeps
|
|
14
|
+
// going.
|
|
15
|
+
//
|
|
16
|
+
// The two clients expose different plugin verbs (confirmed against the shipped
|
|
17
|
+
// CLIs, July 2026):
|
|
18
|
+
// - Claude Code: `plugin marketplace add|update`, `plugin install`, `plugin update`
|
|
19
|
+
// - Codex: `plugin marketplace add|upgrade`, `plugin add` (install + refresh)
|
|
20
|
+
// so we keep a per-client command table rather than assuming one shape.
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
import { resolveDefaultGateway, normalizeGatewayUrl } from "./config.js";
|
|
25
|
+
import { nativeCommandInvocation } from "./nativeProcess.js";
|
|
26
|
+
|
|
27
|
+
/** The bundled plugin the Bifrost registry publishes; contains every served skill. */
|
|
28
|
+
export const SKILL_PLUGIN_NAME = "bifrost-all-skills";
|
|
29
|
+
|
|
30
|
+
/** Final fallback only — prefer the configured gateway (see resolveSkillsGateway). */
|
|
31
|
+
export const SKILLS_FALLBACK_GATEWAY_URL = "https://gateway.useimpel.ai";
|
|
32
|
+
|
|
33
|
+
// Benign, expected non-zero outcomes when re-running an idempotent sync. Codex
|
|
34
|
+
// and Claude both exit non-zero when a marketplace/plugin is already present, so
|
|
35
|
+
// these must not be reported as real failures.
|
|
36
|
+
const BENIGN_OUTPUT = /already (exist|install|add|present|configur)|up[ -]?to[ -]?date|no changes|nothing to (do|update)/i;
|
|
37
|
+
|
|
38
|
+
const CLIENT_SPECS = {
|
|
39
|
+
claude: {
|
|
40
|
+
bin: "claude",
|
|
41
|
+
label: "Claude Code",
|
|
42
|
+
marketplacePath: "/api/skills/serve/claude-code/.claude-plugin/marketplace.json",
|
|
43
|
+
marketplaceSourcePath: "/api/skills/serve/claude-code/.claude-plugin/marketplace.json",
|
|
44
|
+
},
|
|
45
|
+
codex: {
|
|
46
|
+
bin: "codex",
|
|
47
|
+
label: "Codex",
|
|
48
|
+
marketplacePath: "/api/skills/serve/codex/.agents/plugins/marketplace.json",
|
|
49
|
+
marketplaceSourcePath: "/api/skills/serve/codex",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Public marketplace manifest URL used to resolve the registered name. */
|
|
54
|
+
export function marketplaceUrl(gatewayUrl, client) {
|
|
55
|
+
const spec = CLIENT_SPECS[client];
|
|
56
|
+
if (!spec) throw new Error(`unknown skills client "${client}"`);
|
|
57
|
+
return `${normalizeGatewayUrl(gatewayUrl)}${spec.marketplacePath}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Public source accepted by the client's marketplace registration command.
|
|
62
|
+
* Claude accepts its manifest URL; Codex clones the Git repository root and
|
|
63
|
+
* discovers `.agents/plugins/marketplace.json` inside it.
|
|
64
|
+
*/
|
|
65
|
+
export function marketplaceSourceUrl(gatewayUrl, client) {
|
|
66
|
+
const spec = CLIENT_SPECS[client];
|
|
67
|
+
if (!spec) throw new Error(`unknown skills client "${client}"`);
|
|
68
|
+
return `${normalizeGatewayUrl(gatewayUrl)}${spec.marketplaceSourcePath}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The registered marketplace name, read from a fetched marketplace.json (its `name`). */
|
|
72
|
+
export function resolveMarketplaceName(marketplace) {
|
|
73
|
+
const name = marketplace?.name;
|
|
74
|
+
return typeof name === "string" && name.trim() ? name.trim() : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the gateway URL for skill serving. Reuses the CLI's configured gateway
|
|
79
|
+
* (the caller passes `config.gatewayUrl`), then the env/default from config.js,
|
|
80
|
+
* and only as a last resort the hardcoded fallback.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveSkillsGateway(explicit) {
|
|
83
|
+
let base = explicit && String(explicit).trim() ? explicit : null;
|
|
84
|
+
if (!base) {
|
|
85
|
+
try {
|
|
86
|
+
base = resolveDefaultGateway();
|
|
87
|
+
} catch {
|
|
88
|
+
base = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return normalizeGatewayUrl(base || SKILLS_FALLBACK_GATEWAY_URL);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the ordered, idempotent command list (argv after the client binary) for
|
|
96
|
+
* a client. When the marketplace name is unknown (fetch failed), commands that
|
|
97
|
+
* strictly need it are dropped and installs fall back to a bare plugin id.
|
|
98
|
+
*/
|
|
99
|
+
export function buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, marketplaceName }) {
|
|
100
|
+
const spec = CLIENT_SPECS[client];
|
|
101
|
+
if (!spec) throw new Error(`unknown skills client "${client}"`);
|
|
102
|
+
const plugin = SKILL_PLUGIN_NAME;
|
|
103
|
+
const name = marketplaceName || null;
|
|
104
|
+
const commands = [
|
|
105
|
+
{ phase: "register", description: "register marketplace", args: ["plugin", "marketplace", "add", sourceUrl] },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
if (client === "claude") {
|
|
109
|
+
// Claude resolves a bare plugin id across all marketplaces, so install works
|
|
110
|
+
// even without the name; @name just pins the source when we know it.
|
|
111
|
+
commands.push({
|
|
112
|
+
phase: "install",
|
|
113
|
+
description: `install ${plugin}`,
|
|
114
|
+
args: ["plugin", "install", name ? `${plugin}@${name}` : plugin],
|
|
115
|
+
});
|
|
116
|
+
if (name) {
|
|
117
|
+
commands.push({
|
|
118
|
+
phase: "refresh-marketplace",
|
|
119
|
+
description: "refresh marketplace",
|
|
120
|
+
args: ["plugin", "marketplace", "update", name],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
commands.push({
|
|
124
|
+
phase: "refresh-plugin",
|
|
125
|
+
description: `update ${plugin}`,
|
|
126
|
+
// Claude registers the plugin under PLUGIN@MARKETPLACE; the bare name is
|
|
127
|
+
// "not found" once installed from a named marketplace.
|
|
128
|
+
args: ["plugin", "update", name ? `${plugin}@${name}` : plugin],
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
// Codex's `plugin add` requires a marketplace (PLUGIN@MARKETPLACE). Without a
|
|
132
|
+
// name we can only best-effort a bare add; with a name we install, upgrade
|
|
133
|
+
// the snapshot, and re-add to pin the refreshed bundle.
|
|
134
|
+
if (name) {
|
|
135
|
+
commands.push({
|
|
136
|
+
phase: "install",
|
|
137
|
+
description: `install ${plugin}`,
|
|
138
|
+
args: ["plugin", "add", `${plugin}@${name}`],
|
|
139
|
+
});
|
|
140
|
+
commands.push({
|
|
141
|
+
phase: "refresh-marketplace",
|
|
142
|
+
description: "refresh marketplace",
|
|
143
|
+
args: ["plugin", "marketplace", "upgrade", name],
|
|
144
|
+
});
|
|
145
|
+
commands.push({
|
|
146
|
+
phase: "refresh-plugin",
|
|
147
|
+
description: `update ${plugin}`,
|
|
148
|
+
args: ["plugin", "add", `${plugin}@${name}`],
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
commands.push({
|
|
152
|
+
phase: "install",
|
|
153
|
+
description: `install ${plugin}`,
|
|
154
|
+
args: ["plugin", "add", plugin],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return commands;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Default command runner: spawns the client binary non-interactively. */
|
|
163
|
+
function defaultRun(bin, args, env) {
|
|
164
|
+
const environment = { ...process.env, ...env };
|
|
165
|
+
let invocation;
|
|
166
|
+
try {
|
|
167
|
+
invocation = nativeCommandInvocation(bin, args, environment);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
missing: false,
|
|
172
|
+
status: null,
|
|
173
|
+
stdout: "",
|
|
174
|
+
stderr: error?.message || String(error),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
178
|
+
env: environment,
|
|
179
|
+
encoding: "utf8",
|
|
180
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
181
|
+
timeout: 120000,
|
|
182
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
183
|
+
});
|
|
184
|
+
if (result.error) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
missing: result.error.code === "ENOENT",
|
|
188
|
+
status: null,
|
|
189
|
+
stdout: "",
|
|
190
|
+
stderr: result.error.message || String(result.error),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
ok: result.status === 0,
|
|
195
|
+
missing: false,
|
|
196
|
+
status: result.status,
|
|
197
|
+
stdout: result.stdout || "",
|
|
198
|
+
stderr: result.stderr || "",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function firstLine(text) {
|
|
203
|
+
return String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isBenign(result) {
|
|
207
|
+
return result.ok || BENIGN_OUTPUT.test(`${result.stdout}\n${result.stderr}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Fetch the marketplace.json and return its registered name, or null on any failure. */
|
|
211
|
+
export async function fetchMarketplaceName(url, fetchImpl = fetch) {
|
|
212
|
+
const controller = new AbortController();
|
|
213
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
214
|
+
try {
|
|
215
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
216
|
+
if (!response?.ok) return null;
|
|
217
|
+
const payload = await response.json();
|
|
218
|
+
return resolveMarketplaceName(payload);
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
} finally {
|
|
222
|
+
clearTimeout(timeout);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Idempotently sync the Bifrost shared-skills plugin into one managed client
|
|
228
|
+
* profile. Targets the SAME binary + profile the caller manages by passing the
|
|
229
|
+
* relevant `env` overrides (e.g. `CODEX_HOME` for a Codex profile,
|
|
230
|
+
* `CLAUDE_CONFIG_DIR` for an isolated Claude profile). Never throws.
|
|
231
|
+
*
|
|
232
|
+
* @returns {Promise<{client, label, synced?, skipped?, reason?, marketplaceName?, failures?}>}
|
|
233
|
+
*/
|
|
234
|
+
export async function syncSkills({
|
|
235
|
+
client,
|
|
236
|
+
gatewayUrl,
|
|
237
|
+
env = {},
|
|
238
|
+
label,
|
|
239
|
+
fetchImpl = fetch,
|
|
240
|
+
run = defaultRun,
|
|
241
|
+
logger = console,
|
|
242
|
+
} = {}) {
|
|
243
|
+
const spec = CLIENT_SPECS[client];
|
|
244
|
+
if (!spec) {
|
|
245
|
+
logger.warn(`impel: cannot sync skills for unknown client "${client}".`);
|
|
246
|
+
return { client, label: label || String(client), skipped: true, reason: "unknown-client" };
|
|
247
|
+
}
|
|
248
|
+
const displayLabel = label || spec.label;
|
|
249
|
+
|
|
250
|
+
// Escape hatch for offline/CI/enterprise use: skip all skill syncing.
|
|
251
|
+
if (process.env.IMPEL_SKIP_SKILL_SYNC === "1" || process.env.IMPEL_SKIP_SKILL_SYNC === "true") {
|
|
252
|
+
return { client, label: displayLabel, skipped: true, reason: "disabled" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
// Confirm the binary and its `plugin` subcommand exist before doing anything.
|
|
257
|
+
const help = run(spec.bin, ["plugin", "--help"], env);
|
|
258
|
+
if (help.missing) {
|
|
259
|
+
logger.warn(`impel: skipping skill sync for ${displayLabel} — \`${spec.bin}\` CLI not found on PATH.`);
|
|
260
|
+
return { client, label: displayLabel, skipped: true, reason: "binary-missing" };
|
|
261
|
+
}
|
|
262
|
+
if (!help.ok) {
|
|
263
|
+
logger.warn(`impel: skipping skill sync for ${displayLabel} — \`${spec.bin}\` has no \`plugin\` command (update the CLI).`);
|
|
264
|
+
return { client, label: displayLabel, skipped: true, reason: "no-plugin-subcommand" };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const manifestUrl = marketplaceUrl(gatewayUrl, client);
|
|
268
|
+
const sourceUrl = marketplaceSourceUrl(gatewayUrl, client);
|
|
269
|
+
const marketplaceName = await fetchMarketplaceName(manifestUrl, fetchImpl);
|
|
270
|
+
const commands = buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, marketplaceName });
|
|
271
|
+
|
|
272
|
+
logger.log(`Skills: syncing ${SKILL_PLUGIN_NAME} for ${displayLabel}…`);
|
|
273
|
+
const failures = [];
|
|
274
|
+
for (const command of commands) {
|
|
275
|
+
const result = run(spec.bin, command.args, env);
|
|
276
|
+
if (result.missing) {
|
|
277
|
+
failures.push({ phase: command.phase, reason: `\`${spec.bin}\` disappeared mid-sync` });
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
if (!isBenign(result)) {
|
|
281
|
+
failures.push({ phase: command.phase, reason: firstLine(result.stderr) || `exit ${result.status}` });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (failures.length > 0) {
|
|
286
|
+
const first = failures[0];
|
|
287
|
+
logger.warn(
|
|
288
|
+
`impel: skill sync for ${displayLabel} finished with warnings (${first.phase}: ${first.reason}). ` +
|
|
289
|
+
`Skills may be stale; re-run \`impel skills sync\`.`
|
|
290
|
+
);
|
|
291
|
+
return { client, label: displayLabel, synced: false, marketplaceName, failures };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
logger.log(`Skills: ${displayLabel} up to date (${SKILL_PLUGIN_NAME}${marketplaceName ? ` via ${marketplaceName}` : ""}).`);
|
|
295
|
+
return { client, label: displayLabel, synced: true, marketplaceName };
|
|
296
|
+
} catch (error) {
|
|
297
|
+
logger.warn(`impel: skill sync for ${displayLabel} failed (${error?.message || error}); continuing.`);
|
|
298
|
+
return { client, label: displayLabel, synced: false, reason: "error" };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Convenience wrapper used by install/update call sites: awaits syncSkills and
|
|
304
|
+
* swallows any unexpected rejection so the parent command can never fail because
|
|
305
|
+
* of skill syncing.
|
|
306
|
+
*/
|
|
307
|
+
export async function syncSkillsSafe(options) {
|
|
308
|
+
try {
|
|
309
|
+
return await syncSkills(options);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
(options?.logger || console).warn(`impel: skill sync error (${error?.message || error}); continuing.`);
|
|
312
|
+
return { synced: false, reason: "error" };
|
|
313
|
+
}
|
|
314
|
+
}
|