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 +43 -108
- package/package.json +1 -1
- package/src/cli.ts +14 -2
- package/src/commands/install.ts +86 -8
- package/src/commands/list.ts +4 -1
- package/src/commands/scan.ts +113 -58
- package/src/commands/update.ts +97 -0
- package/src/git.ts +75 -0
- package/src/github.ts +100 -0
- package/src/registry.ts +11 -3
- package/src/types.ts +9 -2
package/README.md
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
# OpenCode Marketplace
|
|
2
2
|
|
|
3
|
-
CLI
|
|
3
|
+
CLI for installing OpenCode plugins from local directories or GitHub repositories.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Features
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
**
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
-
|
|
32
|
+
# Install from GitHub
|
|
33
|
+
opencode-marketplace install https://github.com/user/repo
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
opencode-marketplace
|
|
41
|
-
```
|
|
35
|
+
# Install from subfolder
|
|
36
|
+
opencode-marketplace install https://github.com/user/repo/tree/main/plugins/foo
|
|
42
37
|
|
|
43
|
-
|
|
38
|
+
# Update a remote plugin
|
|
39
|
+
opencode-marketplace update my-plugin
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
opencode-marketplace
|
|
47
|
-
```
|
|
41
|
+
# List installed plugins
|
|
42
|
+
opencode-marketplace list
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
# Scan before installing (dry-run)
|
|
45
|
+
opencode-marketplace scan https://github.com/user/repo
|
|
50
46
|
|
|
51
|
-
|
|
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
|
|
53
|
+
A plugin is a directory with components in well-known locations:
|
|
58
54
|
|
|
59
55
|
```
|
|
60
56
|
my-plugin/
|
|
61
|
-
โโโ command/
|
|
57
|
+
โโโ command/ # or .opencode/command/, .claude/commands/
|
|
62
58
|
โ โโโ reflect.md
|
|
63
|
-
โโโ agent/
|
|
59
|
+
โโโ agent/ # or .opencode/agent/, .claude/agents/
|
|
64
60
|
โ โโโ reviewer.md
|
|
65
|
-
โโโ skill/
|
|
61
|
+
โโโ skill/ # or .opencode/skill/, .claude/skills/
|
|
66
62
|
โโโ code-review/
|
|
67
63
|
โโโ SKILL.md
|
|
68
|
-
โโโ
|
|
64
|
+
โโโ reference.md
|
|
69
65
|
```
|
|
70
66
|
|
|
71
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
83
|
+
Use `--scope project` for project-local installations.
|
|
84
|
+
|
|
85
|
+
## Example Output
|
|
109
86
|
|
|
110
87
|
```bash
|
|
111
|
-
$ opencode-marketplace install
|
|
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--
|
|
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
|
-
|
|
100
|
+
User scope:
|
|
124
101
|
misc [a1b2c3d4] (1 command, 1 skill)
|
|
125
|
-
Source: /
|
|
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
|
|
152
|
-
bun run
|
|
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
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
|
|
package/src/commands/install.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
package/src/commands/list.ts
CHANGED
|
@@ -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
|
-
|
|
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}`);
|
package/src/commands/scan.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
hash =
|
|
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 (
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
console.log(`
|
|
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:
|
|
26
|
+
return { version: 2, plugins: {} };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
try {
|
|
30
30
|
const content = await readFile(path, "utf-8");
|
|
31
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|