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 +43 -108
- package/package.json +2 -1
- package/src/cli.ts +15 -2
- package/src/commands/install.ts +118 -13
- package/src/commands/list.ts +4 -1
- package/src/commands/scan.ts +95 -59
- package/src/commands/update.ts +97 -0
- package/src/git.ts +75 -0
- package/src/github.ts +100 -0
- package/src/interactive.ts +105 -0
- package/src/manifest.ts +33 -0
- package/src/registry.ts +11 -3
- package/src/{identity.ts โ resolution.ts} +48 -1
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-marketplace",
|
|
3
|
-
"version": "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
|
|
package/src/commands/install.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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:
|
|
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
|
-
// Step 2: Resolve plugin identity
|
|
34
|
-
const pluginName =
|
|
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(
|
|
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 = [...
|
|
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
|
-
|
|
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:
|
|
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 {
|
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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
hash =
|
|
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 (
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
console.log(`
|
|
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
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -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:
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|