diff --git a/CONFORMANCE.md b/CONFORMANCE.md index fb0a764..d33fca0 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -96,6 +96,33 @@ target-agnostic: each event's entries must be an array, no `command` string may be empty, and any command referencing the generated update-check script (`scripts/pluginpack-update-check.sh`) must ship that script. +## Install-snippet facts + +`pluginpack install-info` (and the `buildInstallSnippet` library function) +prints the real command or URL a user needs to add a pluginpack-built +marketplace. Each target's snippet and citation live on its own +`PluginTargetDefinition.installSnippet` in `src/targets/.ts`, verified +directly against product docs: + +| Target | Snippet | Source | Verified | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ---------- | +| `claude` | `/plugin marketplace add ` then `/plugin install @` (slash-only) | | 2026-07-25 | +| `codex` | `codex plugin marketplace add ` | | 2026-07-25 | +| `copilot` | `copilot plugin marketplace add ` then `copilot plugin install @` | | 2026-07-25 | +| `antigravity` | `git clone plugin-source && cd plugin-source && agy plugin install ` | | 2026-07-25 | +| `cursor` | No CLI equivalent exists. Prints the repo URL, pasted into Dashboard → Plugins → Team Marketplaces → "Import from Repo." | | 2026-07-25 | + +`claude`'s two-step sequence is not symmetric with `codex`/`copilot`'s shell +commands: `/plugin marketplace add` is slash-only inside an active Claude Code +session, with no shell equivalent — but once a marketplace is already added, +`claude plugin install @` does work as a standalone shell +command, surfaced as a secondary `note`. + +Every target resolves to `userConfigurable: true` today; +`getUnsupportedInstallTargets()` returns `[]`. The `false` branch of the +`InstallSnippet` union exists for forward-compatibility, not because any +target needs it now. + ## Refreshing vendored schemas The Cursor schemas are pinned copies. To update them, re-fetch from the source diff --git a/README.md b/README.md index 576e6dd..7323b58 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,17 @@ Disable for a single plugin with `updateCheck: false` on that plugin. Configurin Like MCP config, the generated hook is wired in regardless of a plugin's `components` selection: on `cursor`, the manifest's `hooks` field is set even if `components` doesn't include `"hooks"`, since the check itself is a separate opt-in from which source-authored component dirs get emitted. +## Install Snippet + +Once a target's output is pushed to a repo, `pluginpack install-info` prints the real, doc-verified command or URL a user needs to add that marketplace — one per configured target: + +```bash +pluginpack install-info +pluginpack install-info --target claude +``` + +The repo comes from `targets..repository`, defaulting to `metadata.repository` (an error if neither is set) — the same fallback `updateCheck.repository` uses. Every target today resolves to a real command except `cursor`, which has no CLI equivalent: it prints the repo URL and a note to paste it into Cursor's Dashboard under Team Marketplaces. See `CONFORMANCE.md`'s "Install-snippet facts" section for the doc citation behind each target's snippet. + ## Target Overrides Skill files are not always perfectly portable. When one app needs different frontmatter or content, add a target override next to the base file: @@ -395,17 +406,18 @@ To publish a repo-root file (for example a README authored once in the source re **`targets.`** — `` is one of `cursor`, `claude`, `antigravity`, `copilot`, `codex`. -| Field | Type | Required | Meaning | -| ------------------ | ---------------------- | -------- | ------------------------------------------------------------------------------------------ | -| `outDir` | string | yes | Output directory for this target, relative to the config root. | -| `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets..plugins.`**). | -| `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). | -| `pluginRoot` | string (safe relative) | no | Override the plugin root dir (`claude`; defaults to `plugins`). | -| `version` | string | no | Override the version for this target (defaults to top-level `version`). | -| `manifest` | object | no | Deep-merged into the generated marketplace manifest. | -| `ignoredDiffPaths` | string[] | no | Output-relative paths `diff` ignores (a dir entry ignores everything below it). | -| `rootFiles` | record (safe relative) | no | Map of output path → source path emitted verbatim at the output root. | -| `updateCheck` | `{ repository? }` | no | Generate a session-start update-check hook (`claude`/`cursor` only; see **Update Check**). | +| Field | Type | Required | Meaning | +| ------------------ | ---------------------- | -------- | ------------------------------------------------------------------------------------------- | +| `outDir` | string | yes | Output directory for this target, relative to the config root. | +| `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets..plugins.`**). | +| `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). | +| `pluginRoot` | string (safe relative) | no | Override the plugin root dir (`claude`; defaults to `plugins`). | +| `version` | string | no | Override the version for this target (defaults to top-level `version`). | +| `manifest` | object | no | Deep-merged into the generated marketplace manifest. | +| `ignoredDiffPaths` | string[] | no | Output-relative paths `diff` ignores (a dir entry ignores everything below it). | +| `rootFiles` | record (safe relative) | no | Map of output path → source path emitted verbatim at the output root. | +| `updateCheck` | `{ repository? }` | no | Generate a session-start update-check hook (`claude`/`cursor` only; see **Update Check**). | +| `repository` | string | no | Repo this target's output lives in, for `install-info` (defaults to `metadata.repository`). | **`targets..plugins.`** @@ -434,18 +446,24 @@ import { validateOutput, prune, clean, + buildInstallSnippet, + getSupportedInstallTargets, + getUnsupportedInstallTargets, } from "@gleanwork/pluginpack"; ``` -| Function | Returns | Purpose | -| ------------------------------- | --------------------------- | ---------------------------------------------------------------- | -| `defineConfig(config)` | `PluginpackConfig` | Identity helper that types `pluginpack.config.ts`. | -| `loadConfig(cwd?, configPath?)` | `Promise` | Resolve config and discover source plugins. | -| `build(options?)` | `Promise` | Emit configured targets; writes to disk unless `options.dryRun`. | -| `diffTarget(options)` | `Promise` | Build into a temp dir and compare against an existing repo. | -| `validateOutput(target, dir)` | `Promise` | Validate an existing target output directory. | -| `prune(options?)` | `Promise` | Remove stale managed files no longer emitted by the config. | -| `clean(options?)` | `Promise` | Remove all managed files for configured targets. | +| Function | Returns | Purpose | +| ------------------------------------- | --------------------------- | ----------------------------------------------------------------- | +| `defineConfig(config)` | `PluginpackConfig` | Identity helper that types `pluginpack.config.ts`. | +| `loadConfig(cwd?, configPath?)` | `Promise` | Resolve config and discover source plugins. | +| `build(options?)` | `Promise` | Emit configured targets; writes to disk unless `options.dryRun`. | +| `diffTarget(options)` | `Promise` | Build into a temp dir and compare against an existing repo. | +| `validateOutput(target, dir)` | `Promise` | Validate an existing target output directory. | +| `prune(options?)` | `Promise` | Remove stale managed files no longer emitted by the config. | +| `clean(options?)` | `Promise` | Remove all managed files for configured targets. | +| `buildInstallSnippet(target, params)` | `InstallSnippet` | The install command/URL for one target (see **Install Snippet**). | +| `getSupportedInstallTargets()` | `TargetName[]` | Targets with a real install snippet today. | +| `getUnsupportedInstallTargets()` | `TargetName[]` | Targets with none (empty today, kept for forward-compatibility). | Option objects: @@ -593,6 +611,30 @@ Exit codes: - 0 when managed files are removed or listed - 1 when config, manifest loading, or cleanup fails +### `install-info` + +Print the real install command or URL for a target's built marketplace. + +```bash +pluginpack install-info [--target copilot|antigravity|cursor|claude|codex] [--json] +``` + +Options: + +- `--target `: Print only one configured target's install info. +- `--json`: Print machine-readable JSON instead of text. + +Examples: + +- `pluginpack install-info` +- `pluginpack install-info --target claude` +- `pluginpack install-info --json` + +Exit codes: + +- 0 when install info is printed +- 1 when config loading fails or a target has no repository configured + ### `docs` Generate the README CLI reference section from command metadata. diff --git a/src/cli.ts b/src/cli.ts index abd554d..afdbdcb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,9 @@ import { build } from "./build.js"; import { clean, prune } from "./cleanup.js"; import { loadConfig } from "./config.js"; import { diffTarget } from "./diff.js"; +import { buildInstallSnippet } from "./install-snippet.js"; import { targetNames, validateOutput } from "./adapters.js"; +import { targets as targetRegistry } from "./targets/registry.js"; import type { TargetName } from "./types.js"; /** CLI entry point. */ @@ -198,6 +200,71 @@ function createProgram(): Command { }, ); + program + .command("install-info") + .description( + "Print the real install command or URL for a target's built marketplace.", + ) + .usage(`[--target ${targetList}] [--json]`) + .addOption( + new Option( + "--target ", + "Print only one configured target's install info.", + ).choices([...targetNames]), + ) + .option("--json", "Print machine-readable JSON instead of text.") + .action(async (options: { target?: TargetName; json?: boolean }) => { + const project = await loadConfig(); + const targets = options.target + ? [options.target] + : targetNames.filter((target) => project.config.targets[target]); + const entries = targets.flatMap((target) => { + const targetConfig = project.config.targets[target]; + if (!targetConfig) { + throw new Error(`Target "${target}" is not configured.`); + } + const repository = + targetConfig.repository ?? project.config.metadata?.repository; + if (!repository) { + throw new Error( + `Target "${target}" has no repository configured ` + + `(set targets.${target}.repository or metadata.repository).`, + ); + } + return Object.entries(targetConfig.plugins).map( + ([pluginName, pluginConfig]) => ({ + target, + plugin: pluginName, + snippet: buildInstallSnippet(target, { + repository, + marketplaceName: project.config.name, + pluginName, + pluginPath: targetRegistry[target].resolvePluginPath( + pluginName, + pluginConfig, + targetConfig, + ), + }), + }), + ); + }); + if (options.json) { + console.log(JSON.stringify(entries, null, 2)); + return; + } + for (const entry of entries) { + console.log(`${entry.target} / ${entry.plugin}`); + if (entry.snippet.userConfigurable) { + console.log(` ${entry.snippet.snippet.replaceAll("\n", "\n ")}`); + if (entry.snippet.note) { + console.log(` note: ${entry.snippet.note}`); + } + } else { + console.log(` unsupported: ${entry.snippet.reason}`); + } + } + }); + program .command("docs") .description( @@ -383,6 +450,12 @@ function commandExamples(commandName: string): string[] { return ["pluginpack prune", "pluginpack prune --target claude --dry-run"]; case "clean": return ["pluginpack clean", "pluginpack clean --target cursor --dry-run"]; + case "install-info": + return [ + "pluginpack install-info", + "pluginpack install-info --target claude", + "pluginpack install-info --json", + ]; case "docs": return ["pluginpack docs", "pluginpack docs --check"]; default: @@ -420,6 +493,11 @@ function commandExitCodes(commandName: string): string[] { "0 when managed files are removed or listed", "1 when config, manifest loading, or cleanup fails", ]; + case "install-info": + return [ + "0 when install info is printed", + "1 when config loading fails or a target has no repository configured", + ]; case "docs": return [ "0 when docs are current or updated", diff --git a/src/index.ts b/src/index.ts index a272e7c..d0aa61e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,17 @@ export { build } from "./build.js"; export { clean, prune } from "./cleanup.js"; export { diffTarget } from "./diff.js"; export { validateOutput } from "./adapters.js"; +export { + buildInstallSnippet, + getInstallSnippetCitation, + getSupportedInstallTargets, + getUnsupportedInstallTargets, +} from "./install-snippet.js"; +export type { + Citation, + InstallParams, + InstallSnippet, +} from "./install-snippet.js"; export type { Artifact, Author, diff --git a/src/install-snippet.ts b/src/install-snippet.ts new file mode 100644 index 0000000..10b2276 --- /dev/null +++ b/src/install-snippet.ts @@ -0,0 +1,54 @@ +import { targets as registry } from "./targets/registry.js"; +import type { + Citation, + InstallParams, + InstallSnippet, +} from "./targets/types.js"; +import type { TargetName } from "./types.js"; + +export type { + Citation, + InstallParams, + InstallSnippet, +} from "./targets/types.js"; + +/** + * Builds the real, doc-verified command or URL a user needs to add a + * pluginpack-built marketplace for `target`. See each target's + * `installSnippet.citation` (and `getInstallSnippetCitation`) for the source + * this is based on. + */ +export function buildInstallSnippet( + target: TargetName, + params: InstallParams, +): InstallSnippet { + const definition = registry[target].installSnippet; + if (!definition.userConfigurable || !definition.build) { + return { + userConfigurable: false, + reason: + definition.unsupportedReason ?? + `${target} has no install command or URL.`, + }; + } + return { userConfigurable: true, ...definition.build(params) }; +} + +/** The citation backing `target`'s install snippet. */ +export function getInstallSnippetCitation(target: TargetName): Citation { + return registry[target].installSnippet.citation; +} + +/** Targets with a real, user-configurable install snippet today. */ +export function getSupportedInstallTargets(): TargetName[] { + return (Object.keys(registry) as TargetName[]).filter( + (target) => registry[target].installSnippet.userConfigurable, + ); +} + +/** Targets with no install snippet — empty today, kept for forward-compatibility. */ +export function getUnsupportedInstallTargets(): TargetName[] { + return (Object.keys(registry) as TargetName[]).filter( + (target) => !registry[target].installSnippet.userConfigurable, + ); +} diff --git a/src/schema.ts b/src/schema.ts index c2463d7..93e29a3 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -75,6 +75,10 @@ const targetSchema = z.object({ marketplaceDir: safeRelativePath.optional(), pluginRoot: safeRelativePath.optional(), version: z.string().optional(), + // The repo this target's output lives in, for install-snippet generation + // (falls back to metadata.repository) — the same "which repo" question + // updateCheck.repository answers, asked by a different feature. + repository: z.string().min(1).optional(), plugins: z.record(z.string(), emittedPluginSchema), manifest: z.record(z.string(), z.unknown()).optional(), ignoredDiffPaths: z.array(z.string()).optional(), diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 1a768c7..4ad13b0 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -361,4 +361,61 @@ describe("emitted output conforms to external target schemas", () => { mcpServers: "./.mcp.json", }); }); + + it("install-info prints every configured target's snippet by default", async () => { + const result = await runBin("install-info"); + expect(result.exitCode, String(result.stderr)).toBe(0); + expect(result.stdout).toContain( + "/plugin marketplace add https://github.com/gleanwork/plugins", + ); + expect(result.stdout).toContain( + "codex plugin marketplace add https://github.com/gleanwork/plugins", + ); + expect(result.stdout).toContain( + "copilot plugin marketplace add https://github.com/gleanwork/plugins", + ); + expect(result.stdout).toContain("cursor / glean"); + }); + + it("install-info --target restricts output to one target", async () => { + const result = await runBin("install-info", "--target", "claude"); + expect(result.exitCode, String(result.stderr)).toBe(0); + expect(result.stdout).toContain("claude / glean"); + expect(result.stdout).not.toContain("codex / glean"); + }); + + it("install-info --json prints a parseable, per-target-per-plugin array", async () => { + const result = await runBin("install-info", "--target", "codex", "--json"); + expect(result.exitCode, String(result.stderr)).toBe(0); + const entries = JSON.parse(String(result.stdout)) as Array<{ + target: string; + plugin: string; + snippet: { userConfigurable: boolean }; + }>; + expect(entries).toEqual([ + { + target: "codex", + plugin: "glean", + snippet: { + userConfigurable: true, + kind: "command", + snippet: + "codex plugin marketplace add https://github.com/gleanwork/plugins", + note: "Installs the marketplace; individual plugins are then installed from Codex's plugin picker.", + }, + }, + ]); + }); + + it("install-info fails clearly when a target has no repository configured", async () => { + await project.write({ + "pluginpack.config.ts": CONFIG.replace( + ' repository: "https://github.com/gleanwork/plugins",\n', + "", + ), + }); + const result = await runBin("install-info", "--target", "cursor"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("has no repository configured"); + }); }); diff --git a/tests/install-snippet.test.ts b/tests/install-snippet.test.ts new file mode 100644 index 0000000..701a718 --- /dev/null +++ b/tests/install-snippet.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + buildInstallSnippet, + getInstallSnippetCitation, + getSupportedInstallTargets, + getUnsupportedInstallTargets, +} from "../src/install-snippet.js"; +import type { InstallParams } from "../src/install-snippet.js"; + +const params: InstallParams = { + repository: "https://github.com/gleanwork/plugins", + marketplaceName: "glean-plugins", + pluginName: "glean", + pluginPath: "plugins/glean", +}; + +describe("install snippet", () => { + it("builds the exact claude slash-command sequence, with the shell alternative as a note", () => { + const snippet = buildInstallSnippet("claude", params); + expect(snippet).toMatchObject({ + userConfigurable: true, + kind: "command", + snippet: + "/plugin marketplace add https://github.com/gleanwork/plugins\n/plugin install glean@glean-plugins", + }); + expect(snippet.userConfigurable && snippet.note).toContain( + "claude plugin install glean@glean-plugins", + ); + }); + + it("builds the exact codex marketplace-add command", () => { + const snippet = buildInstallSnippet("codex", params); + expect(snippet).toMatchObject({ + userConfigurable: true, + kind: "command", + snippet: + "codex plugin marketplace add https://github.com/gleanwork/plugins", + }); + }); + + it("builds the exact copilot marketplace-add + install sequence", () => { + const snippet = buildInstallSnippet("copilot", params); + expect(snippet).toMatchObject({ + userConfigurable: true, + kind: "command", + snippet: + "copilot plugin marketplace add https://github.com/gleanwork/plugins\ncopilot plugin install glean@glean-plugins", + }); + }); + + it("builds the exact antigravity clone-and-install sequence, using pluginPath", () => { + const snippet = buildInstallSnippet("antigravity", params); + expect(snippet).toMatchObject({ + userConfigurable: true, + kind: "command", + snippet: + "git clone https://github.com/gleanwork/plugins plugin-source && cd plugin-source && agy plugin install plugins/glean", + }); + }); + + it("builds a repo URL for cursor, with a GUI-paste note (no CLI marketplace-add exists)", () => { + const snippet = buildInstallSnippet("cursor", params); + expect(snippet).toMatchObject({ + userConfigurable: true, + kind: "url", + snippet: "https://github.com/gleanwork/plugins", + }); + expect(snippet.userConfigurable && snippet.note).toContain( + "Import from Repo", + ); + }); + + it("every target is user-configurable today", () => { + expect(getSupportedInstallTargets().sort()).toEqual( + ["antigravity", "claude", "codex", "copilot", "cursor"].sort(), + ); + expect(getUnsupportedInstallTargets()).toEqual([]); + }); + + it("every target carries a dated documentation citation", () => { + for (const target of getSupportedInstallTargets()) { + const citation = getInstallSnippetCitation(target); + expect(citation.documentationUrl).toMatch(/^https:\/\//); + expect(citation.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + + it("represents the userConfigurable:false shape (no target hits it today, but the type is exercised)", () => { + const unsupported: ReturnType = { + userConfigurable: false, + reason: "No CLI or URL install path exists for this target.", + }; + expect(unsupported.userConfigurable).toBe(false); + }); +});