@iglooinsure/agent-plugin 0.1.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 +124 -0
- package/bin/igloo-agent-plugin.js +8 -0
- package/package.json +22 -0
- package/src/adapters/claude.js +23 -0
- package/src/adapters/codex.js +111 -0
- package/src/adapters/common.js +28 -0
- package/src/adapters/cursor.js +45 -0
- package/src/adapters/index.js +15 -0
- package/src/cli.js +251 -0
- package/src/config.js +39 -0
- package/src/format.js +46 -0
- package/src/marketplace.js +129 -0
- package/src/operations.js +51 -0
- package/src/plugin.js +161 -0
- package/src/utils.js +127 -0
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Igloo Agent Plugin
|
|
2
|
+
|
|
3
|
+
Universal compatibility installer for Claude-style agent plugin marketplaces.
|
|
4
|
+
|
|
5
|
+
`igloo-agent-plugin` reads a Claude Code marketplace (`.claude-plugin/marketplace.json`), normalizes plugin metadata and skills, then installs the portable skill subset into Claude, Codex, or Cursor layouts.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @iglooinsure/agent-plugin
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package exposes two commands:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
igloo-agent-plugin --help
|
|
17
|
+
iap --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
igloo-agent-plugin marketplace add .
|
|
24
|
+
igloo-agent-plugin marketplace list
|
|
25
|
+
igloo-agent-plugin search
|
|
26
|
+
igloo-agent-plugin doctor ops-toolkit
|
|
27
|
+
igloo-agent-plugin install ops-toolkit --target codex --dry-run
|
|
28
|
+
igloo-agent-plugin install ops-toolkit --target codex
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
### Marketplace
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
igloo-agent-plugin marketplace add <source>
|
|
37
|
+
igloo-agent-plugin marketplace list
|
|
38
|
+
igloo-agent-plugin marketplace refresh [name]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Supported sources:
|
|
42
|
+
|
|
43
|
+
- Local marketplace root containing `.claude-plugin/marketplace.json`
|
|
44
|
+
- Direct path to `.claude-plugin/marketplace.json`
|
|
45
|
+
- Git URL
|
|
46
|
+
|
|
47
|
+
CLI state is stored under `~/.igloo-agent-plugin` by default. Set `IGLOO_AGENT_PLUGIN_HOME` to override it.
|
|
48
|
+
|
|
49
|
+
### Search
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
igloo-agent-plugin search [query]
|
|
53
|
+
igloo-agent-plugin search kubernetes --target codex
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Search lists marketplace plugin entries and compatibility status when a target is provided.
|
|
57
|
+
|
|
58
|
+
### Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
igloo-agent-plugin install <plugin[@marketplace]> --target codex
|
|
62
|
+
igloo-agent-plugin install <plugin[@marketplace]> --target cursor
|
|
63
|
+
igloo-agent-plugin install <plugin[@marketplace]> --target claude
|
|
64
|
+
igloo-agent-plugin install <plugin[@marketplace]> --target all
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Common options:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
--scope user|project
|
|
71
|
+
--dry-run
|
|
72
|
+
--force
|
|
73
|
+
--symlink
|
|
74
|
+
--layout namespaced|flat
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Codex installs are generated at:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
~/.codex/plugins/<plugin-name>/
|
|
81
|
+
.codex-plugin/plugin.json
|
|
82
|
+
skills/<skill-name>/SKILL.md
|
|
83
|
+
|
|
84
|
+
~/.agents/plugins/
|
|
85
|
+
marketplace.json
|
|
86
|
+
<plugin-name>/
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Cursor installs the skill subset at:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
~/.cursor/skills/<plugin-name>/<skill-name>/SKILL.md
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Claude support is currently a passthrough guide because Claude Code owns its native plugin install state.
|
|
96
|
+
|
|
97
|
+
### Uninstall
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
igloo-agent-plugin uninstall <plugin> --target codex
|
|
101
|
+
igloo-agent-plugin uninstall <plugin> --target cursor
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Uninstall only removes paths recorded in this CLI's install manifest.
|
|
105
|
+
|
|
106
|
+
### Doctor
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
igloo-agent-plugin doctor
|
|
110
|
+
igloo-agent-plugin doctor --all
|
|
111
|
+
igloo-agent-plugin doctor ops-toolkit --target cursor
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Doctor reports skill availability and warns about unsupported cross-tool components such as hooks, agents, MCP, LSP, monitors, commands, `bin`, and settings.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
This implementation intentionally has no runtime dependencies.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm test
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The tests use temporary `HOME` and `IGLOO_AGENT_PLUGIN_HOME` directories so they do not modify your real tool configuration.
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iglooinsure/agent-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Universal compatibility installer for Claude-style agent plugin marketplaces.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"igloo-agent-plugin": "./bin/igloo-agent-plugin.js",
|
|
8
|
+
"iap": "./bin/igloo-agent-plugin.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"license": "UNLICENSED"
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { compatibility, defaultInstallPlan } from "./common.js";
|
|
2
|
+
|
|
3
|
+
export const claudeAdapter = {
|
|
4
|
+
name: "claude",
|
|
5
|
+
|
|
6
|
+
doctor(pkg) {
|
|
7
|
+
return compatibility(pkg, "claude");
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
async planInstall(pkg, opts) {
|
|
11
|
+
const command = `claude plugin install ${pkg.name}@${pkg.marketplace} --scope ${opts.scope || "user"}`;
|
|
12
|
+
return defaultInstallPlan("claude", pkg, opts, [], [
|
|
13
|
+
`Claude is the source format. Run native install command: ${command}`,
|
|
14
|
+
]);
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async planUninstall(installRef) {
|
|
18
|
+
const command = `claude plugin uninstall ${installRef.plugin} --scope ${installRef.scope || "user"}`;
|
|
19
|
+
return defaultInstallPlan("claude", { name: installRef.plugin, marketplace: installRef.marketplace }, installRef, [], [
|
|
20
|
+
`Run native Claude command: ${command}`,
|
|
21
|
+
]);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { op } from "../operations.js";
|
|
4
|
+
import { exists, readJson } from "../utils.js";
|
|
5
|
+
import { compatibility, defaultInstallPlan } from "./common.js";
|
|
6
|
+
|
|
7
|
+
export const codexAdapter = {
|
|
8
|
+
name: "codex",
|
|
9
|
+
|
|
10
|
+
doctor(pkg) {
|
|
11
|
+
return compatibility(pkg, "codex");
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
async planInstall(pkg, opts) {
|
|
15
|
+
const home = os.homedir();
|
|
16
|
+
const pluginDir = path.join(home, ".codex", "plugins", pkg.name);
|
|
17
|
+
const marketplaceRoot = path.join(home, ".agents", "plugins");
|
|
18
|
+
const marketplacePath = path.join(marketplaceRoot, "marketplace.json");
|
|
19
|
+
const linkPath = path.join(marketplaceRoot, pkg.name);
|
|
20
|
+
const operations = [
|
|
21
|
+
op("mkdir", { path: pluginDir }),
|
|
22
|
+
...pkg.skills.map((skill) =>
|
|
23
|
+
op("copy", {
|
|
24
|
+
from: skill.path,
|
|
25
|
+
to: path.join(pluginDir, "skills", skill.directoryName),
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
op("write_json", {
|
|
29
|
+
to: path.join(pluginDir, ".codex-plugin", "plugin.json"),
|
|
30
|
+
value: {
|
|
31
|
+
name: pkg.name,
|
|
32
|
+
version: pkg.version || "0.0.0-local",
|
|
33
|
+
description: pkg.description,
|
|
34
|
+
skills: "./skills/",
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
if (opts.symlink) operations.push(op("symlink", { from: pluginDir, to: linkPath }));
|
|
40
|
+
else operations.push(op("copy", { from: pluginDir, to: linkPath }));
|
|
41
|
+
|
|
42
|
+
operations.push(
|
|
43
|
+
op("write_json", {
|
|
44
|
+
to: marketplacePath,
|
|
45
|
+
value: await nextCodexMarketplace(marketplacePath, pkg),
|
|
46
|
+
allowExisting: true,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const report = this.doctor(pkg);
|
|
51
|
+
return defaultInstallPlan(
|
|
52
|
+
"codex",
|
|
53
|
+
pkg,
|
|
54
|
+
{ ...opts, recordPaths: [pluginDir, linkPath, marketplacePath] },
|
|
55
|
+
operations,
|
|
56
|
+
report.warnings,
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async planUninstall(installRef) {
|
|
61
|
+
const home = os.homedir();
|
|
62
|
+
const pluginDir = path.join(home, ".codex", "plugins", installRef.plugin);
|
|
63
|
+
const marketplaceRoot = path.join(home, ".agents", "plugins");
|
|
64
|
+
const linkPath = path.join(marketplaceRoot, installRef.plugin);
|
|
65
|
+
const marketplacePath = path.join(marketplaceRoot, "marketplace.json");
|
|
66
|
+
const operations = [
|
|
67
|
+
op("remove", { path: pluginDir }),
|
|
68
|
+
op("remove", { path: linkPath }),
|
|
69
|
+
];
|
|
70
|
+
if (await exists(marketplacePath)) {
|
|
71
|
+
operations.push(
|
|
72
|
+
op("write_json", {
|
|
73
|
+
to: marketplacePath,
|
|
74
|
+
value: await removeCodexMarketplaceEntry(marketplacePath, installRef.plugin),
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return defaultInstallPlan(
|
|
79
|
+
"codex",
|
|
80
|
+
{ name: installRef.plugin, marketplace: installRef.marketplace },
|
|
81
|
+
{ scope: installRef.scope, recordPaths: [pluginDir, linkPath, marketplacePath] },
|
|
82
|
+
operations,
|
|
83
|
+
[],
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
async function nextCodexMarketplace(filePath, pkg) {
|
|
89
|
+
const current = (await exists(filePath))
|
|
90
|
+
? await readJson(filePath)
|
|
91
|
+
: { name: "igloo-agent-plugin-local", plugins: [] };
|
|
92
|
+
current.name ||= "igloo-agent-plugin-local";
|
|
93
|
+
current.plugins ||= [];
|
|
94
|
+
const entry = {
|
|
95
|
+
name: pkg.name,
|
|
96
|
+
description: pkg.description,
|
|
97
|
+
source: { source: "local", path: `./${pkg.name}` },
|
|
98
|
+
policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
|
|
99
|
+
category: pkg.entry?.category || "Agent Skills",
|
|
100
|
+
};
|
|
101
|
+
const index = current.plugins.findIndex((item) => item.name === pkg.name);
|
|
102
|
+
if (index >= 0) current.plugins[index] = { ...current.plugins[index], ...entry };
|
|
103
|
+
else current.plugins.push(entry);
|
|
104
|
+
return current;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function removeCodexMarketplaceEntry(filePath, pluginName) {
|
|
108
|
+
const current = await readJson(filePath);
|
|
109
|
+
current.plugins = (current.plugins || []).filter((item) => item.name !== pluginName);
|
|
110
|
+
return current;
|
|
111
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { unsupportedComponents } from "../plugin.js";
|
|
2
|
+
|
|
3
|
+
export function compatibility(pkg, target) {
|
|
4
|
+
const unsupported = unsupportedComponents(pkg, target);
|
|
5
|
+
const warnings = [];
|
|
6
|
+
if (pkg.skills.length === 0) warnings.push("plugin contains no skills/SKILL.md files");
|
|
7
|
+
if (unsupported.length > 0) {
|
|
8
|
+
warnings.push(`${target} install supports the skill subset only; unsupported components: ${unsupported.join(", ")}`);
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
target,
|
|
12
|
+
status: pkg.skills.length === 0 ? "unsupported" : unsupported.length > 0 ? "partial" : "yes",
|
|
13
|
+
unsupported,
|
|
14
|
+
warnings,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function defaultInstallPlan(target, pkg, opts, operations, warnings = []) {
|
|
19
|
+
return {
|
|
20
|
+
target,
|
|
21
|
+
plugin: pkg.name,
|
|
22
|
+
marketplace: pkg.marketplace,
|
|
23
|
+
scope: opts.scope || "user",
|
|
24
|
+
operations,
|
|
25
|
+
warnings,
|
|
26
|
+
recordPaths: opts.recordPaths,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { op } from "../operations.js";
|
|
4
|
+
import { compatibility, defaultInstallPlan } from "./common.js";
|
|
5
|
+
|
|
6
|
+
export const cursorAdapter = {
|
|
7
|
+
name: "cursor",
|
|
8
|
+
|
|
9
|
+
doctor(pkg) {
|
|
10
|
+
const report = compatibility(pkg, "cursor");
|
|
11
|
+
if (report.status === "yes") report.status = "partial";
|
|
12
|
+
report.warnings.push("cursor does not support Claude marketplace metadata directly; installed skills only");
|
|
13
|
+
return report;
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
async planInstall(pkg, opts) {
|
|
17
|
+
const layout = opts.layout || "namespaced";
|
|
18
|
+
const base =
|
|
19
|
+
opts.scope === "project"
|
|
20
|
+
? path.join(process.cwd(), ".cursor", "skills")
|
|
21
|
+
: path.join(os.homedir(), ".cursor", "skills");
|
|
22
|
+
const operations = [];
|
|
23
|
+
const recordPaths = [];
|
|
24
|
+
for (const skill of pkg.skills) {
|
|
25
|
+
const destination =
|
|
26
|
+
layout === "flat"
|
|
27
|
+
? path.join(base, `${pkg.name}-${skill.directoryName}`)
|
|
28
|
+
: path.join(base, pkg.name, skill.directoryName);
|
|
29
|
+
operations.push(op("copy", { from: skill.path, to: destination }));
|
|
30
|
+
recordPaths.push(destination);
|
|
31
|
+
}
|
|
32
|
+
const report = this.doctor(pkg);
|
|
33
|
+
return defaultInstallPlan("cursor", pkg, { ...opts, recordPaths }, operations, report.warnings);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async planUninstall(installRef) {
|
|
37
|
+
return defaultInstallPlan(
|
|
38
|
+
"cursor",
|
|
39
|
+
{ name: installRef.plugin, marketplace: installRef.marketplace },
|
|
40
|
+
{ scope: installRef.scope },
|
|
41
|
+
installRef.paths.map((targetPath) => op("remove", { path: targetPath })),
|
|
42
|
+
[],
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { claudeAdapter } from "./claude.js";
|
|
2
|
+
import { codexAdapter } from "./codex.js";
|
|
3
|
+
import { cursorAdapter } from "./cursor.js";
|
|
4
|
+
|
|
5
|
+
export const adapters = {
|
|
6
|
+
claude: claudeAdapter,
|
|
7
|
+
codex: codexAdapter,
|
|
8
|
+
cursor: cursorAdapter,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function targetsFromOption(target) {
|
|
12
|
+
if (!target || target === "all") return ["claude", "codex", "cursor"];
|
|
13
|
+
if (!adapters[target]) throw new Error(`error: unsupported target "${target}"\n\nUse one of: claude, codex, cursor, all`);
|
|
14
|
+
return [target];
|
|
15
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { addMarketplace, listMarketplaces, loadMarketplaces, refreshMarketplaces } from "./marketplace.js";
|
|
2
|
+
import { loadPluginPackage } from "./plugin.js";
|
|
3
|
+
import { adapters, targetsFromOption } from "./adapters/index.js";
|
|
4
|
+
import { executePlan, pathsFromPlan, validatePlan } from "./operations.js";
|
|
5
|
+
import { loadInstalls, saveInstalls } from "./config.js";
|
|
6
|
+
import { formatDoctor, formatPlan, table } from "./format.js";
|
|
7
|
+
|
|
8
|
+
export async function main(argv) {
|
|
9
|
+
const parsed = parseArgs(argv);
|
|
10
|
+
const [command, subcommand] = parsed.positionals;
|
|
11
|
+
|
|
12
|
+
if (!command || parsed.flags.help) {
|
|
13
|
+
printHelp();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (command === "marketplace") {
|
|
18
|
+
await marketplaceCommand(subcommand, parsed);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (command === "search") {
|
|
22
|
+
await searchCommand(parsed);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (command === "install") {
|
|
26
|
+
await installCommand(parsed);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (command === "uninstall") {
|
|
30
|
+
await uninstallCommand(parsed);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (command === "doctor") {
|
|
34
|
+
await doctorCommand(parsed);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
throw new Error(`error: unknown command "${command}"\n\nRun:\n igloo-agent-plugin --help`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const flags = {};
|
|
43
|
+
const positionals = [];
|
|
44
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
45
|
+
const arg = argv[index];
|
|
46
|
+
if (!arg.startsWith("--")) {
|
|
47
|
+
positionals.push(arg);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const raw = arg.slice(2);
|
|
51
|
+
const [key, inlineValue] = raw.split("=", 2);
|
|
52
|
+
if (key.startsWith("no-")) {
|
|
53
|
+
flags[key.slice(3)] = false;
|
|
54
|
+
} else if (inlineValue !== undefined) {
|
|
55
|
+
flags[key] = inlineValue;
|
|
56
|
+
} else if (["target", "scope", "layout"].includes(key)) {
|
|
57
|
+
flags[key] = argv[++index];
|
|
58
|
+
} else {
|
|
59
|
+
flags[key] = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { flags, positionals };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function marketplaceCommand(subcommand, parsed) {
|
|
66
|
+
if (subcommand === "add") {
|
|
67
|
+
const source = parsed.positionals[2];
|
|
68
|
+
if (!source) throw new Error("error: marketplace add requires a source");
|
|
69
|
+
const added = await addMarketplace(source);
|
|
70
|
+
console.log(`Added marketplace ${added.name} (${added.plugins} plugins)\nSource: ${added.source}`);
|
|
71
|
+
console.log("\nOnly add marketplace sources you trust. Plugin scripts and MCP configs are copied but not executed by this CLI.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (subcommand === "list") {
|
|
76
|
+
const rows = await listMarketplaces();
|
|
77
|
+
console.log(table(["NAME", "SOURCE", "STATUS", "PLUGINS"], rows.map((row) => [
|
|
78
|
+
row.name,
|
|
79
|
+
row.source,
|
|
80
|
+
row.status,
|
|
81
|
+
row.plugins,
|
|
82
|
+
])));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (subcommand === "refresh") {
|
|
87
|
+
const name = parsed.positionals[2];
|
|
88
|
+
const rows = await refreshMarketplaces(name);
|
|
89
|
+
console.log(table(["NAME", "STATUS", "PLUGINS"], rows.map((row) => [row.name, row.status, row.plugins])));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error("error: marketplace command must be one of: add, list, refresh");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function searchCommand(parsed) {
|
|
97
|
+
const query = (parsed.positionals[1] || "").toLowerCase();
|
|
98
|
+
const target = parsed.flags.target;
|
|
99
|
+
const marketplaces = await loadMarketplaces();
|
|
100
|
+
const rows = [];
|
|
101
|
+
for (const marketplace of marketplaces) {
|
|
102
|
+
for (const entry of marketplace.plugins) {
|
|
103
|
+
const haystack = `${entry.name} ${entry.description} ${entry.category} ${(entry.tags || []).join(" ")}`.toLowerCase();
|
|
104
|
+
if (query && !haystack.includes(query)) continue;
|
|
105
|
+
const compatibility = target ? await compatibilityFor(entry.name, marketplace.name, target) : {};
|
|
106
|
+
rows.push([
|
|
107
|
+
entry.name,
|
|
108
|
+
marketplace.name,
|
|
109
|
+
compatibility.claude || (target ? "" : "yes"),
|
|
110
|
+
compatibility.codex || "",
|
|
111
|
+
compatibility.cursor || "",
|
|
112
|
+
entry.description,
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
console.log(table(["PLUGIN", "MARKETPLACE", "CLAUDE", "CODEX", "CURSOR", "DESCRIPTION"], rows));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function compatibilityFor(pluginName, marketplaceName, target) {
|
|
120
|
+
const pkg = await loadPluginPackage(`${pluginName}@${marketplaceName}`);
|
|
121
|
+
const result = {};
|
|
122
|
+
for (const targetName of targetsFromOption(target)) {
|
|
123
|
+
result[targetName] = adapters[targetName].doctor(pkg).status;
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function installCommand(parsed) {
|
|
129
|
+
const pluginRef = parsed.positionals[1];
|
|
130
|
+
if (!pluginRef) throw new Error("error: install requires a plugin name");
|
|
131
|
+
const targets = targetsFromOption(parsed.flags.target || "codex");
|
|
132
|
+
const pkg = await loadPluginPackage(pluginRef);
|
|
133
|
+
const installs = await loadInstalls();
|
|
134
|
+
const plans = [];
|
|
135
|
+
|
|
136
|
+
for (const target of targets) {
|
|
137
|
+
const adapter = adapters[target];
|
|
138
|
+
const plan = await adapter.planInstall(pkg, {
|
|
139
|
+
target,
|
|
140
|
+
scope: parsed.flags.scope || "user",
|
|
141
|
+
dryRun: Boolean(parsed.flags["dry-run"]),
|
|
142
|
+
force: Boolean(parsed.flags.force),
|
|
143
|
+
symlink: parsed.flags.symlink === true,
|
|
144
|
+
layout: parsed.flags.layout || "namespaced",
|
|
145
|
+
});
|
|
146
|
+
const managedPaths = installs.installed
|
|
147
|
+
.filter((item) => item.plugin === pkg.name && item.target === target)
|
|
148
|
+
.flatMap((item) => item.paths || []);
|
|
149
|
+
await validatePlan(plan, { force: parsed.flags.force, managedPaths });
|
|
150
|
+
plans.push(plan);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (parsed.flags["dry-run"]) {
|
|
154
|
+
for (const plan of plans) console.log(formatPlan(plan));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const plan of plans) {
|
|
159
|
+
await executePlan(plan);
|
|
160
|
+
installs.installed = installs.installed.filter(
|
|
161
|
+
(item) => !(item.plugin === pkg.name && item.target === plan.target && item.scope === plan.scope),
|
|
162
|
+
);
|
|
163
|
+
installs.installed.push({
|
|
164
|
+
plugin: pkg.name,
|
|
165
|
+
marketplace: pkg.marketplace,
|
|
166
|
+
target: plan.target,
|
|
167
|
+
scope: plan.scope,
|
|
168
|
+
installedAt: new Date().toISOString(),
|
|
169
|
+
sourceHash: pkg.sourceHash,
|
|
170
|
+
paths: pathsFromPlan(plan),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
await saveInstalls(installs);
|
|
174
|
+
|
|
175
|
+
console.log(`Installed ${pkg.name}:`);
|
|
176
|
+
for (const plan of plans) {
|
|
177
|
+
const status = plan.warnings.length > 0 ? "partial" : "ok";
|
|
178
|
+
console.log(`- ${plan.target}: ${status}`);
|
|
179
|
+
}
|
|
180
|
+
const warnings = plans.flatMap((plan) => plan.warnings.map((warning) => `${plan.target}: ${warning}`));
|
|
181
|
+
if (warnings.length > 0) {
|
|
182
|
+
console.log("\nWarnings:");
|
|
183
|
+
for (const warning of warnings) console.log(`- ${warning}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function uninstallCommand(parsed) {
|
|
188
|
+
const plugin = parsed.positionals[1];
|
|
189
|
+
if (!plugin) throw new Error("error: uninstall requires a plugin name");
|
|
190
|
+
const targets = targetsFromOption(parsed.flags.target || "codex");
|
|
191
|
+
const installs = await loadInstalls();
|
|
192
|
+
const selected = installs.installed.filter((item) => item.plugin === plugin && targets.includes(item.target));
|
|
193
|
+
if (selected.length === 0) throw new Error(`error: no managed install found for "${plugin}"`);
|
|
194
|
+
|
|
195
|
+
const plans = [];
|
|
196
|
+
for (const installRef of selected) {
|
|
197
|
+
const plan = await adapters[installRef.target].planUninstall(installRef, { force: parsed.flags.force });
|
|
198
|
+
plans.push(plan);
|
|
199
|
+
}
|
|
200
|
+
if (parsed.flags["dry-run"]) {
|
|
201
|
+
for (const plan of plans) console.log(formatPlan(plan));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
for (const plan of plans) await executePlan(plan);
|
|
205
|
+
installs.installed = installs.installed.filter((item) => !selected.includes(item));
|
|
206
|
+
await saveInstalls(installs);
|
|
207
|
+
console.log(`Uninstalled ${plugin}: ${selected.map((item) => item.target).join(", ")}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function doctorCommand(parsed) {
|
|
211
|
+
const pluginRef = parsed.positionals[1];
|
|
212
|
+
const target = parsed.flags.target || "all";
|
|
213
|
+
const targetNames = targetsFromOption(target);
|
|
214
|
+
|
|
215
|
+
if (parsed.flags.all || !pluginRef) {
|
|
216
|
+
const marketplaces = await loadMarketplaces();
|
|
217
|
+
const rows = [];
|
|
218
|
+
for (const marketplace of marketplaces) {
|
|
219
|
+
for (const entry of marketplace.plugins) {
|
|
220
|
+
const pkg = await loadPluginPackage(`${entry.name}@${marketplace.name}`);
|
|
221
|
+
rows.push([
|
|
222
|
+
entry.name,
|
|
223
|
+
marketplace.name,
|
|
224
|
+
...targetNames.map((targetName) => adapters[targetName].doctor(pkg).status),
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
console.log(table(["PLUGIN", "MARKETPLACE", ...targetNames.map((name) => name.toUpperCase())], rows));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const pkg = await loadPluginPackage(pluginRef);
|
|
233
|
+
const reports = targetNames.map((targetName) => adapters[targetName].doctor(pkg));
|
|
234
|
+
console.log(formatDoctor(pkg, reports));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function printHelp() {
|
|
238
|
+
console.log(`igloo-agent-plugin
|
|
239
|
+
|
|
240
|
+
Usage:
|
|
241
|
+
igloo-agent-plugin marketplace add <source>
|
|
242
|
+
igloo-agent-plugin marketplace list
|
|
243
|
+
igloo-agent-plugin marketplace refresh [name]
|
|
244
|
+
igloo-agent-plugin search [query] [--target claude|codex|cursor|all]
|
|
245
|
+
igloo-agent-plugin install <plugin[@marketplace]> [--target codex|cursor|claude|all] [--scope user|project] [--dry-run] [--force] [--symlink]
|
|
246
|
+
igloo-agent-plugin uninstall <plugin> [--target codex|cursor|claude|all] [--dry-run]
|
|
247
|
+
igloo-agent-plugin doctor [plugin] [--target codex|cursor|claude|all] [--all]
|
|
248
|
+
|
|
249
|
+
State:
|
|
250
|
+
${process.env.IGLOO_AGENT_PLUGIN_HOME || "~/.igloo-agent-plugin"}`);
|
|
251
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ensureDir, readJson, stateRoot, writeJson } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
export function configPath() {
|
|
5
|
+
return path.join(stateRoot(), "config.json");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function installsPath() {
|
|
9
|
+
return path.join(stateRoot(), "installs.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function ensureState() {
|
|
13
|
+
await ensureDir(stateRoot());
|
|
14
|
+
await ensureDir(path.join(stateRoot(), "marketplaces"));
|
|
15
|
+
await ensureDir(path.join(stateRoot(), "cache"));
|
|
16
|
+
await ensureDir(path.join(stateRoot(), "logs"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function loadConfig() {
|
|
20
|
+
await ensureState();
|
|
21
|
+
const config = await readJson(configPath(), { version: 1, marketplaces: [] });
|
|
22
|
+
config.marketplaces ||= [];
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function saveConfig(config) {
|
|
27
|
+
await writeJson(configPath(), { version: 1, marketplaces: config.marketplaces || [] });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loadInstalls() {
|
|
31
|
+
await ensureState();
|
|
32
|
+
const installs = await readJson(installsPath(), { version: 1, installed: [] });
|
|
33
|
+
installs.installed ||= [];
|
|
34
|
+
return installs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function saveInstalls(installs) {
|
|
38
|
+
await writeJson(installsPath(), { version: 1, installed: installs.installed || [] });
|
|
39
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { operationTarget } from "./operations.js";
|
|
2
|
+
|
|
3
|
+
export function table(headers, rows) {
|
|
4
|
+
const allRows = [headers, ...rows];
|
|
5
|
+
const widths = headers.map((_, index) =>
|
|
6
|
+
Math.max(...allRows.map((row) => String(row[index] ?? "").length)),
|
|
7
|
+
);
|
|
8
|
+
return allRows
|
|
9
|
+
.map((row) =>
|
|
10
|
+
row.map((cell, index) => String(cell ?? "").padEnd(widths[index])).join(" ").trimEnd(),
|
|
11
|
+
)
|
|
12
|
+
.join("\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatPlan(plan) {
|
|
16
|
+
const lines = [`${plan.target}: ${plan.operations.length === 0 ? "no file operations" : "planned operations"}`];
|
|
17
|
+
for (const operation of plan.operations) {
|
|
18
|
+
if (operation.kind === "copy") lines.push(`- copy ${operation.from} -> ${operation.to}`);
|
|
19
|
+
else if (operation.kind === "write_json") lines.push(`- write_json ${operation.to}`);
|
|
20
|
+
else if (operation.kind === "mkdir") lines.push(`- mkdir ${operation.path}`);
|
|
21
|
+
else if (operation.kind === "symlink") lines.push(`- symlink ${operation.from} -> ${operation.to}`);
|
|
22
|
+
else if (operation.kind === "remove") lines.push(`- remove ${operation.path}`);
|
|
23
|
+
else lines.push(`- ${operation.kind} ${operationTarget(operation)}`);
|
|
24
|
+
}
|
|
25
|
+
if (plan.warnings.length > 0) {
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push("Warnings:");
|
|
28
|
+
for (const warning of plan.warnings) lines.push(`- ${warning}`);
|
|
29
|
+
}
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatDoctor(pkg, reports) {
|
|
34
|
+
const lines = [
|
|
35
|
+
`Plugin: ${pkg.name}`,
|
|
36
|
+
`Source: ${pkg.root}`,
|
|
37
|
+
`Skills: ${pkg.skills.length}`,
|
|
38
|
+
"",
|
|
39
|
+
table(["TARGET", "STATUS", "WARNINGS"], reports.map((report) => [
|
|
40
|
+
report.target,
|
|
41
|
+
report.status,
|
|
42
|
+
report.warnings.length ? report.warnings.join("; ") : "-",
|
|
43
|
+
])),
|
|
44
|
+
];
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
basenameFromSource,
|
|
6
|
+
ensureDir,
|
|
7
|
+
exists,
|
|
8
|
+
expandHome,
|
|
9
|
+
readJson,
|
|
10
|
+
sanitizeName,
|
|
11
|
+
stateRoot,
|
|
12
|
+
} from "./utils.js";
|
|
13
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
export function marketplaceJsonPath(root) {
|
|
18
|
+
return path.join(root, ".claude-plugin", "marketplace.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readMarketplace(root, source = {}) {
|
|
22
|
+
const filePath = marketplaceJsonPath(root);
|
|
23
|
+
if (!(await exists(filePath))) {
|
|
24
|
+
throw new Error(`error: marketplace file not found\n\nExpected:\n ${filePath}`);
|
|
25
|
+
}
|
|
26
|
+
const raw = await readJson(filePath);
|
|
27
|
+
if (!Array.isArray(raw.plugins)) {
|
|
28
|
+
throw new Error(`error: marketplace JSON must contain a plugins array\n\nFile:\n ${filePath}`);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
name: raw.name || sanitizeName(path.basename(root)) || "local-marketplace",
|
|
32
|
+
description: raw.description || "",
|
|
33
|
+
root,
|
|
34
|
+
source,
|
|
35
|
+
plugins: raw.plugins.map((plugin) => ({
|
|
36
|
+
name: plugin.name,
|
|
37
|
+
description: plugin.description || "",
|
|
38
|
+
version: plugin.version || "",
|
|
39
|
+
category: plugin.category || "",
|
|
40
|
+
tags: plugin.tags || [],
|
|
41
|
+
homepage: plugin.homepage || "",
|
|
42
|
+
source: plugin.source,
|
|
43
|
+
marketplace: raw.name || sanitizeName(path.basename(root)) || "local-marketplace",
|
|
44
|
+
})),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sourceKind(source) {
|
|
49
|
+
if (/^(git@|https?:\/\/|ssh:\/\/)/.test(source)) return "git";
|
|
50
|
+
return "local";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeLocalRoot(source) {
|
|
54
|
+
const absolute = path.resolve(expandHome(source));
|
|
55
|
+
if (path.basename(absolute) === "marketplace.json") {
|
|
56
|
+
const parent = path.dirname(absolute);
|
|
57
|
+
return path.basename(parent) === ".claude-plugin" ? path.dirname(parent) : parent;
|
|
58
|
+
}
|
|
59
|
+
return absolute;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function addMarketplace(sourceInput) {
|
|
63
|
+
const source = expandHome(sourceInput);
|
|
64
|
+
const type = sourceKind(source);
|
|
65
|
+
let root;
|
|
66
|
+
let ref = "main";
|
|
67
|
+
|
|
68
|
+
if (type === "git") {
|
|
69
|
+
const initialName = basenameFromSource(source) || "marketplace";
|
|
70
|
+
root = path.join(stateRoot(), "marketplaces", initialName);
|
|
71
|
+
await ensureDir(path.dirname(root));
|
|
72
|
+
if (!(await exists(root))) {
|
|
73
|
+
await execFileAsync("git", ["clone", source, root]);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
root = normalizeLocalRoot(source);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const marketplace = await readMarketplace(root, { sourceType: type, source, ref, root });
|
|
80
|
+
const config = await loadConfig();
|
|
81
|
+
const name = marketplace.name;
|
|
82
|
+
const entry = { name, sourceType: type, source, ref, root };
|
|
83
|
+
const index = config.marketplaces.findIndex((item) => item.name === name);
|
|
84
|
+
if (index >= 0) config.marketplaces[index] = entry;
|
|
85
|
+
else config.marketplaces.push(entry);
|
|
86
|
+
await saveConfig(config);
|
|
87
|
+
return { ...entry, plugins: marketplace.plugins.length };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function listMarketplaces() {
|
|
91
|
+
const config = await loadConfig();
|
|
92
|
+
const rows = [];
|
|
93
|
+
for (const entry of config.marketplaces) {
|
|
94
|
+
try {
|
|
95
|
+
const marketplace = await readMarketplace(entry.root, entry);
|
|
96
|
+
rows.push({ ...entry, status: "ok", plugins: marketplace.plugins.length });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
rows.push({ ...entry, status: "error", error: error.message, plugins: 0 });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function loadMarketplaces() {
|
|
105
|
+
const config = await loadConfig();
|
|
106
|
+
const marketplaces = [];
|
|
107
|
+
for (const entry of config.marketplaces) {
|
|
108
|
+
marketplaces.push(await readMarketplace(entry.root, entry));
|
|
109
|
+
}
|
|
110
|
+
return marketplaces;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function refreshMarketplaces(name) {
|
|
114
|
+
const config = await loadConfig();
|
|
115
|
+
const selected = name ? config.marketplaces.filter((item) => item.name === name) : config.marketplaces;
|
|
116
|
+
if (name && selected.length === 0) {
|
|
117
|
+
throw new Error(`error: marketplace "${name}" not found`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const results = [];
|
|
121
|
+
for (const entry of selected) {
|
|
122
|
+
if (entry.sourceType === "git") {
|
|
123
|
+
await execFileAsync("git", ["-C", entry.root, "pull", "--ff-only"]);
|
|
124
|
+
}
|
|
125
|
+
const marketplace = await readMarketplace(entry.root, entry);
|
|
126
|
+
results.push({ name: marketplace.name, plugins: marketplace.plugins.length, status: "ok" });
|
|
127
|
+
}
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { copyPath, ensureDir, exists, removePath, symlinkPath, writeJson } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
export function op(kind, fields) {
|
|
5
|
+
return { kind, ...fields };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function validatePlan(plan, opts = {}) {
|
|
9
|
+
const force = Boolean(opts.force);
|
|
10
|
+
const managedPaths = new Set(opts.managedPaths || []);
|
|
11
|
+
for (const operation of plan.operations) {
|
|
12
|
+
const target = operation.to || operation.path;
|
|
13
|
+
if (!target) continue;
|
|
14
|
+
if (operation.allowExisting) continue;
|
|
15
|
+
if (["copy", "write_json", "symlink", "mkdir"].includes(operation.kind) && (await exists(target))) {
|
|
16
|
+
if (!force && !isManagedTarget(target, managedPaths)) {
|
|
17
|
+
throw new Error(`error: target path already exists and is not managed by igloo-agent-plugin\n\nPath:\n ${target}\n\nUse --force to overwrite, choose another scope/layout, or uninstall the existing copy first.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isManagedTarget(target, managedPaths) {
|
|
24
|
+
const resolved = path.resolve(target);
|
|
25
|
+
for (const managedPath of managedPaths) {
|
|
26
|
+
const managed = path.resolve(managedPath);
|
|
27
|
+
const relative = path.relative(managed, resolved);
|
|
28
|
+
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function executePlan(plan) {
|
|
34
|
+
for (const operation of plan.operations) {
|
|
35
|
+
if (operation.kind === "mkdir") await ensureDir(operation.path);
|
|
36
|
+
else if (operation.kind === "copy") await copyPath(operation.from, operation.to);
|
|
37
|
+
else if (operation.kind === "write_json") await writeJson(operation.to, operation.value);
|
|
38
|
+
else if (operation.kind === "symlink") await symlinkPath(operation.from, operation.to);
|
|
39
|
+
else if (operation.kind === "remove") await removePath(operation.path);
|
|
40
|
+
else throw new Error(`error: unsupported operation kind "${operation.kind}"`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function operationTarget(operation) {
|
|
45
|
+
return operation.to || operation.path || "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function pathsFromPlan(plan) {
|
|
49
|
+
if (plan.recordPaths) return plan.recordPaths.map((item) => path.resolve(item));
|
|
50
|
+
return plan.operations.map(operationTarget).filter(Boolean).map((item) => path.resolve(item));
|
|
51
|
+
}
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import {
|
|
6
|
+
basenameFromSource,
|
|
7
|
+
ensureDir,
|
|
8
|
+
exists,
|
|
9
|
+
hashPath,
|
|
10
|
+
listDirNames,
|
|
11
|
+
parseFrontmatter,
|
|
12
|
+
readJson,
|
|
13
|
+
sanitizeName,
|
|
14
|
+
stateRoot,
|
|
15
|
+
} from "./utils.js";
|
|
16
|
+
import { loadMarketplaces } from "./marketplace.js";
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
export async function findPlugin(ref) {
|
|
21
|
+
const [pluginName, marketplaceName] = ref.split("@");
|
|
22
|
+
const marketplaces = await loadMarketplaces();
|
|
23
|
+
const matches = [];
|
|
24
|
+
|
|
25
|
+
for (const marketplace of marketplaces) {
|
|
26
|
+
if (marketplaceName && marketplace.name !== marketplaceName) continue;
|
|
27
|
+
for (const entry of marketplace.plugins) {
|
|
28
|
+
if (entry.name === pluginName) matches.push({ marketplace, entry });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (matches.length === 0) {
|
|
33
|
+
const names = marketplaces.map((item) => `- ${item.name}`).join("\n") || "- none";
|
|
34
|
+
throw new Error(`error: plugin "${pluginName}" not found\n\nChecked marketplaces:\n${names}\n\nTry:\n igloo-agent-plugin marketplace refresh\n igloo-agent-plugin search ${pluginName}`);
|
|
35
|
+
}
|
|
36
|
+
if (matches.length > 1) {
|
|
37
|
+
const found = matches.map((match) => `- ${match.entry.name}@${match.marketplace.name}`).join("\n");
|
|
38
|
+
throw new Error(`error: plugin "${pluginName}" is ambiguous\n\nMatches:\n${found}\n\nUse:\n igloo-agent-plugin install ${pluginName}@<marketplace>`);
|
|
39
|
+
}
|
|
40
|
+
return matches[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resolvePluginSource(marketplace, entry) {
|
|
44
|
+
const source = entry.source;
|
|
45
|
+
if (typeof source === "string") {
|
|
46
|
+
if (/^(git@|https?:\/\/|ssh:\/\/)/.test(source)) return clonePluginSource(source);
|
|
47
|
+
return path.resolve(marketplace.root, source);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (source && typeof source === "object") {
|
|
51
|
+
const value = source.path || source.url || source.source;
|
|
52
|
+
if (!value) throw new Error(`error: plugin "${entry.name}" has an unsupported source object`);
|
|
53
|
+
if (source.source === "url" || /^(git@|https?:\/\/|ssh:\/\/)/.test(value)) return clonePluginSource(value);
|
|
54
|
+
return path.resolve(marketplace.root, value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new Error(`error: plugin "${entry.name}" has no installable source`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function clonePluginSource(source) {
|
|
61
|
+
const name = basenameFromSource(source) || "plugin";
|
|
62
|
+
const root = path.join(stateRoot(), "cache", "plugins", name);
|
|
63
|
+
await ensureDir(path.dirname(root));
|
|
64
|
+
if (!(await exists(root))) await execFileAsync("git", ["clone", source, root]);
|
|
65
|
+
return root;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function loadPluginPackage(ref) {
|
|
69
|
+
const { marketplace, entry } = await findPlugin(ref);
|
|
70
|
+
const root = await resolvePluginSource(marketplace, entry);
|
|
71
|
+
if (!(await exists(root))) {
|
|
72
|
+
throw new Error(`error: plugin source does not exist\n\nPlugin:\n ${entry.name}\nPath:\n ${root}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const manifestPath = path.join(root, ".claude-plugin", "plugin.json");
|
|
76
|
+
const manifest = (await exists(manifestPath)) ? await readJson(manifestPath) : {};
|
|
77
|
+
const scanned = await scanPlugin(root);
|
|
78
|
+
return {
|
|
79
|
+
name: manifest.name || entry.name,
|
|
80
|
+
description: manifest.description || entry.description || "",
|
|
81
|
+
version: manifest.version || entry.version || "",
|
|
82
|
+
author: manifest.author || undefined,
|
|
83
|
+
root,
|
|
84
|
+
marketplace: marketplace.name,
|
|
85
|
+
marketplaceRoot: marketplace.root,
|
|
86
|
+
entry,
|
|
87
|
+
skills: scanned.skills,
|
|
88
|
+
components: scanned.components,
|
|
89
|
+
sourceHash: await hashPath(root),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function scanPlugin(root) {
|
|
94
|
+
const skills = [];
|
|
95
|
+
const skillRoot = path.join(root, "skills");
|
|
96
|
+
const skillNames = await listDirNames(skillRoot);
|
|
97
|
+
for (const name of skillNames) {
|
|
98
|
+
const skillDir = path.join(skillRoot, name);
|
|
99
|
+
const skillMd = path.join(skillDir, "SKILL.md");
|
|
100
|
+
if (!(await exists(skillMd))) continue;
|
|
101
|
+
const content = await readFile(skillMd, "utf8");
|
|
102
|
+
const frontmatter = parseFrontmatter(content);
|
|
103
|
+
const resources = (await listDirNames(skillDir))
|
|
104
|
+
.filter((item) => item !== "SKILL.md")
|
|
105
|
+
.map((item) => path.join(skillDir, item));
|
|
106
|
+
skills.push({
|
|
107
|
+
name: sanitizeName(frontmatter.name || name) || name,
|
|
108
|
+
directoryName: name,
|
|
109
|
+
description: frontmatter.description || "",
|
|
110
|
+
path: skillDir,
|
|
111
|
+
skillFile: skillMd,
|
|
112
|
+
resources,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rootSkill = path.join(root, "SKILL.md");
|
|
117
|
+
if (await exists(rootSkill)) {
|
|
118
|
+
const content = await readFile(rootSkill, "utf8");
|
|
119
|
+
const frontmatter = parseFrontmatter(content);
|
|
120
|
+
skills.push({
|
|
121
|
+
name: sanitizeName(frontmatter.name || path.basename(root)) || path.basename(root),
|
|
122
|
+
directoryName: sanitizeName(frontmatter.name || path.basename(root)) || path.basename(root),
|
|
123
|
+
description: frontmatter.description || "",
|
|
124
|
+
path: root,
|
|
125
|
+
skillFile: rootSkill,
|
|
126
|
+
resources: [],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const components = {
|
|
131
|
+
hasSkills: skills.length > 0,
|
|
132
|
+
hasHooks: await exists(path.join(root, "hooks", "hooks.json")),
|
|
133
|
+
hasAgents: await exists(path.join(root, "agents")),
|
|
134
|
+
hasMCP: await exists(path.join(root, ".mcp.json")),
|
|
135
|
+
hasLSP: await exists(path.join(root, ".lsp.json")),
|
|
136
|
+
hasCommands: await exists(path.join(root, "commands")),
|
|
137
|
+
hasMonitors: await exists(path.join(root, "monitors", "monitors.json")),
|
|
138
|
+
hasBin: await exists(path.join(root, "bin")),
|
|
139
|
+
hasSettings: await exists(path.join(root, "settings.json")),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { skills, components };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function unsupportedComponents(pkg, target) {
|
|
146
|
+
const unsupported = [];
|
|
147
|
+
const add = (flag, label) => {
|
|
148
|
+
if (pkg.components[flag]) unsupported.push(label);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (target === "claude") return unsupported;
|
|
152
|
+
add("hasHooks", "hooks/hooks.json");
|
|
153
|
+
add("hasAgents", "agents/");
|
|
154
|
+
add("hasMCP", ".mcp.json");
|
|
155
|
+
add("hasLSP", ".lsp.json");
|
|
156
|
+
add("hasCommands", "commands/");
|
|
157
|
+
add("hasMonitors", "monitors/monitors.json");
|
|
158
|
+
add("hasBin", "bin/");
|
|
159
|
+
add("hasSettings", "settings.json");
|
|
160
|
+
return unsupported;
|
|
161
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access, cp, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export function stateRoot() {
|
|
8
|
+
return process.env.IGLOO_AGENT_PLUGIN_HOME || path.join(os.homedir(), ".igloo-agent-plugin");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function expandHome(input) {
|
|
12
|
+
if (!input) return input;
|
|
13
|
+
if (input === "~") return os.homedir();
|
|
14
|
+
if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
|
|
15
|
+
return input;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function exists(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
await access(filePath, constants.F_OK);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function ensureDir(dirPath) {
|
|
28
|
+
await mkdir(dirPath, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readJson(filePath, fallback = undefined) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (fallback !== undefined && error.code === "ENOENT") return fallback;
|
|
36
|
+
throw new Error(`error: failed to read JSON ${filePath}\n\n${error.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function writeJson(filePath, value) {
|
|
41
|
+
await ensureDir(path.dirname(filePath));
|
|
42
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sanitizeName(name) {
|
|
46
|
+
return String(name || "")
|
|
47
|
+
.trim()
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/\.git$/, "")
|
|
50
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
51
|
+
.replace(/^-+|-+$/g, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function listDirNames(dirPath) {
|
|
55
|
+
try {
|
|
56
|
+
return await readdir(dirPath);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error.code === "ENOENT") return [];
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function copyPath(from, to) {
|
|
64
|
+
await ensureDir(path.dirname(to));
|
|
65
|
+
await cp(from, to, { recursive: true, force: true, verbatimSymlinks: false });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function removePath(target) {
|
|
69
|
+
await rm(target, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function symlinkPath(from, to) {
|
|
73
|
+
await ensureDir(path.dirname(to));
|
|
74
|
+
await symlink(from, to, "dir");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function hashFile(filePath) {
|
|
78
|
+
const content = await readFile(filePath);
|
|
79
|
+
return createHash("sha256").update(content).digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function hashPath(root) {
|
|
83
|
+
const hash = createHash("sha256");
|
|
84
|
+
|
|
85
|
+
async function visit(current) {
|
|
86
|
+
const info = await stat(current);
|
|
87
|
+
const relative = path.relative(root, current);
|
|
88
|
+
hash.update(relative);
|
|
89
|
+
if (info.isDirectory()) {
|
|
90
|
+
const names = await readdir(current);
|
|
91
|
+
names.sort();
|
|
92
|
+
for (const name of names) await visit(path.join(current, name));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (info.isFile()) hash.update(await readFile(current));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await visit(root);
|
|
99
|
+
return `sha256:${hash.digest("hex")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseFrontmatter(markdown) {
|
|
103
|
+
if (!markdown.startsWith("---\n")) return {};
|
|
104
|
+
const end = markdown.indexOf("\n---", 4);
|
|
105
|
+
if (end === -1) return {};
|
|
106
|
+
const block = markdown.slice(4, end);
|
|
107
|
+
const result = {};
|
|
108
|
+
for (const line of block.split(/\r?\n/)) {
|
|
109
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
110
|
+
if (match) result[match[1]] = match[2].replace(/^["']|["']$/g, "");
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function basenameFromSource(source) {
|
|
116
|
+
const cleaned = String(source).replace(/[#?].*$/, "").replace(/\/$/, "");
|
|
117
|
+
return sanitizeName(path.basename(cleaned));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function uniqueByName(items) {
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
return items.filter((item) => {
|
|
123
|
+
if (seen.has(item.name)) return false;
|
|
124
|
+
seen.add(item.name);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|