Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.ts`, verified
directly against product docs:

| Target | Snippet | Source | Verified |
| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ---------- |
| `claude` | `/plugin marketplace add <repo>` then `/plugin install <name>@<marketplace>` (slash-only) | <https://code.claude.com/docs/en/plugins-reference#cli-commands-reference> | 2026-07-25 |
| `codex` | `codex plugin marketplace add <repo>` | <https://learn.chatgpt.com/codex/developer-commands> | 2026-07-25 |
| `copilot` | `copilot plugin marketplace add <repo>` then `copilot plugin install <name>@<marketplace>` | <https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-finding-installing> | 2026-07-25 |
| `antigravity` | `git clone <repo> plugin-source && cd plugin-source && agy plugin install <pluginPath>` | <https://antigravity.google/docs/cli/plugins> | 2026-07-25 |
| `cursor` | No CLI equivalent exists. Prints the repo URL, pasted into Dashboard → Plugins → Team Marketplaces → "Import from Repo." | <https://cursor.com/docs/plugins> | 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 <name>@<marketplace>` 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
Expand Down
82 changes: 62 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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:
Expand Down Expand Up @@ -395,17 +406,18 @@ To publish a repo-root file (for example a README authored once in the source re

**`targets.<name>`** — `<name>` 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.<name>.plugins.<name>`**). |
| `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.<name>.plugins.<name>`**). |
| `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.<name>.plugins.<name>`**

Expand Down Expand Up @@ -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<ResolvedProject>` | Resolve config and discover source plugins. |
| `build(options?)` | `Promise<Artifact[]>` | Emit configured targets; writes to disk unless `options.dryRun`. |
| `diffTarget(options)` | `Promise<DiffResult>` | Build into a temp dir and compare against an existing repo. |
| `validateOutput(target, dir)` | `Promise<ValidationResult>` | Validate an existing target output directory. |
| `prune(options?)` | `Promise<CleanupResult[]>` | Remove stale managed files no longer emitted by the config. |
| `clean(options?)` | `Promise<CleanupResult[]>` | Remove all managed files for configured targets. |
| Function | Returns | Purpose |
| ------------------------------------- | --------------------------- | ----------------------------------------------------------------- |
| `defineConfig(config)` | `PluginpackConfig` | Identity helper that types `pluginpack.config.ts`. |
| `loadConfig(cwd?, configPath?)` | `Promise<ResolvedProject>` | Resolve config and discover source plugins. |
| `build(options?)` | `Promise<Artifact[]>` | Emit configured targets; writes to disk unless `options.dryRun`. |
| `diffTarget(options)` | `Promise<DiffResult>` | Build into a temp dir and compare against an existing repo. |
| `validateOutput(target, dir)` | `Promise<ValidationResult>` | Validate an existing target output directory. |
| `prune(options?)` | `Promise<CleanupResult[]>` | Remove stale managed files no longer emitted by the config. |
| `clean(options?)` | `Promise<CleanupResult[]>` | 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:

Expand Down Expand Up @@ -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 <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.
Expand Down
78 changes: 78 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 <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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions src/install-snippet.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Potential TypeError: buildInstallSnippet indexes target registry without handling partial registry entries. If registry[target] is undefined (e.g., targets not yet migrated to the registry), accessing .installSnippet will throw at runtime when CLI install-info or library callers pass such a target.

Suggested fix: Mirror the adapters' partial-registry pattern: guard registry[target] and fall back to a legacy install snippet definition or return a structured userConfigurable:false result with a clear reason. For example: const def = registry[target]?.installSnippet; if (!def || !def.userConfigurable || !def.build) return { userConfigurable: false, reason: def?.unsupportedReason ?? ${target} has no install command or URL. }; otherwise return { userConfigurable: true, ...def.build(params) }. Alternatively, ensure all TargetName variants are present in the registry before using it here.

🔧 Tag @ glean-for-engineering to fix or click here to fix in Glean

💬 Help us improve! Was this comment helpful? React with 👍 or 👎

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,
);
}
4 changes: 4 additions & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading