opencode-marketplace 0.1.0 โ†’ 0.2.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.2.0",
4
4
  "description": "CLI marketplace for OpenCode plugins",
5
5
  "type": "module",
6
6
  "author": "nikiforovall",
package/src/cli.ts CHANGED
@@ -4,12 +4,13 @@ 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 })
15
16
  .action((path, options) => {
@@ -43,11 +44,22 @@ export function run(argv = process.argv) {
43
44
  });
44
45
 
45
46
  cli
46
- .command("scan <path>", "Scan a directory for plugin components (dry-run)")
47
+ .command("scan <path>", "Scan a local directory or GitHub URL for plugin components (dry-run)")
47
48
  .action((path, options) => {
48
49
  return scan(path, options);
49
50
  });
50
51
 
52
+ cli
53
+ .command("update <name>", "Update a plugin from its remote source")
54
+ .option("--scope <scope>", "Installation scope (user/project)", { default: "user" })
55
+ .action((name, options) => {
56
+ if (options.scope !== "user" && options.scope !== "project") {
57
+ console.error(`Invalid scope: ${options.scope}. Must be 'user' or 'project'.`);
58
+ process.exit(1);
59
+ }
60
+ return update(name, options);
61
+ });
62
+
51
63
  // Global options
52
64
  cli.option("--verbose", "Enable verbose logging");
53
65
 
@@ -3,10 +3,19 @@ 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 { cleanup, cloneToTemp } from "../git";
7
+ import { isGitHubUrl, parseGitHubUrl } from "../github";
6
8
  import { computePluginHash, resolvePluginName } from "../identity";
7
9
  import { ensureComponentDirsExist, getComponentTargetPath } from "../paths";
8
10
  import { getInstalledPlugin, loadRegistry, saveRegistry } from "../registry";
9
- import type { ComponentType, DiscoveredComponent, InstalledPlugin, Scope } from "../types";
11
+ import type {
12
+ ComponentType,
13
+ DiscoveredComponent,
14
+ InstalledPlugin,
15
+ PluginSource,
16
+ Scope,
17
+ } from "../types";
18
+ import { validatePluginName } from "../types";
10
19
 
11
20
  export interface InstallOptions {
12
21
  scope: "user" | "project";
@@ -23,15 +32,74 @@ interface ConflictInfo {
23
32
  export async function install(path: string, options: InstallOptions) {
24
33
  const { scope, force, verbose } = options;
25
34
 
35
+ let tempDir: string | null = null;
36
+ let pluginSource: PluginSource;
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
81
  // Step 2: Resolve plugin identity
34
- const pluginName = resolvePluginName(pluginPath);
82
+ // For remote installations, derive name from URL instead of temp directory
83
+ let pluginName: string;
84
+ if (pluginSource.type === "remote") {
85
+ const parsed = parseGitHubUrl(path);
86
+ if (!parsed) {
87
+ throw new Error(`Invalid GitHub URL: ${path}`);
88
+ }
89
+ // Use subpath if present (e.g., "foo" from "plugins/foo"), otherwise use repo name
90
+ const lastPathPart = parsed.subpath?.split("/").filter(Boolean).pop();
91
+ pluginName = (lastPathPart || parsed.repo).toLowerCase();
92
+
93
+ // Validate the derived name
94
+ if (!validatePluginName(pluginName)) {
95
+ throw new Error(
96
+ `Invalid plugin name "${pluginName}" derived from URL. Plugin names must be lowercase alphanumeric with hyphens.`,
97
+ );
98
+ }
99
+ } else {
100
+ pluginName = resolvePluginName(pluginPath);
101
+ }
102
+
35
103
  if (verbose) {
36
104
  console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
37
105
  }
@@ -144,7 +212,7 @@ export async function install(path: string, options: InstallOptions) {
144
212
  name: pluginName,
145
213
  hash: pluginHash,
146
214
  scope,
147
- sourcePath: pluginPath,
215
+ source: pluginSource,
148
216
  installedAt: new Date().toISOString(),
149
217
  components: installedComponents,
150
218
  };
@@ -152,10 +220,20 @@ export async function install(path: string, options: InstallOptions) {
152
220
  registry.plugins[pluginName] = newPlugin;
153
221
  await saveRegistry(registry, scope);
154
222
 
155
- // Step 10: Print success message
223
+ // Step 10: Cleanup temp directory if remote installation
224
+ if (tempDir) {
225
+ await cleanup(tempDir);
226
+ }
227
+
228
+ // Step 11: Print success message
156
229
  const componentCounts = formatComponentCount(installedComponents);
157
230
  console.log(`\nInstalled ${pluginName} (${componentCounts}) to ${scope} scope.`);
158
231
  } catch (error) {
232
+ // Cleanup temp directory on error
233
+ if (tempDir) {
234
+ await cleanup(tempDir);
235
+ }
236
+
159
237
  if (error instanceof Error) {
160
238
  console.error(`\nError: ${error.message}`);
161
239
  } 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,8 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { discoverComponents } from "../discovery";
4
+ import { cleanup, cloneToTemp } from "../git";
5
+ import { isGitHubUrl, parseGitHubUrl } from "../github";
4
6
  import { computePluginHash, resolvePluginName } from "../identity";
5
7
  import type { DiscoveredComponent } from "../types";
8
+ import { validatePluginName } from "../types";
6
9
 
7
10
  export interface ScanOptions {
8
11
  verbose?: boolean;
@@ -13,77 +16,129 @@ export interface ScanOptions {
13
16
  * This is a dry-run operation that doesn't modify any files.
14
17
  */
15
18
  export async function scan(path: string, options: ScanOptions): Promise<void> {
16
- // 1. Validate and resolve path
17
- const absolutePath = resolve(path);
19
+ let tempDir: string | null = null;
18
20
 
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
21
  try {
31
- pluginName = resolvePluginName(absolutePath);
32
- if (options.verbose) {
33
- console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
22
+ // 1. Detect if path is a GitHub URL or local path
23
+ let absolutePath: string;
24
+
25
+ if (isGitHubUrl(path)) {
26
+ // Remote scan
27
+ const parsed = parseGitHubUrl(path);
28
+ if (!parsed) {
29
+ console.error(`Error: Invalid GitHub URL: ${path}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ if (options.verbose) {
34
+ console.log(
35
+ `[VERBOSE] Cloning from GitHub: ${parsed.owner}/${parsed.repo}${parsed.ref ? `@${parsed.ref}` : ""}`,
36
+ );
37
+ }
38
+
39
+ // Clone repository - use base URL without /tree/ path
40
+ const repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
41
+ const cloneResult = await cloneToTemp(repoUrl, parsed.ref, parsed.subpath);
42
+ tempDir = cloneResult.tempDir;
43
+ absolutePath = cloneResult.pluginPath;
44
+ } else {
45
+ // Local scan
46
+ absolutePath = resolve(path);
47
+
48
+ if (options.verbose) {
49
+ console.log(`[VERBOSE] Scanning path ${absolutePath}`);
50
+ }
51
+
52
+ if (!existsSync(absolutePath)) {
53
+ console.error(`Error: Directory not found: ${path}`);
54
+ process.exit(1);
55
+ }
34
56
  }
35
- } catch (error) {
36
- console.error(error instanceof Error ? error.message : String(error));
37
- process.exit(1);
38
- }
39
57
 
40
- // 3. Discover components
41
- const components = await discoverComponents(absolutePath, pluginName);
58
+ // 2. Resolve plugin identity
59
+ let pluginName: string;
60
+ try {
61
+ // For remote scans, derive name from URL instead of temp directory
62
+ if (isGitHubUrl(path)) {
63
+ const parsed = parseGitHubUrl(path);
64
+ if (!parsed) {
65
+ throw new Error(`Invalid GitHub URL: ${path}`);
66
+ }
67
+ // Use subpath if present (e.g., "foo" from "plugins/foo"), otherwise use repo name
68
+ const lastPathPart = parsed.subpath?.split("/").filter(Boolean).pop();
69
+ pluginName = (lastPathPart || parsed.repo).toLowerCase();
70
+
71
+ // Validate the derived name
72
+ if (!validatePluginName(pluginName)) {
73
+ throw new Error(
74
+ `Invalid plugin name "${pluginName}" derived from URL. Plugin names must be lowercase alphanumeric with hyphens.`,
75
+ );
76
+ }
77
+ } else {
78
+ pluginName = resolvePluginName(absolutePath);
79
+ }
80
+
81
+ if (options.verbose) {
82
+ console.log(`[VERBOSE] Resolved plugin name: ${pluginName}`);
83
+ }
84
+ } catch (error) {
85
+ console.error(error instanceof Error ? error.message : String(error));
86
+ process.exit(1);
87
+ }
42
88
 
43
- // 4. Compute and shorten hash
44
- let hash = "";
45
- try {
46
- const fullHash = await computePluginHash(components);
47
- hash = shortenHash(fullHash);
89
+ // 3. Discover components
90
+ const components = await discoverComponents(absolutePath, pluginName);
91
+
92
+ // 4. Compute and shorten hash
93
+ let hash = "";
94
+ try {
95
+ const fullHash = await computePluginHash(components);
96
+ hash = shortenHash(fullHash);
97
+
98
+ if (options.verbose) {
99
+ console.log(`[VERBOSE] Computed hash: ${fullHash} (shortened to ${hash})`);
100
+ console.log();
101
+ }
102
+ } catch (error) {
103
+ // Partial results with warning (per design decision #3)
104
+ console.warn(
105
+ `Warning: Failed to compute hash: ${error instanceof Error ? error.message : String(error)}`,
106
+ );
107
+ hash = "????????"; // Placeholder for failed hash
108
+ }
109
+
110
+ // 5. Display results
111
+ console.log(`Scanning ${pluginName} [${hash}]...`);
48
112
 
49
- if (options.verbose) {
50
- console.log(`[VERBOSE] Computed hash: ${fullHash} (shortened to ${hash})`);
113
+ if (components.length === 0) {
51
114
  console.log();
115
+ console.log("No components found.");
116
+ console.log();
117
+ console.log("Expected directories:");
118
+ console.log(" - .opencode/command/, .claude/commands/, command/, or commands/");
119
+ console.log(" - .opencode/agent/, .claude/agents/, agent/, or agents/");
120
+ console.log(" - .opencode/skill/, .claude/skills/, skill/, or skills/");
121
+ return;
52
122
  }
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
123
 
61
- // 5. Display results
62
- console.log(`Scanning ${pluginName} [${hash}]...`);
124
+ // Display components (matching install output format)
125
+ for (const component of components) {
126
+ const suffix = component.type === "skill" ? "/" : "";
127
+ console.log(` โ†’ ${component.type}/${component.targetName}${suffix}`);
128
+ }
63
129
 
64
- if (components.length === 0) {
65
- console.log();
66
- console.log("No components found.");
67
130
  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
131
 
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}`);
132
+ // Display summary
133
+ const counts = countComponentsByType(components);
134
+ const summary = formatComponentCount(counts);
135
+ console.log(`Found ${summary}`);
136
+ } finally {
137
+ // Cleanup temp directory if remote scan
138
+ if (tempDir) {
139
+ await cleanup(tempDir);
140
+ }
79
141
  }
80
-
81
- console.log();
82
-
83
- // Display summary
84
- const counts = countComponentsByType(components);
85
- const summary = formatComponentCount(counts);
86
- console.log(`Found ${summary}`);
87
142
  }
88
143
 
89
144
  /**
@@ -0,0 +1,97 @@
1
+ import { discoverComponents } from "../discovery";
2
+ import { cleanup, cloneToTemp } from "../git";
3
+ import { parseGitHubUrl } from "../github";
4
+ import { computePluginHash } from "../identity";
5
+ import { getInstalledPlugin } from "../registry";
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
+ }
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
 
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