frontmcp 1.2.1 → 1.4.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 +38 -29
- package/package.json +4 -4
- package/src/commands/build/exec/bin-meta.d.ts +49 -0
- package/src/commands/build/exec/bin-meta.js +68 -0
- package/src/commands/build/exec/bin-meta.js.map +1 -0
- package/src/commands/build/exec/cli-runtime/generate-cli-entry.js +195 -3
- package/src/commands/build/exec/cli-runtime/generate-cli-entry.js.map +1 -1
- package/src/commands/build/exec/cli-runtime/plugin-emitter.d.ts +160 -0
- package/src/commands/build/exec/cli-runtime/plugin-emitter.js +512 -0
- package/src/commands/build/exec/cli-runtime/plugin-emitter.js.map +1 -0
- package/src/commands/build/exec/cli-runtime/schema-extractor.d.ts +13 -1
- package/src/commands/build/exec/cli-runtime/schema-extractor.js +29 -3
- package/src/commands/build/exec/cli-runtime/schema-extractor.js.map +1 -1
- package/src/commands/build/exec/cli-runtime/skill-md-compose.d.ts +25 -0
- package/src/commands/build/exec/cli-runtime/skill-md-compose.js +63 -0
- package/src/commands/build/exec/cli-runtime/skill-md-compose.js.map +1 -0
- package/src/commands/build/exec/index.js +26 -0
- package/src/commands/build/exec/index.js.map +1 -1
- package/src/commands/build/exec/runner-script.js +16 -4
- package/src/commands/build/exec/runner-script.js.map +1 -1
- package/src/commands/dev/bridge/child-supervisor.d.ts +48 -0
- package/src/commands/dev/bridge/child-supervisor.js +228 -0
- package/src/commands/dev/bridge/child-supervisor.js.map +1 -0
- package/src/commands/dev/bridge/errors.d.ts +23 -0
- package/src/commands/dev/bridge/errors.js +34 -0
- package/src/commands/dev/bridge/errors.js.map +1 -0
- package/src/commands/dev/bridge/index.d.ts +30 -0
- package/src/commands/dev/bridge/index.js +220 -0
- package/src/commands/dev/bridge/index.js.map +1 -0
- package/src/commands/dev/bridge/log.d.ts +29 -0
- package/src/commands/dev/bridge/log.js +82 -0
- package/src/commands/dev/bridge/log.js.map +1 -0
- package/src/commands/dev/bridge/state-machine.d.ts +56 -0
- package/src/commands/dev/bridge/state-machine.js +245 -0
- package/src/commands/dev/bridge/state-machine.js.map +1 -0
- package/src/commands/dev/bridge/stdio-framer.d.ts +47 -0
- package/src/commands/dev/bridge/stdio-framer.js +128 -0
- package/src/commands/dev/bridge/stdio-framer.js.map +1 -0
- package/src/commands/dev/bridge/upstream-client.d.ts +49 -0
- package/src/commands/dev/bridge/upstream-client.js +159 -0
- package/src/commands/dev/bridge/upstream-client.js.map +1 -0
- package/src/commands/dev/bridge/watcher.d.ts +30 -0
- package/src/commands/dev/bridge/watcher.js +87 -0
- package/src/commands/dev/bridge/watcher.js.map +1 -0
- package/src/commands/dev/dev.d.ts +34 -1
- package/src/commands/dev/dev.js +168 -14
- package/src/commands/dev/dev.js.map +1 -1
- package/src/commands/dev/inspector.d.ts +13 -1
- package/src/commands/dev/inspector.js +77 -3
- package/src/commands/dev/inspector.js.map +1 -1
- package/src/commands/dev/port.d.ts +23 -0
- package/src/commands/dev/port.js +87 -0
- package/src/commands/dev/port.js.map +1 -0
- package/src/commands/dev/register.d.ts +1 -1
- package/src/commands/dev/register.js +28 -4
- package/src/commands/dev/register.js.map +1 -1
- package/src/commands/dev/test.d.ts +26 -1
- package/src/commands/dev/test.js +181 -64
- package/src/commands/dev/test.js.map +1 -1
- package/src/commands/eject/mcp-client.d.ts +25 -0
- package/src/commands/eject/mcp-client.js +74 -0
- package/src/commands/eject/mcp-client.js.map +1 -0
- package/src/commands/eject/register.d.ts +9 -0
- package/src/commands/eject/register.js +56 -0
- package/src/commands/eject/register.js.map +1 -0
- package/src/commands/install/install-claude-plugin.d.ts +13 -0
- package/src/commands/install/install-claude-plugin.js +327 -0
- package/src/commands/install/install-claude-plugin.js.map +1 -0
- package/src/commands/install/register.d.ts +16 -0
- package/src/commands/install/register.js +70 -0
- package/src/commands/install/register.js.map +1 -0
- package/src/commands/scaffold/create.js +52 -8
- package/src/commands/scaffold/create.js.map +1 -1
- package/src/commands/skills/from-entry.d.ts +31 -0
- package/src/commands/skills/from-entry.js +68 -0
- package/src/commands/skills/from-entry.js.map +1 -0
- package/src/commands/skills/install.d.ts +12 -0
- package/src/commands/skills/install.js +173 -8
- package/src/commands/skills/install.js.map +1 -1
- package/src/commands/skills/register.js +7 -3
- package/src/commands/skills/register.js.map +1 -1
- package/src/config/frontmcp-config.loader.d.ts +28 -0
- package/src/config/frontmcp-config.loader.js +146 -67
- package/src/config/frontmcp-config.loader.js.map +1 -1
- package/src/config/frontmcp-config.resolve.d.ts +67 -0
- package/src/config/frontmcp-config.resolve.js +118 -0
- package/src/config/frontmcp-config.resolve.js.map +1 -0
- package/src/config/frontmcp-config.schema.d.ts +207 -0
- package/src/config/frontmcp-config.schema.js +217 -1
- package/src/config/frontmcp-config.schema.js.map +1 -1
- package/src/config/frontmcp-config.types.d.ts +133 -0
- package/src/config/frontmcp-config.types.js.map +1 -1
- package/src/config/index.d.ts +2 -1
- package/src/config/index.js +3 -1
- package/src/config/index.js.map +1 -1
- package/src/core/args.d.ts +13 -0
- package/src/core/args.js.map +1 -1
- package/src/core/bridge.js +39 -0
- package/src/core/bridge.js.map +1 -1
- package/src/core/cli.d.ts +0 -6
- package/src/core/cli.js +23 -3
- package/src/core/cli.js.map +1 -1
- package/src/core/help.d.ts +1 -1
- package/src/core/help.js +27 -6
- package/src/core/help.js.map +1 -1
- package/src/core/program.d.ts +1 -1
- package/src/core/program.js +56 -12
- package/src/core/program.js.map +1 -1
- package/src/core/project-commands.d.ts +44 -0
- package/src/core/project-commands.js +216 -0
- package/src/core/project-commands.js.map +1 -0
- package/src/core/tsconfig.d.ts +20 -0
- package/src/core/tsconfig.js +41 -2
- package/src/core/tsconfig.js.map +1 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin emitter for issue #411 — turn a FrontMCP server's metadata into
|
|
3
|
+
* a Claude Code plugin folder (`.claude/plugins/<bin>/`) or a Codex
|
|
4
|
+
* `mcp_servers` entry. Shared by the dev-tool `frontmcp install` command
|
|
5
|
+
* and the per-bin `<bin> install -p claude|codex` command.
|
|
6
|
+
*
|
|
7
|
+
* Design constraints (from planning/issues/411.md):
|
|
8
|
+
* - Pure helper module — no side-effects at import time.
|
|
9
|
+
* - All filesystem ops go through `@frontmcp/utils`.
|
|
10
|
+
* - Re-running install must be idempotent: managed files tracked in
|
|
11
|
+
* `_meta.frontmcp.managedFiles`; user-added files in the plugin
|
|
12
|
+
* directory are NEVER deleted.
|
|
13
|
+
* - Manifest written deterministically (sorted keys, fixed spacing) so
|
|
14
|
+
* snapshots are stable and re-runs are no-ops when nothing changed.
|
|
15
|
+
* - Codex emitter writes a TOML fragment without pulling in a TOML
|
|
16
|
+
* dependency — only the `[[mcp_servers]]` shape is needed.
|
|
17
|
+
*/
|
|
18
|
+
export interface PluginEmitterSkillInput {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
/**
|
|
22
|
+
* Tags from `@Skill({ tags })`. Forwarded into the synthesized SKILL.md
|
|
23
|
+
* frontmatter so Claude Code's filesystem loader can index by tag.
|
|
24
|
+
*/
|
|
25
|
+
tags?: string[];
|
|
26
|
+
/** License from `@Skill({ license })`. Forwarded into the synthesized frontmatter. */
|
|
27
|
+
license?: string;
|
|
28
|
+
/** Absolute path to SKILL.md. When missing, only frontmatter is emitted. */
|
|
29
|
+
instructionFile?: string;
|
|
30
|
+
/** Absolute paths to the skill's resource subdirectories. */
|
|
31
|
+
resourceDirs?: {
|
|
32
|
+
references?: string;
|
|
33
|
+
examples?: string;
|
|
34
|
+
scripts?: string;
|
|
35
|
+
assets?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface PluginEmitterCommandInput {
|
|
39
|
+
/** Slash-command name without leading `/`. */
|
|
40
|
+
name: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
arguments?: Array<{
|
|
43
|
+
name: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
required?: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
export interface EmitClaudePluginOptions {
|
|
49
|
+
/** Root dir under which `<name>/` will be created. */
|
|
50
|
+
destRoot: string;
|
|
51
|
+
name: string;
|
|
52
|
+
version: string;
|
|
53
|
+
description: string;
|
|
54
|
+
/** MCP server invocation command (e.g. the bin name, or `node ./dist/main.js`). */
|
|
55
|
+
mcpCommand: string;
|
|
56
|
+
/** Typically `['serve', '--stdio']`. */
|
|
57
|
+
mcpArgs: string[];
|
|
58
|
+
/** Env-var names (placeholder values) to surface in `mcpServers.<bin>.env`. */
|
|
59
|
+
envHints: string[];
|
|
60
|
+
skills: PluginEmitterSkillInput[];
|
|
61
|
+
commands: PluginEmitterCommandInput[];
|
|
62
|
+
/** Used for `_meta.frontmcp.installedBy`. */
|
|
63
|
+
cliVersion: string;
|
|
64
|
+
/** If true: plan only, do not write. */
|
|
65
|
+
dryRun?: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface EmitClaudePluginResult {
|
|
68
|
+
pluginDir: string;
|
|
69
|
+
manifest: ClaudePluginManifest;
|
|
70
|
+
/** Absolute paths written (or that WOULD be written when dryRun). */
|
|
71
|
+
filesWritten: string[];
|
|
72
|
+
/** Absolute paths of pre-existing user files left in place. */
|
|
73
|
+
filesPreserved: string[];
|
|
74
|
+
/** Managed files from a previous install that were removed this run. */
|
|
75
|
+
filesRemoved: string[];
|
|
76
|
+
}
|
|
77
|
+
export interface ClaudePluginManifest {
|
|
78
|
+
name: string;
|
|
79
|
+
version: string;
|
|
80
|
+
description: string;
|
|
81
|
+
mcpServers: Record<string, McpServerEntry>;
|
|
82
|
+
skills: string[];
|
|
83
|
+
commands?: string[];
|
|
84
|
+
_meta: {
|
|
85
|
+
frontmcp: {
|
|
86
|
+
installedBy: string;
|
|
87
|
+
installedAt: string;
|
|
88
|
+
bin: string;
|
|
89
|
+
binVersion: string;
|
|
90
|
+
managedFields: string[];
|
|
91
|
+
managedFiles: string[];
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export interface McpServerEntry {
|
|
96
|
+
command: string;
|
|
97
|
+
args: string[];
|
|
98
|
+
transport?: 'stdio';
|
|
99
|
+
env?: Record<string, string>;
|
|
100
|
+
}
|
|
101
|
+
export interface EmitCodexOptions {
|
|
102
|
+
/** `~/.codex/config.toml` typically. */
|
|
103
|
+
configPath: string;
|
|
104
|
+
name: string;
|
|
105
|
+
command: string;
|
|
106
|
+
args: string[];
|
|
107
|
+
env?: Record<string, string>;
|
|
108
|
+
dryRun?: boolean;
|
|
109
|
+
}
|
|
110
|
+
export interface EmitCodexResult {
|
|
111
|
+
written: boolean;
|
|
112
|
+
previousVersion?: string;
|
|
113
|
+
/** Final TOML content (or what would be written when dryRun). */
|
|
114
|
+
configContent: string;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Validate a plugin name before it touches the filesystem or TOML blocks.
|
|
118
|
+
* Rejects anything that could traverse out of the destination directory,
|
|
119
|
+
* inject newlines into the codex block markers (`# frontmcp:codex-start:<name>`
|
|
120
|
+
* / `# frontmcp:codex-end:<name>` — finding from #411 review), or smuggle
|
|
121
|
+
* TOML-significant characters past the `tomlString` quoting.
|
|
122
|
+
*
|
|
123
|
+
* The allowed set is intentionally narrow — npm-style scoped names
|
|
124
|
+
* (`@scope/name`) are NOT allowed since the `@` and `/` characters would
|
|
125
|
+
* still produce surprising directory layouts. Callers that need scoped
|
|
126
|
+
* names should sanitise first.
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* Validate a slash-command name (frontmatter / body content). Same rules
|
|
130
|
+
* as `assertValidPluginName` so a malicious command name cannot inject
|
|
131
|
+
* YAML directives or new markdown sections via interpolated body text
|
|
132
|
+
* in `renderCommandFile`.
|
|
133
|
+
*/
|
|
134
|
+
export declare function assertValidCommandName(name: string, where: string): void;
|
|
135
|
+
export declare function assertValidPluginName(name: string, where: string): void;
|
|
136
|
+
/**
|
|
137
|
+
* Returns true when `rel` resolves to a path strictly inside `pluginDir`.
|
|
138
|
+
* Defends `emitClaudePlugin` and `removeClaudePlugin` against a tampered
|
|
139
|
+
* prior `plugin.json` whose `_meta.frontmcp.managedFiles` entries try to
|
|
140
|
+
* escape the plugin root with `../` traversals or absolute paths.
|
|
141
|
+
*/
|
|
142
|
+
export declare function isPluginContainedPath(pluginDir: string, rel: string): boolean;
|
|
143
|
+
export declare function emitClaudePlugin(opts: EmitClaudePluginOptions): Promise<EmitClaudePluginResult>;
|
|
144
|
+
export declare function removeClaudePlugin(args: {
|
|
145
|
+
destRoot: string;
|
|
146
|
+
name: string;
|
|
147
|
+
}): Promise<{
|
|
148
|
+
removed: string[];
|
|
149
|
+
preserved: string[];
|
|
150
|
+
pluginDir: string;
|
|
151
|
+
}>;
|
|
152
|
+
export declare function readInstalledPluginVersion(pluginDir: string): Promise<string | undefined>;
|
|
153
|
+
export declare function emitCodexEntry(opts: EmitCodexOptions): Promise<EmitCodexResult>;
|
|
154
|
+
export declare function removeCodexEntry(args: {
|
|
155
|
+
configPath: string;
|
|
156
|
+
name: string;
|
|
157
|
+
}): Promise<{
|
|
158
|
+
removed: boolean;
|
|
159
|
+
configContent: string;
|
|
160
|
+
}>;
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Plugin emitter for issue #411 — turn a FrontMCP server's metadata into
|
|
4
|
+
* a Claude Code plugin folder (`.claude/plugins/<bin>/`) or a Codex
|
|
5
|
+
* `mcp_servers` entry. Shared by the dev-tool `frontmcp install` command
|
|
6
|
+
* and the per-bin `<bin> install -p claude|codex` command.
|
|
7
|
+
*
|
|
8
|
+
* Design constraints (from planning/issues/411.md):
|
|
9
|
+
* - Pure helper module — no side-effects at import time.
|
|
10
|
+
* - All filesystem ops go through `@frontmcp/utils`.
|
|
11
|
+
* - Re-running install must be idempotent: managed files tracked in
|
|
12
|
+
* `_meta.frontmcp.managedFiles`; user-added files in the plugin
|
|
13
|
+
* directory are NEVER deleted.
|
|
14
|
+
* - Manifest written deterministically (sorted keys, fixed spacing) so
|
|
15
|
+
* snapshots are stable and re-runs are no-ops when nothing changed.
|
|
16
|
+
* - Codex emitter writes a TOML fragment without pulling in a TOML
|
|
17
|
+
* dependency — only the `[[mcp_servers]]` shape is needed.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.assertValidCommandName = assertValidCommandName;
|
|
21
|
+
exports.assertValidPluginName = assertValidPluginName;
|
|
22
|
+
exports.isPluginContainedPath = isPluginContainedPath;
|
|
23
|
+
exports.emitClaudePlugin = emitClaudePlugin;
|
|
24
|
+
exports.removeClaudePlugin = removeClaudePlugin;
|
|
25
|
+
exports.readInstalledPluginVersion = readInstalledPluginVersion;
|
|
26
|
+
exports.emitCodexEntry = emitCodexEntry;
|
|
27
|
+
exports.removeCodexEntry = removeCodexEntry;
|
|
28
|
+
const tslib_1 = require("tslib");
|
|
29
|
+
const path = tslib_1.__importStar(require("path"));
|
|
30
|
+
const utils_1 = require("@frontmcp/utils");
|
|
31
|
+
const skill_md_compose_1 = require("./skill-md-compose");
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Claude plugin emitter
|
|
34
|
+
// ============================================================================
|
|
35
|
+
/**
|
|
36
|
+
* Validate a plugin name before it touches the filesystem or TOML blocks.
|
|
37
|
+
* Rejects anything that could traverse out of the destination directory,
|
|
38
|
+
* inject newlines into the codex block markers (`# frontmcp:codex-start:<name>`
|
|
39
|
+
* / `# frontmcp:codex-end:<name>` — finding from #411 review), or smuggle
|
|
40
|
+
* TOML-significant characters past the `tomlString` quoting.
|
|
41
|
+
*
|
|
42
|
+
* The allowed set is intentionally narrow — npm-style scoped names
|
|
43
|
+
* (`@scope/name`) are NOT allowed since the `@` and `/` characters would
|
|
44
|
+
* still produce surprising directory layouts. Callers that need scoped
|
|
45
|
+
* names should sanitise first.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Validate a slash-command name (frontmatter / body content). Same rules
|
|
49
|
+
* as `assertValidPluginName` so a malicious command name cannot inject
|
|
50
|
+
* YAML directives or new markdown sections via interpolated body text
|
|
51
|
+
* in `renderCommandFile`.
|
|
52
|
+
*/
|
|
53
|
+
function assertValidCommandName(name, where) {
|
|
54
|
+
assertValidPluginName(name, where);
|
|
55
|
+
}
|
|
56
|
+
function assertValidPluginName(name, where) {
|
|
57
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
58
|
+
throw new Error(`Invalid plugin name passed to ${where}: must be a non-empty string`);
|
|
59
|
+
}
|
|
60
|
+
if (name.length > 64) {
|
|
61
|
+
throw new Error(`Invalid plugin name "${name}" passed to ${where}: must be 64 chars or fewer`);
|
|
62
|
+
}
|
|
63
|
+
if (name === '.' || name === '..') {
|
|
64
|
+
throw new Error(`Invalid plugin name "${name}" passed to ${where}: must not be "." or ".."`);
|
|
65
|
+
}
|
|
66
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) {
|
|
67
|
+
throw new Error(`Invalid plugin name "${name}" passed to ${where}: must match /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ (no slashes, newlines, or special chars)`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Returns true when `rel` resolves to a path strictly inside `pluginDir`.
|
|
72
|
+
* Defends `emitClaudePlugin` and `removeClaudePlugin` against a tampered
|
|
73
|
+
* prior `plugin.json` whose `_meta.frontmcp.managedFiles` entries try to
|
|
74
|
+
* escape the plugin root with `../` traversals or absolute paths.
|
|
75
|
+
*/
|
|
76
|
+
function isPluginContainedPath(pluginDir, rel) {
|
|
77
|
+
if (typeof rel !== 'string' || rel.length === 0)
|
|
78
|
+
return false;
|
|
79
|
+
if (path.isAbsolute(rel))
|
|
80
|
+
return false;
|
|
81
|
+
const resolved = path.resolve(pluginDir, rel);
|
|
82
|
+
const relative = path.relative(pluginDir, resolved);
|
|
83
|
+
if (relative === '')
|
|
84
|
+
return false; // never delete the plugin dir itself via a managedFile entry
|
|
85
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
86
|
+
}
|
|
87
|
+
const FRAMEWORK_MANAGED_FIELDS = [
|
|
88
|
+
'name',
|
|
89
|
+
'version',
|
|
90
|
+
'description',
|
|
91
|
+
'mcpServers',
|
|
92
|
+
'skills',
|
|
93
|
+
'commands',
|
|
94
|
+
'_meta.frontmcp',
|
|
95
|
+
];
|
|
96
|
+
async function emitClaudePlugin(opts) {
|
|
97
|
+
assertValidPluginName(opts.name, 'emitClaudePlugin');
|
|
98
|
+
const pluginDir = path.join(opts.destRoot, opts.name);
|
|
99
|
+
const claudePluginDir = path.join(pluginDir, '.claude-plugin');
|
|
100
|
+
const manifestPath = path.join(claudePluginDir, 'plugin.json');
|
|
101
|
+
// Read prior manifest (idempotency baseline) — file may not exist on first install.
|
|
102
|
+
const previousManifest = await readPluginManifest(manifestPath);
|
|
103
|
+
const previouslyManaged = previousManifest?._meta?.frontmcp?.managedFiles ?? [];
|
|
104
|
+
// Build the new file plan in deterministic order.
|
|
105
|
+
const plannedFiles = [];
|
|
106
|
+
// Skills subtree. Validate the name before it lands in a filesystem
|
|
107
|
+
// path or in synthesized SKILL.md frontmatter — same rules as command
|
|
108
|
+
// names (issue #411 security pass), so a malicious `@Skill({ name: '../x' })`
|
|
109
|
+
// can't escape the plugin tree.
|
|
110
|
+
for (const skill of [...opts.skills].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
111
|
+
assertValidPluginName(skill.name, 'emitClaudePlugin.skill');
|
|
112
|
+
const skillDir = path.join(pluginDir, 'skills', skill.name);
|
|
113
|
+
const skillMd = path.join(skillDir, 'SKILL.md');
|
|
114
|
+
plannedFiles.push({
|
|
115
|
+
absPath: skillMd,
|
|
116
|
+
relPath: path.relative(pluginDir, skillMd),
|
|
117
|
+
action: async () => {
|
|
118
|
+
await (0, utils_1.ensureDir)(skillDir);
|
|
119
|
+
const body = skill.instructionFile && (await (0, utils_1.fileExists)(skill.instructionFile))
|
|
120
|
+
? await (0, utils_1.readFile)(skill.instructionFile)
|
|
121
|
+
: '';
|
|
122
|
+
// The instruction file is typically a raw markdown body authored by
|
|
123
|
+
// the user; Claude Code's filesystem loader needs YAML frontmatter
|
|
124
|
+
// with at least `name` + `description`. composeSkillMd preserves a
|
|
125
|
+
// pre-existing frontmatter block verbatim and otherwise synthesizes
|
|
126
|
+
// one from the decorator metadata we plumbed through bin-meta.json.
|
|
127
|
+
await (0, utils_1.writeFile)(skillMd, (0, skill_md_compose_1.composeSkillMd)({ name: skill.name, description: skill.description, tags: skill.tags, license: skill.license }, body));
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
for (const kind of ['references', 'examples', 'scripts', 'assets']) {
|
|
131
|
+
const src = skill.resourceDirs?.[kind];
|
|
132
|
+
if (!src)
|
|
133
|
+
continue;
|
|
134
|
+
const dest = path.join(skillDir, kind);
|
|
135
|
+
plannedFiles.push({
|
|
136
|
+
absPath: dest,
|
|
137
|
+
relPath: path.relative(pluginDir, dest),
|
|
138
|
+
action: async () => {
|
|
139
|
+
await (0, utils_1.ensureDir)(skillDir);
|
|
140
|
+
if (await (0, utils_1.fileExists)(src)) {
|
|
141
|
+
await (0, utils_1.cp)(src, dest, { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Commands subtree. Validate each command name before it lands in the
|
|
148
|
+
// markdown body or the filesystem path — same rules as the plugin name.
|
|
149
|
+
for (const cmd of [...opts.commands].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
150
|
+
assertValidCommandName(cmd.name, 'emitClaudePlugin.command');
|
|
151
|
+
const cmdPath = path.join(pluginDir, 'commands', `${cmd.name}.md`);
|
|
152
|
+
plannedFiles.push({
|
|
153
|
+
absPath: cmdPath,
|
|
154
|
+
relPath: path.relative(pluginDir, cmdPath),
|
|
155
|
+
action: async () => {
|
|
156
|
+
await (0, utils_1.ensureDir)(path.dirname(cmdPath));
|
|
157
|
+
await (0, utils_1.writeFile)(cmdPath, renderCommandFile(cmd, opts.name));
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// Build the manifest. Manifest is itself a managed file and appears last in
|
|
162
|
+
// managedFiles so removal-on-uninstall happens after subtree cleanup.
|
|
163
|
+
const newManagedFiles = plannedFiles.map((f) => f.relPath).sort();
|
|
164
|
+
const manifest = {
|
|
165
|
+
name: opts.name,
|
|
166
|
+
version: opts.version,
|
|
167
|
+
description: opts.description,
|
|
168
|
+
mcpServers: {
|
|
169
|
+
[opts.name]: makeMcpServerEntry(opts),
|
|
170
|
+
},
|
|
171
|
+
skills: opts.skills.map((s) => s.name).sort(),
|
|
172
|
+
...(opts.commands.length > 0 ? { commands: opts.commands.map((c) => c.name).sort() } : {}),
|
|
173
|
+
_meta: {
|
|
174
|
+
frontmcp: {
|
|
175
|
+
installedBy: `frontmcp@${opts.cliVersion}`,
|
|
176
|
+
installedAt: new Date().toISOString(),
|
|
177
|
+
bin: opts.name,
|
|
178
|
+
binVersion: opts.version,
|
|
179
|
+
managedFields: [...FRAMEWORK_MANAGED_FIELDS].sort(),
|
|
180
|
+
managedFiles: newManagedFiles,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
// Merge any user-owned top-level keys back in from the previous manifest.
|
|
185
|
+
const mergedManifest = mergeUserManifestFields(manifest, previousManifest);
|
|
186
|
+
// Compute the file delta. We don't `rm -rf` the plugin dir; we only:
|
|
187
|
+
// 1. Remove previously-managed files that are no longer in newManagedFiles.
|
|
188
|
+
// 2. Write/overwrite the new managed files.
|
|
189
|
+
// 3. Write the manifest.
|
|
190
|
+
const previousSet = new Set(previouslyManaged);
|
|
191
|
+
const newSet = new Set(newManagedFiles);
|
|
192
|
+
const removed = [];
|
|
193
|
+
const written = [];
|
|
194
|
+
if (!opts.dryRun) {
|
|
195
|
+
await (0, utils_1.ensureDir)(claudePluginDir);
|
|
196
|
+
// 1. Drop stale managed files. Guard against a tampered prior manifest
|
|
197
|
+
// that may have injected escape-paths into `managedFiles`.
|
|
198
|
+
for (const rel of previousSet) {
|
|
199
|
+
if (newSet.has(rel))
|
|
200
|
+
continue;
|
|
201
|
+
if (!isPluginContainedPath(pluginDir, rel))
|
|
202
|
+
continue;
|
|
203
|
+
const abs = path.join(pluginDir, rel);
|
|
204
|
+
if (await (0, utils_1.fileExists)(abs)) {
|
|
205
|
+
await (0, utils_1.rm)(abs, { recursive: true, force: true });
|
|
206
|
+
removed.push(abs);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// 2. Write new/refreshed managed files.
|
|
210
|
+
for (const f of plannedFiles) {
|
|
211
|
+
await f.action();
|
|
212
|
+
written.push(f.absPath);
|
|
213
|
+
}
|
|
214
|
+
// 3. Manifest.
|
|
215
|
+
await (0, utils_1.writeFile)(manifestPath, serializeManifest(mergedManifest));
|
|
216
|
+
written.push(manifestPath);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
written.push(...plannedFiles.map((f) => f.absPath), manifestPath);
|
|
220
|
+
}
|
|
221
|
+
const preserved = previousManifest
|
|
222
|
+
? Object.keys(previousManifest)
|
|
223
|
+
.filter((k) => !FRAMEWORK_MANAGED_FIELDS.includes(k) && k !== '_meta')
|
|
224
|
+
.map((k) => `<plugin.json>.${k}`)
|
|
225
|
+
: [];
|
|
226
|
+
return {
|
|
227
|
+
pluginDir,
|
|
228
|
+
manifest: mergedManifest,
|
|
229
|
+
filesWritten: written,
|
|
230
|
+
filesPreserved: preserved,
|
|
231
|
+
filesRemoved: removed,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
async function removeClaudePlugin(args) {
|
|
235
|
+
assertValidPluginName(args.name, 'removeClaudePlugin');
|
|
236
|
+
const pluginDir = path.join(args.destRoot, args.name);
|
|
237
|
+
const manifestPath = path.join(pluginDir, '.claude-plugin', 'plugin.json');
|
|
238
|
+
const manifest = await readPluginManifest(manifestPath);
|
|
239
|
+
const removed = [];
|
|
240
|
+
const preserved = [];
|
|
241
|
+
if (!manifest) {
|
|
242
|
+
return { removed, preserved, pluginDir };
|
|
243
|
+
}
|
|
244
|
+
// A tampered `plugin.json` could have injected paths like `../../etc/foo`
|
|
245
|
+
// into `_meta.frontmcp.managedFiles` to coerce `rm` outside `pluginDir`
|
|
246
|
+
// on uninstall. Guard each entry against escaping the plugin root.
|
|
247
|
+
const managed = manifest._meta?.frontmcp?.managedFiles ?? [];
|
|
248
|
+
for (const rel of managed) {
|
|
249
|
+
if (!isPluginContainedPath(pluginDir, rel))
|
|
250
|
+
continue;
|
|
251
|
+
const abs = path.join(pluginDir, rel);
|
|
252
|
+
if (await (0, utils_1.fileExists)(abs)) {
|
|
253
|
+
await (0, utils_1.rm)(abs, { recursive: true, force: true });
|
|
254
|
+
removed.push(abs);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (await (0, utils_1.fileExists)(manifestPath)) {
|
|
258
|
+
await (0, utils_1.rm)(manifestPath, { force: true });
|
|
259
|
+
removed.push(manifestPath);
|
|
260
|
+
}
|
|
261
|
+
// Bottom-up empty-dir cleanup. Stops at first non-empty dir, preserving
|
|
262
|
+
// user-added files.
|
|
263
|
+
await cleanupEmptyDirs(pluginDir, preserved);
|
|
264
|
+
return { removed, preserved, pluginDir };
|
|
265
|
+
}
|
|
266
|
+
async function readInstalledPluginVersion(pluginDir) {
|
|
267
|
+
const manifestPath = path.join(pluginDir, '.claude-plugin', 'plugin.json');
|
|
268
|
+
const manifest = await readPluginManifest(manifestPath);
|
|
269
|
+
return manifest?._meta?.frontmcp?.binVersion;
|
|
270
|
+
}
|
|
271
|
+
// ============================================================================
|
|
272
|
+
// Codex emitter
|
|
273
|
+
// ============================================================================
|
|
274
|
+
const CODEX_BLOCK_START = '# frontmcp:codex-start';
|
|
275
|
+
const CODEX_BLOCK_END = '# frontmcp:codex-end';
|
|
276
|
+
async function emitCodexEntry(opts) {
|
|
277
|
+
assertValidPluginName(opts.name, 'emitCodexEntry');
|
|
278
|
+
const existing = (await (0, utils_1.fileExists)(opts.configPath)) ? await (0, utils_1.readFile)(opts.configPath) : '';
|
|
279
|
+
const previousVersion = extractCodexEntryComment(existing, opts.name);
|
|
280
|
+
const newBlock = renderCodexBlock(opts);
|
|
281
|
+
const merged = replaceCodexBlock(existing, opts.name, newBlock);
|
|
282
|
+
if (!opts.dryRun) {
|
|
283
|
+
await (0, utils_1.ensureDir)(path.dirname(opts.configPath));
|
|
284
|
+
await (0, utils_1.writeFile)(opts.configPath, merged);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
written: !opts.dryRun,
|
|
288
|
+
previousVersion,
|
|
289
|
+
configContent: merged,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
async function removeCodexEntry(args) {
|
|
293
|
+
assertValidPluginName(args.name, 'removeCodexEntry');
|
|
294
|
+
if (!(await (0, utils_1.fileExists)(args.configPath))) {
|
|
295
|
+
return { removed: false, configContent: '' };
|
|
296
|
+
}
|
|
297
|
+
const existing = await (0, utils_1.readFile)(args.configPath);
|
|
298
|
+
const stripped = stripCodexBlock(existing, args.name);
|
|
299
|
+
const removed = stripped !== existing;
|
|
300
|
+
if (removed) {
|
|
301
|
+
await (0, utils_1.writeFile)(args.configPath, stripped);
|
|
302
|
+
}
|
|
303
|
+
return { removed, configContent: stripped };
|
|
304
|
+
}
|
|
305
|
+
// ============================================================================
|
|
306
|
+
// Internals
|
|
307
|
+
// ============================================================================
|
|
308
|
+
function makeMcpServerEntry(opts) {
|
|
309
|
+
const entry = {
|
|
310
|
+
command: opts.mcpCommand,
|
|
311
|
+
args: opts.mcpArgs,
|
|
312
|
+
transport: 'stdio',
|
|
313
|
+
};
|
|
314
|
+
if (opts.envHints.length > 0) {
|
|
315
|
+
const env = {};
|
|
316
|
+
for (const name of opts.envHints.slice().sort()) {
|
|
317
|
+
env[name] = `\${${name}}`;
|
|
318
|
+
}
|
|
319
|
+
entry.env = env;
|
|
320
|
+
}
|
|
321
|
+
return entry;
|
|
322
|
+
}
|
|
323
|
+
async function readPluginManifest(manifestPath) {
|
|
324
|
+
if (!(await (0, utils_1.fileExists)(manifestPath)))
|
|
325
|
+
return undefined;
|
|
326
|
+
try {
|
|
327
|
+
const raw = await (0, utils_1.readFile)(manifestPath);
|
|
328
|
+
return JSON.parse(raw);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function mergeUserManifestFields(next, previous) {
|
|
335
|
+
if (!previous)
|
|
336
|
+
return next;
|
|
337
|
+
const out = { ...next };
|
|
338
|
+
// Preserve user-added entries in mcpServers other than the framework-owned one.
|
|
339
|
+
if (previous.mcpServers && typeof previous.mcpServers === 'object') {
|
|
340
|
+
const mergedMcp = { ...next.mcpServers };
|
|
341
|
+
for (const [k, v] of Object.entries(previous.mcpServers)) {
|
|
342
|
+
if (k !== next.name)
|
|
343
|
+
mergedMcp[k] = v;
|
|
344
|
+
}
|
|
345
|
+
out.mcpServers = sortObjectKeys(mergedMcp);
|
|
346
|
+
}
|
|
347
|
+
// Preserve any unknown top-level key the user added.
|
|
348
|
+
for (const [k, v] of Object.entries(previous)) {
|
|
349
|
+
if (!FRAMEWORK_MANAGED_FIELDS.includes(k) && k !== '_meta' && !(k in out)) {
|
|
350
|
+
out[k] = v;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
function serializeManifest(manifest) {
|
|
356
|
+
return JSON.stringify(sortObjectKeys(manifest), null, 2) + '\n';
|
|
357
|
+
}
|
|
358
|
+
function sortObjectKeys(value) {
|
|
359
|
+
if (Array.isArray(value)) {
|
|
360
|
+
return value.map((v) => sortObjectKeys(v));
|
|
361
|
+
}
|
|
362
|
+
if (value && typeof value === 'object') {
|
|
363
|
+
const sorted = {};
|
|
364
|
+
for (const key of Object.keys(value).sort()) {
|
|
365
|
+
sorted[key] = sortObjectKeys(value[key]);
|
|
366
|
+
}
|
|
367
|
+
return sorted;
|
|
368
|
+
}
|
|
369
|
+
return value;
|
|
370
|
+
}
|
|
371
|
+
function renderCommandFile(cmd, binName) {
|
|
372
|
+
const args = cmd.arguments ?? [];
|
|
373
|
+
const argHint = args
|
|
374
|
+
.map((a) => (a.required === false ? `[${a.name}]` : `<${a.name}>`))
|
|
375
|
+
.join(' ');
|
|
376
|
+
const fm = ['---'];
|
|
377
|
+
if (cmd.description)
|
|
378
|
+
fm.push(`description: ${JSON.stringify(cmd.description)}`);
|
|
379
|
+
if (argHint)
|
|
380
|
+
fm.push(`argument-hint: ${JSON.stringify(argHint)}`);
|
|
381
|
+
fm.push('---', '');
|
|
382
|
+
const body = [
|
|
383
|
+
`Invokes the MCP prompt \`${cmd.name}\` from the \`${binName}\` server.`,
|
|
384
|
+
'',
|
|
385
|
+
`See \`${binName} prompt get ${cmd.name}\` for the latest argument list.`,
|
|
386
|
+
'',
|
|
387
|
+
];
|
|
388
|
+
return fm.join('\n') + '\n' + body.join('\n');
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Bottom-up empty-directory cleanup after `removeClaudePlugin`. We only
|
|
392
|
+
* remove a directory when:
|
|
393
|
+
* 1. it's a framework-managed directory we know we created
|
|
394
|
+
* (`commands/`, `skills/`, `skills/<name>/`, `.claude-plugin/`,
|
|
395
|
+
* and the plugin root itself), AND
|
|
396
|
+
* 2. it is empty (`readdir` returns []).
|
|
397
|
+
*
|
|
398
|
+
* Any user-added file under those dirs blocks the cleanup at that level
|
|
399
|
+
* and bubbles all the way up — so a single user file anywhere in the
|
|
400
|
+
* plugin tree leaves the entire tree (and the plugin dir itself) intact.
|
|
401
|
+
* We never traverse INTO user-added subdirs.
|
|
402
|
+
*/
|
|
403
|
+
async function cleanupEmptyDirs(root, _preserved) {
|
|
404
|
+
if (!(await (0, utils_1.fileExists)(root)))
|
|
405
|
+
return;
|
|
406
|
+
// `readdir` and `rm` are imported statically at the top of this module so
|
|
407
|
+
// the file bundles cleanly into the per-bin CLI (esbuild can't resolve
|
|
408
|
+
// `await import('@frontmcp/utils')` inside a CJS bundle, which manifests
|
|
409
|
+
// as a runtime "Dynamic require of fs is not supported" error when the
|
|
410
|
+
// bin's own `uninstall -p claude` walks the plugin tree).
|
|
411
|
+
// Pass 1: each per-skill folder under skills/ (one level deep).
|
|
412
|
+
const skillsDir = path.join(root, 'skills');
|
|
413
|
+
if (await (0, utils_1.fileExists)(skillsDir)) {
|
|
414
|
+
for (const entry of await safeReaddir(utils_1.readdir, skillsDir)) {
|
|
415
|
+
const sub = path.join(skillsDir, entry);
|
|
416
|
+
await removeIfEmpty(sub, utils_1.rm, utils_1.readdir);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Pass 2: framework-managed dirs directly under the plugin root.
|
|
420
|
+
for (const sub of ['commands', 'skills', '.claude-plugin']) {
|
|
421
|
+
await removeIfEmpty(path.join(root, sub), utils_1.rm, utils_1.readdir);
|
|
422
|
+
}
|
|
423
|
+
// Pass 3: the plugin root itself (only if user added nothing).
|
|
424
|
+
await removeIfEmpty(root, utils_1.rm, utils_1.readdir);
|
|
425
|
+
}
|
|
426
|
+
async function removeIfEmpty(dir, rmFn, readdirFn) {
|
|
427
|
+
if (!(await (0, utils_1.fileExists)(dir)))
|
|
428
|
+
return;
|
|
429
|
+
const entries = await safeReaddir(readdirFn, dir);
|
|
430
|
+
if (entries.length === 0) {
|
|
431
|
+
await rmFn(dir, { recursive: true, force: true });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function safeReaddir(readdirFn, dir) {
|
|
435
|
+
try {
|
|
436
|
+
return await readdirFn(dir);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// ----- Codex TOML -----
|
|
443
|
+
function renderCodexBlock(opts) {
|
|
444
|
+
const lines = [];
|
|
445
|
+
lines.push(`${CODEX_BLOCK_START}:${opts.name}`);
|
|
446
|
+
lines.push(`[[mcp_servers]]`);
|
|
447
|
+
lines.push(`name = ${tomlString(opts.name)}`);
|
|
448
|
+
lines.push(`command = ${tomlString(opts.command)}`);
|
|
449
|
+
lines.push(`args = [${opts.args.map(tomlString).join(', ')}]`);
|
|
450
|
+
if (opts.env && Object.keys(opts.env).length > 0) {
|
|
451
|
+
const entries = Object.entries(opts.env)
|
|
452
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
453
|
+
.map(([k, v]) => `${tomlBareKey(k)} = ${tomlString(v)}`);
|
|
454
|
+
lines.push(`env = { ${entries.join(', ')} }`);
|
|
455
|
+
}
|
|
456
|
+
lines.push(`${CODEX_BLOCK_END}:${opts.name}`);
|
|
457
|
+
return lines.join('\n');
|
|
458
|
+
}
|
|
459
|
+
function replaceCodexBlock(existing, name, newBlock) {
|
|
460
|
+
const start = `${CODEX_BLOCK_START}:${name}`;
|
|
461
|
+
const end = `${CODEX_BLOCK_END}:${name}`;
|
|
462
|
+
const startIdx = existing.indexOf(start);
|
|
463
|
+
if (startIdx === -1) {
|
|
464
|
+
// Normalize the existing content to end in exactly one newline before
|
|
465
|
+
// appending the new block, so we don't double-up on first insert.
|
|
466
|
+
const normalized = existing.length === 0 ? '' : existing.replace(/\n*$/, '\n');
|
|
467
|
+
const sep = normalized.length === 0 ? '' : '\n';
|
|
468
|
+
return `${normalized}${sep}${newBlock}\n`;
|
|
469
|
+
}
|
|
470
|
+
const endIdx = existing.indexOf(end, startIdx);
|
|
471
|
+
if (endIdx === -1) {
|
|
472
|
+
// Corrupt block — treat as missing and append fresh.
|
|
473
|
+
return `${existing.replace(/\n*$/, '\n')}\n${newBlock}\n`;
|
|
474
|
+
}
|
|
475
|
+
const before = existing.slice(0, startIdx);
|
|
476
|
+
const after = existing.slice(endIdx + end.length).replace(/^\n/, '');
|
|
477
|
+
return `${before}${newBlock}\n${after}`;
|
|
478
|
+
}
|
|
479
|
+
function stripCodexBlock(existing, name) {
|
|
480
|
+
const start = `${CODEX_BLOCK_START}:${name}`;
|
|
481
|
+
const end = `${CODEX_BLOCK_END}:${name}`;
|
|
482
|
+
const startIdx = existing.indexOf(start);
|
|
483
|
+
if (startIdx === -1)
|
|
484
|
+
return existing;
|
|
485
|
+
const endIdx = existing.indexOf(end, startIdx);
|
|
486
|
+
if (endIdx === -1)
|
|
487
|
+
return existing;
|
|
488
|
+
const before = existing.slice(0, startIdx).replace(/\n+$/, '\n');
|
|
489
|
+
const after = existing.slice(endIdx + end.length).replace(/^\n+/, '\n');
|
|
490
|
+
return (before + after).replace(/\n{3,}/g, '\n\n');
|
|
491
|
+
}
|
|
492
|
+
function extractCodexEntryComment(content, name) {
|
|
493
|
+
// Optional: look for an `# version:` comment inside the block; for now we
|
|
494
|
+
// just return undefined since the block itself doesn't carry a version.
|
|
495
|
+
void content;
|
|
496
|
+
void name;
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
function tomlString(value) {
|
|
500
|
+
// Basic strings: escape backslash + double-quote, encode common control chars.
|
|
501
|
+
const escaped = value
|
|
502
|
+
.replace(/\\/g, '\\\\')
|
|
503
|
+
.replace(/"/g, '\\"')
|
|
504
|
+
.replace(/\n/g, '\\n')
|
|
505
|
+
.replace(/\r/g, '\\r')
|
|
506
|
+
.replace(/\t/g, '\\t');
|
|
507
|
+
return `"${escaped}"`;
|
|
508
|
+
}
|
|
509
|
+
function tomlBareKey(key) {
|
|
510
|
+
return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
|
|
511
|
+
}
|
|
512
|
+
//# sourceMappingURL=plugin-emitter.js.map
|