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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/site-makeover/home-hero.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/site-makeover/notice-page.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions .github/workflows/pages-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
- '.claude/skills/**'
- 'CHANGELOG.md'
- 'copilot-instructions.md'
- 'NOTICE'
- '.github/workflows/pages-deploy.yml'
workflow_dispatch:

Expand Down
404 changes: 404 additions & 0 deletions NOTICE

Large diffs are not rendered by default.

27 changes: 22 additions & 5 deletions copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,37 @@ Cache the answers for the session. If the user doesn't know, run `git remote -v`

## Installing Skills

pncli ships with Claude Code skills — step-by-step workflow guides that agents can follow. To install them into your repo:
pncli installs agent skills — step-by-step workflow guides for GitHub Copilot and Claude Code — from git-hosted skills marketplaces: plain git repos of plugins that your team publishes. Register a marketplace once:

```
pncli skills install
pncli skills marketplace setup <git-clone-url>
```

This downloads the latest pncli skills from GitHub. Default installs to `.agents/skills/` (GitHub Copilot). For Claude Code, add `--agent claude-code`. For user-scope, add `--scope user`. Only pncli-managed skills are replaced — any custom skills you've added are left untouched.
This clones the marketplace repo to `~/.agents/marketplaces/<repo-name>`, registers it in your global pncli config, and installs every plugin's skills into your user-level skills folder — `~/.copilot/skills` for GitHub Copilot (the default), or `~/.claude/skills` with `--claude`. Use `--branch` to pin a specific branch and `--token` for repos that require an HTTP access token (GitHub PAT or Bitbucket token). Run `setup` (or its equivalent, `marketplace add`) again with a different URL to register additional marketplaces.

To see what's installed locally:
From then on, one command keeps your skills current:

```
pncli skills list
pncli skills marketplace sync
```

Sync works because every installed skill carries provenance: pncli writes a `pncli-origin.json` into each skill directory recording which marketplace and plugin it came from, the source URL and branch, and when it was installed, plus a `.pncli-installed.json` index in the skills folder. On each run, sync does a `git pull` on the marketplace clone, then:

- **No new commits upstream** — it skips reinstalling and changes nothing locally (pass `--force` to reinstall anyway).
- **New commits** — it re-copies the plugin's skills over the installed versions and refreshes their origin records.

Only pncli-managed skills are ever replaced or purged; skills you wrote yourself are left untouched. Run `sync` whenever your marketplace publishes new or updated skills — after a teammate merges a skill change, everyone else just runs `pncli skills marketplace sync` (with the same `--claude` flag they used for setup) to pick it up. With one marketplace registered, sync prompts you to pick a plugin; with several, it first asks which marketplace. Skip the prompts:

```
pncli skills marketplace sync my-plugin # skip the plugin picker
pncli skills marketplace sync all # every plugin in the marketplace
pncli skills marketplace sync --marketplace all # every plugin from every marketplace
```

To list registered marketplaces and browse their plugins without installing anything, use `pncli skills marketplace list` and `pncli skills marketplace plugins <name>`. To see what's installed locally, run `pncli skills list --scope user`.

The skills bundled with pncli itself can still be installed straight into a repo with `pncli skills install` (default target: `.agents/skills/`; add `--agent claude-code` for Claude Code).

## Common Workflows

> These workflows are also available as skills — run `pncli skills install` (Copilot default) or `pncli skills install --agent claude-code` (Claude Code) to download them into your repo. Add `--scope user` for global access.
Expand Down
5 changes: 5 additions & 0 deletions site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import mdx from '@astrojs/mdx';
export default defineConfig({
site: 'https://kolatts.github.io',
base: '/pncli/',
markdown: {
// Changelog bodies are raw commit messages full of bare CLI flags
// (e.g. --output-file); smartypants would turn the "--" into an em dash.
smartypants: false,
},
vite: {
plugins: [tailwindcss()],
server: {
Expand Down
10 changes: 5 additions & 5 deletions site/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"dependencies": {
"@astrojs/mdx": "^5.0.3",
"@fontsource-variable/bricolage-grotesque": "^5.2.10",
"@fontsource-variable/fraunces": "^5.3.0",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tailwindcss/typography": "^0.5.19",
Expand Down
Binary file modified site/public/favicon.ico
Binary file not shown.
Binary file modified site/public/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions site/scripts/parse-changelog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ for (const block of blocks) {
summary = firstBullet[1]
.replace(/\s*\(\[#\d+\]\([^)]+\)\)/g, '') // PR refs
.replace(/\s*\(\[[a-f0-9]+\]\([^)]+\)\)/g, '') // commit hashes
.replace(/,?\s*closes\s+\[#\d+\]\([^)]+\)/gi, '') // "closes [#N](url)" trailers
.replace(/,?\s*closes\s+#\d+/gi, '') // "closes #N" trailers
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> link text
.replace(/\*\*([^*]+)\*\*/g, '$1') // bold markers
.replace(/\*([^*]+)\*/g, '$1') // italic markers
.replace(/`([^`]+)`/g, '$1') // inline code backticks
.replace(/[\u{1F300}-\u{1FFFF}]/gu, '') // emoji
.trim();
summary = summary.charAt(0).toUpperCase() + summary.slice(1);
Expand Down
85 changes: 75 additions & 10 deletions site/scripts/parse-commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
* Strategy: split each commands.ts file on `.action(` to get per-command blocks,
* then extract the last `.command('name')`, `.description('text')`, and all
* `.option`/`.requiredOption` calls from each block.
*
* Nested subgroups (e.g. `const entities = dynatrace.command('entities')` with
* leaves registered as `entities.command('list')`) are handled by a first pass
* that maps each subgroup variable to its path segments. A variable whose parent
* is untracked (`program`, or a Command passed in as a function parameter) is the
* file's root group — its own name is assumed to be covered by the SERVICES
* prefix, so its path is empty. Leaf names are then built as
* `pncli <prefix> <subgroup path...> <leaf>`, which lets one file mix flat
* commands and nested subgroups (dynatrace, checkmarx, servicenow, contrast).
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
Expand All @@ -31,38 +40,94 @@ const SERVICES = [
{ name: 'Azure DevOps — Repos & PRs', file: 'src/services/ado/commands/repo.ts', prefix: 'ado repo' },
{ name: 'Azure DevOps — Pipelines', file: 'src/services/ado/commands/pipeline.ts', prefix: 'ado pipeline' },
{ name: 'Azure DevOps — Projects', file: 'src/services/ado/commands/project.ts', prefix: 'ado project' },
{ name: 'Jenkins', file: 'src/services/jenkins/commands.ts', prefix: 'jenkins pipeline' },
{ name: 'Jenkins', file: 'src/services/jenkins/commands.ts', prefix: 'jenkins' },
{ name: 'JFrog Artifactory', file: 'src/services/artifactory/commands.ts', prefix: 'artifactory' },
{ name: 'IBM UrbanCode Deploy', file: 'src/services/udeploy/commands.ts', prefix: 'udeploy' },
{ name: 'Checkmarx', file: 'src/services/checkmarx/commands.ts', prefix: 'checkmarx' },
{ name: 'GitHub', file: 'src/services/github/commands.ts', prefix: 'github' },
{ name: 'ServiceNow', file: 'src/services/servicenow/commands.ts', prefix: 'servicenow' },
{ name: 'Contrast IAST', file: 'src/services/contrast/commands.ts', prefix: 'contrast' },
{ name: 'Dynatrace', file: 'src/services/dynatrace/commands.ts', prefix: 'dynatrace' },
];

// Command groups hidden from the public site. The CLI still ships these commands;
// they just don't render on /commands/. Remove a prefix here to re-add its group.
const SKIP_PREFIXES = new Set(['udeploy']);

// Site-only text scrubs applied to command/option descriptions so hidden services
// aren't mentioned in other groups' docs. The CLI source text is unchanged.
// Each entry: [pattern, replacement]. Remove an entry to restore the mention.
const DESCRIPTION_SCRUBS = [
[/ or uDeploy component names/g, ' names'],
];

// Wrap flag-like tokens (--foo, -x) in backticks so they render as code spans.
// Without this, remark turns "--" in prose into an em dash. Skips text already
// inside inline code spans to avoid double-wrapping.
function scrubDescription(text) {
for (const [pattern, replacement] of DESCRIPTION_SCRUBS) {
text = text.replace(pattern, replacement);
}
return text;
}

function codeifyFlags(text) {
return text
.split(/(`[^`]*`)/)
.map((part, i) => {
if (i % 2 === 1) return part; // already an inline code span
return part.replace(/(^|[\s(,"'/])(--?[a-zA-Z][\w-]*)/g, '$1`$2`');
})
.join('');
}

// Matches `.command('name')` with optional receiver variable and optional
// `const <var> =` declaration. Whitespace (incl. newlines) may separate the
// receiver from `.command(`, e.g. `entities\n .command('list')`.
const COMMAND_RE = /(?:const\s+(\w+)\s*=\s*)?(\w+)\s*\.command\s*\(\s*'([^']+)'\s*\)/g;

function extractCommands(filePath, prefix) {
const content = readFileSync(filePath, 'utf8');
const commands = [];

// Split on .action( so each segment ends with the registration for one command.
// Pass 1: map subgroup variables to their path segments.
// `const <var> = <parent>.command('<name>')` — if <parent> is tracked, the
// subgroup's path is the parent's path + name; otherwise <var> is the file's
// root group and its name is already covered by the SERVICES prefix.
const groupPaths = new Map();
for (const m of content.matchAll(COMMAND_RE)) {
const [, declaredVar, parentVar, name] = m;
if (!declaredVar) continue;
groupPaths.set(
declaredVar,
groupPaths.has(parentVar) ? [...groupPaths.get(parentVar), name] : []
);
}

// Pass 2: split on .action( so each segment ends with the registration for one command.
const segments = content.split(/\.action\s*\(/);

for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];

// Find all .command('name') in this segment; take the last one — that's the leaf subcommand.
const cmdMatches = [...segment.matchAll(/\.command\s*\(\s*'([^']+)'\s*\)/g)];
// Find all .command('name') in this segment; the last one that is not a
// subgroup declaration is the leaf subcommand.
const cmdMatches = [...segment.matchAll(COMMAND_RE)].filter(m => !m[1]);
if (cmdMatches.length === 0) continue;
const lastCmd = cmdMatches[cmdMatches.length - 1];
const cmdName = lastCmd[1];
const receiverVar = lastCmd[2];
const cmdName = [...(groupPaths.get(receiverVar) ?? []), lastCmd[3]].join(' ');

// Slice from the last .command('name') onwards to find description and options.
const afterCmd = segment.slice(lastCmd.index + lastCmd[0].length);

const descMatch = afterCmd.match(/\.description\s*\(\s*'([^']+)'\s*\)/);
if (!descMatch) continue;
const description = descMatch[1];
const description = scrubDescription(descMatch[1]);

const options = [];
for (const m of afterCmd.matchAll(/\.(requiredOption|option)\s*\(\s*'([^']+)'\s*,\s*'([^']+)'/g)) {
options.push({ flag: m[2], description: m[3], required: m[1] === 'requiredOption' });
options.push({ flag: m[2], description: scrubDescription(m[3]), required: m[1] === 'requiredOption' });
}

commands.push({
Expand Down Expand Up @@ -97,13 +162,13 @@ function buildMdx(services) {
for (const cmd of commands) {
lines.push(`### \`${cmd.name}\``);
lines.push('');
lines.push(cmd.description);
lines.push(codeifyFlags(cmd.description));
lines.push('');

if (cmd.options.length > 0) {
for (const opt of cmd.options) {
const req = opt.required ? ' **required**' : '';
lines.push(`- \`${opt.flag}\`${req} — ${opt.description}`);
lines.push(`- \`${opt.flag}\`${req} — ${codeifyFlags(opt.description)}`);
}
lines.push('');
}
Expand All @@ -113,7 +178,7 @@ function buildMdx(services) {
return lines.join('\n');
}

const services = SERVICES.map(({ name, file, prefix }) => ({
const services = SERVICES.filter(({ prefix }) => !SKIP_PREFIXES.has(prefix)).map(({ name, file, prefix }) => ({
name,
commands: extractCommands(join(root, file), prefix),
}));
Expand Down
28 changes: 24 additions & 4 deletions site/scripts/parse-instructions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,22 @@ const END_MARKER = '<!-- COMMAND-REFERENCE:END -->';
const startIdx = raw.indexOf(START_MARKER);
const endIdx = raw.indexOf(END_MARKER);

// Services hidden from the public site. copilot-instructions.md is untouched;
// mentions are stripped from the rendered page only (their command reference is
// already dropped via the COMMAND-REFERENCE markers + parse-commands SKIP list).
// Remove a name here to bring a service back onto the site.
const HIDDEN_SERVICES = ['IBM UrbanCode Deploy'];

function hideHiddenServices(text) {
for (const svc of HIDDEN_SERVICES) {
// Drop mentions from comma-separated prose lists
text = text.replaceAll(`${svc}, `, '').replaceAll(`, ${svc}`, '');
}
return text;
}

// Strip the H1 on line 1 so the page's own <h1> is the only one
const rawNoH1 = raw.replace(/^# .+\n/, '');
const rawNoH1 = hideHiddenServices(raw.replace(/^# .+\n/, ''));

// Escape MDX footguns outside fenced code blocks and outside inline code spans.
// Walk line-by-line: toggle inFence on ``` lines, then on non-fence lines escape
Expand All @@ -40,9 +54,15 @@ function escapeMdxOutsideFences(text) {
continue;
}

// Preserve blockquote markers ("> " prefixes) so they render as real
// blockquotes instead of an escaped literal "&gt;" in the output.
const bqMatch = line.match(/^(\s{0,3}(?:>\s?)+)(.*)$/);
const bqPrefix = bqMatch ? bqMatch[1] : '';
const rest = bqMatch ? bqMatch[2] : line;

// Outside fences: escape characters inside inline code spans, then outside
// Split on inline code spans (backtick-delimited), escape only the non-code parts
const parts = line.split(/(`[^`]+`)/);
const parts = rest.split(/(`[^`]+`)/);
const escaped = parts.map((part, i) => {
// Odd indices are backtick-wrapped (inline code) — leave them as-is
if (i % 2 === 1) return part;
Expand All @@ -52,7 +72,7 @@ function escapeMdxOutsideFences(text) {
.replace(/\{/g, '&#123;')
.replace(/\}/g, '&#125;');
}).join('');
result.push(escaped);
result.push(bqPrefix + escaped);
}

return result.join('\n');
Expand Down Expand Up @@ -98,7 +118,7 @@ if (startIdxNoH1 === -1 || endIdxNoH1 === -1) {

bodyBefore = escapedBody
+ '\n\n## Skills'
+ '\n\nEach workflow is packaged as a Claude Code skill. Run `pncli skills install` to download them into your repo (default: `.agents/skills/` for Copilot; add `--agent claude-code` for Claude Code). Skills marked `/invoke` can be called directly by name once installed.'
+ '\n\nEach workflow is packaged as a Claude Code skill. Register your team’s marketplace once with `pncli skills marketplace setup <url>`, then keep every installed skill current with `pncli skills marketplace sync` (see Installing Skills above). Skills marked `/invoke` can be called directly by name once installed.'
+ '\n\nBrowse all skills at [/skills](/pncli/skills/).';
} else {
bodyBefore = escapeMdxOutsideFences(beforeRaw) + '\n\n<ConfigSetupCallout />';
Expand Down
21 changes: 20 additions & 1 deletion site/scripts/parse-skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ const sources = [
{ dir: join(__dirname, '../../example-skills'), distributable: false },
];

// Services hidden from the public site. The distributed skill still documents
// them (skills/pncli/SKILL.md is untouched); they just don't render on the
// skill detail pages. Remove a name here to bring a service back onto the site.
const HIDDEN_SERVICES = ['IBM UrbanCode Deploy'];

function hideHiddenServices(text) {
let lines = text.split('\n');
for (const svc of HIDDEN_SERVICES) {
lines = lines
// Drop markdown table rows for the hidden service (e.g. "Available services")
.filter((line) => !(line.trimStart().startsWith('|') && line.includes(svc)))
// Drop mentions from comma-separated prose lists
.map((line) => line.includes(svc)
? line.replace(`${svc}, `, '').replace(`, ${svc}`, '')
: line);
}
return lines.join('\n');
}

function parseFrontmatter(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { data: {}, body: content };
Expand Down Expand Up @@ -103,7 +122,7 @@ for (const { dir, distributable } of sources) {
`generatedAt: ${JSON.stringify(new Date().toISOString())}`,
'---',
'',
escapeMdxOutsideFences(body.trim()),
escapeMdxOutsideFences(hideHiddenServices(body.trim())),
'',
].join('\n');

Expand Down
Binary file added site/src/assets/hero-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/src/assets/logo-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/src/assets/maverick-badge.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/src/assets/mundane-paperwork.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading