From d67914a5ca5eb808d0a94170e01759e55bb11159 Mon Sep 17 00:00:00 2001 From: Ramon Niebla Date: Tue, 3 Feb 2026 07:43:05 -0800 Subject: [PATCH] Feat: Add skill scaffolding and Flag cleanup skill (#1) --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 22 ++ .github/ISSUE_TEMPLATE/feature_request.md | 13 + .github/PULL_REQUEST_TEMPLATE.md | 12 + .github/workflows/release.yml | 66 +++++ .github/workflows/validate-skills.yml | 21 ++ .gitignore | 8 + CHANGELOG.md | 7 + CONTRIBUTING.md | 48 ++++ LICENSE | 13 + README.md | 50 +++- SECURITY.md | 5 + scripts/generate_catalog.py | 128 +++++++++ scripts/validate_skills.py | 155 +++++++++++ skills.json | 31 +++ .../launchdarkly-flag-cleanup/README.md | 93 +++++++ .../launchdarkly-flag-cleanup/SKILL.md | 146 +++++++++++ .../marketplace.json | 22 ++ .../references/pr-template.md | 136 ++++++++++ .../references/sdk-patterns.md | 242 ++++++++++++++++++ skills/skill-authoring/create-skill/README.md | 32 +++ skills/skill-authoring/create-skill/SKILL.md | 76 ++++++ template/SKILL.md | 60 +++++ tests/test_validate_skills.py | 116 +++++++++ 24 files changed, 1501 insertions(+), 2 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate-skills.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 scripts/generate_catalog.py create mode 100644 scripts/validate_skills.py create mode 100644 skills.json create mode 100644 skills/feature-flags/launchdarkly-flag-cleanup/README.md create mode 100644 skills/feature-flags/launchdarkly-flag-cleanup/SKILL.md create mode 100644 skills/feature-flags/launchdarkly-flag-cleanup/marketplace.json create mode 100644 skills/feature-flags/launchdarkly-flag-cleanup/references/pr-template.md create mode 100644 skills/feature-flags/launchdarkly-flag-cleanup/references/sdk-patterns.md create mode 100644 skills/skill-authoring/create-skill/README.md create mode 100644 skills/skill-authoring/create-skill/SKILL.md create mode 100644 template/SKILL.md create mode 100644 tests/test_validate_skills.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4bb7482 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @launchdarkly/team-fm-next diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4e5307b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +--- +name: Bug report +about: Report a problem with an agent skill +labels: bug +--- + +## Summary + +## Steps to Reproduce + +1. +2. + +## Expected + +## Actual + +## Environment + +- Agent client: +- Skill name: +- Version or commit: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..5367c39 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,13 @@ +--- +name: Feature request +about: Propose a new skill or enhancement +labels: enhancement +--- + +## Summary + +## Problem Statement + +## Proposed Solution + +## Alternatives Considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d81db9b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## Summary + +- + +## Testing + +- [ ] Not applicable +- [ ] Manual (describe below) + +## Notes + +- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1966e47 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Release Skills + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Version tag (e.g., v1.0.0)" + required: true + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + fi + + - name: Create skill zips + run: | + mkdir -p dist/skills + + find skills -name "SKILL.md" | while read skill_file; do + skill_dir=$(dirname "$skill_file") + skill_name=$(basename "$skill_dir") + + zip_name="${skill_name}.zip" + (cd "$skill_dir/.." && zip -r "../../dist/skills/$zip_name" "$skill_name") + + echo "Created: dist/skills/$zip_name" + done + + - name: Generate release notes + run: | + echo "## Skills" >> release_notes.md + echo "" >> release_notes.md + for zip in dist/skills/*.zip; do + name=$(basename "$zip" .zip) + echo "- \`$name\`" >> release_notes.md + done + echo "" >> release_notes.md + echo "See README for install options." >> release_notes.md + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.version.outputs.version }} + name: Release ${{ steps.version.outputs.version }} + body_path: release_notes.md + files: | + dist/skills/*.zip + draft: false + prerelease: false diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml new file mode 100644 index 0000000..47ed184 --- /dev/null +++ b/.github/workflows/validate-skills.yml @@ -0,0 +1,21 @@ +name: Validate Skills + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Validate SKILL.md files + run: python3 scripts/validate_skills.py + - name: Run validation tests + run: python3 -m unittest discover -s tests + - name: Check skills.json catalog + run: python3 scripts/generate_catalog.py --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e38d870 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.env +.idea/ +.vscode/ +node_modules/ +dist/ +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..96043b7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this repository will be documented in this file. + +## Unreleased + +- Initial public release of LaunchDarkly agent skills diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..769f4f2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to LaunchDarkly Agent Skills + +Thanks for contributing! This repo is a public collection of LaunchDarkly agent skills and playbooks. + +## Quick Start + +1. Fork and clone the repo. +2. Create a branch for your change. +3. Follow the skill conventions in `docs/skills.md`. +4. Open a PR. + +## Adding a New Skill + +1. Create a new directory under `skills/`: + +``` +skills/your-skill-name/ +└── SKILL.md +``` + +2. Copy the template from `template/SKILL.md` and fill it in. +3. Add any references under a `references/` directory. +4. Update the skill list in `README.md`. +5. Regenerate the catalog: `python3 scripts/generate_catalog.py`. + +## Skill Naming Guidelines + +- Use lowercase and hyphens only. +- Keep names under 64 characters. +- Make descriptions explicit and keyword-rich. +- If skill is specific to a launchdarkly domain (i.e feature flags, ai configs, etc), please enclose the skills in a directory with the domain name. + +## Local Testing + +Point your agent client at the `skills/` directory. Specific setup depends on the client. + +## Documentation + +Keep `SKILL.md` under 500 lines. If you need more space, add reference documents. + +## Versioning + +Update `metadata.version` in `SKILL.md` when the skill behavior changes. +See `docs/versioning.md` for details. + +## License + +By contributing, you agree that your contributions will be licensed under the Apache-2.0 License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b1dca4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2026 LaunchDarkly, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 8293c66..06d4fe4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,48 @@ -# agent-skills -LaunchDarkly's official collection of agent skills +# LaunchDarkly Agent Skills + +LaunchDarkly's public collection of AI agent skills and playbooks. These skills encode repeatable workflows for working with LaunchDarkly, so coding agents can execute common tasks safely and consistently. + +## What Is This Repo? + +Agent Skills are modular, text-based playbooks that teach an agent how to perform a workflow. This repo is designed to be a public, open-source home for LaunchDarkly skills and to align with the emerging Agent Skills Open Standard. + +## Available Skills + +| Skill | Description | +|-------|-------------| +| `feature-flags/launchdarkly-flag-cleanup` | Safely remove flags from code using LaunchDarkly as the source of truth | +| `skill-authoring/create-skill` | Add a new skill to the LaunchDarkly agent-skills repo following conventions | + +## Quick Start (Local) + +```bash +# Clone the repo +git clone https://github.com/launchdarkly/agent-skills.git +cd agent-skills + +# If your agent supports skills.sh installs: +npx skills add launchdarkly/agent-skills + +# Or manually copy a skill into your agent's skills path: +cp -r skills/feature-flags/launchdarkly-flag-cleanup / +``` + +Then ask your agent something like: + +``` +Remove the `new-checkout-flow` feature flag from this codebase +``` + +## Install via skills.sh CLI + +```bash +npx skills add +``` + +## Contributing + +See `CONTRIBUTING.md` for how to add new skills and the conventions we follow. + +## License + +Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4c10b2b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Reporting and Fixing Security Issues + +Please report all security issues to the LaunchDarkly security team by submitting a bug bounty report to our HackerOne program: https://hackerone.com/launchdarkly?type=team. LaunchDarkly will triage and address all valid security issues following the response targets defined in our program policy. Valid security issues may be eligible for a bounty. + +Please do not open issues or pull requests for security issues. This makes the problem immediately visible to everyone, including potentially malicious actors. diff --git a/scripts/generate_catalog.py b/scripts/generate_catalog.py new file mode 100644 index 0000000..3f5e88a --- /dev/null +++ b/scripts/generate_catalog.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts import validate_skills # noqa: E402 +CATALOG_PATH = ROOT / "skills.json" + + +def parse_metadata_version(frontmatter_lines: list[str]) -> str | None: + in_metadata = False + for line in frontmatter_lines: + if line.strip() == "metadata:": + in_metadata = True + continue + if in_metadata: + if not line.startswith((" ", "\t")): + in_metadata = False + continue + if ":" not in line: + continue + key, raw_value = line.strip().split(":", 1) + if key.strip() == "version": + return validate_skills.normalize_value(raw_value.strip()) + return None + + +def read_marketplace(skill_dir: pathlib.Path) -> dict: + marketplace_path = skill_dir / "marketplace.json" + if not marketplace_path.is_file(): + return {} + try: + return json.loads(marketplace_path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def build_catalog() -> dict: + skill_files = [ + p + for p in ROOT.rglob(validate_skills.SKILL_GLOB) + if p.is_file() and not validate_skills.is_excluded(p) + ] + + catalog_entries = [] + for skill_file in skill_files: + text = skill_file.read_text(encoding="utf-8") + lines = text.splitlines() + parsed, err = validate_skills.parse_frontmatter(lines) + if err: + raise ValueError(f"{skill_file.relative_to(ROOT)}: {err}") + + frontmatter_lines, _body_lines = parsed + fields, _present = validate_skills.parse_frontmatter_fields(frontmatter_lines) + + name = fields.get("name") + description = fields.get("description") + license_name = fields.get("license") + compatibility = fields.get("compatibility") + + if not name or not description: + raise ValueError( + f"{skill_file.relative_to(ROOT)}: missing required frontmatter fields" + ) + + metadata_version = parse_metadata_version(frontmatter_lines) + marketplace = read_marketplace(skill_file.parent) + + entry = { + "name": name, + "description": description, + "path": skill_file.parent.relative_to(ROOT).as_posix(), + } + + if metadata_version: + entry["version"] = metadata_version + elif isinstance(marketplace.get("version"), str): + entry["version"] = marketplace["version"] + + if license_name: + entry["license"] = license_name + if compatibility: + entry["compatibility"] = compatibility + + tags = marketplace.get("tags") + if isinstance(tags, list) and tags: + entry["tags"] = tags + + catalog_entries.append(entry) + + catalog_entries.sort(key=lambda item: item["name"]) + return {"skills": catalog_entries} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate skills.json catalog.") + parser.add_argument( + "--check", + action="store_true", + help="Fail if skills.json is out of date.", + ) + args = parser.parse_args() + + catalog = build_catalog() + content = json.dumps(catalog, indent=2) + "\n" + + if args.check: + if not CATALOG_PATH.is_file(): + print("skills.json does not exist.") + return 1 + existing = CATALOG_PATH.read_text(encoding="utf-8") + if existing != content: + print("skills.json is out of date. Run scripts/generate_catalog.py") + return 1 + print("skills.json is up to date.") + return 0 + + CATALOG_PATH.write_text(content, encoding="utf-8") + print(f"Wrote {CATALOG_PATH.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_skills.py b/scripts/validate_skills.py new file mode 100644 index 0000000..2f86124 --- /dev/null +++ b/scripts/validate_skills.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] + +SKILL_GLOB = "**/SKILL.md" +EXCLUDED_DIRS = {"template"} +NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +MAX_NAME_LENGTH = 64 +MAX_DESC_LENGTH = 1024 +MAX_COMPAT_LENGTH = 500 + + +def is_excluded(path: pathlib.Path) -> bool: + return any(part in EXCLUDED_DIRS for part in path.parts) + + +def parse_frontmatter(lines): + if not lines or lines[0].strip() != "---": + return None, "Missing opening frontmatter delimiter '---' on first line" + + end_idx = None + for idx in range(1, len(lines)): + if lines[idx].strip() == "---": + end_idx = idx + break + + if end_idx is None: + return None, "Missing closing frontmatter delimiter '---'" + + frontmatter_lines = lines[1:end_idx] + body_lines = lines[end_idx + 1 :] + return (frontmatter_lines, body_lines), None + + +def normalize_value(value: str) -> str | None: + if value in {"", "|", ">", "|-", ">-"}: + return None + if ( + (value.startswith('"') and value.endswith('"')) + or (value.startswith("'") and value.endswith("'")) + ): + return value[1:-1] + return value + + +def parse_frontmatter_fields(frontmatter_lines: list[str]) -> tuple[dict, set]: + fields: dict[str, str | None] = {} + present: set[str] = set() + for line in frontmatter_lines: + if not line.strip(): + continue + if line.startswith((" ", "\t")): + continue + match = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line) + if not match: + continue + key = match.group(1) + raw_value = match.group(2).strip() + present.add(key) + fields[key] = normalize_value(raw_value) + return fields, present + + +def validate_skill(path: pathlib.Path) -> list[str]: + errors = [] + try: + text = path.read_text(encoding="utf-8") + except Exception as exc: + return [f"Failed to read file: {exc}"] + + lines = text.splitlines() + parsed, err = parse_frontmatter(lines) + if err: + return [err] + + frontmatter_lines, body_lines = parsed + fields, present = parse_frontmatter_fields(frontmatter_lines) + + name_value = fields.get("name") + if "name" not in present: + errors.append("Frontmatter missing required field: name") + elif not name_value: + errors.append("Frontmatter field 'name' must be a non-empty string") + else: + if len(name_value) > MAX_NAME_LENGTH: + errors.append(f"Frontmatter field 'name' exceeds {MAX_NAME_LENGTH} chars") + if not NAME_PATTERN.match(name_value): + errors.append( + "Frontmatter field 'name' must be lowercase letters, numbers, " + "and single hyphens only" + ) + if path.parent.name != name_value: + errors.append( + "Frontmatter field 'name' must match the parent directory name" + ) + + description_value = fields.get("description") + if "description" not in present: + errors.append("Frontmatter missing required field: description") + elif not description_value: + errors.append("Frontmatter field 'description' must be a non-empty string") + elif len(description_value) > MAX_DESC_LENGTH: + errors.append( + f"Frontmatter field 'description' exceeds {MAX_DESC_LENGTH} chars" + ) + + compatibility_value = fields.get("compatibility") + if "compatibility" in present: + if not compatibility_value: + errors.append( + "Frontmatter field 'compatibility' must be a non-empty string" + ) + elif len(compatibility_value) > MAX_COMPAT_LENGTH: + errors.append( + f"Frontmatter field 'compatibility' exceeds {MAX_COMPAT_LENGTH} chars" + ) + + body_text = "\n".join(body_lines).strip() + if not body_text: + errors.append("Missing markdown content after frontmatter") + + return errors + + +def main(): + skill_files = [ + p for p in ROOT.rglob(SKILL_GLOB) if p.is_file() and not is_excluded(p) + ] + + if not skill_files: + print("No SKILL.md files found.") + return 1 + + all_errors = [] + for path in skill_files: + errors = validate_skill(path) + if errors: + for err in errors: + all_errors.append(f"{path.relative_to(ROOT)}: {err}") + + if all_errors: + print("Skill validation failed:") + for err in all_errors: + print(f"- {err}") + return 1 + + print(f"Validated {len(skill_files)} SKILL.md files successfully.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills.json b/skills.json new file mode 100644 index 0000000..7b02adf --- /dev/null +++ b/skills.json @@ -0,0 +1,31 @@ +{ + "skills": [ + { + "name": "create-skill", + "description": "Add a new skill to the LaunchDarkly agent-skills repo. Use when creating a new SKILL.md, updating the skills catalog, and aligning with repo conventions.", + "path": "skills/skill-authoring/create-skill", + "version": "0.1.0", + "license": "Apache-2.0", + "compatibility": "Works in repositories following the Agent Skills open standard" + }, + { + "name": "launchdarkly-flag-cleanup", + "description": "Safely automate feature flag cleanup workflows using the LaunchDarkly MCP server. Use when removing flags from code, cleaning up stale flags, assessing removal readiness, or creating PRs that preserve production behavior.", + "path": "skills/feature-flags/launchdarkly-flag-cleanup", + "version": "1.0.0-alpha", + "license": "Apache-2.0", + "compatibility": "Requires LaunchDarkly MCP server (@launchdarkly/mcp-server)", + "tags": [ + "launchdarkly", + "feature-flags", + "feature-management", + "stale-flags", + "tech-debt", + "cleanup", + "code-removal", + "devops", + "mcp" + ] + } + ] +} diff --git a/skills/feature-flags/launchdarkly-flag-cleanup/README.md b/skills/feature-flags/launchdarkly-flag-cleanup/README.md new file mode 100644 index 0000000..0955903 --- /dev/null +++ b/skills/feature-flags/launchdarkly-flag-cleanup/README.md @@ -0,0 +1,93 @@ +# LaunchDarkly Flag Cleanup Skill + +An Agent Skill for safely automating feature flag cleanup workflows using LaunchDarkly as the source of truth. + +## Overview + +This skill teaches agents how to: +- Determine if a feature flag is ready for removal +- Calculate the correct forward value to preserve production behavior +- Safely remove flag references from code +- Create well-documented pull requests + +## Installation (Local) + +For now, install by placing this skill directory where your agent client loads skills. + +Examples: + +- **Generic**: copy `skills/feature-flags/launchdarkly-flag-cleanup/` into your client's skills path + +## Prerequisites + +This skill requires the LaunchDarkly MCP server to be configured in your environment. + +### Configure MCP Server + +**Claude Code (`~/.claude/mcp.json`):** +```json +{ + "mcpServers": { + "launchdarkly": { + "command": "npx", + "args": ["-y", "@launchdarkly/mcp-server", "start"], + "env": { + "LD_ACCESS_TOKEN": "your-api-key" + } + } + } +} +``` + +**Cursor (`.cursor/mcp.json`):** +```json +{ + "mcpServers": { + "launchdarkly": { + "command": "npx", + "args": ["-y", "@launchdarkly/mcp-server", "start"], + "env": { + "LD_ACCESS_TOKEN": "your-api-key" + } + } + } +} +``` + +## Usage + +Once installed, the skill activates automatically when you ask about flag cleanup: + +``` +Remove the `new-checkout-flow` feature flag +``` + +``` +Is the `dark-mode` flag ready to be cleaned up? +``` + +``` +Clean up stale feature flags in this codebase +``` + +## Structure + +``` +launchdarkly-flag-cleanup/ +├── SKILL.md +├── marketplace.json +├── README.md +└── references/ + ├── pr-template.md + └── sdk-patterns.md +``` + +## Related + +- [LaunchDarkly MCP Server](https://github.com/launchdarkly/mcp-server) +- [LaunchDarkly Docs](https://docs.launchdarkly.com) +- [Agent Skills Specification](https://agentskills.io/specification) + +## License + +Apache-2.0 diff --git a/skills/feature-flags/launchdarkly-flag-cleanup/SKILL.md b/skills/feature-flags/launchdarkly-flag-cleanup/SKILL.md new file mode 100644 index 0000000..ae8b441 --- /dev/null +++ b/skills/feature-flags/launchdarkly-flag-cleanup/SKILL.md @@ -0,0 +1,146 @@ +--- +name: launchdarkly-flag-cleanup +description: "Safely automate feature flag cleanup workflows using the LaunchDarkly MCP server. Use when removing flags from code, cleaning up stale flags, assessing removal readiness, or creating PRs that preserve production behavior." +license: Apache-2.0 +compatibility: Requires LaunchDarkly MCP server (@launchdarkly/mcp-server) +metadata: + author: launchdarkly + version: "1.0.0-alpha" +--- + +# LaunchDarkly Flag Cleanup + +A workflow for safely removing feature flags from codebases while preserving production behavior. This skill uses LaunchDarkly as the source of truth to determine removal readiness and the correct forward value. + +## Prerequisites + +This skill requires the LaunchDarkly MCP server to be configured in your environment. + +**Required MCP tools:** +- `get-environments` +- `get-feature-flag` +- `get-flag-status-across-environments` +- `get-code-references` + +## Core Principles + +1. **Safety First**: Always preserve current production behavior. +2. **LaunchDarkly as Source of Truth**: Never guess. Query the actual configuration. +3. **Clear Communication**: Explain reasoning in PR descriptions. +4. **Follow Conventions**: Respect existing code style and structure. + +## Flag Removal Workflow + +### Step 1: Identify Critical Environments + +Use `get-environments` with the project key to find environments marked as critical (typically `production`, `staging`, or user-specified). + +### Step 2: Fetch Flag Configuration + +Use `get-feature-flag` to retrieve the full configuration. Extract: +- `variations`: Possible values +- Per critical environment: + - `on`: Whether enabled + - `fallthrough.variation`: Variation index when no rules match + - `offVariation`: Variation index when flag is off + - `rules`: Targeting rules (complexity indicator) + - `targets`: Individual context targets + +### Step 3: Determine Forward Value + +The **forward value** replaces the flag in code. + +| Scenario | Forward Value | +|----------|---------------| +| All critical envs ON, same fallthrough, no rules/targets | Use `fallthrough.variation` | +| All critical envs OFF, same offVariation | Use `offVariation` | +| Critical envs differ in ON/OFF state | **NOT SAFE** - stop | +| Critical envs serve different variations | **NOT SAFE** - stop | + +### Step 4: Assess Removal Readiness + +Use `get-flag-status-across-environments` to check lifecycle status. + +**READY** if ALL true: +- Status is `launched` or `active` in all critical environments +- Same variation served across all critical environments +- No targeting rules or individual targets in critical environments +- Flag is not already archived/deprecated + +**PROCEED WITH CAUTION** if: +- Status is `inactive` (no recent traffic) +- Zero evaluations in last 7 days (confirm with user) + +**NOT READY** if: +- Status is `new` (still rolling out) +- Different variations across critical environments +- Complex targeting rules exist +- Critical environments differ in ON/OFF state + +### Step 5: Check Code References + +Use `get-code-references` to identify repositories. If the current repo isn't listed, inform the user. Note the count of other repositories for awareness. + +### Step 6: Remove Flag from Code + +Search for all references and replace with the forward value: + +1. **Find evaluation patterns:** + - `variation('flag-key', ...)` + - `boolVariation('flag-key', ...)` + - `featureFlags['flag-key']` + - SDK-specific and wrapper patterns + +2. **Replace with forward value:** + - Preserve the branch matching the forward value + - Remove the dead branch and related code + - If assigned to a variable, replace with the value directly + +3. **Clean up:** + - Remove unused imports/constants + - Avoid unrelated refactors + - Double-check for orphaned exports or files created solely for the flag + (unused components, hooks, helpers, styles, and test files) + - If the repo uses an unused-export tool (e.g., lint rules, Knip, ts-prune), + run it and remove any flag-related orphans it reports + +**Example transformation (forward value = true):** +```typescript +// Before +const showNewCheckout = await ldClient.variation('new-checkout-flow', user, false); +if (showNewCheckout) { + return renderNewCheckout(); +} else { + return renderOldCheckout(); +} + +// After +return renderNewCheckout(); +``` + +### Step 7: Create Pull Request + +Use the template in `references/pr-template.md` for a structured PR description including removal summary, readiness assessment, and risk analysis. + +## Edge Cases + +| Situation | Action | +|-----------|--------| +| Flag not found | Inform user, check for typos | +| Already archived | Ask if code cleanup still needed | +| Multiple SDK patterns | Search all: `variation()`, `boolVariation()`, `variationDetail()`, `allFlags()` | +| Dynamic flag keys (`flag-${id}`) | Warn that automated removal may be incomplete | +| Different default values in code | Flag as inconsistency in PR | +| Orphaned exports/files remain | Run unused-export checks and remove dead files | + +## What NOT to Do + +- Don't change code unrelated to flag cleanup. +- Don't refactor or optimize beyond flag removal. +- Don't remove flags still being rolled out. +- Don't guess the forward value. + +## Related Resources + +- [PR Template](references/pr-template.md) +- [SDK Patterns](references/sdk-patterns.md) diff --git a/skills/feature-flags/launchdarkly-flag-cleanup/marketplace.json b/skills/feature-flags/launchdarkly-flag-cleanup/marketplace.json new file mode 100644 index 0000000..401787e --- /dev/null +++ b/skills/feature-flags/launchdarkly-flag-cleanup/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "launchdarkly-flag-cleanup", + "description": "Safely automate feature flag cleanup workflows using LaunchDarkly MCP server", + "version": "1.0.0-alpha", + "author": "LaunchDarkly", + "repository": "https://github.com/launchdarkly/agent-skills", + "skills": ["./"], + "tags": [ + "launchdarkly", + "feature-flags", + "feature-management", + "stale-flags", + "tech-debt", + "cleanup", + "code-removal", + "devops", + "mcp" + ], + "requirements": { + "mcp-servers": ["@launchdarkly/mcp-server"] + } +} diff --git a/skills/feature-flags/launchdarkly-flag-cleanup/references/pr-template.md b/skills/feature-flags/launchdarkly-flag-cleanup/references/pr-template.md new file mode 100644 index 0000000..c4083e3 --- /dev/null +++ b/skills/feature-flags/launchdarkly-flag-cleanup/references/pr-template.md @@ -0,0 +1,136 @@ +# PR Template for Flag Removal + +Use this template when creating pull requests for flag cleanup. + +```markdown +## Flag Removal: `{flag-key}` + +### Removal Summary +- **Forward Value**: `{variation value being preserved}` +- **Critical Environments**: {list environments} +- **Status**: ✅ Ready for removal / ⚠️ Proceed with caution / ❌ Not ready + +### Removal Readiness Assessment + +**Configuration Analysis:** +| Environment | State | Serving | Rules | Targets | +|-------------|-------|---------|-------|---------| +| production | ON/OFF | `{value}` | none/present | none/count | +| {other env} | ON/OFF | `{value}` | none/present | none/count | + +**Lifecycle Status:** +| Environment | Status | Evaluations (7d) | +|-------------|--------|------------------| +| production | launched/active/inactive/new | {count} | +| {other env} | launched/active/inactive/new | {count} | + +**Code References:** +- Repositories with references: `{count}` +- This PR addresses: `{current repo}` +- Other repos requiring cleanup: `{list if any}` + +### Changes Made +- Removed flag evaluation calls: `{count}` occurrences +- Files modified: `{list files}` +- Preserved behavior: `{describe what code now does}` +- Cleaned up: `{list dead code removed}` + +### Risk Assessment + +{Explain why this change is safe. Address:} +- Why the forward value is correct +- Any edge cases considered +- Impact on other environments (if any) + +### Reviewer Checklist + +- [ ] Forward value matches production behavior +- [ ] All flag references removed +- [ ] No unrelated changes included +- [ ] Tests pass (if applicable) +- [ ] Dead code properly removed + +### Post-Merge Actions + +- [ ] Archive flag in LaunchDarkly (after deployment confirmed) +- [ ] Notify other teams if they have code references +``` + +## Example: Ready for Removal + +```markdown +## Flag Removal: `new-checkout-flow` + +### Removal Summary +- **Forward Value**: `true` +- **Critical Environments**: production, prod-eu +- **Status**: ✅ Ready for removal + +### Removal Readiness Assessment + +**Configuration Analysis:** +| Environment | State | Serving | Rules | Targets | +|-------------|-------|---------|-------|---------| +| production | ON | `true` | none | none | +| prod-eu | ON | `true` | none | none | + +**Lifecycle Status:** +| Environment | Status | Evaluations (7d) | +|-------------|--------|------------------| +| production | launched | 142,531 | +| prod-eu | launched | 89,203 | + +**Code References:** +- Repositories with references: 2 +- This PR addresses: `checkout-service` +- Other repos requiring cleanup: `mobile-app` + +### Changes Made +- Removed flag evaluation calls: 3 occurrences +- Files modified: `CheckoutController.ts`, `CheckoutService.ts`, `checkout.test.ts` +- Preserved behavior: Always renders new checkout experience +- Cleaned up: Removed `renderOldCheckout()` function and related imports + +### Risk Assessment + +This change is safe because: +- Both production environments serve `true` to 100% of traffic +- Flag has been at 100% for 47 days with no issues +- No targeting rules or individual overrides exist +- The new checkout flow has been fully validated + +### Post-Merge Actions + +- [ ] Archive flag in LaunchDarkly (after deployment confirmed) +- [ ] Create follow-up ticket for mobile-app cleanup +``` + +## Example: Proceed with Caution + +```markdown +## Flag Removal: `legacy-api-endpoint` + +### Removal Summary +- **Forward Value**: `false` +- **Critical Environments**: production +- **Status**: ⚠️ Proceed with caution + +### Removal Readiness Assessment + +**Configuration Analysis:** +| Environment | State | Serving | Rules | Targets | +|-------------|-------|---------|-------|---------| +| production | OFF | `false` | none | none | + +**Lifecycle Status:** +| Environment | Status | Evaluations (7d) | +|-------------|--------|------------------| +| production | inactive | 0 | + +⚠️ **Warning**: Zero evaluations in the last 7 days. This flag may be: +- Dead code that's safe to remove +- Used by a batch job or infrequent process +- Referenced but never called + +**Recommendation**: Verify with the team that this code path is truly unused before merging. +``` diff --git a/skills/feature-flags/launchdarkly-flag-cleanup/references/sdk-patterns.md b/skills/feature-flags/launchdarkly-flag-cleanup/references/sdk-patterns.md new file mode 100644 index 0000000..1c02b89 --- /dev/null +++ b/skills/feature-flags/launchdarkly-flag-cleanup/references/sdk-patterns.md @@ -0,0 +1,242 @@ +# SDK Patterns Reference + +Common flag evaluation patterns by SDK and language. Search for these patterns when finding flag references. + +## JavaScript/TypeScript (Node.js) + +```typescript +// Standard evaluation +ldClient.variation('flag-key', context, defaultValue); +ldClient.boolVariation('flag-key', context, false); +ldClient.stringVariation('flag-key', context, 'default'); +ldClient.numberVariation('flag-key', context, 0); +ldClient.jsonVariation('flag-key', context, {}); + +// With details (includes reason) +ldClient.variationDetail('flag-key', context, defaultValue); +ldClient.boolVariationDetail('flag-key', context, false); + +// All flags +ldClient.allFlagsState(context); +``` + +## JavaScript/TypeScript (Browser/React) + +```typescript +// React SDK hooks +const { flags } = useFlags(); +const flagValue = flags['flag-key']; +const flagValue = flags.flagKey; // camelCase access + +// useLDClient hook +const ldClient = useLDClient(); +ldClient.variation('flag-key', defaultValue); + +// withLDConsumer HOC +this.props.flags['flag-key'] +this.props.ldClient.variation('flag-key', defaultValue); + +// Direct client usage +LDClient.variation('flag-key', defaultValue); +``` + +## Python + +```python +# Standard evaluation +ld_client.variation('flag-key', context, default_value) +ld_client.bool_variation('flag-key', context, False) +ld_client.string_variation('flag-key', context, 'default') +ld_client.int_variation('flag-key', context, 0) +ld_client.float_variation('flag-key', context, 0.0) +ld_client.json_variation('flag-key', context, {}) + +# With details +ld_client.variation_detail('flag-key', context, default_value) +ld_client.bool_variation_detail('flag-key', context, False) + +# All flags +ld_client.all_flags_state(context) +``` + +## Go + +```go +// Standard evaluation +ldClient.BoolVariation("flag-key", context, false) +ldClient.StringVariation("flag-key", context, "default") +ldClient.IntVariation("flag-key", context, 0) +ldClient.Float64Variation("flag-key", context, 0.0) +ldClient.JSONVariation("flag-key", context, ldvalue.Null()) + +// With details +ldClient.BoolVariationDetail("flag-key", context, false) +ldClient.StringVariationDetail("flag-key", context, "default") + +// All flags +ldClient.AllFlagsState(context) +``` + +## Java/Kotlin + +```java +// Standard evaluation +ldClient.boolVariation("flag-key", context, false); +ldClient.stringVariation("flag-key", context, "default"); +ldClient.intVariation("flag-key", context, 0); +ldClient.doubleVariation("flag-key", context, 0.0); +ldClient.jsonValueVariation("flag-key", context, LDValue.ofNull()); + +// With details +ldClient.boolVariationDetail("flag-key", context, false); +ldClient.stringVariationDetail("flag-key", context, "default"); + +// All flags +ldClient.allFlagsState(context); +``` + +## Ruby + +```ruby +# Standard evaluation +ld_client.variation('flag-key', context, default_value) +ld_client.bool_variation('flag-key', context, false) +ld_client.string_variation('flag-key', context, 'default') +ld_client.number_variation('flag-key', context, 0) +ld_client.json_variation('flag-key', context, {}) + +# With details +ld_client.variation_detail('flag-key', context, default_value) + +# All flags +ld_client.all_flags_state(context) +``` + +## .NET (C#) + +```csharp +// Standard evaluation +ldClient.BoolVariation("flag-key", context, false); +ldClient.StringVariation("flag-key", context, "default"); +ldClient.IntVariation("flag-key", context, 0); +ldClient.FloatVariation("flag-key", context, 0.0f); +ldClient.DoubleVariation("flag-key", context, 0.0); +ldClient.JsonVariation("flag-key", context, LdValue.Null); + +// With details +ldClient.BoolVariationDetail("flag-key", context, false); +ldClient.StringVariationDetail("flag-key", context, "default"); + +// All flags +ldClient.AllFlagsState(context); +``` + +## Common Wrapper Patterns + +Many teams create abstraction layers. Search for these patterns too: + +```typescript +// Service wrappers +featureFlagService.isEnabled('flag-key'); +featureFlagService.getValue('flag-key'); +featureFlagService.getFlag('flag-key'); +FeatureFlags.isEnabled('flag-key'); + +// Constants/enums +FLAGS.NEW_CHECKOUT_FLOW +FeatureFlag.NEW_CHECKOUT_FLOW +FEATURE_FLAGS['flag-key'] + +// Decorator patterns (Python/Java) +@feature_flag('flag-key') +@FeatureFlag("flag-key") + +// Configuration files +feature_flags: + flag-key: true +``` + +## Search Strategies + +When searching for flag references, use multiple patterns: + +```bash +# Exact string match +grep -r "'flag-key'" . +grep -r '"flag-key"' . + +# Case variations (kebab-case to camelCase) +grep -r "flagKey" . + +# Partial matches for wrapper usage +grep -r "flag-key\|flagKey\|FLAG_KEY" . + +# Check constants files +grep -r "flag-key" . --include="*.constants.*" +grep -r "flag-key" . --include="*flags*" +``` + +## Removal Patterns + +### Boolean flag (forward value = true) + +```typescript +// Before +if (ldClient.variation('flag-key', user, false)) { + doNewThing(); +} else { + doOldThing(); +} + +// After +doNewThing(); +``` + +### Boolean flag (forward value = false) + +```typescript +// Before +if (ldClient.variation('flag-key', user, false)) { + doNewThing(); +} else { + doOldThing(); +} + +// After +doOldThing(); +``` + +### String/multivariate flag + +```typescript +// Before +const variant = ldClient.variation('flag-key', user, 'control'); +switch (variant) { + case 'new': + return renderNew(); + case 'experimental': + return renderExperimental(); + default: + return renderControl(); +} + +// After (forward value = 'new') +return renderNew(); +``` + +### Early return pattern + +```typescript +// Before +const enabled = ldClient.variation('flag-key', user, false); +if (!enabled) { + return null; +} +return ; + +// After (forward value = true) +return ; + +// After (forward value = false) +return null; +``` diff --git a/skills/skill-authoring/create-skill/README.md b/skills/skill-authoring/create-skill/README.md new file mode 100644 index 0000000..f1ebf84 --- /dev/null +++ b/skills/skill-authoring/create-skill/README.md @@ -0,0 +1,32 @@ +# Create Skill (LaunchDarkly) + +This skill helps contributors add new skills to the LaunchDarkly agent-skills repository. + +## Overview + +The workflow covers: +- Selecting a category and skill name +- Creating `SKILL.md` using the template +- Updating `README.md` and `skills.json` +- Validating with the repo scripts + +## Usage + +Ask: + +``` +Add a new skill for in the LaunchDarkly agent-skills repo +``` + +## Structure + +``` +create-skill/ +├── SKILL.md +└── README.md +``` + +## Related + +- [Agent Skills Specification](https://agentskills.io/specification) +- [skills.sh](https://skills.sh/) diff --git a/skills/skill-authoring/create-skill/SKILL.md b/skills/skill-authoring/create-skill/SKILL.md new file mode 100644 index 0000000..cec3005 --- /dev/null +++ b/skills/skill-authoring/create-skill/SKILL.md @@ -0,0 +1,76 @@ +--- +name: create-skill +description: "Add a new skill to the LaunchDarkly agent-skills repo. Use when creating a new SKILL.md, updating the skills catalog, and aligning with repo conventions." +license: Apache-2.0 +compatibility: Works in repositories following the Agent Skills open standard +metadata: + author: launchdarkly + version: "0.1.0" +--- + +# Create a LaunchDarkly Skill + +This skill guides contributors through adding a new skill to the LaunchDarkly agent-skills repository, following the open standard and local repo conventions. + +## Prerequisites + +- Access to the LaunchDarkly agent-skills repo +- Familiarity with the workflow you want to encode + +## Steps + +1. **Pick a category and name** + - Choose a category under `skills/` (for example, `feature-flags`, `ai-config`). + - Create a directory `skills///`. + - Ensure `` is lowercase with hyphens, and matches the `name` field exactly. + +2. **Create `SKILL.md`** + - Copy `template/SKILL.md` into the new skill directory. + - Fill in required frontmatter: `name`, `description`. + - Keep `SKILL.md` under 500 lines and move deep details to `references/`. + +3. **Add supporting files** + - If needed, add `references/` and optional `scripts/` or `assets/`. + - Keep reference files small and focused for on-demand loading. + +4. **Update repo docs** + - Add the skill to the table in `README.md`. + - If the skill requires specific tooling, document it clearly in the skill. + +5. **Update the catalog** + - Run `python3 scripts/generate_catalog.py` to update `skills.json`. + +6. **Validate** + - Run `python3 scripts/validate_skills.py`. + - Run `python3 -m unittest discover -s tests`. + +## Guidelines + +- Follow the Agent Skills spec for naming and frontmatter. +- Make “when to use this” explicit in the description. +- Avoid internal-only links or tools unless the skill is internal-only. + +## Examples + +### Example: Add an AI config skill + +**User**: "Add a skill to guide creating AI Configs" + +**Expected behavior**: +1. Create `skills/ai-configs/create-ai-config/`. +2. Fill `SKILL.md` using the template. +3. Add references if needed. +4. Update `README.md` and `skills.json`. +5. Run validation scripts. + +## Edge Cases + +- **Name mismatch**: If `name` doesn’t match the folder name, fix the folder or frontmatter. +- **Overlong SKILL.md**: Move detailed content into `references/`. +- **Missing catalog update**: Regenerate `skills.json` before committing. + +## References + +- `README.md` +- `docs/skills.md` +- `docs/versioning.md` diff --git a/template/SKILL.md b/template/SKILL.md new file mode 100644 index 0000000..28379db --- /dev/null +++ b/template/SKILL.md @@ -0,0 +1,60 @@ +--- +name: skill-name +description: A clear description of what this skill does and when the agent should use it. Include keywords that help the agent identify relevant tasks. +compatibility: Specify platform requirements (for example, "Works on all platforms" or "Requires LaunchDarkly MCP server"). +metadata: + author: your-name + version: "0.1.0" +--- + +# Skill Title + +Brief overview of what this skill helps accomplish. + +## Prerequisites + +List any requirements: +- Required tools or CLIs +- Required MCP servers +- Required permissions or access + +## Steps + +Describe the workflow the agent should follow: + +1. **First step**: What to do first +2. **Second step**: What comes next +3. **Continue**: As needed + +## Guidelines + +- Key principle or best practice +- Another guideline +- Things to watch out for + +## Examples + +### Example 1: Basic usage + +**User**: Example user request + +**Expected behavior**: +1. What the agent should do +2. Expected outcome + +### Example 2: Edge case + +**User**: Another example + +**Expected behavior**: +1. How to handle this case + +## Edge Cases + +- **Scenario A**: How to handle it +- **Scenario B**: How to handle it + +## References + +Link to any additional documentation: +- [External resources](https://example.com) diff --git a/tests/test_validate_skills.py b/tests/test_validate_skills.py new file mode 100644 index 0000000..cfa1160 --- /dev/null +++ b/tests/test_validate_skills.py @@ -0,0 +1,116 @@ +import tempfile +import textwrap +import unittest +from pathlib import Path + +from scripts import validate_skills + + +class ValidateSkillsTests(unittest.TestCase): + def setUp(self): + self._temp_dirs = [] + + def tearDown(self): + for temp_dir in self._temp_dirs: + temp_dir.cleanup() + + def test_parse_frontmatter_missing_opening(self): + lines = ["name: test", "---", "content"] + parsed, err = validate_skills.parse_frontmatter(lines) + self.assertIsNone(parsed) + self.assertIn("opening frontmatter", err) + + def test_parse_frontmatter_missing_closing(self): + lines = ["---", "name: test", "description: ok"] + parsed, err = validate_skills.parse_frontmatter(lines) + self.assertIsNone(parsed) + self.assertIn("closing frontmatter", err) + + def test_validate_skill_requires_name_and_description(self): + content = textwrap.dedent( + """\ + --- + name: test-skill + --- + + # Title + Body + """ + ) + path = self._write_temp_skill(content) + errors = validate_skills.validate_skill(path) + self.assertIn("description", " ".join(errors)) + + def test_validate_skill_requires_body(self): + content = textwrap.dedent( + """\ + --- + name: test-skill + description: ok + --- + """ + ) + path = self._write_temp_skill(content) + errors = validate_skills.validate_skill(path) + self.assertIn("markdown content", " ".join(errors)) + + def test_validate_skill_name_must_match_directory(self): + content = textwrap.dedent( + """\ + --- + name: test-skill + description: ok + --- + + # Title + Body + """ + ) + path = self._write_temp_skill(content, dir_name="other-skill") + errors = validate_skills.validate_skill(path) + self.assertIn("parent directory", " ".join(errors)) + + def test_validate_skill_name_constraints(self): + content = textwrap.dedent( + """\ + --- + name: Test-Skill + description: ok + --- + + # Title + Body + """ + ) + path = self._write_temp_skill(content, dir_name="Test-Skill") + errors = validate_skills.validate_skill(path) + self.assertIn("lowercase", " ".join(errors)) + + def test_validate_skill_happy_path(self): + content = textwrap.dedent( + """\ + --- + name: test-skill + description: ok + --- + + # Title + Body + """ + ) + path = self._write_temp_skill(content) + errors = validate_skills.validate_skill(path) + self.assertEqual(errors, []) + + def _write_temp_skill(self, content: str, dir_name: str = "test-skill") -> Path: + temp_dir = tempfile.TemporaryDirectory() + self._temp_dirs.append(temp_dir) + skill_dir = Path(temp_dir.name) / dir_name + skill_dir.mkdir(parents=True, exist_ok=True) + path = skill_dir / "SKILL.md" + path.write_text(content, encoding="utf-8") + return path + + +if __name__ == "__main__": + unittest.main()