opencode-marketplace 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-marketplace",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "CLI marketplace for OpenCode plugins",
5
5
  "type": "module",
6
6
  "author": "nikiforovall",
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cac } from "cac";
2
2
  import { version } from "../package.json";
3
+ import { importPlugins } from "./commands/import";
3
4
  import { install } from "./commands/install";
4
5
  import { list } from "./commands/list";
5
6
  import { scan } from "./commands/scan";
@@ -12,14 +13,36 @@ export function run(argv = process.argv) {
12
13
  cli
13
14
  .command("install <path>", "Install a plugin from a local directory or GitHub URL")
14
15
  .option("--scope <scope>", "Installation scope (user/project)", { default: "user" })
16
+ .option(
17
+ "--target-dir <dir>",
18
+ "Custom installation directory (overrides default scope directory)",
19
+ )
15
20
  .option("--force", "Overwrite existing components", { default: false })
16
21
  .option("-i, --interactive", "Interactively select components to install", { default: false })
17
- .action((path, options) => {
22
+ .action(async (path, options) => {
18
23
  if (options.scope !== "user" && options.scope !== "project") {
19
24
  console.error(`Invalid scope: ${options.scope}. Must be 'user' or 'project'.`);
20
25
  process.exit(1);
21
26
  }
22
- return install(path, options);
27
+
28
+ try {
29
+ await install(path, options);
30
+ } catch (error) {
31
+ if (error instanceof Error) {
32
+ console.error(`\nError: ${error.message}`);
33
+ } else {
34
+ console.error("\nUnknown error occurred during installation");
35
+ }
36
+ process.exit(1);
37
+ }
38
+ });
39
+
40
+ cli
41
+ .command("import [config-path]", "Install plugins from import config file")
42
+ .option("--target-dir <dir>", "Custom installation directory (overrides ~/.config/opencode)")
43
+ .option("--force", "Overwrite existing components", { default: false })
44
+ .action((configPath, options) => {
45
+ return importPlugins(configPath, options);
23
46
  });
24
47
 
25
48
  cli
@@ -67,14 +90,15 @@ export function run(argv = process.argv) {
67
90
  cli.help();
68
91
  cli.version(version);
69
92
 
70
- try {
71
- const parsed = cli.parse(argv);
93
+ // Show help when no command is provided (just "opencode-marketplace")
94
+ // argv.length is 2 when only executable is run (process.argv includes [node, script])
95
+ if (argv.length <= 2) {
96
+ cli.outputHelp();
97
+ process.exit(0);
98
+ }
72
99
 
73
- // Show help when no command is provided
74
- if (!parsed.args.length && !parsed.options.help && !parsed.options.version) {
75
- cli.outputHelp();
76
- process.exit(0);
77
- }
100
+ try {
101
+ cli.parse(argv);
78
102
  } catch (error) {
79
103
  if (error instanceof Error && error.message.includes("missing required args")) {
80
104
  console.error(error.message);
@@ -0,0 +1,69 @@
1
+ import { getDefaultImportConfigPath, loadImportConfig } from "../import-config";
2
+ import { install } from "./install";
3
+
4
+ export interface ImportOptions {
5
+ targetDir?: string;
6
+ force: boolean;
7
+ verbose?: boolean;
8
+ }
9
+
10
+ export async function importPlugins(configPath: string | undefined, options: ImportOptions) {
11
+ const { targetDir, force, verbose } = options;
12
+ const actualConfigPath = configPath || getDefaultImportConfigPath();
13
+
14
+ try {
15
+ console.log(`Importing plugins from ${actualConfigPath}...\n`);
16
+
17
+ const config = await loadImportConfig(actualConfigPath);
18
+
19
+ if (config.plugins.length === 0) {
20
+ console.log("No plugins found in configuration.");
21
+ return;
22
+ }
23
+
24
+ const results = {
25
+ installed: 0,
26
+ updated: 0,
27
+ skipped: 0,
28
+ failed: 0,
29
+ };
30
+
31
+ for (let i = 0; i < config.plugins.length; i++) {
32
+ const source = config.plugins[i];
33
+ const displayNum = `[${i + 1}/${config.plugins.length}]`;
34
+
35
+ console.log(`${displayNum} ${source}`);
36
+
37
+ try {
38
+ const result = await install(source, {
39
+ scope: "user",
40
+ force,
41
+ verbose,
42
+ skipIfSameHash: true,
43
+ targetDir,
44
+ });
45
+
46
+ if (result.status === "installed") results.installed++;
47
+ else if (result.status === "updated") results.updated++;
48
+ else if (result.status === "skipped") results.skipped++;
49
+ } catch (error) {
50
+ results.failed++;
51
+ console.error(` Error: ${error instanceof Error ? error.message : String(error)}`);
52
+ }
53
+ console.log(""); // Empty line between plugins
54
+ }
55
+
56
+ console.log("Import complete:");
57
+ console.log(` Installed: ${results.installed}`);
58
+ console.log(` Updated: ${results.updated}`);
59
+ console.log(` Skipped: ${results.skipped}`);
60
+ console.log(` Failed: ${results.failed}`);
61
+
62
+ if (results.failed > 0) {
63
+ process.exit(1);
64
+ }
65
+ } catch (error) {
66
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
67
+ process.exit(1);
68
+ }
69
+ }
@@ -21,6 +21,8 @@ export interface InstallOptions {
21
21
  force: boolean;
22
22
  verbose?: boolean;
23
23
  interactive?: boolean;
24
+ skipIfSameHash?: boolean;
25
+ targetDir?: string;
24
26
  }
25
27
 
26
28
  interface ConflictInfo {
@@ -29,10 +31,16 @@ interface ConflictInfo {
29
31
  conflictingPlugin: string | null; // null = untracked file
30
32
  }
31
33
 
32
- export async function install(path: string, options: InstallOptions) {
33
- const { scope, force, verbose, interactive } = options;
34
+ export interface InstallResult {
35
+ status: "installed" | "updated" | "skipped";
36
+ pluginName: string;
37
+ }
38
+
39
+ export async function install(path: string, options: InstallOptions): Promise<InstallResult> {
40
+ const { scope, force, verbose, interactive, skipIfSameHash, targetDir } = options;
34
41
 
35
42
  let tempDir: string | null = null;
43
+
36
44
  let pluginSource: PluginSource;
37
45
 
38
46
  try {
@@ -110,7 +118,7 @@ export async function install(path: string, options: InstallOptions) {
110
118
  if (tempDir) {
111
119
  await cleanup(tempDir);
112
120
  }
113
- return;
121
+ return { status: "skipped", pluginName: "" };
114
122
  }
115
123
 
116
124
  if (result.selected.length === 0) {
@@ -118,7 +126,7 @@ export async function install(path: string, options: InstallOptions) {
118
126
  if (tempDir) {
119
127
  await cleanup(tempDir);
120
128
  }
121
- return;
129
+ return { status: "skipped", pluginName: "" };
122
130
  }
123
131
 
124
132
  componentsToInstall = result.selected;
@@ -128,7 +136,7 @@ export async function install(path: string, options: InstallOptions) {
128
136
  if (tempDir) {
129
137
  await cleanup(tempDir);
130
138
  }
131
- return;
139
+ return { status: "skipped", pluginName: "" };
132
140
  }
133
141
  throw error;
134
142
  }
@@ -151,14 +159,25 @@ export async function install(path: string, options: InstallOptions) {
151
159
  console.log(`Installing ${pluginName} [${shortHash}]...`);
152
160
 
153
161
  // Step 5: Check for existing installation
154
- const existingPlugin = await getInstalledPlugin(pluginName, scope);
162
+ const existingPlugin = await getInstalledPlugin(pluginName, scope, targetDir);
163
+ let installStatus: "installed" | "updated" | "skipped" = "installed";
155
164
 
156
165
  if (existingPlugin) {
157
166
  if (existingPlugin.hash === pluginHash) {
167
+ if (skipIfSameHash) {
168
+ if (verbose) {
169
+ console.log(`[VERBOSE] Skipping ${pluginName} (already up to date)`);
170
+ }
171
+ if (tempDir) {
172
+ await cleanup(tempDir);
173
+ }
174
+ return { status: "skipped", pluginName };
175
+ }
158
176
  // Same plugin, same hash - reinstall
159
177
  if (verbose) {
160
178
  console.log(`[VERBOSE] Reinstalling existing plugin (same hash)`);
161
179
  }
180
+ installStatus = "installed";
162
181
  } else {
163
182
  // Same plugin, different hash - update
164
183
  if (verbose) {
@@ -166,11 +185,12 @@ export async function install(path: string, options: InstallOptions) {
166
185
  `[VERBOSE] Updating plugin from [${existingPlugin.hash.substring(0, 8)}] to [${shortHash}]`,
167
186
  );
168
187
  }
188
+ installStatus = "updated";
169
189
  }
170
190
  }
171
191
 
172
192
  // Step 6: Detect conflicts
173
- const conflicts = await detectConflicts(componentsToInstall, pluginName, scope);
193
+ const conflicts = await detectConflicts(componentsToInstall, pluginName, scope, targetDir);
174
194
 
175
195
  if (conflicts.length > 0 && !force) {
176
196
  console.error("\nConflict detected:");
@@ -194,7 +214,7 @@ export async function install(path: string, options: InstallOptions) {
194
214
  }
195
215
 
196
216
  // Step 7: Ensure target directories exist
197
- await ensureComponentDirsExist(scope);
217
+ await ensureComponentDirsExist(scope, targetDir);
198
218
 
199
219
  // Step 8: Copy components
200
220
  const installedComponents = {
@@ -207,7 +227,13 @@ export async function install(path: string, options: InstallOptions) {
207
227
  const sortedComponents = [...componentsToInstall].sort((a, b) => a.name.localeCompare(b.name));
208
228
 
209
229
  for (const component of sortedComponents) {
210
- const targetPath = getComponentTargetPath(pluginName, component.name, component.type, scope);
230
+ const targetPath = getComponentTargetPath(
231
+ pluginName,
232
+ component.name,
233
+ component.type,
234
+ scope,
235
+ targetDir,
236
+ );
211
237
 
212
238
  // Remove trailing slash for copying
213
239
  const normalizedTarget = targetPath.endsWith("/") ? targetPath.slice(0, -1) : targetPath;
@@ -233,7 +259,7 @@ export async function install(path: string, options: InstallOptions) {
233
259
  }
234
260
 
235
261
  // Step 9: Update registry
236
- const registry = await loadRegistry(scope);
262
+ const registry = await loadRegistry(scope, targetDir);
237
263
 
238
264
  const newPlugin: InstalledPlugin = {
239
265
  name: pluginName,
@@ -245,7 +271,7 @@ export async function install(path: string, options: InstallOptions) {
245
271
  };
246
272
 
247
273
  registry.plugins[pluginName] = newPlugin;
248
- await saveRegistry(registry, scope);
274
+ await saveRegistry(registry, scope, targetDir);
249
275
 
250
276
  // Step 10: Cleanup temp directory if remote installation
251
277
  if (tempDir) {
@@ -254,19 +280,18 @@ export async function install(path: string, options: InstallOptions) {
254
280
 
255
281
  // Step 11: Print success message
256
282
  const componentCounts = formatComponentCount(installedComponents);
257
- console.log(`\nInstalled ${pluginName} (${componentCounts}) to ${scope} scope.`);
283
+ const locationMsg = targetDir ? `to ${targetDir}` : `to ${scope} scope`;
284
+ console.log(`\nInstalled ${pluginName} (${componentCounts}) ${locationMsg}.`);
285
+
286
+ return { status: installStatus, pluginName };
258
287
  } catch (error) {
259
288
  // Cleanup temp directory on error
260
289
  if (tempDir) {
261
290
  await cleanup(tempDir);
262
291
  }
263
292
 
264
- if (error instanceof Error) {
265
- console.error(`\nError: ${error.message}`);
266
- } else {
267
- console.error("\nUnknown error occurred during installation");
268
- }
269
- process.exit(1);
293
+ // Re-throw the error to let the caller handle it
294
+ throw error;
270
295
  }
271
296
  }
272
297
 
@@ -280,12 +305,19 @@ async function detectConflicts(
280
305
  components: DiscoveredComponent[],
281
306
  pluginName: string,
282
307
  scope: Scope,
308
+ targetDir?: string,
283
309
  ): Promise<ConflictInfo[]> {
284
310
  const conflicts: ConflictInfo[] = [];
285
- const registry = await loadRegistry(scope);
311
+ const registry = await loadRegistry(scope, targetDir);
286
312
 
287
313
  for (const component of components) {
288
- const targetPath = getComponentTargetPath(pluginName, component.name, component.type, scope);
314
+ const targetPath = getComponentTargetPath(
315
+ pluginName,
316
+ component.name,
317
+ component.type,
318
+ scope,
319
+ targetDir,
320
+ );
289
321
 
290
322
  // Remove trailing slash for existence check
291
323
  const normalizedTarget = targetPath.endsWith("/") ? targetPath.slice(0, -1) : targetPath;
@@ -0,0 +1,70 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { isGitHubUrl } from "./github";
6
+ import type { ImportConfig } from "./types";
7
+
8
+ /**
9
+ * Returns the default import config path: ~/.config/opencode/ocm-import.json
10
+ */
11
+ export function getDefaultImportConfigPath(): string {
12
+ return join(homedir(), ".config", "opencode", "ocm-import.json");
13
+ }
14
+
15
+ /**
16
+ * Loads and validates the import configuration file.
17
+ *
18
+ * @param configPath Path to the config file
19
+ * @returns Parsed and validated ImportConfig
20
+ * @throws Error if file not found, invalid JSON, or invalid schema
21
+ */
22
+ export async function loadImportConfig(configPath: string): Promise<ImportConfig> {
23
+ const absolutePath = resolve(configPath);
24
+
25
+ if (!existsSync(absolutePath)) {
26
+ throw new Error(`Import configuration file not found: ${configPath}`);
27
+ }
28
+
29
+ const content = await readFile(absolutePath, "utf-8");
30
+ let config: unknown;
31
+
32
+ try {
33
+ config = JSON.parse(content);
34
+ } catch (error) {
35
+ throw new Error(
36
+ `Failed to parse import configuration (invalid JSON): ${error instanceof Error ? error.message : String(error)}`,
37
+ );
38
+ }
39
+
40
+ // Validation
41
+ if (!config || typeof config !== "object") {
42
+ throw new Error("Invalid import configuration: expected an object");
43
+ }
44
+
45
+ if (!("plugins" in config) || !Array.isArray(config.plugins)) {
46
+ throw new Error("Invalid import configuration: 'plugins' must be an array");
47
+ }
48
+
49
+ // Resolve relative paths
50
+ const configDir = dirname(absolutePath);
51
+ const plugins: string[] = [];
52
+
53
+ for (let i = 0; i < config.plugins.length; i++) {
54
+ const source = config.plugins[i];
55
+ if (typeof source !== "string" || source.trim() === "") {
56
+ throw new Error(`Invalid import configuration: 'plugins[${i}]' must be a non-empty string`);
57
+ }
58
+
59
+ const trimmedSource = source.trim();
60
+
61
+ // Resolve relative paths for local sources (not GitHub URLs and not absolute paths)
62
+ if (!isGitHubUrl(trimmedSource) && !isAbsolute(trimmedSource)) {
63
+ plugins.push(resolve(configDir, trimmedSource));
64
+ } else {
65
+ plugins.push(trimmedSource);
66
+ }
67
+ }
68
+
69
+ return { plugins };
70
+ }
package/src/paths.ts CHANGED
@@ -10,10 +10,10 @@ import { getComponentTargetName } from "./types";
10
10
  * - User scope: "~/.config/opencode/command/"
11
11
  * - Project scope: ".opencode/command/"
12
12
  */
13
- export function getComponentDir(type: ComponentType, scope: Scope): string {
13
+ export function getComponentDir(type: ComponentType, scope: Scope, targetDir?: string): string {
14
14
  const basePath =
15
15
  scope === "user"
16
- ? join(homedir(), ".config", "opencode", type)
16
+ ? join(targetDir || join(homedir(), ".config", "opencode"), type)
17
17
  : join(process.cwd(), ".opencode", type);
18
18
 
19
19
  return `${normalize(basePath)}/`;
@@ -32,8 +32,9 @@ export function getComponentTargetPath(
32
32
  componentName: string,
33
33
  type: ComponentType,
34
34
  scope: Scope,
35
+ targetDir?: string,
35
36
  ): string {
36
- const baseDir = getComponentDir(type, scope);
37
+ const baseDir = getComponentDir(type, scope, targetDir);
37
38
  const targetName = getComponentTargetName(pluginName, componentName);
38
39
  const fullPath = join(baseDir, targetName);
39
40
 
@@ -49,8 +50,10 @@ export function getComponentTargetPath(
49
50
  * Ensures all component directories (command, agent, skill) exist for the given scope.
50
51
  * Idempotent - safe to call multiple times.
51
52
  */
52
- export async function ensureComponentDirsExist(scope: Scope): Promise<void> {
53
+ export async function ensureComponentDirsExist(scope: Scope, targetDir?: string): Promise<void> {
53
54
  const dirs: ComponentType[] = ["command", "agent", "skill"];
54
55
 
55
- await Promise.all(dirs.map((type) => mkdir(getComponentDir(type, scope), { recursive: true })));
56
+ await Promise.all(
57
+ dirs.map((type) => mkdir(getComponentDir(type, scope, targetDir), { recursive: true })),
58
+ );
56
59
  }
package/src/registry.ts CHANGED
@@ -7,9 +7,10 @@ import type { InstalledPlugin, PluginRegistry, Scope } from "./types";
7
7
  /**
8
8
  * Returns the path to the registry file for the given scope.
9
9
  */
10
- export function getRegistryPath(scope: Scope): string {
10
+ export function getRegistryPath(scope: Scope, targetDir?: string): string {
11
11
  if (scope === "user") {
12
- return join(homedir(), ".config", "opencode", "plugins", "installed.json");
12
+ const base = targetDir || join(homedir(), ".config", "opencode");
13
+ return join(base, "plugins", "installed.json");
13
14
  }
14
15
  // project scope
15
16
  return join(process.cwd(), ".opencode", "plugins", "installed.json");
@@ -19,8 +20,8 @@ export function getRegistryPath(scope: Scope): string {
19
20
  * Loads the plugin registry for the given scope.
20
21
  * Returns an empty registry if the file does not exist.
21
22
  */
22
- export async function loadRegistry(scope: Scope): Promise<PluginRegistry> {
23
- const path = getRegistryPath(scope);
23
+ export async function loadRegistry(scope: Scope, targetDir?: string): Promise<PluginRegistry> {
24
+ const path = getRegistryPath(scope, targetDir);
24
25
 
25
26
  if (!existsSync(path)) {
26
27
  return { version: 2, plugins: {} };
@@ -47,8 +48,12 @@ export async function loadRegistry(scope: Scope): Promise<PluginRegistry> {
47
48
  * Saves the plugin registry for the given scope.
48
49
  * Uses atomic write pattern.
49
50
  */
50
- export async function saveRegistry(registry: PluginRegistry, scope: Scope): Promise<void> {
51
- const path = getRegistryPath(scope);
51
+ export async function saveRegistry(
52
+ registry: PluginRegistry,
53
+ scope: Scope,
54
+ targetDir?: string,
55
+ ): Promise<void> {
56
+ const path = getRegistryPath(scope, targetDir);
52
57
  const dir = join(path, "..");
53
58
 
54
59
  if (!existsSync(dir)) {
@@ -66,8 +71,9 @@ export async function saveRegistry(registry: PluginRegistry, scope: Scope): Prom
66
71
  export async function getInstalledPlugin(
67
72
  name: string,
68
73
  scope: Scope,
74
+ targetDir?: string,
69
75
  ): Promise<InstalledPlugin | null> {
70
- const registry = await loadRegistry(scope);
76
+ const registry = await loadRegistry(scope, targetDir);
71
77
  return registry.plugins[name] || null;
72
78
  }
73
79
 
package/src/types.ts CHANGED
@@ -41,6 +41,13 @@ export interface PluginRegistry {
41
41
  plugins: Record<string, InstalledPlugin>;
42
42
  }
43
43
 
44
+ /**
45
+ * Configuration for importing multiple plugins at once
46
+ */
47
+ export interface ImportConfig {
48
+ plugins: string[];
49
+ }
50
+
44
51
  // Validation & Helpers
45
52
 
46
53
  /**