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