@rbbtsn0w/adg 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +308 -0
- package/bin/adg.ts +758 -0
- package/docs/agents-spec.md +132 -0
- package/docs/authoring.md +352 -0
- package/package.json +50 -0
- package/schemas/adg-plugin.schema.json +77 -0
- package/schemas/marketplace.schema.json +86 -0
- package/schemas/plugin-lock.schema.json +90 -0
- package/src/adapters/anthropic.ts +54 -0
- package/src/adapters/index.ts +24 -0
- package/src/adapters/openai.ts +37 -0
- package/src/adapters/reverse.ts +60 -0
- package/src/agents/claude.ts +124 -0
- package/src/agents/codex.ts +67 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/registry.ts +30 -0
- package/src/agents/types.ts +47 -0
- package/src/commands/adapt.ts +36 -0
- package/src/commands/import.ts +69 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/install.ts +411 -0
- package/src/commands/link.ts +61 -0
- package/src/commands/list.ts +28 -0
- package/src/commands/marketplace.ts +198 -0
- package/src/commands/migrate.ts +84 -0
- package/src/commands/multiselect-skills.ts +137 -0
- package/src/commands/remove.ts +136 -0
- package/src/commands/select-agents.ts +45 -0
- package/src/commands/select-components.ts +66 -0
- package/src/commands/select-plugins.ts +28 -0
- package/src/commands/select-scope.ts +21 -0
- package/src/commands/update.ts +85 -0
- package/src/commands/validate.ts +57 -0
- package/src/components.ts +90 -0
- package/src/deps.ts +64 -0
- package/src/fsutil.ts +38 -0
- package/src/hash.ts +61 -0
- package/src/lock.ts +57 -0
- package/src/manifest.ts +113 -0
- package/src/marketplace.ts +41 -0
- package/src/package.ts +74 -0
- package/src/paths.ts +129 -0
- package/src/semver.ts +67 -0
- package/src/skills.ts +88 -0
- package/src/sources.ts +159 -0
- package/src/types.ts +140 -0
- package/vendor/skills/LICENSE +29 -0
- package/vendor/skills/PROVENANCE.md +60 -0
- package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
- package/vendor/skills/package.json +143 -0
- package/vendor/skills/src/add.ts +1999 -0
- package/vendor/skills/src/agents.ts +755 -0
- package/vendor/skills/src/blob.ts +567 -0
- package/vendor/skills/src/cli.ts +387 -0
- package/vendor/skills/src/constants.ts +3 -0
- package/vendor/skills/src/detect-agent.ts +62 -0
- package/vendor/skills/src/find.ts +357 -0
- package/vendor/skills/src/frontmatter.ts +16 -0
- package/vendor/skills/src/git-tree.ts +36 -0
- package/vendor/skills/src/git.ts +277 -0
- package/vendor/skills/src/install.ts +91 -0
- package/vendor/skills/src/installer.ts +1097 -0
- package/vendor/skills/src/list.ts +231 -0
- package/vendor/skills/src/local-lock.ts +182 -0
- package/vendor/skills/src/plugin-manifest.ts +183 -0
- package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
- package/vendor/skills/src/providers/index.ts +14 -0
- package/vendor/skills/src/providers/registry.ts +51 -0
- package/vendor/skills/src/providers/types.ts +97 -0
- package/vendor/skills/src/providers/wellknown.ts +804 -0
- package/vendor/skills/src/remove.ts +323 -0
- package/vendor/skills/src/sanitize.ts +65 -0
- package/vendor/skills/src/self-cli.ts +20 -0
- package/vendor/skills/src/skill-lock.ts +329 -0
- package/vendor/skills/src/skills.ts +316 -0
- package/vendor/skills/src/source-parser.ts +438 -0
- package/vendor/skills/src/sync.ts +478 -0
- package/vendor/skills/src/telemetry.ts +186 -0
- package/vendor/skills/src/test-utils.ts +73 -0
- package/vendor/skills/src/types.ts +128 -0
- package/vendor/skills/src/update-source.ts +90 -0
- package/vendor/skills/src/update.ts +749 -0
- package/vendor/skills/src/use.ts +675 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { installedPluginDir, lockPath } from "../paths.ts";
|
|
2
|
+
import { readLock } from "../lock.ts";
|
|
3
|
+
import { readManifest } from "../manifest.ts";
|
|
4
|
+
import { exposedContents, pluginContents, type PluginContents } from "../components.ts";
|
|
5
|
+
import type { LockEntry } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
export type { PluginContents };
|
|
8
|
+
|
|
9
|
+
export interface ListedPlugin extends LockEntry {
|
|
10
|
+
name: string;
|
|
11
|
+
/** What the installed plugin exposes (honoring any partial-install selection). */
|
|
12
|
+
contents?: PluginContents;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** List plugins recorded in a plugins directory's .plugin-lock.json. */
|
|
16
|
+
export function listPlugins(pluginsDir: string): ListedPlugin[] {
|
|
17
|
+
const lock = readLock(lockPath(pluginsDir));
|
|
18
|
+
return Object.entries(lock.plugins).map(([name, entry]) => {
|
|
19
|
+
const dir = installedPluginDir(pluginsDir, name, entry.origin);
|
|
20
|
+
let contents: PluginContents | undefined;
|
|
21
|
+
try {
|
|
22
|
+
contents = exposedContents(pluginContents(dir, readManifest(dir)), entry.selection);
|
|
23
|
+
} catch {
|
|
24
|
+
contents = undefined; // no/invalid manifest on disk — show provenance only
|
|
25
|
+
}
|
|
26
|
+
return { name, ...entry, contents };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { AdapterTarget } from "../adapters/index.ts";
|
|
2
|
+
import type { GitRunner } from "../sources.ts";
|
|
3
|
+
import type { PluginSource } from "../types.ts";
|
|
4
|
+
import { lockPath } from "../paths.ts";
|
|
5
|
+
import { readLock } from "../lock.ts";
|
|
6
|
+
import { addPlugins, type InstallResult } from "./install.ts";
|
|
7
|
+
import { removePlugin } from "./remove.ts";
|
|
8
|
+
import type { AgentScope } from "../agents/index.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The marketplace layer is a *view* over installed plugins grouped by their
|
|
12
|
+
* source, not a separate registry. Every plugin records where it came from in
|
|
13
|
+
* `lock.origin`; a "marketplace" is just the set of plugins sharing a source.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Active-scope context, used to make "source not found" errors actionable. */
|
|
17
|
+
export interface ScopeInfo {
|
|
18
|
+
/** Human label for the active scope: "project", "global", or an explicit dir. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** The global plugins dir, so we can suggest `-g` when a source is only there. */
|
|
21
|
+
globalDir?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MarketplaceScope {
|
|
25
|
+
pluginsDir: string;
|
|
26
|
+
now?: string;
|
|
27
|
+
/** Where the active pluginsDir came from, for scope-aware error messages. */
|
|
28
|
+
scope?: ScopeInfo;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A stable key identifying the source a plugin came from. GitHub/git plugins
|
|
33
|
+
* group by repo/url (the marketplace root, ignoring per-plugin sub-paths);
|
|
34
|
+
* local installs share a single "(local)" bucket since their original source
|
|
35
|
+
* directory isn't recoverable for re-sync.
|
|
36
|
+
*/
|
|
37
|
+
export function sourceKey(origin: PluginSource): string {
|
|
38
|
+
switch (origin.type) {
|
|
39
|
+
case "github":
|
|
40
|
+
return origin.repo;
|
|
41
|
+
case "git":
|
|
42
|
+
return origin.url;
|
|
43
|
+
case "local":
|
|
44
|
+
return "(local)";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for sources that can be re-fetched (have a recoverable remote spec). */
|
|
49
|
+
function isRemoteKey(key: string): boolean {
|
|
50
|
+
return key !== "(local)";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MarketplaceGroup {
|
|
54
|
+
/** Source key (owner/repo, git url, or "(local)"). */
|
|
55
|
+
source: string;
|
|
56
|
+
/** ref shared by the group's plugins, if any. */
|
|
57
|
+
ref?: string;
|
|
58
|
+
/** Installed plugin names from this source. */
|
|
59
|
+
installed: string[];
|
|
60
|
+
/** Whether this source can be re-synced (remote). */
|
|
61
|
+
remote: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Group installed plugins by source, read straight from the lock. */
|
|
65
|
+
export function marketplaceList(opts: { pluginsDir: string }): MarketplaceGroup[] {
|
|
66
|
+
const lock = readLock(lockPath(opts.pluginsDir));
|
|
67
|
+
const groups = new Map<string, { ref?: string; installed: string[] }>();
|
|
68
|
+
|
|
69
|
+
for (const [name, entry] of Object.entries(lock.plugins)) {
|
|
70
|
+
const key = sourceKey(entry.origin);
|
|
71
|
+
const ref = entry.origin.type === "github" || entry.origin.type === "git" ? entry.origin.ref : undefined;
|
|
72
|
+
const g = groups.get(key) ?? { ref, installed: [] };
|
|
73
|
+
g.installed.push(name);
|
|
74
|
+
if (g.ref === undefined && ref !== undefined) g.ref = ref;
|
|
75
|
+
groups.set(key, g);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return [...groups.entries()]
|
|
79
|
+
.map(([source, g]) => ({
|
|
80
|
+
source,
|
|
81
|
+
...(g.ref ? { ref: g.ref } : {}),
|
|
82
|
+
installed: g.installed.sort(),
|
|
83
|
+
remote: isRemoteKey(source),
|
|
84
|
+
}))
|
|
85
|
+
.sort((a, b) => a.source.localeCompare(b.source));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Find one source group by key, or throw with the available keys. The error is
|
|
90
|
+
* scope-aware: it names the scope/path it searched, and — when the source isn't
|
|
91
|
+
* here but exists globally — suggests re-running with `-g`.
|
|
92
|
+
*/
|
|
93
|
+
function requireGroup(pluginsDir: string, source: string, scope?: ScopeInfo): MarketplaceGroup {
|
|
94
|
+
const groups = marketplaceList({ pluginsDir });
|
|
95
|
+
const hit = groups.find((g) => g.source === source);
|
|
96
|
+
if (hit) return hit;
|
|
97
|
+
|
|
98
|
+
const keys = groups.map((g) => g.source).join(", ") || "(none)";
|
|
99
|
+
const where = scope?.label ? `${scope.label}: ${pluginsDir}` : pluginsDir;
|
|
100
|
+
let msg = `no installed source "${source}" in ${where}. Known sources: ${keys}`;
|
|
101
|
+
|
|
102
|
+
if (scope?.globalDir && scope.globalDir !== pluginsDir) {
|
|
103
|
+
const inGlobal = marketplaceList({ pluginsDir: scope.globalDir }).some((g) => g.source === source);
|
|
104
|
+
if (inGlobal) msg += ` — found in global; did you mean \`-g\`?`;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(msg);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface MarketplaceUpgradeResult {
|
|
110
|
+
source: string;
|
|
111
|
+
/** Plugins re-installed/updated from the source. */
|
|
112
|
+
updated: InstallResult[];
|
|
113
|
+
converted: string[];
|
|
114
|
+
/** Discovered in the source but not installed (offered for `--all`). */
|
|
115
|
+
available: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Re-fetch a source and update the plugins installed from it. By default only
|
|
120
|
+
* already-installed plugins are refreshed; `all` also installs everything new
|
|
121
|
+
* the source now offers. With no `source`, every remote source is upgraded.
|
|
122
|
+
*/
|
|
123
|
+
export async function marketplaceUpgrade(
|
|
124
|
+
opts: MarketplaceScope & {
|
|
125
|
+
source?: string;
|
|
126
|
+
all?: boolean;
|
|
127
|
+
targets?: AdapterTarget[];
|
|
128
|
+
gitRunner?: GitRunner;
|
|
129
|
+
/** Re-activate the agents after upgrade (set by the CLI; off by default). */
|
|
130
|
+
activate?: boolean;
|
|
131
|
+
/** Install scope for re-activation after upgrade. */
|
|
132
|
+
agentScope?: AgentScope;
|
|
133
|
+
},
|
|
134
|
+
): Promise<MarketplaceUpgradeResult[]> {
|
|
135
|
+
const groups = opts.source
|
|
136
|
+
? [requireGroup(opts.pluginsDir, opts.source, opts.scope)]
|
|
137
|
+
: marketplaceList({ pluginsDir: opts.pluginsDir }).filter((g) => g.remote);
|
|
138
|
+
|
|
139
|
+
if (opts.source && !groups[0]!.remote) {
|
|
140
|
+
throw new Error(`source "${opts.source}" is local and cannot be re-synced; re-run \`adg plugins add\`.`);
|
|
141
|
+
}
|
|
142
|
+
if (groups.length === 0) {
|
|
143
|
+
throw new Error(`no remote sources installed in ${opts.pluginsDir}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const now = opts.now ?? new Date().toISOString();
|
|
147
|
+
const results: MarketplaceUpgradeResult[] = [];
|
|
148
|
+
|
|
149
|
+
for (const group of groups) {
|
|
150
|
+
const { installed, converted, available } = await addPlugins({
|
|
151
|
+
spec: group.source,
|
|
152
|
+
pluginsDir: opts.pluginsDir,
|
|
153
|
+
ref: group.ref,
|
|
154
|
+
// Default: refresh what's installed. --all: install everything too.
|
|
155
|
+
...(opts.all ? { all: true } : { plugins: group.installed }),
|
|
156
|
+
targets: opts.targets,
|
|
157
|
+
marketplaceName: group.source,
|
|
158
|
+
gitRunner: opts.gitRunner,
|
|
159
|
+
// Re-activate so the agents pick up the upgraded content, not just the store.
|
|
160
|
+
activate: opts.activate,
|
|
161
|
+
scope: opts.agentScope,
|
|
162
|
+
now,
|
|
163
|
+
});
|
|
164
|
+
const installedSet = new Set(group.installed);
|
|
165
|
+
results.push({
|
|
166
|
+
source: group.source,
|
|
167
|
+
updated: installed,
|
|
168
|
+
converted,
|
|
169
|
+
available: available.filter((n) => !installedSet.has(n)),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface MarketplaceRemoveResult {
|
|
177
|
+
source: string;
|
|
178
|
+
removed: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Uninstall every plugin that came from a given source (and from the agents). */
|
|
182
|
+
export function marketplaceRemove(
|
|
183
|
+
opts: MarketplaceScope & { source: string; force?: boolean; deactivate?: boolean; agentScope?: AgentScope },
|
|
184
|
+
): MarketplaceRemoveResult {
|
|
185
|
+
const group = requireGroup(opts.pluginsDir, opts.source, opts.scope);
|
|
186
|
+
const removed: string[] = [];
|
|
187
|
+
for (const name of group.installed) {
|
|
188
|
+
removePlugin({
|
|
189
|
+
pluginsDir: opts.pluginsDir,
|
|
190
|
+
name,
|
|
191
|
+
force: opts.force,
|
|
192
|
+
deactivate: opts.deactivate,
|
|
193
|
+
scope: opts.agentScope,
|
|
194
|
+
});
|
|
195
|
+
removed.push(name);
|
|
196
|
+
}
|
|
197
|
+
return { source: opts.source, removed };
|
|
198
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { lockPath, marketplacePath, marketplaceSourcePath, pluginDir } from "../paths.ts";
|
|
4
|
+
import { readLock } from "../lock.ts";
|
|
5
|
+
import { readMarketplace, upsertMarketplacePlugin, writeMarketplace } from "../marketplace.ts";
|
|
6
|
+
|
|
7
|
+
export interface MigrateMove {
|
|
8
|
+
name: string;
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MigrateResult {
|
|
14
|
+
/** Plugin directories relocated into their per-marketplace bucket. */
|
|
15
|
+
moved: MigrateMove[];
|
|
16
|
+
/** Already in the right place (or local/flat) — left untouched. */
|
|
17
|
+
unchanged: string[];
|
|
18
|
+
/** In the lock but no directory found at either the old or new path. */
|
|
19
|
+
missing: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Migrate a flat plugins directory to the per-marketplace nested layout.
|
|
24
|
+
*
|
|
25
|
+
* For every locked plugin, move `<pluginsDir>/<name>` to the origin-derived
|
|
26
|
+
* `<pluginsDir>/<segment>/<name>` (remote sources only; local installs stay
|
|
27
|
+
* flat) and rewrite its marketplace.json `source.path` to match. Idempotent:
|
|
28
|
+
* plugins already at their target path are reported as unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export function migrateLayout(pluginsDir: string): MigrateResult {
|
|
31
|
+
const lock = readLock(lockPath(pluginsDir));
|
|
32
|
+
const moved: MigrateMove[] = [];
|
|
33
|
+
const unchanged: string[] = [];
|
|
34
|
+
const missing: string[] = [];
|
|
35
|
+
|
|
36
|
+
const marketFile = marketplacePath(pluginsDir);
|
|
37
|
+
const market = readMarketplace(marketFile, "");
|
|
38
|
+
let marketDirty = false;
|
|
39
|
+
|
|
40
|
+
for (const [name, entry] of Object.entries(lock.plugins)) {
|
|
41
|
+
const flat = join(pluginsDir, name);
|
|
42
|
+
const target = pluginDir(pluginsDir, name, entry.origin);
|
|
43
|
+
|
|
44
|
+
if (target === flat) {
|
|
45
|
+
unchanged.push(name);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (existsSync(target)) {
|
|
50
|
+
// Already migrated; nothing to move, but make sure the export agrees.
|
|
51
|
+
if (rewriteMarketplacePath(market, name, pluginsDir, target)) marketDirty = true;
|
|
52
|
+
unchanged.push(name);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!existsSync(flat)) {
|
|
57
|
+
missing.push(name);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
62
|
+
renameSync(flat, target);
|
|
63
|
+
if (rewriteMarketplacePath(market, name, pluginsDir, target)) marketDirty = true;
|
|
64
|
+
moved.push({ name, from: flat, to: target });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (marketDirty) writeMarketplace(marketFile, market);
|
|
68
|
+
return { moved, unchanged, missing };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Point a marketplace entry's `source.path` at the plugin's on-disk dir. */
|
|
72
|
+
function rewriteMarketplacePath(
|
|
73
|
+
market: ReturnType<typeof readMarketplace>,
|
|
74
|
+
name: string,
|
|
75
|
+
pluginsDir: string,
|
|
76
|
+
dir: string,
|
|
77
|
+
): boolean {
|
|
78
|
+
const existing = market.plugins.find((p) => p.name === name);
|
|
79
|
+
if (!existing) return false;
|
|
80
|
+
const path = marketplaceSourcePath(pluginsDir, dir);
|
|
81
|
+
if (existing.source.path === path) return false;
|
|
82
|
+
upsertMarketplacePlugin(market, { ...existing, source: { ...existing.source, path } });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { MultiSelectPrompt } from "@clack/core";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A skills multiselect with an on-demand description toggle. `@clack/prompts`'
|
|
6
|
+
* `multiselect` can't intercept extra keys, so this builds directly on
|
|
7
|
+
* `@clack/core`'s `MultiSelectPrompt` and replicates clack's chrome, adding a
|
|
8
|
+
* `d` key that lazily loads and inlines each skill's description as a hint.
|
|
9
|
+
*
|
|
10
|
+
* Descriptions are read only while the toggle is on (and cached by the loader),
|
|
11
|
+
* so a list of many skills costs nothing until the user actually asks for them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Match @clack/prompts' unicode-aware glyphs so the picker is visually seamless.
|
|
15
|
+
const unicode =
|
|
16
|
+
process.platform !== "win32" ||
|
|
17
|
+
Boolean(process.env.WT_SESSION) ||
|
|
18
|
+
process.env.TERM_PROGRAM === "vscode" ||
|
|
19
|
+
process.env.TERM === "xterm-256color";
|
|
20
|
+
const g = (a: string, b: string) => (unicode ? a : b);
|
|
21
|
+
const S_BAR = g("│", "|");
|
|
22
|
+
const S_BAR_END = g("└", "—");
|
|
23
|
+
const S_STEP_ACTIVE = g("◆", "*");
|
|
24
|
+
const S_STEP_SUBMIT = g("◇", "o");
|
|
25
|
+
const S_STEP_CANCEL = g("■", "x");
|
|
26
|
+
const S_CHECK_ACTIVE = g("◻", "[•]");
|
|
27
|
+
const S_CHECK_SELECTED = g("◼", "[+]");
|
|
28
|
+
const S_CHECK_INACTIVE = g("◻", "[ ]");
|
|
29
|
+
|
|
30
|
+
export interface SkillOption {
|
|
31
|
+
value: string;
|
|
32
|
+
label: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RowKind = "active" | "selected" | "active-selected" | "inactive";
|
|
36
|
+
|
|
37
|
+
/** Format one option row, appending the description as a dim hint when present. */
|
|
38
|
+
export function formatSkillRow(label: string, hint: string | undefined, kind: RowKind): string {
|
|
39
|
+
const tail = hint ? ` ${pc.dim(hint)}` : "";
|
|
40
|
+
switch (kind) {
|
|
41
|
+
case "active":
|
|
42
|
+
return `${pc.cyan(S_CHECK_ACTIVE)} ${label}${tail}`;
|
|
43
|
+
case "active-selected":
|
|
44
|
+
return `${pc.green(S_CHECK_SELECTED)} ${label}${tail}`;
|
|
45
|
+
case "selected":
|
|
46
|
+
return `${pc.green(S_CHECK_SELECTED)} ${pc.dim(label)}${tail}`;
|
|
47
|
+
default:
|
|
48
|
+
return `${pc.dim(S_CHECK_INACTIVE)} ${pc.dim(label)}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BuildRowsState {
|
|
53
|
+
cursor: number;
|
|
54
|
+
selected: string[];
|
|
55
|
+
/** When false, `loadDescription` is never called — this is the lazy gate. */
|
|
56
|
+
showDesc: boolean;
|
|
57
|
+
loadDescription: (value: string) => string | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the rendered option rows. Descriptions are resolved only when
|
|
62
|
+
* `showDesc` is true, so the picker performs zero SKILL.md reads until the user
|
|
63
|
+
* presses `d`. Exported as the pure core of the picker for direct testing.
|
|
64
|
+
*/
|
|
65
|
+
export function buildSkillRows(options: SkillOption[], state: BuildRowsState): string[] {
|
|
66
|
+
return options.map((o, i) => {
|
|
67
|
+
const sel = state.selected.includes(o.value);
|
|
68
|
+
const active = i === state.cursor;
|
|
69
|
+
const hint = state.showDesc ? state.loadDescription(o.value) : undefined;
|
|
70
|
+
const kind: RowKind = active && sel ? "active-selected" : sel ? "selected" : active ? "active" : "inactive";
|
|
71
|
+
return formatSkillRow(o.label, hint, kind);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MultiselectSkillsOptions {
|
|
76
|
+
message: string;
|
|
77
|
+
options: SkillOption[];
|
|
78
|
+
initialValues: string[];
|
|
79
|
+
/** Lazy, cached description lookup; invoked only while the toggle is on. */
|
|
80
|
+
loadDescription: (value: string) => string | undefined;
|
|
81
|
+
/** Injection seams for tests; default to process.stdin/stdout. */
|
|
82
|
+
input?: NodeJS.ReadStream;
|
|
83
|
+
output?: NodeJS.WriteStream;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolves to the chosen values, or a cancel symbol (use `isCancel` to detect). */
|
|
87
|
+
export function multiselectSkills(opts: MultiselectSkillsOptions): Promise<string[] | symbol> {
|
|
88
|
+
let showDesc = false;
|
|
89
|
+
|
|
90
|
+
const symbol = (state: string): string => {
|
|
91
|
+
if (state === "submit") return pc.green(S_STEP_SUBMIT);
|
|
92
|
+
if (state === "cancel") return pc.red(S_STEP_CANCEL);
|
|
93
|
+
return pc.cyan(S_STEP_ACTIVE);
|
|
94
|
+
};
|
|
95
|
+
const footer = () =>
|
|
96
|
+
pc.dim(`space ${pc.gray("toggle")} · a ${pc.gray("all")} · d ${pc.gray(showDesc ? "hide" : "show")} descriptions`);
|
|
97
|
+
|
|
98
|
+
const prompt = new MultiSelectPrompt({
|
|
99
|
+
input: opts.input,
|
|
100
|
+
output: opts.output,
|
|
101
|
+
options: opts.options,
|
|
102
|
+
initialValues: opts.initialValues,
|
|
103
|
+
required: true,
|
|
104
|
+
validate(this: { required?: boolean }, value: unknown) {
|
|
105
|
+
if (this.required && Array.isArray(value) && value.length === 0) {
|
|
106
|
+
return "Please select at least one option.";
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
},
|
|
110
|
+
render(this: { state: string; value: string[]; cursor: number; options: SkillOption[] }) {
|
|
111
|
+
const head = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
|
|
112
|
+
if (this.state === "submit" || this.state === "cancel") {
|
|
113
|
+
const chosen = this.options
|
|
114
|
+
.filter((o) => this.value.includes(o.value))
|
|
115
|
+
.map((o) => pc.dim(o.label))
|
|
116
|
+
.join(pc.dim(", "));
|
|
117
|
+
return `${head}${pc.gray(S_BAR)} ${chosen || pc.dim("none")}`;
|
|
118
|
+
}
|
|
119
|
+
const rows = buildSkillRows(this.options, {
|
|
120
|
+
cursor: this.cursor,
|
|
121
|
+
selected: this.value,
|
|
122
|
+
showDesc,
|
|
123
|
+
loadDescription: opts.loadDescription,
|
|
124
|
+
}).join(`\n${pc.cyan(S_BAR)} `);
|
|
125
|
+
return `${head}${pc.cyan(S_BAR)} ${rows}\n${pc.cyan(S_BAR)} ${footer()}\n${pc.cyan(S_BAR_END)}\n`;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// `a` (toggle-all) is bound by MultiSelectPrompt itself; `d` is free for us.
|
|
130
|
+
// Touching showDesc here re-renders on the same keypress (onKeypress renders
|
|
131
|
+
// after emitting "key"), so the first `d` press shows descriptions at once.
|
|
132
|
+
(prompt as unknown as { on(e: string, cb: (c: string) => void): void }).on("key", (c) => {
|
|
133
|
+
if (c === "d") showDesc = !showDesc;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return (prompt as unknown as { prompt(): Promise<string[] | symbol> }).prompt();
|
|
137
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readlinkSync, rmdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { claudeSkillsDir, lockPath, marketplacePath, pluginDir } from "../paths.ts";
|
|
4
|
+
import { readLock, removeEntry, writeLock } from "../lock.ts";
|
|
5
|
+
import { readMarketplace, removeMarketplacePlugin, writeMarketplace } from "../marketplace.ts";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import { resolveAgents, type Agent, type AgentScope, type AgentSyncResult } from "../agents/index.ts";
|
|
8
|
+
|
|
9
|
+
export interface RemoveOptions {
|
|
10
|
+
/** Plugins directory the plugin lives in. */
|
|
11
|
+
pluginsDir: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** Remove even if other installed plugins depend on it. */
|
|
14
|
+
force?: boolean;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
/** Also uninstall the plugin from the agents via their CLIs. */
|
|
17
|
+
deactivate?: boolean;
|
|
18
|
+
/** Install scope to uninstall from; "user" (global) or "project". */
|
|
19
|
+
scope?: AgentScope;
|
|
20
|
+
/** Injection seam for tests; defaults to every registered agent. */
|
|
21
|
+
agents?: Agent[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RemoveResult {
|
|
25
|
+
name: string;
|
|
26
|
+
/** Plugin directory that was deleted, if it existed. */
|
|
27
|
+
removedDir?: string;
|
|
28
|
+
/** Claude skills symlinks that were cleaned up. */
|
|
29
|
+
unlinked: string[];
|
|
30
|
+
removedFromLock: boolean;
|
|
31
|
+
removedFromMarketplace: boolean;
|
|
32
|
+
/** Per-agent deactivation outcome (when `deactivate` was set). */
|
|
33
|
+
agents?: AgentSyncResult[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove an installed plugin: delete its directory, drop it from
|
|
38
|
+
* `.plugin-lock.json` and `marketplace.json`, and clean up any Claude
|
|
39
|
+
* skills-dir symlinks that pointed at it. Only paths under `pluginsDir` (plus
|
|
40
|
+
* symlinks that resolve back into it) are touched.
|
|
41
|
+
*
|
|
42
|
+
* Refuses when another installed plugin depends on it, unless `force` is set.
|
|
43
|
+
*/
|
|
44
|
+
export function removePlugin(opts: RemoveOptions): RemoveResult {
|
|
45
|
+
const { name } = opts;
|
|
46
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
47
|
+
|
|
48
|
+
const lockFile = lockPath(opts.pluginsDir);
|
|
49
|
+
const lock = readLock(lockFile);
|
|
50
|
+
|
|
51
|
+
const inLock = name in lock.plugins;
|
|
52
|
+
// Locate the plugin via its recorded origin (nested for remote sources). An
|
|
53
|
+
// orphan that is on disk but absent from the lock falls back to the flat path.
|
|
54
|
+
const dir = inLock
|
|
55
|
+
? pluginDir(opts.pluginsDir, name, lock.plugins[name]!.origin)
|
|
56
|
+
: join(opts.pluginsDir, name);
|
|
57
|
+
const onDisk = existsSync(dir);
|
|
58
|
+
if (!inLock && !onDisk) {
|
|
59
|
+
throw new Error(`plugin "${name}" is not installed in ${opts.pluginsDir}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!opts.force) {
|
|
63
|
+
const dependents = Object.entries(lock.plugins)
|
|
64
|
+
.filter(([n, e]) => n !== name && e.dependencies && name in e.dependencies)
|
|
65
|
+
.map(([n]) => n);
|
|
66
|
+
if (dependents.length > 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`cannot remove "${name}": required by ${dependents.join(", ")}. ` +
|
|
69
|
+
`Remove the dependents first or pass --force.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Clean up Claude symlinks (created by `adg plugins link --target claude`) in
|
|
75
|
+
// both global and project scopes — but only ones that resolve back to this
|
|
76
|
+
// plugin's directory, so unrelated entries are never disturbed.
|
|
77
|
+
const target = resolve(dir);
|
|
78
|
+
const unlinked: string[] = [];
|
|
79
|
+
for (const dir of [claudeSkillsDir(true, cwd), claudeSkillsDir(false, cwd)]) {
|
|
80
|
+
const linkPath = join(dir, name);
|
|
81
|
+
if (isSymlinkTo(linkPath, target)) {
|
|
82
|
+
rmSync(linkPath);
|
|
83
|
+
unlinked.push(linkPath);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let removedDir: string | undefined;
|
|
88
|
+
if (onDisk) {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
removedDir = dir;
|
|
91
|
+
// Drop the per-marketplace bucket if it became empty (nested remote layout).
|
|
92
|
+
pruneEmptyParent(dirname(dir), opts.pluginsDir);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const removedFromLock = removeEntry(lock, name);
|
|
96
|
+
if (removedFromLock) writeLock(lockFile, lock);
|
|
97
|
+
|
|
98
|
+
const marketFile = marketplacePath(opts.pluginsDir);
|
|
99
|
+
const market = readMarketplace(marketFile, basename(opts.pluginsDir));
|
|
100
|
+
const removedFromMarketplace = removeMarketplacePlugin(market, name);
|
|
101
|
+
if (removedFromMarketplace) writeMarketplace(marketFile, market);
|
|
102
|
+
|
|
103
|
+
// Uninstall from the agents so we don't leave them enabled, pointing at a
|
|
104
|
+
// now-deleted directory. Best-effort across all; missing CLIs are skipped.
|
|
105
|
+
let agents: AgentSyncResult[] | undefined;
|
|
106
|
+
if (opts.deactivate) {
|
|
107
|
+
const ctx = { pluginsDir: opts.pluginsDir, plugins: [name], scope: opts.scope ?? "project" };
|
|
108
|
+
agents = (opts.agents ?? resolveAgents()).map((a) => a.deactivate(ctx));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { name, removedDir, unlinked, removedFromLock, removedFromMarketplace, agents };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove `parent` if it is an empty per-marketplace bucket below `pluginsDir`.
|
|
116
|
+
* Never touches `pluginsDir` itself (that would be the flat local layout).
|
|
117
|
+
*/
|
|
118
|
+
function pruneEmptyParent(parent: string, pluginsDir: string): void {
|
|
119
|
+
if (resolve(parent) === resolve(pluginsDir)) return;
|
|
120
|
+
try {
|
|
121
|
+
if (readdirSync(parent).length === 0) rmdirSync(parent);
|
|
122
|
+
} catch {
|
|
123
|
+
// bucket already gone or not readable — nothing to prune
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isSymlinkTo(linkPath: string, target: string): boolean {
|
|
128
|
+
try {
|
|
129
|
+
if (!lstatSync(linkPath).isSymbolicLink()) return false;
|
|
130
|
+
// readlinkSync may return a path relative to the link's own directory; resolve
|
|
131
|
+
// it there (resolve ignores the base when the target is already absolute).
|
|
132
|
+
return resolve(dirname(linkPath), readlinkSync(linkPath)) === resolve(target);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import type { AdapterTarget } from "../adapters/index.ts";
|
|
4
|
+
import { allAgents, detectedAgents } from "../agents/index.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Interactively choose which AI agents to adapt an installed plugin for.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the skills install flow: detect the agents present on the machine,
|
|
10
|
+
* pre-select them, and let the user toggle. When nothing is detected we fall
|
|
11
|
+
* back to pre-selecting every target so a fresh setup still adapts for all.
|
|
12
|
+
*
|
|
13
|
+
* Callers should only reach this in an interactive TTY without an explicit
|
|
14
|
+
* `--target`; non-interactive paths stay flag-driven.
|
|
15
|
+
*/
|
|
16
|
+
export async function selectTargetsInteractive(): Promise<AdapterTarget[]> {
|
|
17
|
+
const agents = allAgents();
|
|
18
|
+
const detectedSet = new Set(detectedAgents().map((a) => a.id));
|
|
19
|
+
const initialValues = (detectedSet.size > 0 ? [...detectedSet] : agents.map((a) => a.id)) as AdapterTarget[];
|
|
20
|
+
|
|
21
|
+
if (detectedSet.size > 0) {
|
|
22
|
+
const names = agents.filter((a) => detectedSet.has(a.id)).map((a) => pc.cyan(a.displayName)).join(", ");
|
|
23
|
+
p.log.info(`Detected: ${names}`);
|
|
24
|
+
} else {
|
|
25
|
+
p.log.info(pc.dim("No agents detected locally — pre-selecting all."));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const selected = await p.multiselect({
|
|
29
|
+
message: `Which agents do you want to adapt for? ${pc.dim("(space to toggle)")}`,
|
|
30
|
+
options: agents.map((a) => ({
|
|
31
|
+
value: a.id as AdapterTarget,
|
|
32
|
+
label: a.displayName,
|
|
33
|
+
...(detectedSet.has(a.id) ? { hint: "detected" } : {}),
|
|
34
|
+
})),
|
|
35
|
+
initialValues,
|
|
36
|
+
required: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (p.isCancel(selected)) {
|
|
40
|
+
p.cancel("Installation cancelled");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return selected as AdapterTarget[];
|
|
45
|
+
}
|