opencode-marketplace 0.1.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/README.md CHANGED
@@ -1,18 +1,15 @@
1
1
  # OpenCode Marketplace
2
2
 
3
- CLI marketplace for OpenCode plugins - declarative, file-based plugin distribution for commands, agents, and skills.
3
+ CLI for installing OpenCode plugins from local directories or GitHub repositories.
4
4
 
5
- ## Overview
5
+ ## Features
6
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
7
+ - ๐Ÿ“ฆ Install from **local directories** or **GitHub URLs**
8
+ - ๐Ÿ”„ **Update** remote plugins with one command
9
+ - ๐ŸŽฏ **Zero-config** convention-based discovery
10
+ - ๐Ÿ” **Content-hash** based change detection
11
+ - ๐ŸŽญ Support for **commands**, **agents**, and **skills**
12
+ - ๐ŸŒ **User-global** or **project-local** scope
16
13
 
17
14
  ## Installation
18
15
 
@@ -28,91 +25,71 @@ bun install -g opencode-marketplace
28
25
 
29
26
  ## Quick Start
30
27
 
31
- ### Install a Plugin
32
-
33
28
  ```bash
29
+ # Install from local directory
34
30
  opencode-marketplace install /path/to/my-plugin
35
- ```
36
31
 
37
- ### List Installed Plugins
32
+ # Install from GitHub
33
+ opencode-marketplace install https://github.com/user/repo
38
34
 
39
- ```bash
40
- opencode-marketplace list
41
- ```
35
+ # Install from subfolder
36
+ opencode-marketplace install https://github.com/user/repo/tree/main/plugins/foo
42
37
 
43
- ### Scan a Plugin (Dry Run)
38
+ # Update a remote plugin
39
+ opencode-marketplace update my-plugin
44
40
 
45
- ```bash
46
- opencode-marketplace scan /path/to/my-plugin
47
- ```
41
+ # List installed plugins
42
+ opencode-marketplace list
48
43
 
49
- ### Uninstall a Plugin
44
+ # Scan before installing (dry-run)
45
+ opencode-marketplace scan https://github.com/user/repo
50
46
 
51
- ```bash
47
+ # Uninstall
52
48
  opencode-marketplace uninstall my-plugin
53
49
  ```
54
50
 
55
51
  ## Plugin Structure
56
52
 
57
- A plugin is a directory containing components in well-known locations:
53
+ A plugin is a directory with components in well-known locations:
58
54
 
59
55
  ```
60
56
  my-plugin/
61
- โ”œโ”€โ”€ command/ # or .opencode/command/, .claude/commands/
57
+ โ”œโ”€โ”€ command/ # or .opencode/command/, .claude/commands/
62
58
  โ”‚ โ””โ”€โ”€ reflect.md
63
- โ”œโ”€โ”€ agent/ # or .opencode/agent/, .claude/agents/
59
+ โ”œโ”€โ”€ agent/ # or .opencode/agent/, .claude/agents/
64
60
  โ”‚ โ””โ”€โ”€ reviewer.md
65
- โ””โ”€โ”€ skill/ # or .opencode/skill/, .claude/skills/
61
+ โ””โ”€โ”€ skill/ # or .opencode/skill/, .claude/skills/
66
62
  โ””โ”€โ”€ code-review/
67
63
  โ”œโ”€โ”€ SKILL.md
68
- โ””โ”€โ”€ data.json
64
+ โ””โ”€โ”€ reference.md
69
65
  ```
70
66
 
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/` |
67
+ **Discovery Priority:** `.opencode/*` โ†’ `.claude/*` โ†’ `./command/` โ†’ `./commands/`
80
68
 
81
69
  ## How It Works
82
70
 
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.
71
+ 1. **Discovery** - Scans for components using convention-based paths
72
+ 2. **Namespacing** - Copies files with prefixes: `my-plugin--reflect.md`
73
+ 3. **Registry** - Tracks installations in `~/.config/opencode/plugins/installed.json`
74
+ 4. **Change Detection** - Content hashing detects actual changes
100
75
 
101
76
  ## Scopes
102
77
 
103
- | Scope | Target Location | Registry |
104
- |-------|----------------|----------|
78
+ | Scope | Target | Registry |
79
+ |-------|--------|----------|
105
80
  | `user` (default) | `~/.config/opencode/` | `~/.config/opencode/plugins/installed.json` |
106
81
  | `project` | `.opencode/` | `.opencode/plugins/installed.json` |
107
82
 
108
- ## Example
83
+ Use `--scope project` for project-local installations.
84
+
85
+ ## Example Output
109
86
 
110
87
  ```bash
111
- $ opencode-marketplace install ~/plugins/misc
88
+ $ opencode-marketplace install https://github.com/user/awesome-plugins/tree/main/misc
112
89
 
113
90
  Installing misc [a1b2c3d4]...
114
91
  โ†’ command/misc--reflect.md
115
- โ†’ skill/misc--git-review/
92
+ โ†’ skill/misc--review/
116
93
 
117
94
  Installed misc (1 command, 1 skill) to user scope.
118
95
  ```
@@ -120,62 +97,20 @@ Installed misc (1 command, 1 skill) to user scope.
120
97
  ```bash
121
98
  $ opencode-marketplace list
122
99
 
123
- Installed plugins (user scope):
100
+ User scope:
124
101
  misc [a1b2c3d4] (1 command, 1 skill)
125
- Source: /home/user/plugins/misc
102
+ Source: https://github.com/user/awesome-plugins/tree/main/misc
126
103
  ```
127
104
 
128
105
  ## Development
129
106
 
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
107
  ```bash
151
- bun run lint
152
- bun run format
108
+ bun install # Install dependencies
109
+ bun run dev # Run locally
110
+ bun test # Run tests
111
+ bun run lint # Lint code
153
112
  ```
154
113
 
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
114
  ## License
176
115
 
177
116
  MIT
178
-
179
- ## Contributing
180
-
181
- Contributions welcome! Please open an issue or PR on [GitHub](https://github.com/NikiforovAll/opencode-marketplace).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-marketplace",
3
- "version": "0.1.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
@@ -4,14 +4,16 @@ import { install } from "./commands/install";
4
4
  import { list } from "./commands/list";
5
5
  import { scan } from "./commands/scan";
6
6
  import { uninstall } from "./commands/uninstall";
7
+ import { update } from "./commands/update";
7
8
 
8
9
  export function run(argv = process.argv) {
9
10
  const cli = cac("opencode-marketplace");
10
11
 
11
12
  cli
12
- .command("install <path>", "Install a plugin from a local directory")
13
+ .command("install <path>", "Install a plugin from a local directory or GitHub URL")
13
14
  .option("--scope <scope>", "Installation scope (user/project)", { default: "user" })
14
15
  .option("--force", "Overwrite existing components", { default: false })
16
+ .option("-i, --interactive", "Interactively select components to install", { default: false })
15
17
  .action((path, options) => {
16
18
  if (options.scope !== "user" && options.scope !== "project") {
17
19
  console.error(`Invalid scope: ${options.scope}. Must be 'user' or 'project'.`);
@@ -43,11 +45,22 @@ export function run(argv = process.argv) {
43
45
  });
44
46
 
45
47
  cli
46
- .command("scan <path>", "Scan a directory for plugin components (dry-run)")
48
+ .command("scan <path>", "Scan a local directory or GitHub URL for plugin components (dry-run)")
47
49
  .action((path, options) => {
48
50
  return scan(path, options);
49
51
  });
50
52
 
53
+ cli
54
+ .command("update <name>", "Update a plugin from its remote source")
55
+ .option("--scope <scope>", "Installation scope (user/project)", { default: "user" })
56
+ .action((name, options) => {
57
+ if (options.scope !== "user" && options.scope !== "project") {
58
+ console.error(`Invalid scope: ${options.scope}. Must be 'user' or 'project'.`);
59
+ process.exit(1);
60
+ }
61
+ return update(name, options);
62
+ });
63
+
51
64
  // Global options
52
65
  cli.option("--verbose", "Enable verbose logging");
53
66
 
@@ -3,15 +3,24 @@ import { copyFile, cp, mkdir } from "node:fs/promises";
3
3
  import { basename, dirname, resolve } from "node:path";
4
4
  import { discoverComponents } from "../discovery";
5
5
  import { formatComponentCount } from "../format";
6
- import { computePluginHash, resolvePluginName } from "../identity";
6
+ import { cleanup, cloneToTemp } from "../git";
7
+ import { isGitHubUrl, parseGitHubUrl } from "../github";
7
8
  import { ensureComponentDirsExist, getComponentTargetPath } from "../paths";
8
9
  import { getInstalledPlugin, loadRegistry, saveRegistry } from "../registry";
9
- import type { ComponentType, DiscoveredComponent, InstalledPlugin, Scope } from "../types";
10
+ import { computePluginHash, inferPluginName } from "../resolution";
11
+ import type {
12
+ ComponentType,
13
+ DiscoveredComponent,
14
+ InstalledPlugin,
15
+ PluginSource,
16
+ Scope,
17
+ } from "../types";
10
18
 
11
19
  export interface InstallOptions {
12
20
  scope: "user" | "project";
13
21
  force: boolean;
14
22
  verbose?: boolean;
23
+ interactive?: boolean;
15
24
  }
16
25
 
17
26
  interface ConflictInfo {
@@ -21,17 +30,60 @@ interface ConflictInfo {
21
30
  }
22
31
 
23
32
  export async function install(path: string, options: InstallOptions) {
24
- const { scope, force, verbose } = options;
33
+ const { scope, force, verbose, interactive } = options;
34
+
35
+ let tempDir: string | null = null;
36
+ let pluginSource: PluginSource;
25
37
 
26
38
  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}`);
39
+ // Step 1: Detect if path is a GitHub URL or local path
40
+ let pluginPath: string;
41
+
42
+ if (isGitHubUrl(path)) {
43
+ // Remote installation
44
+ const parsed = parseGitHubUrl(path);
45
+ if (!parsed) {
46
+ throw new Error(`Invalid GitHub URL: ${path}`);
47
+ }
48
+
49
+ if (verbose) {
50
+ console.log(
51
+ `[VERBOSE] Cloning from GitHub: ${parsed.owner}/${parsed.repo}${parsed.ref ? `@${parsed.ref}` : ""}`,
52
+ );
53
+ }
54
+
55
+ // Clone repository - use base URL without /tree/ path
56
+ const repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
57
+ const cloneResult = await cloneToTemp(repoUrl, parsed.ref, parsed.subpath);
58
+ tempDir = cloneResult.tempDir;
59
+
60
+ // Plugin path from clone result
61
+ pluginPath = cloneResult.pluginPath;
62
+
63
+ pluginSource = {
64
+ type: "remote",
65
+ url: path,
66
+ ref: parsed.ref,
67
+ };
68
+ } else {
69
+ // Local installation
70
+ pluginPath = resolve(path);
71
+ if (!existsSync(pluginPath)) {
72
+ throw new Error(`Plugin directory not found: ${path}`);
73
+ }
74
+
75
+ pluginSource = {
76
+ type: "local",
77
+ path: pluginPath,
78
+ };
31
79
  }
32
80
 
33
- // Step 2: Resolve plugin identity
34
- const pluginName = resolvePluginName(pluginPath);
81
+ // Step 2: Resolve plugin identity using unified logic
82
+ const pluginName = await inferPluginName(
83
+ pluginPath,
84
+ pluginSource.type === "remote" ? path : undefined,
85
+ );
86
+
35
87
  if (verbose) {
36
88
  console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
37
89
  }
@@ -44,6 +96,44 @@ export async function install(path: string, options: InstallOptions) {
44
96
  );
45
97
  }
46
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
+
47
137
  // Step 4: Compute plugin hash
48
138
  const pluginHash = await computePluginHash(components);
49
139
  const shortHash = pluginHash.substring(0, 8);
@@ -51,6 +141,11 @@ export async function install(path: string, options: InstallOptions) {
51
141
  if (verbose) {
52
142
  console.log(`[VERBOSE] Plugin hash: ${pluginHash}`);
53
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
+ }
54
149
  }
55
150
 
56
151
  console.log(`Installing ${pluginName} [${shortHash}]...`);
@@ -75,7 +170,7 @@ export async function install(path: string, options: InstallOptions) {
75
170
  }
76
171
 
77
172
  // Step 6: Detect conflicts
78
- const conflicts = await detectConflicts(components, pluginName, scope);
173
+ const conflicts = await detectConflicts(componentsToInstall, pluginName, scope);
79
174
 
80
175
  if (conflicts.length > 0 && !force) {
81
176
  console.error("\nConflict detected:");
@@ -109,7 +204,7 @@ export async function install(path: string, options: InstallOptions) {
109
204
  };
110
205
 
111
206
  // Sort components by name to ensure deterministic installation order and registry entry
112
- const sortedComponents = [...components].sort((a, b) => a.name.localeCompare(b.name));
207
+ const sortedComponents = [...componentsToInstall].sort((a, b) => a.name.localeCompare(b.name));
113
208
 
114
209
  for (const component of sortedComponents) {
115
210
  const targetPath = getComponentTargetPath(pluginName, component.name, component.type, scope);
@@ -144,7 +239,7 @@ export async function install(path: string, options: InstallOptions) {
144
239
  name: pluginName,
145
240
  hash: pluginHash,
146
241
  scope,
147
- sourcePath: pluginPath,
242
+ source: pluginSource,
148
243
  installedAt: new Date().toISOString(),
149
244
  components: installedComponents,
150
245
  };
@@ -152,10 +247,20 @@ export async function install(path: string, options: InstallOptions) {
152
247
  registry.plugins[pluginName] = newPlugin;
153
248
  await saveRegistry(registry, scope);
154
249
 
155
- // Step 10: Print success message
250
+ // Step 10: Cleanup temp directory if remote installation
251
+ if (tempDir) {
252
+ await cleanup(tempDir);
253
+ }
254
+
255
+ // Step 11: Print success message
156
256
  const componentCounts = formatComponentCount(installedComponents);
157
257
  console.log(`\nInstalled ${pluginName} (${componentCounts}) to ${scope} scope.`);
158
258
  } catch (error) {
259
+ // Cleanup temp directory on error
260
+ if (tempDir) {
261
+ await cleanup(tempDir);
262
+ }
263
+
159
264
  if (error instanceof Error) {
160
265
  console.error(`\nError: ${error.message}`);
161
266
  } else {
@@ -57,7 +57,10 @@ function displayPlugin(plugin: InstalledPlugin, verbose = false) {
57
57
  const shortHash = plugin.hash.substring(0, 8);
58
58
 
59
59
  console.log(` ${plugin.name} [${shortHash}] (${componentCount})`);
60
- console.log(` Source: ${plugin.sourcePath}`);
60
+
61
+ // Display source based on type
62
+ const sourceText = plugin.source.type === "remote" ? plugin.source.url : plugin.source.path;
63
+ console.log(` Source: ${sourceText}`);
61
64
 
62
65
  if (verbose) {
63
66
  console.log(` Installed: ${plugin.installedAt}`);
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { discoverComponents } from "../discovery";
4
- import { computePluginHash, resolvePluginName } from "../identity";
4
+ import { cleanup, cloneToTemp } from "../git";
5
+ import { isGitHubUrl, parseGitHubUrl } from "../github";
6
+ import { computePluginHash, inferPluginName } from "../resolution";
5
7
  import type { DiscoveredComponent } from "../types";
6
8
 
7
9
  export interface ScanOptions {
@@ -13,77 +15,111 @@ export interface ScanOptions {
13
15
  * This is a dry-run operation that doesn't modify any files.
14
16
  */
15
17
  export async function scan(path: string, options: ScanOptions): Promise<void> {
16
- // 1. Validate and resolve path
17
- const absolutePath = resolve(path);
18
+ let tempDir: string | null = null;
18
19
 
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
20
  try {
31
- pluginName = resolvePluginName(absolutePath);
32
- if (options.verbose) {
33
- console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
21
+ // 1. Detect if path is a GitHub URL or local path
22
+ let absolutePath: string;
23
+
24
+ if (isGitHubUrl(path)) {
25
+ // Remote scan
26
+ const parsed = parseGitHubUrl(path);
27
+ if (!parsed) {
28
+ console.error(`Error: Invalid GitHub URL: ${path}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ if (options.verbose) {
33
+ console.log(
34
+ `[VERBOSE] Cloning from GitHub: ${parsed.owner}/${parsed.repo}${parsed.ref ? `@${parsed.ref}` : ""}`,
35
+ );
36
+ }
37
+
38
+ // Clone repository - use base URL without /tree/ path
39
+ const repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
40
+ const cloneResult = await cloneToTemp(repoUrl, parsed.ref, parsed.subpath);
41
+ tempDir = cloneResult.tempDir;
42
+ absolutePath = cloneResult.pluginPath;
43
+ } else {
44
+ // Local scan
45
+ absolutePath = resolve(path);
46
+
47
+ if (options.verbose) {
48
+ console.log(`[VERBOSE] Scanning path ${absolutePath}`);
49
+ }
50
+
51
+ if (!existsSync(absolutePath)) {
52
+ console.error(`Error: Directory not found: ${path}`);
53
+ process.exit(1);
54
+ }
34
55
  }
35
- } catch (error) {
36
- console.error(error instanceof Error ? error.message : String(error));
37
- process.exit(1);
38
- }
39
56
 
40
- // 3. Discover components
41
- const components = await discoverComponents(absolutePath, pluginName);
57
+ // 2. Resolve plugin identity using unified logic
58
+ let pluginName: string;
59
+ try {
60
+ pluginName = await inferPluginName(absolutePath, isGitHubUrl(path) ? path : undefined);
61
+
62
+ if (options.verbose) {
63
+ console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
64
+ }
65
+ } catch (error) {
66
+ console.error(error instanceof Error ? error.message : String(error));
67
+ process.exit(1);
68
+ }
42
69
 
43
- // 4. Compute and shorten hash
44
- let hash = "";
45
- try {
46
- const fullHash = await computePluginHash(components);
47
- hash = shortenHash(fullHash);
70
+ // 3. Discover components
71
+ const components = await discoverComponents(absolutePath, pluginName);
72
+
73
+ // 4. Compute and shorten hash
74
+ let hash = "";
75
+ try {
76
+ const fullHash = await computePluginHash(components);
77
+ hash = shortenHash(fullHash);
78
+
79
+ if (options.verbose) {
80
+ console.log(`[VERBOSE] Computed hash: ${fullHash} (shortened to ${hash})`);
81
+ console.log();
82
+ }
83
+ } catch (error) {
84
+ // Partial results with warning (per design decision #3)
85
+ console.warn(
86
+ `Warning: Failed to compute hash: ${error instanceof Error ? error.message : String(error)}`,
87
+ );
88
+ hash = "????????"; // Placeholder for failed hash
89
+ }
90
+
91
+ // 5. Display results
92
+ console.log(`Scanning ${pluginName} [${hash}]...`);
48
93
 
49
- if (options.verbose) {
50
- console.log(`[VERBOSE] Computed hash: ${fullHash} (shortened to ${hash})`);
94
+ if (components.length === 0) {
51
95
  console.log();
96
+ console.log("No components found.");
97
+ console.log();
98
+ console.log("Expected directories:");
99
+ console.log(" - .opencode/command/, .claude/commands/, command/, or commands/");
100
+ console.log(" - .opencode/agent/, .claude/agents/, agent/, or agents/");
101
+ console.log(" - .opencode/skill/, .claude/skills/, skill/, or skills/");
102
+ return;
52
103
  }
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
104
 
61
- // 5. Display results
62
- console.log(`Scanning ${pluginName} [${hash}]...`);
105
+ // Display components (matching install output format)
106
+ for (const component of components) {
107
+ const suffix = component.type === "skill" ? "/" : "";
108
+ console.log(` โ†’ ${component.type}/${component.targetName}${suffix}`);
109
+ }
63
110
 
64
- if (components.length === 0) {
65
- console.log();
66
- console.log("No components found.");
67
111
  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
112
 
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}`);
113
+ // Display summary
114
+ const counts = countComponentsByType(components);
115
+ const summary = formatComponentCount(counts);
116
+ console.log(`Found ${summary}`);
117
+ } finally {
118
+ // Cleanup temp directory if remote scan
119
+ if (tempDir) {
120
+ await cleanup(tempDir);
121
+ }
79
122
  }
80
-
81
- console.log();
82
-
83
- // Display summary
84
- const counts = countComponentsByType(components);
85
- const summary = formatComponentCount(counts);
86
- console.log(`Found ${summary}`);
87
123
  }
88
124
 
89
125
  /**
@@ -0,0 +1,97 @@
1
+ import { discoverComponents } from "../discovery";
2
+ import { cleanup, cloneToTemp } from "../git";
3
+ import { parseGitHubUrl } from "../github";
4
+ import { getInstalledPlugin } from "../registry";
5
+ import { computePluginHash } from "../resolution";
6
+ import { install } from "./install";
7
+
8
+ export interface UpdateOptions {
9
+ scope: "user" | "project";
10
+ verbose?: boolean;
11
+ }
12
+
13
+ export async function update(pluginName: string, options: UpdateOptions) {
14
+ const { scope, verbose } = options;
15
+
16
+ try {
17
+ // Step 1: Look up plugin in registry
18
+ const plugin = await getInstalledPlugin(pluginName, scope);
19
+
20
+ if (!plugin) {
21
+ throw new Error(
22
+ `Plugin "${pluginName}" is not installed in ${scope} scope. Use 'list' to see installed plugins.`,
23
+ );
24
+ }
25
+
26
+ // Step 2: Check if it's a remote plugin
27
+ if (plugin.source.type === "local") {
28
+ throw new Error(
29
+ `Cannot update local plugin "${pluginName}". Local plugins must be updated at their source and reinstalled.`,
30
+ );
31
+ }
32
+
33
+ // Step 3: Re-fetch from remote
34
+ const { url, ref } = plugin.source;
35
+
36
+ console.log(`Fetching ${url}...`);
37
+
38
+ const parsed = parseGitHubUrl(url);
39
+ if (!parsed) {
40
+ throw new Error(`Invalid GitHub URL in registry: ${url}`);
41
+ }
42
+
43
+ let tempDir: string | null = null;
44
+
45
+ try {
46
+ // Clone repository - use base URL without /tree/ path
47
+ const repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
48
+ const cloneResult = await cloneToTemp(repoUrl, parsed.ref || ref, parsed.subpath);
49
+ tempDir = cloneResult.tempDir;
50
+
51
+ if (verbose) {
52
+ console.log(`[VERBOSE] Cloned to ${tempDir}`);
53
+ }
54
+
55
+ // Step 4: Compute new hash
56
+ const components = await discoverComponents(cloneResult.pluginPath, pluginName);
57
+ const newHash = await computePluginHash(components);
58
+
59
+ // Step 5: Check if already up to date
60
+ if (newHash === plugin.hash) {
61
+ console.log(`\nPlugin ${pluginName} is already up to date [${newHash.substring(0, 8)}].`);
62
+ return;
63
+ }
64
+
65
+ if (verbose) {
66
+ console.log(
67
+ `[VERBOSE] Hash changed: ${plugin.hash.substring(0, 8)} โ†’ ${newHash.substring(0, 8)}`,
68
+ );
69
+ }
70
+
71
+ // Step 6: Run install flow (will overwrite existing)
72
+ // Cleanup temp before install takes over
73
+ const _pluginPath = cloneResult.pluginPath;
74
+ const tmpToKeep = tempDir;
75
+ tempDir = null; // Prevent cleanup in finally
76
+
77
+ console.log(`\nUpdating ${pluginName}...`);
78
+
79
+ // Use install command directly
80
+ await install(url, { scope, force: true, verbose });
81
+
82
+ // Cleanup after install
83
+ await cleanup(tmpToKeep);
84
+ } finally {
85
+ if (tempDir) {
86
+ await cleanup(tempDir);
87
+ }
88
+ }
89
+ } catch (error) {
90
+ if (error instanceof Error) {
91
+ console.error(`\nError: ${error.message}`);
92
+ } else {
93
+ console.error("\nUnknown error occurred during update");
94
+ }
95
+ process.exit(1);
96
+ }
97
+ }
package/src/git.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Git operations for cloning remote repositories
3
+ */
4
+
5
+ import { spawnSync } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ export interface CloneResult {
12
+ tempDir: string; // temp clone location
13
+ pluginPath: string; // actual plugin directory (tempDir + subpath)
14
+ }
15
+
16
+ /**
17
+ * Clones a Git repository to a temporary directory.
18
+ *
19
+ * @param url - Git repository URL
20
+ * @param ref - Optional branch, tag, or commit to checkout
21
+ * @param subpath - Optional subfolder path within the repository
22
+ * @returns Clone result with temp directory and plugin path
23
+ * @throws Error if clone fails
24
+ */
25
+ export async function cloneToTemp(
26
+ url: string,
27
+ ref?: string,
28
+ subpath?: string,
29
+ ): Promise<CloneResult> {
30
+ // Generate unique temp directory
31
+ const tempDir = join(tmpdir(), `opencode-plugin-${randomUUID()}`);
32
+
33
+ // Build git clone command
34
+ const args = ["clone", "--depth", "1"];
35
+
36
+ if (ref) {
37
+ args.push("--branch", ref);
38
+ }
39
+
40
+ args.push(url, tempDir);
41
+
42
+ // Execute git clone
43
+ const result = spawnSync("git", args, {
44
+ stdio: ["ignore", "pipe", "pipe"],
45
+ encoding: "utf-8",
46
+ });
47
+
48
+ if (result.error) {
49
+ throw new Error(`Git command failed: ${result.error.message}`);
50
+ }
51
+
52
+ if (result.status !== 0) {
53
+ const errorMessage = result.stderr || result.stdout || "Unknown error";
54
+ throw new Error(`Failed to clone repository: ${errorMessage.trim()}`);
55
+ }
56
+
57
+ // Determine actual plugin path
58
+ const pluginPath = subpath ? join(tempDir, subpath) : tempDir;
59
+
60
+ return { tempDir, pluginPath };
61
+ }
62
+
63
+ /**
64
+ * Removes a temporary directory.
65
+ *
66
+ * @param tempDir - Path to temporary directory to remove
67
+ */
68
+ export async function cleanup(tempDir: string): Promise<void> {
69
+ try {
70
+ await rm(tempDir, { recursive: true, force: true });
71
+ } catch (_error) {
72
+ // Ignore cleanup errors (temp dir will be cleaned eventually by OS)
73
+ console.warn(`Warning: Failed to cleanup temp directory ${tempDir}`);
74
+ }
75
+ }
package/src/github.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * GitHub URL parsing utilities
3
+ */
4
+
5
+ export interface GitHubSource {
6
+ owner: string;
7
+ repo: string;
8
+ ref?: string; // branch, tag, or commit
9
+ subpath?: string; // subfolder path
10
+ }
11
+
12
+ /**
13
+ * Parses a GitHub URL into structured components.
14
+ *
15
+ * Supported formats:
16
+ * - https://github.com/user/repo
17
+ * - https://github.com/user/repo/tree/main
18
+ * - https://github.com/user/repo/tree/main/plugins/foo
19
+ * - https://github.com/user/repo/tree/v1.0.0/src
20
+ *
21
+ * @param url - GitHub URL to parse
22
+ * @returns Parsed GitHub source or null if invalid
23
+ */
24
+ export function parseGitHubUrl(url: string): GitHubSource | null {
25
+ try {
26
+ const parsed = new URL(url);
27
+
28
+ // Validate it's a GitHub URL
29
+ if (parsed.hostname !== "github.com") {
30
+ return null;
31
+ }
32
+
33
+ // Extract path segments (remove leading slash)
34
+ const pathSegments = parsed.pathname.slice(1).split("/").filter(Boolean);
35
+
36
+ // Need at least owner/repo
37
+ if (pathSegments.length < 2) {
38
+ return null;
39
+ }
40
+
41
+ const [owner, repo, ...rest] = pathSegments;
42
+
43
+ // Basic case: https://github.com/owner/repo
44
+ if (rest.length === 0) {
45
+ return { owner, repo };
46
+ }
47
+
48
+ // Check for /tree/ or /blob/ segment
49
+ const treeOrBlobIndex =
50
+ rest.indexOf("tree") !== -1 ? rest.indexOf("tree") : rest.indexOf("blob");
51
+
52
+ if (treeOrBlobIndex === -1) {
53
+ // No tree/blob segment, treat rest as invalid
54
+ return null;
55
+ }
56
+
57
+ // Format: /tree/<ref>/subpath or /tree/<ref>
58
+ const ref = rest[treeOrBlobIndex + 1];
59
+ const subpathSegments = rest.slice(treeOrBlobIndex + 2);
60
+
61
+ if (!ref) {
62
+ return null;
63
+ }
64
+
65
+ const result: GitHubSource = { owner, repo, ref };
66
+
67
+ if (subpathSegments.length > 0) {
68
+ result.subpath = subpathSegments.join("/");
69
+ }
70
+
71
+ return result;
72
+ } catch {
73
+ // Invalid URL
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Checks if a string is a GitHub URL
80
+ */
81
+ export function isGitHubUrl(input: string): boolean {
82
+ return input.startsWith("https://github.com/");
83
+ }
84
+
85
+ /**
86
+ * Reconstructs a full GitHub URL from parsed components
87
+ */
88
+ export function buildGitHubUrl(source: GitHubSource): string {
89
+ let url = `https://github.com/${source.owner}/${source.repo}`;
90
+
91
+ if (source.ref) {
92
+ url += `/tree/${source.ref}`;
93
+
94
+ if (source.subpath) {
95
+ url += `/${source.subpath}`;
96
+ }
97
+ }
98
+
99
+ return url;
100
+ }
@@ -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
+ }
@@ -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
+ }
package/src/registry.ts CHANGED
@@ -23,15 +23,23 @@ export async function loadRegistry(scope: Scope): Promise<PluginRegistry> {
23
23
  const path = getRegistryPath(scope);
24
24
 
25
25
  if (!existsSync(path)) {
26
- return { version: 1, plugins: {} };
26
+ return { version: 2, plugins: {} };
27
27
  }
28
28
 
29
29
  try {
30
30
  const content = await readFile(path, "utf-8");
31
- return JSON.parse(content);
31
+ const registry = JSON.parse(content);
32
+
33
+ // If old v1 registry, warn and return empty
34
+ if (registry.version === 1) {
35
+ console.warn("Warning: Registry v1 detected. Please reinstall plugins for v2 compatibility.");
36
+ return { version: 2, plugins: {} };
37
+ }
38
+
39
+ return registry;
32
40
  } catch (error) {
33
41
  console.error(`Error loading registry from ${path}:`, error);
34
- return { version: 1, plugins: {} };
42
+ return { version: 2, plugins: {} };
35
43
  }
36
44
  }
37
45
 
@@ -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
- const name = lastPart.toLowerCase();
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(
package/src/types.ts CHANGED
@@ -16,11 +16,18 @@ export interface DiscoveredComponent {
16
16
  targetName: string; // prefixed name
17
17
  }
18
18
 
19
+ /**
20
+ * Source of a plugin - either local path or remote URL
21
+ */
22
+ export type PluginSource =
23
+ | { type: "local"; path: string }
24
+ | { type: "remote"; url: string; ref?: string };
25
+
19
26
  export interface InstalledPlugin {
20
27
  name: string;
21
28
  hash: string;
22
29
  scope: Scope;
23
- sourcePath: string;
30
+ source: PluginSource;
24
31
  installedAt: string; // ISO 8601 timestamp
25
32
  components: {
26
33
  commands: string[]; // list of installed filenames (prefixed)
@@ -30,7 +37,7 @@ export interface InstalledPlugin {
30
37
  }
31
38
 
32
39
  export interface PluginRegistry {
33
- version: 1;
40
+ version: 1 | 2;
34
41
  plugins: Record<string, InstalledPlugin>;
35
42
  }
36
43