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.
Files changed (114) hide show
  1. package/README.md +38 -29
  2. package/package.json +4 -4
  3. package/src/commands/build/exec/bin-meta.d.ts +49 -0
  4. package/src/commands/build/exec/bin-meta.js +68 -0
  5. package/src/commands/build/exec/bin-meta.js.map +1 -0
  6. package/src/commands/build/exec/cli-runtime/generate-cli-entry.js +195 -3
  7. package/src/commands/build/exec/cli-runtime/generate-cli-entry.js.map +1 -1
  8. package/src/commands/build/exec/cli-runtime/plugin-emitter.d.ts +160 -0
  9. package/src/commands/build/exec/cli-runtime/plugin-emitter.js +512 -0
  10. package/src/commands/build/exec/cli-runtime/plugin-emitter.js.map +1 -0
  11. package/src/commands/build/exec/cli-runtime/schema-extractor.d.ts +13 -1
  12. package/src/commands/build/exec/cli-runtime/schema-extractor.js +29 -3
  13. package/src/commands/build/exec/cli-runtime/schema-extractor.js.map +1 -1
  14. package/src/commands/build/exec/cli-runtime/skill-md-compose.d.ts +25 -0
  15. package/src/commands/build/exec/cli-runtime/skill-md-compose.js +63 -0
  16. package/src/commands/build/exec/cli-runtime/skill-md-compose.js.map +1 -0
  17. package/src/commands/build/exec/index.js +26 -0
  18. package/src/commands/build/exec/index.js.map +1 -1
  19. package/src/commands/build/exec/runner-script.js +16 -4
  20. package/src/commands/build/exec/runner-script.js.map +1 -1
  21. package/src/commands/dev/bridge/child-supervisor.d.ts +48 -0
  22. package/src/commands/dev/bridge/child-supervisor.js +228 -0
  23. package/src/commands/dev/bridge/child-supervisor.js.map +1 -0
  24. package/src/commands/dev/bridge/errors.d.ts +23 -0
  25. package/src/commands/dev/bridge/errors.js +34 -0
  26. package/src/commands/dev/bridge/errors.js.map +1 -0
  27. package/src/commands/dev/bridge/index.d.ts +30 -0
  28. package/src/commands/dev/bridge/index.js +220 -0
  29. package/src/commands/dev/bridge/index.js.map +1 -0
  30. package/src/commands/dev/bridge/log.d.ts +29 -0
  31. package/src/commands/dev/bridge/log.js +82 -0
  32. package/src/commands/dev/bridge/log.js.map +1 -0
  33. package/src/commands/dev/bridge/state-machine.d.ts +56 -0
  34. package/src/commands/dev/bridge/state-machine.js +245 -0
  35. package/src/commands/dev/bridge/state-machine.js.map +1 -0
  36. package/src/commands/dev/bridge/stdio-framer.d.ts +47 -0
  37. package/src/commands/dev/bridge/stdio-framer.js +128 -0
  38. package/src/commands/dev/bridge/stdio-framer.js.map +1 -0
  39. package/src/commands/dev/bridge/upstream-client.d.ts +49 -0
  40. package/src/commands/dev/bridge/upstream-client.js +159 -0
  41. package/src/commands/dev/bridge/upstream-client.js.map +1 -0
  42. package/src/commands/dev/bridge/watcher.d.ts +30 -0
  43. package/src/commands/dev/bridge/watcher.js +87 -0
  44. package/src/commands/dev/bridge/watcher.js.map +1 -0
  45. package/src/commands/dev/dev.d.ts +34 -1
  46. package/src/commands/dev/dev.js +168 -14
  47. package/src/commands/dev/dev.js.map +1 -1
  48. package/src/commands/dev/inspector.d.ts +13 -1
  49. package/src/commands/dev/inspector.js +77 -3
  50. package/src/commands/dev/inspector.js.map +1 -1
  51. package/src/commands/dev/port.d.ts +23 -0
  52. package/src/commands/dev/port.js +87 -0
  53. package/src/commands/dev/port.js.map +1 -0
  54. package/src/commands/dev/register.d.ts +1 -1
  55. package/src/commands/dev/register.js +28 -4
  56. package/src/commands/dev/register.js.map +1 -1
  57. package/src/commands/dev/test.d.ts +26 -1
  58. package/src/commands/dev/test.js +181 -64
  59. package/src/commands/dev/test.js.map +1 -1
  60. package/src/commands/eject/mcp-client.d.ts +25 -0
  61. package/src/commands/eject/mcp-client.js +74 -0
  62. package/src/commands/eject/mcp-client.js.map +1 -0
  63. package/src/commands/eject/register.d.ts +9 -0
  64. package/src/commands/eject/register.js +56 -0
  65. package/src/commands/eject/register.js.map +1 -0
  66. package/src/commands/install/install-claude-plugin.d.ts +13 -0
  67. package/src/commands/install/install-claude-plugin.js +327 -0
  68. package/src/commands/install/install-claude-plugin.js.map +1 -0
  69. package/src/commands/install/register.d.ts +16 -0
  70. package/src/commands/install/register.js +70 -0
  71. package/src/commands/install/register.js.map +1 -0
  72. package/src/commands/scaffold/create.js +52 -8
  73. package/src/commands/scaffold/create.js.map +1 -1
  74. package/src/commands/skills/from-entry.d.ts +31 -0
  75. package/src/commands/skills/from-entry.js +68 -0
  76. package/src/commands/skills/from-entry.js.map +1 -0
  77. package/src/commands/skills/install.d.ts +12 -0
  78. package/src/commands/skills/install.js +173 -8
  79. package/src/commands/skills/install.js.map +1 -1
  80. package/src/commands/skills/register.js +7 -3
  81. package/src/commands/skills/register.js.map +1 -1
  82. package/src/config/frontmcp-config.loader.d.ts +28 -0
  83. package/src/config/frontmcp-config.loader.js +146 -67
  84. package/src/config/frontmcp-config.loader.js.map +1 -1
  85. package/src/config/frontmcp-config.resolve.d.ts +67 -0
  86. package/src/config/frontmcp-config.resolve.js +118 -0
  87. package/src/config/frontmcp-config.resolve.js.map +1 -0
  88. package/src/config/frontmcp-config.schema.d.ts +207 -0
  89. package/src/config/frontmcp-config.schema.js +217 -1
  90. package/src/config/frontmcp-config.schema.js.map +1 -1
  91. package/src/config/frontmcp-config.types.d.ts +133 -0
  92. package/src/config/frontmcp-config.types.js.map +1 -1
  93. package/src/config/index.d.ts +2 -1
  94. package/src/config/index.js +3 -1
  95. package/src/config/index.js.map +1 -1
  96. package/src/core/args.d.ts +13 -0
  97. package/src/core/args.js.map +1 -1
  98. package/src/core/bridge.js +39 -0
  99. package/src/core/bridge.js.map +1 -1
  100. package/src/core/cli.d.ts +0 -6
  101. package/src/core/cli.js +23 -3
  102. package/src/core/cli.js.map +1 -1
  103. package/src/core/help.d.ts +1 -1
  104. package/src/core/help.js +27 -6
  105. package/src/core/help.js.map +1 -1
  106. package/src/core/program.d.ts +1 -1
  107. package/src/core/program.js +56 -12
  108. package/src/core/program.js.map +1 -1
  109. package/src/core/project-commands.d.ts +44 -0
  110. package/src/core/project-commands.js +216 -0
  111. package/src/core/project-commands.js.map +1 -0
  112. package/src/core/tsconfig.d.ts +20 -0
  113. package/src/core/tsconfig.js +41 -2
  114. 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