opencode-marketplace 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 ADDED
@@ -0,0 +1,181 @@
1
+ # OpenCode Marketplace
2
+
3
+ CLI marketplace for OpenCode plugins - declarative, file-based plugin distribution for commands, agents, and skills.
4
+
5
+ ## Overview
6
+
7
+ OpenCode Marketplace brings a convention-based plugin system to OpenCode. Instead of npm packages with programmatic hooks, plugins are simply directories with well-known folder structures that get auto-discovered and installed.
8
+
9
+ **Key Features:**
10
+ - ๐Ÿ“ฆ Install plugins from local directories
11
+ - ๐ŸŽฏ Zero-config, convention-based discovery
12
+ - ๐Ÿ”„ Content-hash based change detection (no version numbers)
13
+ - ๐ŸŽญ Support for commands, agents, and skills
14
+ - ๐ŸŒ User-global or project-local scope
15
+ - ๐Ÿงน Clean install/uninstall workflows
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ bunx opencode-marketplace <command>
21
+ ```
22
+
23
+ Or install globally:
24
+
25
+ ```bash
26
+ bun install -g opencode-marketplace
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ### Install a Plugin
32
+
33
+ ```bash
34
+ opencode-marketplace install /path/to/my-plugin
35
+ ```
36
+
37
+ ### List Installed Plugins
38
+
39
+ ```bash
40
+ opencode-marketplace list
41
+ ```
42
+
43
+ ### Scan a Plugin (Dry Run)
44
+
45
+ ```bash
46
+ opencode-marketplace scan /path/to/my-plugin
47
+ ```
48
+
49
+ ### Uninstall a Plugin
50
+
51
+ ```bash
52
+ opencode-marketplace uninstall my-plugin
53
+ ```
54
+
55
+ ## Plugin Structure
56
+
57
+ A plugin is a directory containing components in well-known locations:
58
+
59
+ ```
60
+ my-plugin/
61
+ โ”œโ”€โ”€ command/ # or .opencode/command/, .claude/commands/
62
+ โ”‚ โ””โ”€โ”€ reflect.md
63
+ โ”œโ”€โ”€ agent/ # or .opencode/agent/, .claude/agents/
64
+ โ”‚ โ””โ”€โ”€ reviewer.md
65
+ โ””โ”€โ”€ skill/ # or .opencode/skill/, .claude/skills/
66
+ โ””โ”€โ”€ code-review/
67
+ โ”œโ”€โ”€ SKILL.md
68
+ โ””โ”€โ”€ data.json
69
+ ```
70
+
71
+ ### Discovery Priority
72
+
73
+ The tool searches for components in this order (first match wins):
74
+
75
+ | Component | Priority 1 | Priority 2 | Priority 3 | Priority 4 |
76
+ |-----------|------------|------------|------------|------------|
77
+ | Commands | `.opencode/command/` | `.claude/commands/` | `./command/` | `./commands/` |
78
+ | Agents | `.opencode/agent/` | `.claude/agents/` | `./agent/` | `./agents/` |
79
+ | Skills | `.opencode/skill/` | `.claude/skills/` | `./skill/` | `./skills/` |
80
+
81
+ ## How It Works
82
+
83
+ ### 1. Discovery
84
+ The tool scans your plugin directory for components using convention-based paths.
85
+
86
+ ### 2. Namespacing
87
+ Files are copied with a prefix to avoid conflicts:
88
+ - Source: `my-plugin/command/reflect.md`
89
+ - Target: `~/.config/opencode/command/my-plugin--reflect.md`
90
+
91
+ ### 3. Registry
92
+ Installed plugins are tracked in `~/.config/opencode/plugins/installed.json` with:
93
+ - Content hash (instead of version)
94
+ - Source path
95
+ - Installed components
96
+ - Scope (user/project)
97
+
98
+ ### 4. Change Detection
99
+ Content hashing ensures you only reinstall when files actually change.
100
+
101
+ ## Scopes
102
+
103
+ | Scope | Target Location | Registry |
104
+ |-------|----------------|----------|
105
+ | `user` (default) | `~/.config/opencode/` | `~/.config/opencode/plugins/installed.json` |
106
+ | `project` | `.opencode/` | `.opencode/plugins/installed.json` |
107
+
108
+ ## Example
109
+
110
+ ```bash
111
+ $ opencode-marketplace install ~/plugins/misc
112
+
113
+ Installing misc [a1b2c3d4]...
114
+ โ†’ command/misc--reflect.md
115
+ โ†’ skill/misc--git-review/
116
+
117
+ Installed misc (1 command, 1 skill) to user scope.
118
+ ```
119
+
120
+ ```bash
121
+ $ opencode-marketplace list
122
+
123
+ Installed plugins (user scope):
124
+ misc [a1b2c3d4] (1 command, 1 skill)
125
+ Source: /home/user/plugins/misc
126
+ ```
127
+
128
+ ## Development
129
+
130
+ ### Setup
131
+
132
+ ```bash
133
+ bun install
134
+ ```
135
+
136
+ ### Run Locally
137
+
138
+ ```bash
139
+ bun run dev
140
+ ```
141
+
142
+ ### Test
143
+
144
+ ```bash
145
+ bun test
146
+ ```
147
+
148
+ ### Lint & Format
149
+
150
+ ```bash
151
+ bun run lint
152
+ bun run format
153
+ ```
154
+
155
+ ## Tech Stack
156
+
157
+ - **Runtime**: Bun
158
+ - **Language**: TypeScript
159
+ - **CLI Framework**: CAC
160
+ - **Testing**: Bun's built-in test runner
161
+
162
+ ## Roadmap
163
+
164
+ **v1** (Current):
165
+ - โœ… Local directory installation
166
+ - โœ… Commands, agents, and skills support
167
+ - โœ… User/project scope
168
+ - โœ… Content-hash based updates
169
+
170
+ **Future**:
171
+ - Remote installation (GitHub URLs)
172
+ - Plugin dependencies
173
+ - Marketplace browsing
174
+
175
+ ## License
176
+
177
+ MIT
178
+
179
+ ## Contributing
180
+
181
+ Contributions welcome! Please open an issue or PR on [GitHub](https://github.com/NikiforovAll/opencode-marketplace).
package/bin/opencode ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from "../src/index";
3
+
4
+ main();
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "opencode-marketplace",
3
+ "version": "0.1.0",
4
+ "description": "CLI marketplace for OpenCode plugins",
5
+ "type": "module",
6
+ "author": "nikiforovall",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/nikiforovall/opencode-marketplace.git"
11
+ },
12
+ "keywords": [
13
+ "opencode",
14
+ "mcp",
15
+ "marketplace",
16
+ "cli",
17
+ "plugins"
18
+ ],
19
+ "files": [
20
+ "bin",
21
+ "src"
22
+ ],
23
+ "bin": {
24
+ "opencode": "bin/opencode"
25
+ },
26
+ "scripts": {
27
+ "dev": "bun run src/index.ts",
28
+ "build": "bun build ./src/index.ts --compile --outfile opencode-marketplace",
29
+ "test": "bun test",
30
+ "lint": "biome check .",
31
+ "lint:fix": "biome check --write .",
32
+ "format": "biome format .",
33
+ "format:fix": "biome format --write .",
34
+ "typecheck": "tsc --noEmit",
35
+ "prepare": "husky"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^2.3.10",
39
+ "@types/bun": "latest",
40
+ "husky": "^9.1.7",
41
+ "typescript": "^5.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "typescript": "^5.0.0"
45
+ },
46
+ "dependencies": {
47
+ "cac": "^6.7.14"
48
+ }
49
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { cac } from "cac";
2
+ import { version } from "../package.json";
3
+ import { install } from "./commands/install";
4
+ import { list } from "./commands/list";
5
+ import { scan } from "./commands/scan";
6
+ import { uninstall } from "./commands/uninstall";
7
+
8
+ export function run(argv = process.argv) {
9
+ const cli = cac("opencode-marketplace");
10
+
11
+ cli
12
+ .command("install <path>", "Install a plugin from a local directory")
13
+ .option("--scope <scope>", "Installation scope (user/project)", { default: "user" })
14
+ .option("--force", "Overwrite existing components", { default: false })
15
+ .action((path, options) => {
16
+ if (options.scope !== "user" && options.scope !== "project") {
17
+ console.error(`Invalid scope: ${options.scope}. Must be 'user' or 'project'.`);
18
+ process.exit(1);
19
+ }
20
+ return install(path, options);
21
+ });
22
+
23
+ cli
24
+ .command("uninstall <name>", "Uninstall a plugin")
25
+ .option("--scope <scope>", "Installation scope (user/project)", { default: "user" })
26
+ .action((name, options) => {
27
+ if (options.scope !== "user" && options.scope !== "project") {
28
+ console.error(`Invalid scope: ${options.scope}. Must be 'user' or 'project'.`);
29
+ process.exit(1);
30
+ }
31
+ return uninstall(name, options);
32
+ });
33
+
34
+ cli
35
+ .command("list", "List installed plugins")
36
+ .option("--scope <scope>", "Filter by scope (user/project)")
37
+ .action((options) => {
38
+ if (options.scope && options.scope !== "user" && options.scope !== "project") {
39
+ console.error(`Invalid scope: ${options.scope}. Must be 'user' or 'project'.`);
40
+ process.exit(1);
41
+ }
42
+ return list(options);
43
+ });
44
+
45
+ cli
46
+ .command("scan <path>", "Scan a directory for plugin components (dry-run)")
47
+ .action((path, options) => {
48
+ return scan(path, options);
49
+ });
50
+
51
+ // Global options
52
+ cli.option("--verbose", "Enable verbose logging");
53
+
54
+ cli.help();
55
+ cli.version(version);
56
+
57
+ try {
58
+ cli.parse(argv);
59
+ } catch (error) {
60
+ if (error instanceof Error && error.message.includes("missing required args")) {
61
+ console.error(error.message);
62
+ cli.outputHelp();
63
+ } else {
64
+ console.error(error instanceof Error ? error.message : String(error));
65
+ }
66
+ process.exit(1);
67
+ }
68
+ }
@@ -0,0 +1,222 @@
1
+ import { existsSync } from "node:fs";
2
+ import { copyFile, cp, mkdir } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { discoverComponents } from "../discovery";
5
+ import { formatComponentCount } from "../format";
6
+ import { computePluginHash, resolvePluginName } from "../identity";
7
+ import { ensureComponentDirsExist, getComponentTargetPath } from "../paths";
8
+ import { getInstalledPlugin, loadRegistry, saveRegistry } from "../registry";
9
+ import type { ComponentType, DiscoveredComponent, InstalledPlugin, Scope } from "../types";
10
+
11
+ export interface InstallOptions {
12
+ scope: "user" | "project";
13
+ force: boolean;
14
+ verbose?: boolean;
15
+ }
16
+
17
+ interface ConflictInfo {
18
+ component: DiscoveredComponent;
19
+ targetPath: string;
20
+ conflictingPlugin: string | null; // null = untracked file
21
+ }
22
+
23
+ export async function install(path: string, options: InstallOptions) {
24
+ const { scope, force, verbose } = options;
25
+
26
+ try {
27
+ // Step 1: Validate plugin directory exists
28
+ const pluginPath = resolve(path);
29
+ if (!existsSync(pluginPath)) {
30
+ throw new Error(`Plugin directory not found: ${path}`);
31
+ }
32
+
33
+ // Step 2: Resolve plugin identity
34
+ const pluginName = resolvePluginName(pluginPath);
35
+ if (verbose) {
36
+ console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
37
+ }
38
+
39
+ // Step 3: Discover components
40
+ const components = await discoverComponents(pluginPath, pluginName);
41
+ if (components.length === 0) {
42
+ throw new Error(
43
+ `No components found in ${path}. Ensure plugin contains command/, agent/, or skill/ directories with valid components.`,
44
+ );
45
+ }
46
+
47
+ // Step 4: Compute plugin hash
48
+ const pluginHash = await computePluginHash(components);
49
+ const shortHash = pluginHash.substring(0, 8);
50
+
51
+ if (verbose) {
52
+ console.log(`[VERBOSE] Plugin hash: ${pluginHash}`);
53
+ console.log(`[VERBOSE] Found ${components.length} component(s)`);
54
+ }
55
+
56
+ console.log(`Installing ${pluginName} [${shortHash}]...`);
57
+
58
+ // Step 5: Check for existing installation
59
+ const existingPlugin = await getInstalledPlugin(pluginName, scope);
60
+
61
+ if (existingPlugin) {
62
+ if (existingPlugin.hash === pluginHash) {
63
+ // Same plugin, same hash - reinstall
64
+ if (verbose) {
65
+ console.log(`[VERBOSE] Reinstalling existing plugin (same hash)`);
66
+ }
67
+ } else {
68
+ // Same plugin, different hash - update
69
+ if (verbose) {
70
+ console.log(
71
+ `[VERBOSE] Updating plugin from [${existingPlugin.hash.substring(0, 8)}] to [${shortHash}]`,
72
+ );
73
+ }
74
+ }
75
+ }
76
+
77
+ // Step 6: Detect conflicts
78
+ const conflicts = await detectConflicts(components, pluginName, scope);
79
+
80
+ if (conflicts.length > 0 && !force) {
81
+ console.error("\nConflict detected:");
82
+ for (const conflict of conflicts) {
83
+ if (conflict.conflictingPlugin) {
84
+ console.error(
85
+ ` ${conflict.component.type}/${conflict.component.targetName} already installed by plugin "${conflict.conflictingPlugin}"`,
86
+ );
87
+ } else {
88
+ console.error(
89
+ ` ${conflict.component.type}/${conflict.component.targetName} exists but is untracked`,
90
+ );
91
+ }
92
+ }
93
+ console.error("\nUse --force to override existing files.");
94
+ throw new Error("Installation aborted due to conflicts");
95
+ }
96
+
97
+ if (conflicts.length > 0 && force && verbose) {
98
+ console.log(`[VERBOSE] Overriding ${conflicts.length} conflicting file(s) with --force`);
99
+ }
100
+
101
+ // Step 7: Ensure target directories exist
102
+ await ensureComponentDirsExist(scope);
103
+
104
+ // Step 8: Copy components
105
+ const installedComponents = {
106
+ commands: [] as string[],
107
+ agents: [] as string[],
108
+ skills: [] as string[],
109
+ };
110
+
111
+ // Sort components by name to ensure deterministic installation order and registry entry
112
+ const sortedComponents = [...components].sort((a, b) => a.name.localeCompare(b.name));
113
+
114
+ for (const component of sortedComponents) {
115
+ const targetPath = getComponentTargetPath(pluginName, component.name, component.type, scope);
116
+
117
+ // Remove trailing slash for copying
118
+ const normalizedTarget = targetPath.endsWith("/") ? targetPath.slice(0, -1) : targetPath;
119
+
120
+ if (component.type === "skill") {
121
+ // Recursive directory copy
122
+ await mkdir(dirname(normalizedTarget), { recursive: true });
123
+ await cp(component.sourcePath, normalizedTarget, { recursive: true });
124
+ installedComponents.skills.push(basename(normalizedTarget));
125
+ } else if (component.type === "command") {
126
+ // Single file copy for commands
127
+ await mkdir(dirname(normalizedTarget), { recursive: true });
128
+ await copyFile(component.sourcePath, normalizedTarget);
129
+ installedComponents.commands.push(basename(normalizedTarget));
130
+ } else {
131
+ // Single file copy for agents
132
+ await mkdir(dirname(normalizedTarget), { recursive: true });
133
+ await copyFile(component.sourcePath, normalizedTarget);
134
+ installedComponents.agents.push(basename(normalizedTarget));
135
+ }
136
+
137
+ console.log(` โ†’ ${component.type}/${component.targetName}`);
138
+ }
139
+
140
+ // Step 9: Update registry
141
+ const registry = await loadRegistry(scope);
142
+
143
+ const newPlugin: InstalledPlugin = {
144
+ name: pluginName,
145
+ hash: pluginHash,
146
+ scope,
147
+ sourcePath: pluginPath,
148
+ installedAt: new Date().toISOString(),
149
+ components: installedComponents,
150
+ };
151
+
152
+ registry.plugins[pluginName] = newPlugin;
153
+ await saveRegistry(registry, scope);
154
+
155
+ // Step 10: Print success message
156
+ const componentCounts = formatComponentCount(installedComponents);
157
+ console.log(`\nInstalled ${pluginName} (${componentCounts}) to ${scope} scope.`);
158
+ } catch (error) {
159
+ if (error instanceof Error) {
160
+ console.error(`\nError: ${error.message}`);
161
+ } else {
162
+ console.error("\nUnknown error occurred during installation");
163
+ }
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Detects conflicts for components that would overwrite files from other plugins.
170
+ * Returns conflicts where:
171
+ * - File exists and belongs to a different plugin
172
+ * - File exists but is untracked (not in registry)
173
+ */
174
+ async function detectConflicts(
175
+ components: DiscoveredComponent[],
176
+ pluginName: string,
177
+ scope: Scope,
178
+ ): Promise<ConflictInfo[]> {
179
+ const conflicts: ConflictInfo[] = [];
180
+ const registry = await loadRegistry(scope);
181
+
182
+ for (const component of components) {
183
+ const targetPath = getComponentTargetPath(pluginName, component.name, component.type, scope);
184
+
185
+ // Remove trailing slash for existence check
186
+ const normalizedTarget = targetPath.endsWith("/") ? targetPath.slice(0, -1) : targetPath;
187
+
188
+ if (existsSync(normalizedTarget)) {
189
+ // Find which plugin owns this component
190
+ const owningPlugin = findOwningPlugin(registry, component.type, component.targetName);
191
+
192
+ // Conflict if owned by different plugin OR untracked
193
+ if (owningPlugin !== pluginName) {
194
+ conflicts.push({
195
+ component,
196
+ targetPath: normalizedTarget,
197
+ conflictingPlugin: owningPlugin,
198
+ });
199
+ }
200
+ }
201
+ }
202
+
203
+ return conflicts;
204
+ }
205
+
206
+ /**
207
+ * Finds which plugin owns a specific component by searching the registry.
208
+ * Returns null if the component is not tracked.
209
+ */
210
+ function findOwningPlugin(
211
+ registry: { plugins: Record<string, InstalledPlugin> },
212
+ componentType: ComponentType,
213
+ targetName: string,
214
+ ): string | null {
215
+ for (const [pluginName, plugin] of Object.entries(registry.plugins)) {
216
+ const componentList = plugin.components[`${componentType}s` as keyof typeof plugin.components];
217
+ if (componentList.includes(targetName)) {
218
+ return pluginName;
219
+ }
220
+ }
221
+ return null;
222
+ }
@@ -0,0 +1,76 @@
1
+ import { formatComponentCount } from "../format";
2
+ import { getAllInstalledPlugins } from "../registry";
3
+ import type { InstalledPlugin, Scope } from "../types";
4
+
5
+ export interface ListOptions {
6
+ scope?: Scope;
7
+ verbose?: boolean;
8
+ }
9
+
10
+ export async function list(options: ListOptions) {
11
+ if (options.verbose) {
12
+ console.log("[VERBOSE] Listing plugins with options:", options);
13
+ }
14
+
15
+ const plugins = await getAllInstalledPlugins(options.scope);
16
+
17
+ // Filter by scope if specified
18
+ const filteredPlugins = options.scope
19
+ ? plugins.filter((plugin) => plugin.scope === options.scope)
20
+ : plugins;
21
+
22
+ if (filteredPlugins.length === 0) {
23
+ const scopeText = options.scope ? `${options.scope} scope` : "any scope";
24
+ console.log(`No plugins installed in ${scopeText}.`);
25
+ return;
26
+ }
27
+
28
+ // Sort alphabetically by plugin name
29
+ filteredPlugins.sort((a, b) => a.name.localeCompare(b.name));
30
+
31
+ // Group by scope for display
32
+ const userPlugins = filteredPlugins.filter((p) => p.scope === "user");
33
+ const projectPlugins = filteredPlugins.filter((p) => p.scope === "project");
34
+
35
+ // Display user scope plugins
36
+ if (userPlugins.length > 0) {
37
+ console.log("User scope:");
38
+ for (const plugin of userPlugins) {
39
+ displayPlugin(plugin, options.verbose);
40
+ }
41
+ if (projectPlugins.length > 0) {
42
+ console.log(""); // Add spacing between scopes
43
+ }
44
+ }
45
+
46
+ // Display project scope plugins
47
+ if (projectPlugins.length > 0) {
48
+ console.log("Project scope:");
49
+ for (const plugin of projectPlugins) {
50
+ displayPlugin(plugin, options.verbose);
51
+ }
52
+ }
53
+ }
54
+
55
+ function displayPlugin(plugin: InstalledPlugin, verbose = false) {
56
+ const componentCount = formatComponentCount(plugin.components);
57
+ const shortHash = plugin.hash.substring(0, 8);
58
+
59
+ console.log(` ${plugin.name} [${shortHash}] (${componentCount})`);
60
+ console.log(` Source: ${plugin.sourcePath}`);
61
+
62
+ if (verbose) {
63
+ console.log(` Installed: ${plugin.installedAt}`);
64
+ console.log(` Scope: ${plugin.scope}`);
65
+
66
+ if (plugin.components.commands.length > 0) {
67
+ console.log(` Commands: ${plugin.components.commands.join(", ")}`);
68
+ }
69
+ if (plugin.components.agents.length > 0) {
70
+ console.log(` Agents: ${plugin.components.agents.join(", ")}`);
71
+ }
72
+ if (plugin.components.skills.length > 0) {
73
+ console.log(` Skills: ${plugin.components.skills.join(", ")}`);
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,133 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { discoverComponents } from "../discovery";
4
+ import { computePluginHash, resolvePluginName } from "../identity";
5
+ import type { DiscoveredComponent } from "../types";
6
+
7
+ export interface ScanOptions {
8
+ verbose?: boolean;
9
+ }
10
+
11
+ /**
12
+ * Scans a plugin directory and displays what components would be installed.
13
+ * This is a dry-run operation that doesn't modify any files.
14
+ */
15
+ export async function scan(path: string, options: ScanOptions): Promise<void> {
16
+ // 1. Validate and resolve path
17
+ const absolutePath = resolve(path);
18
+
19
+ if (options.verbose) {
20
+ console.log(`[VERBOSE] Scanning path ${absolutePath}`);
21
+ }
22
+
23
+ if (!existsSync(absolutePath)) {
24
+ console.error(`Error: Directory not found: ${path}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ // 2. Resolve plugin identity
29
+ let pluginName: string;
30
+ try {
31
+ pluginName = resolvePluginName(absolutePath);
32
+ if (options.verbose) {
33
+ console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
34
+ }
35
+ } catch (error) {
36
+ console.error(error instanceof Error ? error.message : String(error));
37
+ process.exit(1);
38
+ }
39
+
40
+ // 3. Discover components
41
+ const components = await discoverComponents(absolutePath, pluginName);
42
+
43
+ // 4. Compute and shorten hash
44
+ let hash = "";
45
+ try {
46
+ const fullHash = await computePluginHash(components);
47
+ hash = shortenHash(fullHash);
48
+
49
+ if (options.verbose) {
50
+ console.log(`[VERBOSE] Computed hash: ${fullHash} (shortened to ${hash})`);
51
+ console.log();
52
+ }
53
+ } catch (error) {
54
+ // Partial results with warning (per design decision #3)
55
+ console.warn(
56
+ `Warning: Failed to compute hash: ${error instanceof Error ? error.message : String(error)}`,
57
+ );
58
+ hash = "????????"; // Placeholder for failed hash
59
+ }
60
+
61
+ // 5. Display results
62
+ console.log(`Scanning ${pluginName} [${hash}]...`);
63
+
64
+ if (components.length === 0) {
65
+ console.log();
66
+ console.log("No components found.");
67
+ console.log();
68
+ console.log("Expected directories:");
69
+ console.log(" - .opencode/command/, .claude/commands/, command/, or commands/");
70
+ console.log(" - .opencode/agent/, .claude/agents/, agent/, or agents/");
71
+ console.log(" - .opencode/skill/, .claude/skills/, skill/, or skills/");
72
+ return;
73
+ }
74
+
75
+ // Display components (matching install output format)
76
+ for (const component of components) {
77
+ const suffix = component.type === "skill" ? "/" : "";
78
+ console.log(` โ†’ ${component.type}/${component.targetName}${suffix}`);
79
+ }
80
+
81
+ console.log();
82
+
83
+ // Display summary
84
+ const counts = countComponentsByType(components);
85
+ const summary = formatComponentCount(counts);
86
+ console.log(`Found ${summary}`);
87
+ }
88
+
89
+ /**
90
+ * Shortens a full hash to 8 characters (per SPEC).
91
+ */
92
+ function shortenHash(fullHash: string): string {
93
+ return fullHash.substring(0, 8);
94
+ }
95
+
96
+ /**
97
+ * Counts components by type.
98
+ */
99
+ function countComponentsByType(components: DiscoveredComponent[]): {
100
+ commands: number;
101
+ agents: number;
102
+ skills: number;
103
+ } {
104
+ return {
105
+ commands: components.filter((c) => c.type === "command").length,
106
+ agents: components.filter((c) => c.type === "agent").length,
107
+ skills: components.filter((c) => c.type === "skill").length,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Formats component counts for display.
113
+ * Example: "1 command, 2 agents, 3 skills"
114
+ */
115
+ function formatComponentCount(counts: {
116
+ commands: number;
117
+ agents: number;
118
+ skills: number;
119
+ }): string {
120
+ const parts: string[] = [];
121
+
122
+ if (counts.commands > 0) {
123
+ parts.push(`${counts.commands} command${counts.commands > 1 ? "s" : ""}`);
124
+ }
125
+ if (counts.agents > 0) {
126
+ parts.push(`${counts.agents} agent${counts.agents > 1 ? "s" : ""}`);
127
+ }
128
+ if (counts.skills > 0) {
129
+ parts.push(`${counts.skills} skill${counts.skills > 1 ? "s" : ""}`);
130
+ }
131
+
132
+ return parts.join(", ");
133
+ }
@@ -0,0 +1,151 @@
1
+ import { existsSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { formatComponentCount } from "../format";
5
+ import { getComponentDir } from "../paths";
6
+ import { getInstalledPlugin, loadRegistry, saveRegistry } from "../registry";
7
+ import type { ComponentType, Scope } from "../types";
8
+
9
+ export interface UninstallOptions {
10
+ scope: "user" | "project";
11
+ verbose?: boolean;
12
+ }
13
+
14
+ interface DeletionResult {
15
+ deleted: string[];
16
+ alreadyMissing: string[];
17
+ }
18
+
19
+ export async function uninstall(name: string, options: UninstallOptions) {
20
+ const { scope, verbose } = options;
21
+
22
+ try {
23
+ // Step 1: Look up plugin in registry
24
+ const plugin = await getInstalledPlugin(name, scope);
25
+
26
+ if (!plugin) {
27
+ throw new Error(`Plugin "${name}" is not installed in ${scope} scope.
28
+
29
+ Run 'opencode-marketplace list --scope ${scope}' to see installed plugins.`);
30
+ }
31
+
32
+ if (verbose) {
33
+ console.log(`[VERBOSE] Found plugin "${name}" with hash ${plugin.hash}`);
34
+ console.log(
35
+ `[VERBOSE] Plugin has ${
36
+ plugin.components.commands.length +
37
+ plugin.components.agents.length +
38
+ plugin.components.skills.length
39
+ } components to remove`,
40
+ );
41
+ }
42
+
43
+ // Step 2: Display uninstall message with hash
44
+ console.log(`Uninstalling ${name} [${plugin.hash.substring(0, 8)}]...`);
45
+
46
+ // Step 3: Delete all component files/directories
47
+ const deletionResults: DeletionResult = {
48
+ deleted: [],
49
+ alreadyMissing: [],
50
+ };
51
+
52
+ // Delete commands
53
+ for (const command of plugin.components.commands) {
54
+ await deleteComponent("command", command, scope, verbose ?? false, deletionResults);
55
+ }
56
+
57
+ // Delete agents
58
+ for (const agent of plugin.components.agents) {
59
+ await deleteComponent("agent", agent, scope, verbose ?? false, deletionResults);
60
+ }
61
+
62
+ // Delete skills
63
+ for (const skill of plugin.components.skills) {
64
+ await deleteComponent("skill", skill, scope, verbose ?? false, deletionResults);
65
+ }
66
+
67
+ // Step 4: Update registry (remove plugin entry)
68
+ if (verbose) {
69
+ console.log(`[VERBOSE] Updating registry to remove plugin "${name}"`);
70
+ }
71
+
72
+ const registry = await loadRegistry(scope);
73
+ delete registry.plugins[name];
74
+ await saveRegistry(registry, scope);
75
+
76
+ if (verbose) {
77
+ console.log("[VERBOSE] Registry updated successfully");
78
+ }
79
+
80
+ // Step 5: Display success message with breakdown
81
+ const componentCounts = formatComponentCount(plugin.components);
82
+ console.log(`\nUninstalled ${name} (${componentCounts}) from ${scope} scope.`);
83
+
84
+ // Step 6: Always show warning if files were missing
85
+ if (deletionResults.alreadyMissing.length > 0) {
86
+ console.warn(
87
+ `\nWarning: ${deletionResults.alreadyMissing.length} component(s) were already deleted.`,
88
+ );
89
+
90
+ if (verbose) {
91
+ for (const component of deletionResults.alreadyMissing) {
92
+ console.warn(` ${component}`);
93
+ }
94
+ }
95
+ }
96
+ } catch (error) {
97
+ if (error instanceof Error) {
98
+ console.error(`\nError: ${error.message}`);
99
+ } else {
100
+ console.error("\nUnknown error occurred during uninstallation");
101
+ }
102
+ process.exit(1);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Deletes a component file or directory and tracks deletion status.
108
+ */
109
+ async function deleteComponent(
110
+ type: ComponentType,
111
+ componentName: string,
112
+ scope: Scope,
113
+ verbose: boolean,
114
+ results: DeletionResult,
115
+ ): Promise<void> {
116
+ const baseDir = getComponentDir(type, scope);
117
+ const fullPath = join(baseDir, componentName);
118
+
119
+ // Remove trailing slash for consistency
120
+ const normalizedPath = fullPath.endsWith("/") ? fullPath.slice(0, -1) : fullPath;
121
+
122
+ if (verbose) {
123
+ console.log(`[VERBOSE] Deleting ${type}/${componentName} from ${normalizedPath}`);
124
+ }
125
+
126
+ try {
127
+ if (existsSync(normalizedPath)) {
128
+ await rm(normalizedPath, { recursive: true, force: true });
129
+
130
+ if (!verbose) {
131
+ console.log(` โœ— ${type}/${componentName}`);
132
+ }
133
+
134
+ results.deleted.push(`${type}/${componentName}`);
135
+ } else {
136
+ // File already deleted - not an error, but track it
137
+ if (!verbose) {
138
+ console.log(` โš  ${type}/${componentName} (already deleted)`);
139
+ }
140
+
141
+ results.alreadyMissing.push(`${type}/${componentName}`);
142
+ }
143
+ } catch (error) {
144
+ // Permission denied or other filesystem error
145
+ throw new Error(
146
+ `Failed to delete ${type}/${componentName}: ${
147
+ error instanceof Error ? error.message : String(error)
148
+ }`,
149
+ );
150
+ }
151
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { ComponentType, DiscoveredComponent } from "./types";
5
+ import { getComponentTargetName } from "./types";
6
+
7
+ const SEARCH_PATHS: Record<ComponentType, string[]> = {
8
+ command: [".opencode/command", ".claude/commands", "command", "commands"],
9
+ agent: [".opencode/agent", ".claude/agents", "agent", "agents"],
10
+ skill: [".opencode/skill", ".claude/skills", "skill", "skills"],
11
+ };
12
+
13
+ /**
14
+ * Discovers components in a plugin directory based on priority paths.
15
+ * Returns a flattened list of all found components.
16
+ */
17
+ export async function discoverComponents(
18
+ pluginRoot: string,
19
+ pluginName: string,
20
+ ): Promise<DiscoveredComponent[]> {
21
+ const components: DiscoveredComponent[] = [];
22
+
23
+ // Parallelize discovery for each type
24
+ await Promise.all([
25
+ discoverType(pluginRoot, pluginName, "command", components),
26
+ discoverType(pluginRoot, pluginName, "agent", components),
27
+ discoverType(pluginRoot, pluginName, "skill", components),
28
+ ]);
29
+
30
+ return components;
31
+ }
32
+
33
+ async function discoverType(
34
+ root: string,
35
+ pluginName: string,
36
+ type: ComponentType,
37
+ results: DiscoveredComponent[],
38
+ ) {
39
+ const paths = SEARCH_PATHS[type];
40
+
41
+ // Find the first path that exists
42
+ for (const relativePath of paths) {
43
+ const fullPath = join(root, relativePath);
44
+
45
+ if (existsSync(fullPath)) {
46
+ await scanDirectory(fullPath, pluginName, type, results);
47
+ return; // Stop after first match (priority wins)
48
+ }
49
+ }
50
+ }
51
+
52
+ async function scanDirectory(
53
+ dirPath: string,
54
+ pluginName: string,
55
+ type: ComponentType,
56
+ results: DiscoveredComponent[],
57
+ ) {
58
+ try {
59
+ const entries = await readdir(dirPath);
60
+
61
+ for (const entry of entries) {
62
+ const entryPath = join(dirPath, entry);
63
+ const stats = await stat(entryPath);
64
+
65
+ if (type === "skill") {
66
+ // Skills must be directories containing SKILL.md
67
+ if (stats.isDirectory()) {
68
+ const skillMdPath = join(entryPath, "SKILL.md");
69
+ if (existsSync(skillMdPath)) {
70
+ results.push({
71
+ type,
72
+ sourcePath: entryPath,
73
+ name: entry,
74
+ targetName: getComponentTargetName(pluginName, entry),
75
+ });
76
+ }
77
+ }
78
+ } else {
79
+ // Commands and Agents must be .md files
80
+ if (stats.isFile() && entry.endsWith(".md")) {
81
+ results.push({
82
+ type,
83
+ sourcePath: entryPath,
84
+ name: entry,
85
+ targetName: getComponentTargetName(pluginName, entry),
86
+ });
87
+ }
88
+ }
89
+ }
90
+ } catch (_error) {
91
+ // Ignore errors (e.g. permission denied) to be robust
92
+ // In a real app we might want to log this in verbose mode
93
+ }
94
+ }
package/src/format.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Formats component counts into a human-readable string.
3
+ * Example: "1 command, 2 agents, 1 skill"
4
+ */
5
+ export function formatComponentCount(components: {
6
+ commands: string[];
7
+ agents: string[];
8
+ skills: string[];
9
+ }): string {
10
+ const parts: string[] = [];
11
+
12
+ if (components.commands.length > 0) {
13
+ parts.push(
14
+ `${components.commands.length} command${components.commands.length === 1 ? "" : "s"}`,
15
+ );
16
+ }
17
+
18
+ if (components.agents.length > 0) {
19
+ parts.push(`${components.agents.length} agent${components.agents.length === 1 ? "" : "s"}`);
20
+ }
21
+
22
+ if (components.skills.length > 0) {
23
+ parts.push(`${components.skills.length} skill${components.skills.length === 1 ? "" : "s"}`);
24
+ }
25
+
26
+ return parts.join(", ");
27
+ }
@@ -0,0 +1,66 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { type DiscoveredComponent, validatePluginName } from "./types";
5
+
6
+ /**
7
+ * Resolves the plugin name from the directory path.
8
+ * Normalizes the name to be lowercase and validates it.
9
+ */
10
+ export function resolvePluginName(pluginPath: string): string {
11
+ // Extract the last part of the path, handling both Windows and POSIX separators
12
+ const parts = pluginPath.split(/[\\/]/);
13
+ const lastPart = parts.filter(Boolean).pop() || "";
14
+ const name = lastPart.toLowerCase();
15
+
16
+ if (!validatePluginName(name)) {
17
+ throw new Error(
18
+ `Invalid plugin name "${name}". Plugin names must be lowercase alphanumeric with hyphens.`,
19
+ );
20
+ }
21
+
22
+ return name;
23
+ }
24
+
25
+ /**
26
+ * Computes a unique hash for the plugin based on its components' content.
27
+ * used for versioning and change detection.
28
+ */
29
+ export async function computePluginHash(components: DiscoveredComponent[]): Promise<string> {
30
+ const hash = createHash("sha256");
31
+
32
+ // Sort components to ensure consistent hashing
33
+ const sortedComponents = [...components].sort((a, b) => {
34
+ // Sort by type first
35
+ const typeCompare = a.type.localeCompare(b.type);
36
+ if (typeCompare !== 0) return typeCompare;
37
+
38
+ // Then by name
39
+ return a.name.localeCompare(b.name);
40
+ });
41
+
42
+ for (const component of sortedComponents) {
43
+ // Update hash with component identity to distinguish different components with same content
44
+ hash.update(`${component.type}:${component.name}:`);
45
+
46
+ try {
47
+ let contentPath = component.sourcePath;
48
+
49
+ if (component.type === "skill") {
50
+ // For skills, we only hash the SKILL.md file
51
+ contentPath = join(component.sourcePath, "SKILL.md");
52
+ }
53
+
54
+ const content = await readFile(contentPath);
55
+ hash.update(content);
56
+ } catch (error) {
57
+ // If a file is missing during hashing (e.g. SKILL.md missing),
58
+ // we throw to ensure we don't generate a valid hash for a broken plugin
59
+ throw new Error(
60
+ `Failed to read component content for hashing: ${component.sourcePath}. ${error}`,
61
+ );
62
+ }
63
+ }
64
+
65
+ return hash.digest("hex");
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { run } from "./cli";
2
+
3
+ export async function main() {
4
+ run();
5
+ }
6
+
7
+ if (import.meta.main) {
8
+ main();
9
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, normalize } from "node:path";
4
+ import type { ComponentType, Scope } from "./types";
5
+ import { getComponentTargetName } from "./types";
6
+
7
+ /**
8
+ * Returns the base directory for a component type with trailing slash.
9
+ * Examples:
10
+ * - User scope: "~/.config/opencode/command/"
11
+ * - Project scope: ".opencode/command/"
12
+ */
13
+ export function getComponentDir(type: ComponentType, scope: Scope): string {
14
+ const basePath =
15
+ scope === "user"
16
+ ? join(homedir(), ".config", "opencode", type)
17
+ : join(process.cwd(), ".opencode", type);
18
+
19
+ return `${normalize(basePath)}/`;
20
+ }
21
+
22
+ /**
23
+ * Returns the full target path for a component with plugin prefix.
24
+ * Handles both files (commands/agents) and directories (skills).
25
+ *
26
+ * Examples:
27
+ * - Command: "~/.config/opencode/command/myplugin--reflect.md"
28
+ * - Skill: "~/.config/opencode/skill/myplugin--code-review/"
29
+ */
30
+ export function getComponentTargetPath(
31
+ pluginName: string,
32
+ componentName: string,
33
+ type: ComponentType,
34
+ scope: Scope,
35
+ ): string {
36
+ const baseDir = getComponentDir(type, scope);
37
+ const targetName = getComponentTargetName(pluginName, componentName);
38
+ const fullPath = join(baseDir, targetName);
39
+
40
+ // For skills (directories), ensure trailing slash; for files, no trailing slash
41
+ if (type === "skill") {
42
+ return `${normalize(fullPath)}/`;
43
+ }
44
+
45
+ return normalize(fullPath);
46
+ }
47
+
48
+ /**
49
+ * Ensures all component directories (command, agent, skill) exist for the given scope.
50
+ * Idempotent - safe to call multiple times.
51
+ */
52
+ export async function ensureComponentDirsExist(scope: Scope): Promise<void> {
53
+ const dirs: ComponentType[] = ["command", "agent", "skill"];
54
+
55
+ await Promise.all(dirs.map((type) => mkdir(getComponentDir(type, scope), { recursive: true })));
56
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { InstalledPlugin, PluginRegistry, Scope } from "./types";
6
+
7
+ /**
8
+ * Returns the path to the registry file for the given scope.
9
+ */
10
+ export function getRegistryPath(scope: Scope): string {
11
+ if (scope === "user") {
12
+ return join(homedir(), ".config", "opencode", "plugins", "installed.json");
13
+ }
14
+ // project scope
15
+ return join(process.cwd(), ".opencode", "plugins", "installed.json");
16
+ }
17
+
18
+ /**
19
+ * Loads the plugin registry for the given scope.
20
+ * Returns an empty registry if the file does not exist.
21
+ */
22
+ export async function loadRegistry(scope: Scope): Promise<PluginRegistry> {
23
+ const path = getRegistryPath(scope);
24
+
25
+ if (!existsSync(path)) {
26
+ return { version: 1, plugins: {} };
27
+ }
28
+
29
+ try {
30
+ const content = await readFile(path, "utf-8");
31
+ return JSON.parse(content);
32
+ } catch (error) {
33
+ console.error(`Error loading registry from ${path}:`, error);
34
+ return { version: 1, plugins: {} };
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Saves the plugin registry for the given scope.
40
+ * Uses atomic write pattern.
41
+ */
42
+ export async function saveRegistry(registry: PluginRegistry, scope: Scope): Promise<void> {
43
+ const path = getRegistryPath(scope);
44
+ const dir = join(path, "..");
45
+
46
+ if (!existsSync(dir)) {
47
+ await mkdir(dir, { recursive: true });
48
+ }
49
+
50
+ const tmpPath = `${path}.tmp`;
51
+ await writeFile(tmpPath, JSON.stringify(registry, null, 2), "utf-8");
52
+ await rename(tmpPath, path);
53
+ }
54
+
55
+ /**
56
+ * Gets an installed plugin by name from the specified scope.
57
+ */
58
+ export async function getInstalledPlugin(
59
+ name: string,
60
+ scope: Scope,
61
+ ): Promise<InstalledPlugin | null> {
62
+ const registry = await loadRegistry(scope);
63
+ return registry.plugins[name] || null;
64
+ }
65
+
66
+ /**
67
+ * Gets all installed plugins. If scope is provided, only from that scope.
68
+ * Otherwise, combines plugins from both scopes.
69
+ */
70
+ export async function getAllInstalledPlugins(scope?: Scope): Promise<InstalledPlugin[]> {
71
+ if (scope) {
72
+ const registry = await loadRegistry(scope);
73
+ return Object.values(registry.plugins);
74
+ }
75
+
76
+ // Combine user and project scopes
77
+ const [userRegistry, projectRegistry] = await Promise.all([
78
+ loadRegistry("user"),
79
+ loadRegistry("project"),
80
+ ]);
81
+
82
+ // We use a Map to handle potential duplicates (though they should be rare)
83
+ // preferring project scope if a plugin exists in both (unlikely but possible)
84
+ const allPlugins = new Map<string, InstalledPlugin>();
85
+
86
+ for (const plugin of Object.values(userRegistry.plugins)) {
87
+ allPlugins.set(plugin.name, plugin);
88
+ }
89
+
90
+ for (const plugin of Object.values(projectRegistry.plugins)) {
91
+ allPlugins.set(plugin.name, plugin);
92
+ }
93
+
94
+ return Array.from(allPlugins.values());
95
+ }
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ export type Scope = "user" | "project";
2
+ export type ComponentType = "command" | "agent" | "skill";
3
+
4
+ export interface PluginIdentity {
5
+ name: string;
6
+ hash: string;
7
+ }
8
+
9
+ /**
10
+ * Represents a discovered component before installation
11
+ */
12
+ export interface DiscoveredComponent {
13
+ type: ComponentType;
14
+ sourcePath: string; // absolute path
15
+ name: string; // original name
16
+ targetName: string; // prefixed name
17
+ }
18
+
19
+ export interface InstalledPlugin {
20
+ name: string;
21
+ hash: string;
22
+ scope: Scope;
23
+ sourcePath: string;
24
+ installedAt: string; // ISO 8601 timestamp
25
+ components: {
26
+ commands: string[]; // list of installed filenames (prefixed)
27
+ agents: string[]; // list of installed filenames (prefixed)
28
+ skills: string[]; // list of installed folder names (prefixed)
29
+ };
30
+ }
31
+
32
+ export interface PluginRegistry {
33
+ version: 1;
34
+ plugins: Record<string, InstalledPlugin>;
35
+ }
36
+
37
+ // Validation & Helpers
38
+
39
+ /**
40
+ * Validates plugin name (lowercase alphanumeric and hyphens only)
41
+ */
42
+ export function validatePluginName(name: string): boolean {
43
+ return /^[a-z0-9-]+$/.test(name);
44
+ }
45
+
46
+ /**
47
+ * Generates the prefixed name for a component
48
+ * Format: {plugin-name}--{original-name}
49
+ */
50
+ export function getComponentTargetName(pluginName: string, originalName: string): string {
51
+ return `${pluginName}--${originalName}`;
52
+ }