opencode-marketplace 0.2.0 → 0.3.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/package.json +2 -1
- package/src/cli.ts +1 -0
- package/src/commands/install.ts +53 -26
- package/src/commands/scan.ts +3 -22
- package/src/commands/update.ts +1 -1
- package/src/interactive.ts +105 -0
- package/src/manifest.ts +33 -0
- package/src/{identity.ts → resolution.ts} +48 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-marketplace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "CLI marketplace for OpenCode plugins",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "nikiforovall",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"typescript": "^5.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"@inquirer/prompts": "^8.1.0",
|
|
47
48
|
"cac": "^6.7.14"
|
|
48
49
|
}
|
|
49
50
|
}
|
package/src/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ export function run(argv = process.argv) {
|
|
|
13
13
|
.command("install <path>", "Install a plugin from a local directory or GitHub URL")
|
|
14
14
|
.option("--scope <scope>", "Installation scope (user/project)", { default: "user" })
|
|
15
15
|
.option("--force", "Overwrite existing components", { default: false })
|
|
16
|
+
.option("-i, --interactive", "Interactively select components to install", { default: false })
|
|
16
17
|
.action((path, options) => {
|
|
17
18
|
if (options.scope !== "user" && options.scope !== "project") {
|
|
18
19
|
console.error(`Invalid scope: ${options.scope}. Must be 'user' or 'project'.`);
|
package/src/commands/install.ts
CHANGED
|
@@ -5,9 +5,9 @@ import { discoverComponents } from "../discovery";
|
|
|
5
5
|
import { formatComponentCount } from "../format";
|
|
6
6
|
import { cleanup, cloneToTemp } from "../git";
|
|
7
7
|
import { isGitHubUrl, parseGitHubUrl } from "../github";
|
|
8
|
-
import { computePluginHash, resolvePluginName } from "../identity";
|
|
9
8
|
import { ensureComponentDirsExist, getComponentTargetPath } from "../paths";
|
|
10
9
|
import { getInstalledPlugin, loadRegistry, saveRegistry } from "../registry";
|
|
10
|
+
import { computePluginHash, inferPluginName } from "../resolution";
|
|
11
11
|
import type {
|
|
12
12
|
ComponentType,
|
|
13
13
|
DiscoveredComponent,
|
|
@@ -15,12 +15,12 @@ import type {
|
|
|
15
15
|
PluginSource,
|
|
16
16
|
Scope,
|
|
17
17
|
} from "../types";
|
|
18
|
-
import { validatePluginName } from "../types";
|
|
19
18
|
|
|
20
19
|
export interface InstallOptions {
|
|
21
20
|
scope: "user" | "project";
|
|
22
21
|
force: boolean;
|
|
23
22
|
verbose?: boolean;
|
|
23
|
+
interactive?: boolean;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
interface ConflictInfo {
|
|
@@ -30,7 +30,7 @@ interface ConflictInfo {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export async function install(path: string, options: InstallOptions) {
|
|
33
|
-
const { scope, force, verbose } = options;
|
|
33
|
+
const { scope, force, verbose, interactive } = options;
|
|
34
34
|
|
|
35
35
|
let tempDir: string | null = null;
|
|
36
36
|
let pluginSource: PluginSource;
|
|
@@ -78,27 +78,11 @@ export async function install(path: string, options: InstallOptions) {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Step 2: Resolve plugin identity
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!parsed) {
|
|
87
|
-
throw new Error(`Invalid GitHub URL: ${path}`);
|
|
88
|
-
}
|
|
89
|
-
// Use subpath if present (e.g., "foo" from "plugins/foo"), otherwise use repo name
|
|
90
|
-
const lastPathPart = parsed.subpath?.split("/").filter(Boolean).pop();
|
|
91
|
-
pluginName = (lastPathPart || parsed.repo).toLowerCase();
|
|
92
|
-
|
|
93
|
-
// Validate the derived name
|
|
94
|
-
if (!validatePluginName(pluginName)) {
|
|
95
|
-
throw new Error(
|
|
96
|
-
`Invalid plugin name "${pluginName}" derived from URL. Plugin names must be lowercase alphanumeric with hyphens.`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
} else {
|
|
100
|
-
pluginName = resolvePluginName(pluginPath);
|
|
101
|
-
}
|
|
81
|
+
// Step 2: Resolve plugin identity using unified logic
|
|
82
|
+
const pluginName = await inferPluginName(
|
|
83
|
+
pluginPath,
|
|
84
|
+
pluginSource.type === "remote" ? path : undefined,
|
|
85
|
+
);
|
|
102
86
|
|
|
103
87
|
if (verbose) {
|
|
104
88
|
console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
|
|
@@ -112,6 +96,44 @@ export async function install(path: string, options: InstallOptions) {
|
|
|
112
96
|
);
|
|
113
97
|
}
|
|
114
98
|
|
|
99
|
+
// Step 3.5: Interactive selection (if enabled)
|
|
100
|
+
let componentsToInstall = components;
|
|
101
|
+
|
|
102
|
+
if (interactive) {
|
|
103
|
+
const { selectComponents } = await import("../interactive");
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const result = await selectComponents(pluginName, components);
|
|
107
|
+
|
|
108
|
+
if (result.cancelled) {
|
|
109
|
+
console.log("\nInstallation cancelled.");
|
|
110
|
+
if (tempDir) {
|
|
111
|
+
await cleanup(tempDir);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (result.selected.length === 0) {
|
|
117
|
+
console.log("No components selected. Nothing installed.");
|
|
118
|
+
if (tempDir) {
|
|
119
|
+
await cleanup(tempDir);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
componentsToInstall = result.selected;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof Error && error.message.includes("User force closed")) {
|
|
127
|
+
console.log("\nInstallation cancelled.");
|
|
128
|
+
if (tempDir) {
|
|
129
|
+
await cleanup(tempDir);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
115
137
|
// Step 4: Compute plugin hash
|
|
116
138
|
const pluginHash = await computePluginHash(components);
|
|
117
139
|
const shortHash = pluginHash.substring(0, 8);
|
|
@@ -119,6 +141,11 @@ export async function install(path: string, options: InstallOptions) {
|
|
|
119
141
|
if (verbose) {
|
|
120
142
|
console.log(`[VERBOSE] Plugin hash: ${pluginHash}`);
|
|
121
143
|
console.log(`[VERBOSE] Found ${components.length} component(s)`);
|
|
144
|
+
if (interactive && componentsToInstall.length < components.length) {
|
|
145
|
+
console.log(
|
|
146
|
+
`[VERBOSE] Selected ${componentsToInstall.length} component(s) for installation`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
console.log(`Installing ${pluginName} [${shortHash}]...`);
|
|
@@ -143,7 +170,7 @@ export async function install(path: string, options: InstallOptions) {
|
|
|
143
170
|
}
|
|
144
171
|
|
|
145
172
|
// Step 6: Detect conflicts
|
|
146
|
-
const conflicts = await detectConflicts(
|
|
173
|
+
const conflicts = await detectConflicts(componentsToInstall, pluginName, scope);
|
|
147
174
|
|
|
148
175
|
if (conflicts.length > 0 && !force) {
|
|
149
176
|
console.error("\nConflict detected:");
|
|
@@ -177,7 +204,7 @@ export async function install(path: string, options: InstallOptions) {
|
|
|
177
204
|
};
|
|
178
205
|
|
|
179
206
|
// Sort components by name to ensure deterministic installation order and registry entry
|
|
180
|
-
const sortedComponents = [...
|
|
207
|
+
const sortedComponents = [...componentsToInstall].sort((a, b) => a.name.localeCompare(b.name));
|
|
181
208
|
|
|
182
209
|
for (const component of sortedComponents) {
|
|
183
210
|
const targetPath = getComponentTargetPath(pluginName, component.name, component.type, scope);
|
package/src/commands/scan.ts
CHANGED
|
@@ -3,9 +3,8 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { discoverComponents } from "../discovery";
|
|
4
4
|
import { cleanup, cloneToTemp } from "../git";
|
|
5
5
|
import { isGitHubUrl, parseGitHubUrl } from "../github";
|
|
6
|
-
import { computePluginHash,
|
|
6
|
+
import { computePluginHash, inferPluginName } from "../resolution";
|
|
7
7
|
import type { DiscoveredComponent } from "../types";
|
|
8
|
-
import { validatePluginName } from "../types";
|
|
9
8
|
|
|
10
9
|
export interface ScanOptions {
|
|
11
10
|
verbose?: boolean;
|
|
@@ -55,28 +54,10 @@ export async function scan(path: string, options: ScanOptions): Promise<void> {
|
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
// 2. Resolve plugin identity
|
|
57
|
+
// 2. Resolve plugin identity using unified logic
|
|
59
58
|
let pluginName: string;
|
|
60
59
|
try {
|
|
61
|
-
|
|
62
|
-
if (isGitHubUrl(path)) {
|
|
63
|
-
const parsed = parseGitHubUrl(path);
|
|
64
|
-
if (!parsed) {
|
|
65
|
-
throw new Error(`Invalid GitHub URL: ${path}`);
|
|
66
|
-
}
|
|
67
|
-
// Use subpath if present (e.g., "foo" from "plugins/foo"), otherwise use repo name
|
|
68
|
-
const lastPathPart = parsed.subpath?.split("/").filter(Boolean).pop();
|
|
69
|
-
pluginName = (lastPathPart || parsed.repo).toLowerCase();
|
|
70
|
-
|
|
71
|
-
// Validate the derived name
|
|
72
|
-
if (!validatePluginName(pluginName)) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`Invalid plugin name "${pluginName}" derived from URL. Plugin names must be lowercase alphanumeric with hyphens.`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
} else {
|
|
78
|
-
pluginName = resolvePluginName(absolutePath);
|
|
79
|
-
}
|
|
60
|
+
pluginName = await inferPluginName(absolutePath, isGitHubUrl(path) ? path : undefined);
|
|
80
61
|
|
|
81
62
|
if (options.verbose) {
|
|
82
63
|
console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
|
package/src/commands/update.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { discoverComponents } from "../discovery";
|
|
2
2
|
import { cleanup, cloneToTemp } from "../git";
|
|
3
3
|
import { parseGitHubUrl } from "../github";
|
|
4
|
-
import { computePluginHash } from "../identity";
|
|
5
4
|
import { getInstalledPlugin } from "../registry";
|
|
5
|
+
import { computePluginHash } from "../resolution";
|
|
6
6
|
import { install } from "./install";
|
|
7
7
|
|
|
8
8
|
export interface UpdateOptions {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { checkbox, Separator } from "@inquirer/prompts";
|
|
2
|
+
import type { DiscoveredComponent } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface SelectionResult {
|
|
5
|
+
selected: DiscoveredComponent[];
|
|
6
|
+
cancelled: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Presents an interactive multi-select UI for choosing components to install
|
|
11
|
+
* @param pluginName - Name of the plugin being installed
|
|
12
|
+
* @param components - All discovered components
|
|
13
|
+
* @returns Selected components or empty array if cancelled/nothing selected
|
|
14
|
+
* @throws Error if not running in a TTY
|
|
15
|
+
*/
|
|
16
|
+
export async function selectComponents(
|
|
17
|
+
pluginName: string,
|
|
18
|
+
components: DiscoveredComponent[],
|
|
19
|
+
): Promise<SelectionResult> {
|
|
20
|
+
// Check TTY
|
|
21
|
+
if (!process.stdin.isTTY) {
|
|
22
|
+
throw new Error("Interactive mode requires a terminal");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Group by type
|
|
26
|
+
const grouped = groupByType(components);
|
|
27
|
+
|
|
28
|
+
// Build choices with separators
|
|
29
|
+
const choices = buildChoices(grouped);
|
|
30
|
+
|
|
31
|
+
if (choices.length === 0) {
|
|
32
|
+
return { selected: [], cancelled: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const selected = await checkbox({
|
|
37
|
+
message: `Select components to install from "${pluginName}":`,
|
|
38
|
+
choices,
|
|
39
|
+
pageSize: 15,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return { selected, cancelled: false };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Handle Ctrl+C gracefully
|
|
45
|
+
if (error instanceof Error && error.message.includes("User force closed")) {
|
|
46
|
+
return { selected: [], cancelled: true };
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface GroupedComponents {
|
|
53
|
+
commands: DiscoveredComponent[];
|
|
54
|
+
agents: DiscoveredComponent[];
|
|
55
|
+
skills: DiscoveredComponent[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Groups components by their type
|
|
60
|
+
*/
|
|
61
|
+
function groupByType(components: DiscoveredComponent[]): GroupedComponents {
|
|
62
|
+
return {
|
|
63
|
+
commands: components.filter((c) => c.type === "command"),
|
|
64
|
+
agents: components.filter((c) => c.type === "agent"),
|
|
65
|
+
skills: components.filter((c) => c.type === "skill"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Builds inquirer choices with separators and proper formatting
|
|
71
|
+
*/
|
|
72
|
+
function buildChoices(
|
|
73
|
+
grouped: GroupedComponents,
|
|
74
|
+
): Array<Separator | { name: string; value: DiscoveredComponent }> {
|
|
75
|
+
const choices: Array<Separator | { name: string; value: DiscoveredComponent }> = [];
|
|
76
|
+
|
|
77
|
+
if (grouped.commands.length > 0) {
|
|
78
|
+
if (choices.length > 0) choices.push(new Separator(""));
|
|
79
|
+
choices.push(new Separator(`\x1b[1m📋 Commands (${grouped.commands.length})\x1b[0m`));
|
|
80
|
+
choices.push(new Separator("─".repeat(50)));
|
|
81
|
+
for (const c of grouped.commands) {
|
|
82
|
+
choices.push({ name: ` ${c.name}`, value: c });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (grouped.agents.length > 0) {
|
|
87
|
+
if (choices.length > 0) choices.push(new Separator(""));
|
|
88
|
+
choices.push(new Separator(`\x1b[1m🤖 Agents (${grouped.agents.length})\x1b[0m`));
|
|
89
|
+
choices.push(new Separator("─".repeat(50)));
|
|
90
|
+
for (const c of grouped.agents) {
|
|
91
|
+
choices.push({ name: ` ${c.name}`, value: c });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (grouped.skills.length > 0) {
|
|
96
|
+
if (choices.length > 0) choices.push(new Separator(""));
|
|
97
|
+
choices.push(new Separator(`\x1b[1m🎯 Skills (${grouped.skills.length})\x1b[0m`));
|
|
98
|
+
choices.push(new Separator("─".repeat(50)));
|
|
99
|
+
for (const c of grouped.skills) {
|
|
100
|
+
choices.push({ name: ` ${c.name}/`, value: c });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return choices;
|
|
105
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads the plugin name from plugin.json if it exists.
|
|
7
|
+
* Returns null if plugin.json doesn't exist, is invalid, or missing name field.
|
|
8
|
+
*
|
|
9
|
+
* @param pluginPath - Absolute path to plugin directory
|
|
10
|
+
* @returns Plugin name from manifest or null
|
|
11
|
+
*/
|
|
12
|
+
export async function readPluginManifest(pluginPath: string): Promise<{ name: string } | null> {
|
|
13
|
+
const manifestPath = join(pluginPath, "plugin.json");
|
|
14
|
+
|
|
15
|
+
if (!existsSync(manifestPath)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const content = await readFile(manifestPath, "utf-8");
|
|
21
|
+
const json = JSON.parse(content);
|
|
22
|
+
|
|
23
|
+
// Only extract name field
|
|
24
|
+
if (json.name && typeof json.name === "string") {
|
|
25
|
+
return { name: json.name };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
} catch {
|
|
30
|
+
// Invalid JSON or read error
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -1,8 +1,54 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { parseGitHubUrl } from "./github";
|
|
5
|
+
import { readPluginManifest } from "./manifest";
|
|
4
6
|
import { type DiscoveredComponent, validatePluginName } from "./types";
|
|
5
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Infers plugin name from multiple sources with priority:
|
|
10
|
+
* 1. plugin.json name field if present
|
|
11
|
+
* 2. Derived from GitHub URL (with dot-stripping)
|
|
12
|
+
* 3. Local directory name (with dot-stripping)
|
|
13
|
+
*
|
|
14
|
+
* @param pluginPath - Absolute path to plugin directory
|
|
15
|
+
* @param originalPath - Original path/URL provided by user (for remote sources)
|
|
16
|
+
* @returns Validated plugin name
|
|
17
|
+
*/
|
|
18
|
+
export async function inferPluginName(pluginPath: string, originalPath?: string): Promise<string> {
|
|
19
|
+
// Try reading plugin.json name field first
|
|
20
|
+
const manifest = await readPluginManifest(pluginPath);
|
|
21
|
+
|
|
22
|
+
if (manifest?.name) {
|
|
23
|
+
const name = manifest.name.toLowerCase();
|
|
24
|
+
if (!validatePluginName(name)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Invalid plugin name "${name}" in plugin.json. Plugin names must be lowercase alphanumeric with hyphens.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// For remote URLs, derive from URL with dot-stripping
|
|
33
|
+
if (originalPath?.startsWith("https://github.com/")) {
|
|
34
|
+
const parsed = parseGitHubUrl(originalPath);
|
|
35
|
+
if (parsed) {
|
|
36
|
+
const lastPathPart = parsed.subpath?.split("/").filter(Boolean).pop();
|
|
37
|
+
const name = (lastPathPart || parsed.repo).replace(/^\.+/, "").toLowerCase();
|
|
38
|
+
|
|
39
|
+
if (!validatePluginName(name)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Invalid plugin name "${name}" derived from URL. Plugin names must be lowercase alphanumeric with hyphens.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return name;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback to directory name
|
|
49
|
+
return resolvePluginName(pluginPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
6
52
|
/**
|
|
7
53
|
* Resolves the plugin name from the directory path.
|
|
8
54
|
* Normalizes the name to be lowercase and validates it.
|
|
@@ -11,7 +57,8 @@ export function resolvePluginName(pluginPath: string): string {
|
|
|
11
57
|
// Extract the last part of the path, handling both Windows and POSIX separators
|
|
12
58
|
const parts = pluginPath.split(/[\\/]/);
|
|
13
59
|
const lastPart = parts.filter(Boolean).pop() || "";
|
|
14
|
-
|
|
60
|
+
// Strip leading dots (e.g., .claude-plugin -> claude-plugin)
|
|
61
|
+
const name = lastPart.replace(/^\.+/, "").toLowerCase();
|
|
15
62
|
|
|
16
63
|
if (!validatePluginName(name)) {
|
|
17
64
|
throw new Error(
|