diff --git a/.github/workflows/skills-manifest.yml b/.github/workflows/skills-manifest.yml new file mode 100644 index 0000000..7514a12 --- /dev/null +++ b/.github/workflows/skills-manifest.yml @@ -0,0 +1,34 @@ +name: Update skills manifest + +on: + push: + branches: [ main ] + paths: + - 'v*/skills/**' + - '.studio/gen_skills_manifest.py' + workflow_dispatch: + +permissions: + contents: write + +jobs: + update-manifest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Generate manifest + run: python3 .studio/gen_skills_manifest.py + - name: Commit if changed + run: | + if git diff --quiet -- .studio/skills-manifest.json; then + echo "manifest unchanged" + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .studio/skills-manifest.json + git commit -m "chore: update skills-manifest.json [skip ci]" + git push + fi diff --git a/.studio/gen_skills_manifest.py b/.studio/gen_skills_manifest.py new file mode 100644 index 0000000..18646f0 --- /dev/null +++ b/.studio/gen_skills_manifest.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Generate .studio/skills-manifest.json (version -> {skills[], sha256}). + +The aggregate hash is byte-identical to jmix-studio logic: +for each listed skill folder, walk files; entry = relpath(UTF-8) + 0x00 + bytes, +relpath is POSIX-separated and relative to the version's skills/ dir; sort +entries by relpath bytes; SHA-256 over the concatenation; lowercase hex. +""" +import hashlib +import json +import os + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Canonical per-scope skill store (relative to the scope root: the user home for +# "global", the project base for "local"). The global store appends /v. +# Studio reads this from the manifest; install.sh / install.ps1 mirror it. +STORE = { + "global": ".agents/.jmix/skills", + "local": ".skills", +} + +def list_skill_names(skills_dir): + return sorted( + name for name in os.listdir(skills_dir) + if os.path.isdir(os.path.join(skills_dir, name)) + ) + +def aggregate_hash(skills_dir, skill_names): + entries = [] + for name in sorted(skill_names): + base = os.path.join(skills_dir, name) + if not os.path.isdir(base): + continue + for current, _dirs, files in os.walk(base): + for filename in files: + full = os.path.join(current, filename) + rel = os.path.relpath(full, skills_dir).replace(os.sep, "/") + with open(full, "rb") as f: + entries.append((rel, f.read())) + entries.sort(key=lambda e: e[0].encode("utf-8")) + digest = hashlib.sha256() + for rel, data in entries: + digest.update(rel.encode("utf-8")) + digest.update(b"\x00") + digest.update(data) + return digest.hexdigest() + +def build_manifest(): + versions = {} + for entry in sorted(os.listdir(REPO_ROOT)): + if not entry.startswith("v"): + continue + skills_dir = os.path.join(REPO_ROOT, entry, "skills") + if not os.path.isdir(skills_dir): + continue + names = list_skill_names(skills_dir) + versions[entry] = {"skills": names, "sha256": aggregate_hash(skills_dir, names)} + return {"schemaVersion": 1, "store": STORE, "versions": versions} + +def main(): + manifest = build_manifest() + out_dir = os.path.join(REPO_ROOT, ".studio") + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, "skills-manifest.json") + text = json.dumps(manifest, indent=2, ensure_ascii=False, sort_keys=True) + "\n" + with open(out_path, "w", encoding="utf-8") as f: + f.write(text) + print("wrote " + out_path) + +if __name__ == "__main__": + main() diff --git a/.studio/skills-manifest.json b/.studio/skills-manifest.json new file mode 100644 index 0000000..598f813 --- /dev/null +++ b/.studio/skills-manifest.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "store": { + "global": ".agents/.jmix/skills", + "local": ".skills" + }, + "versions": { + "v2": { + "sha256": "d2d53b6cc53b623c51d05611fc48908342d4cdda2afeefe5289d69eac3c4c2a7", + "skills": [ + "jmix-dto", + "jmix-entities", + "jmix-enums", + "jmix-fetch-plans", + "jmix-fragments", + "jmix-i18n", + "jmix-liquibase", + "jmix-security-roles", + "jmix-services", + "jmix-testing", + "jmix-views" + ] + } + } +} diff --git a/.studio/studio-meta-data.json b/.studio/studio-meta-data.json new file mode 100644 index 0000000..a1c88f7 --- /dev/null +++ b/.studio/studio-meta-data.json @@ -0,0 +1,231 @@ +{ + "$schema": "./studio-meta-data.schema.json", + "install-flow": { + "steps": [ + { + "id": "skills", + "title": "Skills", + "message": "🛠️ Install Jmix skills for the selected agents in the preferred scope (local/global).
⏭️ Click Skip to opt out.", + "inputs": [ + { + "id": "skillsScope", + "label": "Installation scope", + "type": "options", + "choices": [ + { + "value": "local", + "label": "Local (project)", + "default": true + }, + { + "value": "global", + "label": "Global (user home)" + } + ] + }, + { + "id": "skillsAgents", + "label": "🤖 Agents", + "type": "options", + "multi": true, + "choices": [ + { + "value": "claude", + "label": "Claude Code", + "default": true + }, + { + "value": "codex", + "label": "Codex", + "default": true + }, + { + "value": "opencode", + "label": "OpenCode", + "default": true + }, + { + "value": "junie", + "label": "Junie", + "default": true + } + ] + } + ], + "command": { + "windows": "& ([scriptblock]::Create((iwr -useb https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.ps1).Content)) skills -Agents \"${skillsAgents}\" -Scope \"${skillsScope}\" -Version \"${JMIX_VERSION}\"", + "macos": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- skills --agents \"${skillsAgents}\" --scope \"${skillsScope}\" --version \"${JMIX_VERSION}\"", + "linux": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- skills --agents \"${skillsAgents}\" --scope \"${skillsScope}\" --version \"${JMIX_VERSION}\"" + } + }, + { + "id": "guidelines", + "title": "Guidelines", + "message": "📘 Add Jmix coding guidelines (CLAUDE.md / AGENTS.md / guidelines.md) to the project root for the selected agents.
⏭️ Click Skip to leave the project as is.", + "inputs": [ + { + "id": "guidelinesAgents", + "label": "\uD83E\uDD16 Agents", + "type": "options", + "multi": true, + "choices": [ + { + "value": "claude", + "label": "Claude Code", + "default": true + }, + { + "value": "codex", + "label": "Codex", + "default": true + }, + { + "value": "opencode", + "label": "OpenCode", + "default": true + }, + { + "value": "junie", + "label": "Junie", + "default": true + } + ] + } + ], + "command": { + "windows": "& ([scriptblock]::Create((iwr -useb https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.ps1).Content)) agents-md -Agents \"${guidelinesAgents}\" -Version \"${JMIX_VERSION}\"", + "macos": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- agents-md --agents \"${guidelinesAgents}\" --version \"${JMIX_VERSION}\"", + "linux": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- agents-md --agents \"${guidelinesAgents}\" --version \"${JMIX_VERSION}\"" + } + }, + { + "id": "jetbrainsMcp", + "title": "JetBrains MCP", + "message": "🔌 Register the JetBrains MCP server with the selected agents.
⏭️ Click Skip to opt out.", + "inputs": [ + { + "id": "jetbrainsMcpAgents", + "label": "\uD83E\uDD16 Agents", + "type": "options", + "multi": true, + "choices": [ + { + "value": "claude", + "label": "Claude Code", + "default": true + }, + { + "value": "codex", + "label": "Codex", + "default": true + }, + { + "value": "opencode", + "label": "OpenCode", + "default": true + }, + { + "value": "junie", + "label": "Junie", + "default": true + } + ] + } + ], + "command": { + "windows": "& ([scriptblock]::Create((iwr -useb https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.ps1).Content)) mcp-jetbrains -Agents \"${jetbrainsMcpAgents}\"", + "macos": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- mcp-jetbrains --agents \"${jetbrainsMcpAgents}\"", + "linux": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- mcp-jetbrains --agents \"${jetbrainsMcpAgents}\"" + } + }, + { + "id": "context7", + "title": "Context7 MCP", + "message": "\uD83D\uDD0C Register the Context7 MCP server with the selected agents.
\uD83C\uDF10 You can get key on context7.com. Your key is sent to the MCP CLI and never stored by Studio.
⏭\uFE0F Click Skip to opt out.", + "inputs": [ + { + "id": "context7Agents", + "label": "\uD83E\uDD16 Agents", + "type": "options", + "multi": true, + "choices": [ + { + "value": "claude", + "label": "Claude Code", + "default": true + }, + { + "value": "codex", + "label": "Codex", + "default": true + }, + { + "value": "opencode", + "label": "OpenCode", + "default": true + }, + { + "value": "junie", + "label": "Junie", + "default": true + } + ] + }, + { + "id": "context7Key", + "label": "\uD83D\uDD11 API key", + "type": "userInput", + "regex": "^.{8,}$", + "errorMessage": "Context7 API key must be at least 8 characters.", + "placeholder": "ctx7_..." + } + ], + "command": { + "windows": "& ([scriptblock]::Create((iwr -useb https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.ps1).Content)) mcp-context7 -Agents \"${context7Agents}\" -Context7Key \"${context7Key}\"", + "macos": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- mcp-context7 --agents \"${context7Agents}\" --context7-key \"${context7Key}\"", + "linux": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- mcp-context7 --agents \"${context7Agents}\" --context7-key \"${context7Key}\"" + } + }, + { + "id": "playwright", + "title": "Playwright", + "message": "\uD83D\uDD0C Install Playwright testing skills for the selected agents.
📦 Requires npm to be installed on PATH.
⏭️ Click Skip to opt out.", + "inputs": [ + { + "id": "playwrightAgents", + "label": "\uD83E\uDD16 Agents", + "type": "options", + "multi": true, + "choices": [ + { + "value": "claude", + "label": "Claude Code", + "default": true + }, + { + "value": "codex", + "label": "Codex", + "default": true + }, + { + "value": "opencode", + "label": "OpenCode", + "default": true + }, + { + "value": "junie", + "label": "Junie", + "default": true + } + ] + } + ], + "command": { + "windows": "& ([scriptblock]::Create((iwr -useb https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.ps1).Content)) playwright -Agents \"${playwrightAgents}\"", + "macos": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- playwright --agents \"${playwrightAgents}\"", + "linux": "curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash -s -- playwright --agents \"${playwrightAgents}\"" + } + } + ] + } +} \ No newline at end of file diff --git a/.studio/studio-meta-data.schema.json b/.studio/studio-meta-data.schema.json new file mode 100644 index 0000000..eb65558 --- /dev/null +++ b/.studio/studio-meta-data.schema.json @@ -0,0 +1,235 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/.studio/studio-meta-data.schema.json", + "title": "Jmix Studio install-flow metadata", + "description": "Metadata consumed by Jmix Studio to drive the AI Agent Toolkit wizard. Defines a sequence of multi-input steps; each step optionally executes a shell command assembled from collected input values.", + "type": "object", + "additionalProperties": true, + "required": [ + "install-flow" + ], + "properties": { + "install-flow": { + "type": "object", + "description": "Top-level container for the wizard flow.", + "required": [ + "steps" + ], + "additionalProperties": false, + "properties": { + "steps": { + "type": "array", + "description": "Steps shown to the user in order. Steps with a 'runIf' that points to a falsy earlier input id are skipped entirely.", + "items": { + "$ref": "#/$defs/step" + } + } + } + } + }, + "$defs": { + "step": { + "type": "object", + "description": "A single step of the install flow. Steps may collect multiple inputs and optionally execute a shell command.", + "additionalProperties": false, + "required": [ + "title", + "message" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Optional step identifier used for diagnostics and per-step state restoration. Command substitution uses input ids, not step ids." + }, + "title": { + "type": "string", + "description": "Main step title shown on the top." + }, + "message": { + "type": "string", + "description": "Header text shown above the inputs." + }, + "runIf": { + "type": "string", + "description": "If set, the step is rendered only when the referenced earlier input id has a truthy value. Falsy values: checkbox=false, options-multi=empty, userInput=blank." + }, + "inputs": { + "type": "array", + "description": "Inputs collected from the user on this step. Each input has its own id used for ${id} command substitution.", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/inputUserInput" + }, + { + "$ref": "#/$defs/inputOptions" + }, + { + "$ref": "#/$defs/inputCheckbox" + } + ] + } + }, + "command": { + "$ref": "#/$defs/commandByOs", + "description": "Optional per-OS shell command. Supports ${id} placeholders that reference input ids (from this step or earlier) plus the implicit ${JMIX_VERSION} placeholder. The command is skipped when any input on this step has a falsy value." + } + } + }, + "inputUserInput": { + "type": "object", + "description": "Free-text input. Optionally validated against a regular expression.", + "additionalProperties": false, + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique input identifier used in command substitution via ${id}." + }, + "label": { + "type": "string", + "description": "Optional row prefix shown to the left of the text field." + }, + "type": { + "const": "userInput" + }, + "regex": { + "type": "string", + "description": "Java regular expression. The full value must match (Regex.matches semantics)." + }, + "errorMessage": { + "type": "string", + "description": "Inline error shown when the regex does not match. Falls back to a generic message." + }, + "placeholder": { + "type": "string", + "description": "Hint shown inside the text field when it is empty." + }, + "default": { + "type": "string", + "description": "Initial value pre-filled in the field." + } + } + }, + "inputOptions": { + "type": "object", + "description": "Choice between predefined values. Single-select renders as a combo box; multi-select renders as a check-box list.", + "additionalProperties": false, + "required": [ + "id", + "type", + "choices" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique input identifier used in command substitution via ${id}." + }, + "label": { + "type": "string", + "description": "Optional row prefix shown to the left of the widget." + }, + "type": { + "const": "options" + }, + "multi": { + "type": "boolean", + "default": false, + "description": "When true, multiple choices may be selected; the resolved value is a comma-joined list of chosen 'value' fields." + }, + "choices": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/optionsChoice" + } + } + } + }, + "optionsChoice": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "label" + ], + "properties": { + "value": { + "type": "string", + "description": "Value substituted into commands when this choice is selected." + }, + "label": { + "type": "string", + "description": "Human-readable label shown in the UI." + }, + "default": { + "type": "boolean", + "default": false, + "description": "When true, this choice is preselected. For single-select inputs the last 'default: true' wins; for multi-select all matching choices are preselected." + } + } + }, + "inputCheckbox": { + "type": "object", + "description": "Single boolean toggle. The 'label' field doubles as the text rendered next to the checkbox.", + "additionalProperties": false, + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique input identifier used in command substitution via ${id}." + }, + "label": { + "type": "string", + "description": "Text shown next to the checkbox." + }, + "type": { + "const": "checkbox" + }, + "default": { + "type": "boolean", + "default": false, + "description": "Initial checked state." + }, + "valueIfTrue": { + "type": "string", + "description": "Substitution string when the checkbox is checked. Defaults to 'true'." + }, + "valueIfFalse": { + "type": "string", + "description": "Substitution string when the checkbox is unchecked. Defaults to 'false'." + } + } + }, + "commandByOs": { + "type": "object", + "description": "Per-OS shell command. The host OS is detected via SystemInfo and the corresponding entry is executed.", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "windows": { + "type": "string", + "description": "Command executed via 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command'." + }, + "macos": { + "type": "string", + "description": "Command executed via '/bin/bash -c'." + }, + "linux": { + "type": "string", + "description": "Command executed via '/bin/bash -c'." + } + } + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index e8e6be4..4576f10 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,63 @@ The AI agent will use these resources to understand Jmix-specific patterns, mand - `SKILL.md`: Detailed instructions and rules for the agent regarding a specific Jmix feature. - Optional subdirectories with examples or other materials. -## How to Use +## Quick Install -To enable these guidelines for your AI agent, follow the steps below. +A single command launches an interactive wizard that walks through every setup step: +installing skills, adding guidelines registering the recommended MCP servers. -Take the files from the `v2/` directory if you are using Jmix 2. +**macOS / Linux:** + +```bash +curl -fsSL https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.sh | bash +``` + +**Windows (PowerShell 5+):** + +```powershell +iwr -useb https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.ps1 | iex +``` + +> In Jmix Studio plugin, the same wizard is available from the **Jmix AI Agents Toolkit** action. + +### Non-Interactive Subcommands + +Use these to run a single step without the wizard. Every subcommand takes the +same `--agents CSV` flag: + +```bash +install.sh skills --agents CSV [--scope global|local] [--version V] +install.sh agents-md --agents CSV [--version V] +install.sh mcp-jetbrains --agents CSV +install.sh mcp-context7 --agents CSV [--context7-key KEY] +install.sh playwright --agents CSV # requires npx (Node.js) on PATH +``` + +PowerShell mirrors the same shape: `install.ps1 skills -Agents claude,codex`, `install.ps1 mcp-context7 -Agents claude -Context7Key KEY`, `install.ps1 playwright -Agents claude,codex`, etc. + +**CSV** = comma-separated agent list (e.g. `claude,codex`) or a single value (e.g. `claude`). + +### Flags + +| Flag (bash) | Flag (PowerShell) | Default | Meaning | +|:--------------------------|:-----------------------|:--------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--version V` | `-Version V` | latest | Jmix version. Picks the best-matching `v*` folder. | +| `--ref REF` | `-Ref REF` | `main` | Git ref (branch or tag) of this repository to download. | +| `--agents CSV` | `-Agents CSV` | - | Comma-separated agents. Required by every subcommand. | +| `--scope global\|local` | `-Scope global\|local` | global | `skills` only. `global` installs the store under `~/.agents/.jmix/skills/v`; `local` installs the store at `/.skills`. Agent dirs are symlinked to the store. | +| `--context7-key K` | `-Context7Key K` | prompt | Context7 API key. Prompted interactively when omitted. | +| `--backup-existing-files` | `-BackupExistingFiles` | off | Rename overwritten files/dirs to `.bak-` instead of deleting them. Off by default. | +| `--verbose`, `--debug` | `-Verbose` | off | Print extra diagnostic output (OS, PATH, resolved paths, tool versions) for troubleshooting. | + +**Skills storages:** +- **Global:** store at `~/.agents/.jmix/skills/v/` (e.g. `v2`); each `jmix-*` folder symlinked into `~/.claude/skills` (Claude Code), `~/.agents/skills` (Codex, OpenCode), `~/.junie/skills` (Junie). +- **Local:** store at `/.skills/`; each `jmix-*` folder symlinked into `/.agents/skills`, `/.claude/skills`, `/.junie/skills`. + +> The automatic installer covers skills (installed globally or into the project), project guidelines, MCP server registration, and Playwright testing skills. The Playwright step runs `@playwright/cli` via `npx`, so `npx` (Node.js) must be available on PATH. + +## Manual Installation + +If you prefer not to run the script, follow these steps. Take the files from the `v2/` directory if you are using Jmix 2. ### 1. Project Guidelines @@ -169,16 +221,11 @@ Add to your `~/.config/opencode/opencode.json`: To enable Playwright support: -- Install Playwright CLI globally: - ```bash - npm i -g @playwright/cli@latest - ``` - - Install Playwright skills: ```bash - playwright-cli install --skills + npx -y @playwright/cli@latest install --skills ``` - The command above creates Playwrite skills in the `.claude/skills` directory. If you are using a different agent, copy or symlink them to the directory supported by your agent (see [Agent Skills](#2-agent-skills) section). + The command above creates Playwright skills in the `~/.claude/skills` directory. If you are using a different agent, copy or symlink them to the directory supported by your agent (see [Agent Skills](#2-agent-skills) section). Once set up, you can give the agent instructions like: diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..8270fd2 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,909 @@ +<# +.SYNOPSIS + Jmix AI Agents Toolkit installer. + +.DESCRIPTION + Default invocation (no subcommand) launches an interactive wizard that + guides through: + 1. Installing Jmix skills (globally or into the project) for one or all agents. + 2. Adding project-level guidelines (CLAUDE.md / AGENTS.md / .junie\guidelines.md). + 3. Registering the JetBrains MCP server with the agent. + 4. Registering the Context7 MCP server with the agent. + + Subcommands are available for non-interactive use: + install.ps1 skills -Agents CSV [-Scope global|local] [-Version V] [-Ref REF] + Installs skills into a canonical store once, then symlinks each + selected agent's skills dir to that store. + install.ps1 agents-md -Agents CSV [-Version V] [-Ref REF] + install.ps1 mcp-jetbrains -Agents CSV + install.ps1 mcp-context7 -Agents CSV [-Context7Key KEY] + install.ps1 playwright -Agents CSV # requires npx (Node.js) on PATH + + Add -BackupExistingFiles to any subcommand to rename overwritten files/dirs + to .bak- instead of deleting them. + +.PARAMETER Subcommand + Optional subcommand. When omitted, the interactive wizard is started. + +.PARAMETER Version + Jmix version (e.g. 2, 2.8, 2.8.0). Optional. Best-matching folder is picked: + exact -> major.minor -> major -> latest. + +.PARAMETER Ref + Git ref (branch or tag) to download. Default: main. + +.PARAMETER Agents + Comma-separated list of agents (e.g. "claude,codex"). Single value is also + accepted (e.g. "claude"). Required by every subcommand. Valid values: + claude, codex, opencode, junie. + +.PARAMETER Scope + Skills install scope: "global" (default) writes to the per-agent user-home + dir; "local" writes to the matching dir under the current project (e.g. + .\.claude\skills). Applies to the `skills` subcommand. + +.PARAMETER Context7Key + Context7 API key (mcp-context7). Prompted interactively when missing. + +.PARAMETER BackupExistingFiles + When set, an existing destination file or folder is renamed to + .bak- instead of being deleted before the new content is + copied. Off by default. + +.EXAMPLE + iwr -useb https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.ps1 | iex + +.EXAMPLE + & ([scriptblock]::Create((iwr -useb https://raw.githubusercontent.com/jmix-framework/jmix-agent-guidelines/main/install.ps1).Content)) skills -Agent claude +#> +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$Subcommand = '', + [string]$Version = '', + [string]$Ref = 'main', + [string]$Agents = '', + [string]$Scope = '', + [string]$Context7Key = '', + [switch]$BackupExistingFiles +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$script:RepoOwner = 'jmix-framework' +$script:RepoName = 'jmix-agent-guidelines' + +$script:AllAgents = @('claude', 'codex', 'opencode', 'junie') +$script:JetbrainsAgents = @('claude', 'codex', 'opencode', 'junie') +$script:Context7Agents = @('claude', 'codex', 'opencode', 'junie') + +$script:TarballReady = $false +$script:Staging = $null +$script:ExtractedDir = $null +$script:SourceSkillsDir = $null +$script:SourceAgentsMd = $null +$script:ResolvedVersionDir = $null +$script:Timestamp = (Get-Date).ToString('yyyyMMdd-HHmmss') + +# ================================================================= +# Helpers +# ================================================================= + +function Write-Info { + param([string]$Message) + Write-Output $Message +} + +# Emits environment + tool versions through Write-Verbose (shown only with -Verbose) +# to help diagnose user problems. +function Write-EnvDiagnostics { + Write-Verbose "os: $([System.Environment]::OSVersion.VersionString)" + Write-Verbose "pwd: $((Get-Location).Path)" + Write-Verbose "HOME: $HOME" + Write-Verbose "PSVersion: $($PSVersionTable.PSVersion)" + foreach ($tool in 'git', 'node', 'npx') { + $cmd = Get-Command $tool -ErrorAction SilentlyContinue + Write-Verbose "${tool}: $(if ($cmd) { $cmd.Source } else { 'not found' })" + } +} + +function Write-ErrAndExit { + param([string]$Message) + [Console]::Error.WriteLine("error: $Message") + exit 1 +} + +function Test-Tool { + param([string]$Tool) + if (-not (Get-Command $Tool -ErrorAction SilentlyContinue)) { + Write-ErrAndExit "$Tool not found. Install it and re-run." + } +} + +# Ensures npx (Node.js) is on PATH. When missing, prints install guidance and +# exits (no automatic runtime install). +function Assert-Npx { + if (Get-Command npx -ErrorAction SilentlyContinue) { return } + Write-Info 'npx (Node.js) is required for the Playwright step but was not found on PATH.' + Write-Info 'Install Node.js (includes npx), then re-run:' + Write-Info ' Windows: winget install OpenJS.NodeJS (or download from https://nodejs.org)' + Write-ErrAndExit 'npx not available on PATH' +} + +function Read-Prompt { + param( + [string]$Message, + [string]$Default = '' + ) + $hint = '' + if ($Default) { $hint = " [$Default]" } + $answer = Read-Host "$Message$hint" + if ([string]::IsNullOrEmpty($answer) -and $Default) { + return $Default + } + return $answer +} + +function Read-YesNo { + param( + [string]$Message, + [string]$Default = 'y' + ) + $hint = if ($Default -eq 'n') { '[y/N]' } else { '[Y/n]' } + $answer = Read-Prompt -Message "$Message $hint" -Default $Default + return ($answer -match '^(y|yes)$') +} + +function Get-AgentLabel { + param([string]$Agent) + switch ($Agent) { + 'claude' { 'Claude Code' } + 'codex' { 'Codex' } + 'opencode' { 'OpenCode' } + 'junie' { 'Junie' } + default { $Agent } + } +} + +function Write-Dest { + param( + [string]$Src, + [string]$Dest, + [string]$Label + ) + $existed = Test-Path $Dest + $backupInfo = '' + if ($existed) { + if ($BackupExistingFiles) { + $backupName = "$([System.IO.Path]::GetFileName($Dest)).bak-$($script:Timestamp)" + Rename-Item -Path $Dest -NewName $backupName -ErrorAction Stop + $backupInfo = " (backup: $backupName)" + } else { + Remove-Item -Path $Dest -Recurse -Force -ErrorAction Stop + } + } + Copy-Item -Path $Src -Destination $Dest -Recurse -Force -ErrorAction Stop + if ($existed) { + Write-Info " Updated: $Label$backupInfo" + } else { + Write-Info " Installed: $Label" + } +} + +function Resolve-AgentsCsv { + param( + [string]$Csv, + [string]$Subcommand + ) + if ([string]::IsNullOrWhiteSpace($Csv)) { + Write-ErrAndExit "${Subcommand}: -Agents is required (e.g. -Agents claude,codex)" + } + $known = @('claude', 'codex', 'opencode', 'junie') + $tokens = $Csv -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } + $resolved = @() + foreach ($t in $tokens) { + if ($known -notcontains $t) { + Write-ErrAndExit "unknown agent in -Agents: '$t'" + } + $resolved += $t + } + if ($resolved.Count -eq 0) { + Write-ErrAndExit "${Subcommand}: -Agents resolved to an empty list" + } + return $resolved +} + +# ================================================================= +# Tarball + version resolution +# ================================================================= + +function Get-VersionSortKey { + param([string]$Version) + $parts = $Version -split '[.-]' + $key = '' + for ($i = 0; $i -lt 5; $i++) { + $segment = if ($i -lt $parts.Length) { $parts[$i] } else { '0' } + $value = 0 + [void][int]::TryParse($segment, [ref]$value) + $key += $value.ToString('00000') + } + return $key +} + +function Find-LatestSkillsDir { + param([string]$ExtractedDir) + $bestKey = $null + $bestPath = $null + foreach ($dir in Get-ChildItem -Path $ExtractedDir -Directory) { + if (-not $dir.Name.StartsWith('v')) { continue } + $skillsPath = Join-Path $dir.FullName 'skills' + if (-not (Test-Path $skillsPath -PathType Container)) { continue } + $name = $dir.Name.Substring(1) + if ([string]::IsNullOrEmpty($name)) { continue } + $key = Get-VersionSortKey -Version $name + if ($null -eq $bestKey -or [string]::Compare($key, $bestKey) -gt 0) { + $bestKey = $key + $bestPath = $skillsPath + } + } + return $bestPath +} + +function Resolve-SkillsDir { + param( + [string]$ExtractedDir, + [string]$Requested + ) + + if ([string]::IsNullOrWhiteSpace($Requested)) { + $path = Find-LatestSkillsDir -ExtractedDir $ExtractedDir + if ($path) { + return [PSCustomObject]@{ Path = $path; Status = 'matched' } + } + return [PSCustomObject]@{ Path = $null; Status = 'none' } + } + + $exact = Join-Path $ExtractedDir "v$Requested/skills" + if (Test-Path $exact -PathType Container) { + return [PSCustomObject]@{ Path = $exact; Status = 'matched' } + } + + $parts = $Requested -split '[.-]' + if ($parts.Length -ge 2 -and $parts[0] -ne '' -and $parts[1] -ne '') { + $majorMinor = "$($parts[0]).$($parts[1])" + if ($majorMinor -ne $Requested) { + $candidate = Join-Path $ExtractedDir "v$majorMinor/skills" + if (Test-Path $candidate -PathType Container) { + return [PSCustomObject]@{ Path = $candidate; Status = 'matched' } + } + } + } + + if ($parts.Length -ge 1 -and $parts[0] -ne '') { + $major = $parts[0] + if ($major -ne $Requested) { + $candidate = Join-Path $ExtractedDir "v$major/skills" + if (Test-Path $candidate -PathType Container) { + return [PSCustomObject]@{ Path = $candidate; Status = 'matched' } + } + } + } + + $fallback = Find-LatestSkillsDir -ExtractedDir $ExtractedDir + if ($fallback) { + return [PSCustomObject]@{ Path = $fallback; Status = 'fallback' } + } + return [PSCustomObject]@{ Path = $null; Status = 'none' } +} + +function Initialize-Tarball { + if ($script:TarballReady) { return } + + if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) { + Write-ErrAndExit 'Expand-Archive not found. PowerShell 5+ is required.' + } + + $script:Staging = Join-Path ([System.IO.Path]::GetTempPath()) ("jmix-install-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:Staging -Force | Out-Null + + $archiveUrl = "https://codeload.github.com/$($script:RepoOwner)/$($script:RepoName)/zip/$Ref" + $zipPath = Join-Path $script:Staging 'source.zip' + Write-Verbose "staging: $($script:Staging)" + Write-Verbose "archiveUrl: $archiveUrl ; requested version: '$Version', ref: '$Ref'" + + Write-Info "Downloading $archiveUrl" + $downloaded = $false + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $archiveUrl -OutFile $zipPath -TimeoutSec 300 + $downloaded = $true + break + } catch { + $status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } + if ($attempt -lt 3) { + Write-Info "Download attempt $attempt failed (HTTP $status); retrying in 2s..." + Start-Sleep -Seconds 2 + } else { + Write-ErrAndExit "failed to download $archiveUrl after $attempt attempts (HTTP $status)" + } + } + } + if (-not $downloaded) { Write-ErrAndExit "failed to download $archiveUrl" } + + Expand-Archive -Path $zipPath -DestinationPath $script:Staging -Force + + $script:ExtractedDir = (Get-ChildItem -Path $script:Staging -Directory | + Where-Object { $_.Name -like "$($script:RepoName)-*" } | + Select-Object -First 1).FullName + + if (-not $script:ExtractedDir) { + Write-ErrAndExit "extracted source directory not found in $($script:Staging)" + } + + $resolved = Resolve-SkillsDir -ExtractedDir $script:ExtractedDir -Requested $Version + if ($resolved.Status -eq 'none' -or -not $resolved.Path) { + $available = (Get-ChildItem -Path $script:ExtractedDir -Directory | Select-Object -ExpandProperty Name) -join ' ' + Write-ErrAndExit "no v*/skills directory found in $Ref. Available top-level entries: $available" + } + + $script:SourceSkillsDir = $resolved.Path + $script:ResolvedVersionDir = Split-Path -Leaf (Split-Path -Parent $script:SourceSkillsDir) + $script:SourceAgentsMd = Join-Path (Split-Path -Parent $script:SourceSkillsDir) 'AGENTS.md' + Write-Verbose "extracted dir: $($script:ExtractedDir)" + Write-Verbose "resolved version dir: $($script:ResolvedVersionDir)" + Write-Verbose "source skills dir: $($script:SourceSkillsDir)" + + if ($resolved.Status -eq 'fallback') { + Write-Info "Version '$Version' did not match any folder, falling back to latest available ($($script:ResolvedVersionDir))" + } + Write-Info "Using guidelines from $($script:SourceSkillsDir.Substring($script:ExtractedDir.Length + 1))" + + $script:TarballReady = $true +} + +# ================================================================= +# skills install (global, per agent) +# ================================================================= + +function Resolve-Scope { + param([string]$Scope) + if ([string]::IsNullOrWhiteSpace($Scope)) { return 'global' } + switch ($Scope) { + 'global' { return 'global' } + 'local' { return 'local' } + default { Write-ErrAndExit "skills: -Scope must be 'global' or 'local' (got '$Scope')" } + } +} + +function Get-AgentSymlinkRel { + param([string]$Agent) + switch ($Agent) { + 'claude' { '.claude/skills' } + 'codex' { '.agents/skills' } + 'opencode' { '.agents/skills' } + 'junie' { '.junie/skills' } + default { throw "unknown agent '$Agent'" } + } +} + +# Creates/refreshes a whole-dir symlink $Link -> $Target. Replaces an existing +# symlink; an existing real dir is backed up (when -BackupExistingFiles) or removed. +# Requires symlink privileges; fails with guidance otherwise. +function New-DirSymlink { + param([string]$Link, [string]$Target) + if (Test-Path $Link) { + $item = Get-Item $Link -Force + if ($item.LinkType) { + Remove-Item $Link -Force + } elseif ($BackupExistingFiles) { + Rename-Item -Path $Link -NewName "$([System.IO.Path]::GetFileName($Link)).bak-$($script:Timestamp)" + } else { + Remove-Item $Link -Recurse -Force + } + } + $parent = Split-Path -Parent $Link + if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } + try { + New-Item -ItemType SymbolicLink -Path $Link -Target $Target -ErrorAction Stop | Out-Null + } catch { + Write-ErrAndExit "cannot create symlink $Link -> $Target. Enable Windows Developer Mode or run as Administrator to allow symlinks." + } +} + +function Install-SkillsToStore { + param([string]$StoreDir) + Write-Info '' + Write-Info "Installing skills into store $StoreDir" + if (-not (Test-Path $StoreDir)) { New-Item -ItemType Directory -Path $StoreDir -Force | Out-Null } + foreach ($skill in Get-ChildItem -Path $script:SourceSkillsDir -Directory) { + $dest = Join-Path $StoreDir $skill.Name + Write-Dest -Src $skill.FullName -Dest $dest -Label $skill.Name + } +} + +# Removes a path only when it is a dangling (broken) symlink, so directory creation +# does not fail when an agent base/dir (e.g. ~/.junie) points at a missing target. +# A symlink that resolves to an existing directory is left untouched. +function Clear-DanglingSymlink { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + if ($null -eq $item -or -not $item.LinkType) { return } + $target = @($item.Target) | Select-Object -First 1 + if ($target -and (Test-Path -LiteralPath $target)) { return } + if ($BackupExistingFiles) { + Rename-Item -LiteralPath $Path -NewName "$([System.IO.Path]::GetFileName($Path)).bak-$($script:Timestamp)" -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Force -Recurse -ErrorAction SilentlyContinue + } +} + +# Per-skill symlinks: link each store skill folder into the agent skills dir, +# so Jmix skills coexist with other skills already present there. +function New-SkillSymlinks { + param([string]$AgentDir, [string]$StoreDir) + # Clear a broken-symlink agent base/dir (e.g. ~/.junie -> missing) so creation works. + Clear-DanglingSymlink -Path (Split-Path -Parent $AgentDir) + Clear-DanglingSymlink -Path $AgentDir + if (-not (Test-Path $AgentDir)) { New-Item -ItemType Directory -Path $AgentDir -Force | Out-Null } + foreach ($skill in Get-ChildItem -Path $StoreDir -Directory) { + $link = Join-Path $AgentDir $skill.Name + New-DirSymlink -Link $link -Target $skill.FullName + } +} + +function Invoke-CmdSkills { + $agents = Resolve-AgentsCsv -Csv $Agents -Subcommand 'skills' + $resolvedScope = Resolve-Scope -Scope $Scope + Initialize-Tarball + + if ($resolvedScope -eq 'local') { + $root = (Get-Location).Path + $storeDir = Join-Path $root '.skills' + } else { + $root = $HOME + $storeDir = Join-Path $HOME (Join-Path '.agents/.jmix/skills' $script:ResolvedVersionDir) + } + + Write-Verbose "scope=$resolvedScope root=$root store=$storeDir" + Install-SkillsToStore -StoreDir $storeDir + + Write-Info '' + Write-Info 'Linking store skills into agent dirs' + $seen = @{} + foreach ($a in $agents) { + $rel = Get-AgentSymlinkRel -Agent $a + if ($seen.ContainsKey($rel)) { continue } + $seen[$rel] = $true + $agentDir = Join-Path $root $rel + New-SkillSymlinks -AgentDir $agentDir -StoreDir $storeDir + Write-Info " Linked skills into $agentDir" + } + + Write-Info '' + Write-Info "Done. Installed $resolvedScope skills store at $storeDir and linked: $($agents -join ', ')" +} + +# ================================================================= +# agents-md install (project-level) +# ================================================================= + +function Get-AgentsMdDest { + param([string]$Agent) + $proj = (Get-Location).Path + switch ($Agent) { + 'claude' { Join-Path $proj 'CLAUDE.md' } + 'codex' { Join-Path $proj 'AGENTS.md' } + 'opencode' { Join-Path $proj 'AGENTS.md' } + 'junie' { Join-Path $proj '.junie/guidelines.md' } + default { throw "unknown agent '$Agent'" } + } +} + +function Install-AgentsMdFor { + param([string]$Agent) + $dest = Get-AgentsMdDest -Agent $Agent + $label = Get-AgentLabel -Agent $Agent + + if (-not (Test-Path $script:SourceAgentsMd)) { + Write-ErrAndExit "AGENTS.md not found in $($script:ResolvedVersionDir)" + } + + $destDir = Split-Path -Parent $dest + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + } + + Write-Dest -Src $script:SourceAgentsMd -Dest $dest -Label $dest + Write-Info " Project guidelines installed for $label" +} + +function Invoke-CmdAgentsMd { + $agents = Resolve-AgentsCsv -Csv $Agents -Subcommand 'agents-md' + Write-Info "Project guidelines target directory: $((Get-Location).Path)" + Initialize-Tarball + foreach ($a in $agents) { + Install-AgentsMdFor -Agent $a + } +} + +# ================================================================= +# MCP install - JetBrains +# ================================================================= + +function Get-OpencodeConfigPath { + $dir = Join-Path $HOME '.config/opencode' + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $file = Join-Path $dir 'opencode.json' + if (-not (Test-Path $file)) { '{}' | Out-File -FilePath $file -Encoding utf8 } + return $file +} + +function Set-OpencodeMcpEntry { + param( + [string]$Name, + [hashtable]$Entry + ) + $file = Get-OpencodeConfigPath + $json = Get-Content -Raw -Path $file | ConvertFrom-Json -ErrorAction Stop + if (-not $json.PSObject.Properties.Match('mcp')) { + $json | Add-Member -MemberType NoteProperty -Name 'mcp' -Value (New-Object PSObject) + } + if ($json.mcp.PSObject.Properties.Match($Name)) { + $json.mcp.PSObject.Properties.Remove($Name) + } + $json.mcp | Add-Member -MemberType NoteProperty -Name $Name -Value ([PSCustomObject]$Entry) + $json | ConvertTo-Json -Depth 10 | Out-File -FilePath $file -Encoding utf8 + Write-Info "Updated $file with $Name MCP entry." +} + +function Install-JetbrainsForClaude { + Test-Tool -Tool 'claude' + Write-Info 'Adding JetBrains MCP for Claude Code...' + & claude mcp add --transport sse jetbrains --scope user http://localhost:64342/sse +} + +function Install-JetbrainsForCodex { + Test-Tool -Tool 'codex' + Write-Info 'Adding JetBrains MCP for Codex (Streamable HTTP; requires IntelliJ 2026.1+)...' + Write-Info 'For older IntelliJ versions, follow the STDIO setup in the README manually.' + & codex mcp add jetbrains --url http://localhost:64342/stream +} + +function Install-JetbrainsForOpencode { + Set-OpencodeMcpEntry -Name 'jetbrains' -Entry @{ + type = 'remote' + url = 'http://localhost:64342/sse' + enabled = $true + } +} + +function Install-JetbrainsForJunie { + Write-Info 'Junie runs inside IntelliJ and already has native IDE access. No JetBrains MCP needed.' +} + +function Install-JetbrainsFor { + param([string]$Agent) + Write-Info '' + Write-Info "[JetBrains MCP] $(Get-AgentLabel -Agent $Agent)" + switch ($Agent) { + 'claude' { Install-JetbrainsForClaude } + 'codex' { Install-JetbrainsForCodex } + 'opencode' { Install-JetbrainsForOpencode } + 'junie' { Install-JetbrainsForJunie } + default { throw "unknown agent '$Agent'" } + } +} + +function Invoke-CmdMcpJetbrains { + $agents = Resolve-AgentsCsv -Csv $Agents -Subcommand 'mcp-jetbrains' + foreach ($a in $agents) { + try { Install-JetbrainsFor -Agent $a } catch { Write-Info "error: $($_.Exception.Message)" } + } +} + +# ================================================================= +# MCP install - Context7 +# ================================================================= + +function Install-Context7ForClaude { + param([string]$Key) + Test-Tool -Tool 'claude' + Write-Info 'Adding Context7 MCP for Claude Code...' + & claude mcp add context7 --scope user -- npx -y '@upstash/context7-mcp' --api-key $Key +} + +function Install-Context7ForCodex { + param([string]$Key) + Test-Tool -Tool 'codex' + Write-Info 'Adding Context7 MCP for Codex...' + & codex mcp add context7 -- npx -y '@upstash/context7-mcp' --api-key $Key +} + +function Install-Context7ForOpencode { + param([string]$Key) + Set-OpencodeMcpEntry -Name 'context7' -Entry @{ + type = 'local' + command = @('npx', '-y', '@upstash/context7-mcp', '--api-key', $Key) + enabled = $true + } +} + +function Install-Context7ForJunie { + param([string]$Key) + Write-Info 'Junie does not support automated MCP setup.' + Write-Info 'Open IntelliJ Settings -> Tools -> Junie -> MCP Settings, click Add, then paste:' + Write-Output @" +{ + "mcpServers": { + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp", "--api-key", "$Key"] + } + } +} +"@ +} + +function Install-Context7For { + param( + [string]$Agent, + [string]$Key + ) + Write-Info '' + Write-Info "[Context7 MCP] $(Get-AgentLabel -Agent $Agent)" + switch ($Agent) { + 'claude' { Install-Context7ForClaude -Key $Key } + 'codex' { Install-Context7ForCodex -Key $Key } + 'opencode' { Install-Context7ForOpencode -Key $Key } + 'junie' { Install-Context7ForJunie -Key $Key } + default { throw "unknown agent '$Agent'" } + } +} + +function Invoke-CmdMcpContext7 { + $agents = Resolve-AgentsCsv -Csv $Agents -Subcommand 'mcp-context7' + + $apiKey = $Context7Key + if (-not $apiKey) { + $apiKey = Read-Prompt -Message 'Context7 API key' -Default '' + if (-not $apiKey) { Write-ErrAndExit 'Context7 API key is required' } + } + + foreach ($a in $agents) { + try { Install-Context7For -Agent $a -Key $apiKey } catch { Write-Info "error: $($_.Exception.Message)" } + } +} + +# ================================================================= +# Playwright install (npx @playwright/cli) +# ================================================================= + +function Install-PlaywrightForAgents { + param([string[]]$Agents) + + Assert-Npx + + # Playwright skills always install globally. Mirror the Jmix model: copy the + # skills into a canonical store, then per-skill symlink them into each agent + # skills dir so they coexist with other skills already present there. + $root = $HOME + $storeDir = Join-Path $HOME '.agents/.playwright/skills' + + # @playwright/cli install --skills writes to /.claude/skills/. + # Run it inside a private staging dir so nothing leaks into the project or a + # real agent dir, then copy the produced skill folders into the store. + $pwStaging = Join-Path ([System.IO.Path]::GetTempPath()) ("jmix-playwright-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $pwStaging -Force | Out-Null + try { + Write-Info 'Installing Playwright skills via npx (@playwright/cli)...' + Push-Location $pwStaging + try { + & npx -y '@playwright/cli@latest' install --skills + $playwrightExit = $LASTEXITCODE + } finally { + Pop-Location + } + if ($playwrightExit -ne 0) { + Write-ErrAndExit '@playwright/cli install --skills failed' + } + + $produced = Join-Path $pwStaging '.claude/skills' + if (-not (Test-Path $produced)) { + Write-ErrAndExit "@playwright/cli produced no skills under $produced" + } + + Write-Info '' + Write-Info "Installing Playwright skills into store $storeDir" + if (-not (Test-Path $storeDir)) { New-Item -ItemType Directory -Path $storeDir -Force | Out-Null } + $count = 0 + foreach ($skill in Get-ChildItem -Path $produced -Directory) { + $dest = Join-Path $storeDir $skill.Name + Write-Dest -Src $skill.FullName -Dest $dest -Label $skill.Name + $count++ + } + if ($count -eq 0) { + Write-ErrAndExit "no Playwright skill folders found under $produced" + } + + Write-Info '' + Write-Info 'Linking store skills into agent dirs' + $seen = @{} + foreach ($a in $Agents) { + $rel = Get-AgentSymlinkRel -Agent $a + if ($seen.ContainsKey($rel)) { continue } + $seen[$rel] = $true + $agentDir = Join-Path $root $rel + New-SkillSymlinks -AgentDir $agentDir -StoreDir $storeDir + Write-Info " Linked skills into $agentDir" + } + + Write-Info '' + Write-Info "Done. Installed Playwright skills store at $storeDir and linked: $($Agents -join ', ')" + } finally { + if (Test-Path $pwStaging) { Remove-Item $pwStaging -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +function Invoke-CmdPlaywright { + $agents = Resolve-AgentsCsv -Csv $Agents -Subcommand 'playwright' + Install-PlaywrightForAgents -Agents $agents +} + +# ================================================================= +# Wizard +# ================================================================= + +function Read-AgentChoice { + param( + [string]$Label, + [string[]]$Options, + [string]$Default = 'skip' + ) + Write-Info '' + Write-Info $Label + Write-Output ' a) For all agents' + $i = 1 + foreach ($opt in $Options) { + Write-Output (" {0}) {1}" -f $i, (Get-AgentLabel -Agent $opt)) + $i++ + } + Write-Output ' s) Skip' + + $answer = Read-Prompt -Message 'Choice' -Default $Default + if ($answer -match '^(s|skip)$') { return @('skip') } + if ($answer -match '^(a|all)$') { return $Options } + if ($answer -notmatch '^\d+$') { + Write-Info "Unrecognized choice '$answer'. Skipping." + return @('skip') + } + $num = [int]$answer + if ($num -ge 1 -and $num -le $Options.Length) { return @($Options[$num - 1]) } + Write-Info "Unrecognized choice '$answer'. Skipping." + return @('skip') +} + +function Invoke-Wizard { + Write-Info '=== Jmix AI Agents Toolkit ===' + if ($Version) { Write-Info "Jmix version: $Version" } + Write-Info "Working directory: $((Get-Location).Path)" + + $summaryStrings = @{ + skills = 'skipped' + guidelines = 'skipped' + jetbrains = 'skipped' + context7 = 'skipped' + playwright = 'skipped' + } + + # Step 1: skills + $sel = Read-AgentChoice -Label '[1/5] Install Jmix skills?' -Options $script:AllAgents -Default 'all' + if ($sel[0] -ne 'skip') { + $scopeAnswer = Read-Prompt -Message 'Install scope: (l)ocal project dir or (g)lobal user home' -Default 'l' + $resolvedScope = if ($scopeAnswer -match '^(g|global)$') { 'global' } else { 'local' } + Initialize-Tarball + try { + if ($resolvedScope -eq 'local') { + $wizRoot = (Get-Location).Path + $wizStoreDir = Join-Path $wizRoot '.skills' + } else { + $wizRoot = $HOME + $wizStoreDir = Join-Path $HOME (Join-Path '.agents/.jmix/skills' $script:ResolvedVersionDir) + } + Install-SkillsToStore -StoreDir $wizStoreDir + Write-Info '' + Write-Info 'Linking agent skill dirs to the store' + $wizSeen = @{} + foreach ($a in $sel) { + $rel = Get-AgentSymlinkRel -Agent $a + if ($wizSeen.ContainsKey($rel)) { continue } + $wizSeen[$rel] = $true + $agentDir = Join-Path $wizRoot $rel + New-SkillSymlinks -AgentDir $agentDir -StoreDir $wizStoreDir + Write-Info " Linked skills into $agentDir" + } + } catch { Write-Info "error: $($_.Exception.Message)" } + $summaryStrings.skills = "$($sel -join ', ') ($resolvedScope)" + } + + # Step 2: agents-md + $sel = Read-AgentChoice -Label '[2/5] Add Jmix coding guidelines to this directory?' -Options $script:AllAgents -Default 'all' + if ($sel[0] -ne 'skip') { + if (Read-YesNo -Message "Target directory: $((Get-Location).Path). Proceed?" -Default 'y') { + Initialize-Tarball + foreach ($a in $sel) { + try { Install-AgentsMdFor -Agent $a } catch { Write-Info "error: $($_.Exception.Message)" } + } + $summaryStrings.guidelines = $sel -join ', ' + } else { + $summaryStrings.guidelines = 'skipped (declined)' + } + } + + # Step 3: JetBrains MCP + $sel = Read-AgentChoice -Label '[3/5] Connect agent to IntelliJ IDEA via JetBrains MCP?' -Options $script:JetbrainsAgents + if ($sel[0] -ne 'skip') { + foreach ($a in $sel) { + try { Install-JetbrainsFor -Agent $a } catch { Write-Info "error: $($_.Exception.Message)" } + } + $summaryStrings.jetbrains = $sel -join ', ' + } + + # Step 4: Context7 MCP + $sel = Read-AgentChoice -Label '[4/5] Connect agent to library docs via Context7 MCP?' -Options $script:Context7Agents + if ($sel[0] -ne 'skip') { + $apiKey = Read-Prompt -Message 'Context7 API key' -Default '' + if ($apiKey) { + foreach ($a in $sel) { + try { Install-Context7For -Agent $a -Key $apiKey } catch { Write-Info "error: $($_.Exception.Message)" } + } + $summaryStrings.context7 = $sel -join ', ' + } else { + Write-Info 'API key not provided, skipping Context7 setup.' + $summaryStrings.context7 = 'skipped (no key)' + } + } + + # Step 5: Playwright + $sel = Read-AgentChoice -Label '[5/5] Install Playwright? (requires npx)' -Options $script:AllAgents + if ($sel[0] -ne 'skip') { + try { + Install-PlaywrightForAgents -Agents $sel + $summaryStrings.playwright = $sel -join ', ' + } catch { + Write-Info "error: $($_.Exception.Message)" + } + } + + Write-Info '' + Write-Info '=== Setup complete ===' + Write-Info " Skills: $($summaryStrings.skills)" + Write-Info " Guidelines: $($summaryStrings.guidelines)" + Write-Info " JetBrains: $($summaryStrings.jetbrains)" + Write-Info " Context7: $($summaryStrings.context7)" + Write-Info " Playwright: $($summaryStrings.playwright)" +} + +# ================================================================= +# Main dispatch +# ================================================================= + +try { + Write-EnvDiagnostics + + switch ($Subcommand) { + '' { Invoke-Wizard } + 'skills' { Invoke-CmdSkills } + 'agents-md' { Invoke-CmdAgentsMd } + 'mcp-jetbrains' { Invoke-CmdMcpJetbrains } + 'mcp-context7' { Invoke-CmdMcpContext7 } + 'playwright' { Invoke-CmdPlaywright } + default { Write-ErrAndExit "unknown subcommand: $Subcommand" } + } +} +finally { + if ($script:Staging -and (Test-Path $script:Staging)) { + Remove-Item -Recurse -Force -Path $script:Staging -ErrorAction SilentlyContinue + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..018acb6 --- /dev/null +++ b/install.sh @@ -0,0 +1,1061 @@ +#!/usr/bin/env bash +# Jmix AI Agents Toolkit installer. +# +# Default (no subcommand) launches an interactive wizard that guides through: +# 1. Installing Jmix skills (globally or into the project) for one or all agents. +# 2. Adding project-level guidelines (CLAUDE.md / AGENTS.md / .junie/guidelines.md). +# 3. Registering the JetBrains MCP server with the agent. +# 4. Registering the Context7 MCP server with the agent. +# +# Subcommands are available for non-interactive use; see `install.sh --help`. + +set -euo pipefail + +REPO_OWNER="jmix-framework" +REPO_NAME="jmix-agent-guidelines" + +# Global state populated by ensure_tarball() +STAGING="" +# Temp dir for the Playwright install; global so the EXIT trap can clean it +# after cmd_playwright() returns (function locals are out of scope by then). +PW_STAGING="" +EXTRACTED_DIR="" +SOURCE_SKILLS_DIR="" +SOURCE_AGENTS_MD="" +RESOLVED_VERSION_DIR="" +TARBALL_READY=0 + +VERSION="" +REF="main" +BACKUP_EXISTING=0 +VERBOSE=0 + +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" + +ALL_AGENTS="claude codex opencode junie" +JETBRAINS_AGENTS="claude codex opencode junie" +CONTEXT7_AGENTS="claude codex opencode junie" + +# ================================================================= +# Helpers +# ================================================================= + +log() { + printf '%s\n' "$*" +} + +err() { + printf 'error: %s\n' "$*" >&2 +} + +die() { + err "$*" + exit 1 +} + +# Prints a diagnostic line to stderr, only when --verbose/--debug is set. +vlog() { + [ "$VERBOSE" -eq 1 ] && printf '[debug] %s\n' "$*" >&2 + return 0 +} + +# Dumps environment + tool versions (verbose only) to help diagnose user issues. +debug_env() { + [ "$VERBOSE" -eq 1 ] || return 0 + vlog "os: $(uname -a 2>/dev/null)" + vlog "pwd: $(pwd -P 2>/dev/null)" + vlog "HOME: ${HOME:-}" + vlog "PATH: ${PATH:-}" + vlog "bash: ${BASH_VERSION:-?}" + vlog "curl: $(command -v curl 2>/dev/null || echo 'not found')" + vlog "tar: $(command -v tar 2>/dev/null || echo 'not found')" + vlog "git: $(git --version 2>/dev/null || echo 'not found')" + vlog "node: $(node --version 2>/dev/null || echo 'not found')" + vlog "npx: $(npx --version 2>/dev/null || echo 'not found')" +} + +require_tool() { + command -v "$1" >/dev/null 2>&1 || die "$1 not found. Install it and re-run." +} + +# Ensures npx (Node.js) is on PATH. When missing, prints per-OS install guidance +# and exits (no automatic runtime install). +require_npx() { + command -v npx >/dev/null 2>&1 && return 0 + err "npx (Node.js) is required for the Playwright step but was not found on PATH." + err "Install Node.js (includes npx), then re-run:" + case "$(uname -s 2>/dev/null)" in + Darwin) err " macOS: brew install node (or download from https://nodejs.org)" ;; + Linux) err " Linux: install via your package manager (e.g. 'sudo apt install nodejs npm') or download from https://nodejs.org" ;; + *) err " See https://nodejs.org/en/download" ;; + esac + exit 1 +} + +# Replaces or installs $dest with a copy of $src. When BACKUP_EXISTING=1, an +# existing $dest is moved aside to .bak-; otherwise it is +# deleted. Prints a per-item log line. +# $1 - src path (file or dir) +# $2 - dest path +# $3 - short label shown in the log line +write_dest() { + local src="$1" + local dest="$2" + local label="$3" + local existed=0 + [ -e "$dest" ] && existed=1 + local backup_info="" + if [ "$existed" -eq 1 ]; then + if [ "$BACKUP_EXISTING" -eq 1 ]; then + local backup="${dest}.bak-${TIMESTAMP}" + mv "$dest" "$backup" || die "cannot rename ${dest}" + backup_info=" (backup: $(basename "$backup"))" + else + rm -rf "$dest" || die "cannot remove ${dest}" + fi + fi + cp -R "$src" "$dest" || die "cannot copy to ${dest}" + if [ "$existed" -eq 1 ]; then + log " Updated: ${label}${backup_info}" + else + log " Installed: ${label}" + fi +} + +# Parses a comma-separated agents list. Single value (e.g. "claude") is allowed. +# Validates each token. Emits a space-separated list to stdout. +# $1 - csv string (may be empty) +# $2 - subcommand name for the error message +parse_agents_csv() { + local csv="$1" + local subcommand="$2" + if [ -z "$csv" ]; then + die "${subcommand}: --agents is required (e.g. --agents claude,codex)" + fi + local result="" + local token + for token in $(printf '%s' "$csv" | tr ',' ' ' | tr -s ' ' ' '); do + case "$token" in + claude|codex|opencode|junie) result="${result} ${token}" ;; + "") ;; + *) die "unknown agent in --agents: '$token'" ;; + esac + done + result="$(printf '%s' "$result" | sed 's/^ //;s/ $//')" + [ -n "$result" ] || die "${subcommand}: --agents resolved to an empty list" + printf '%s' "$result" +} + +# Reads a line from /dev/tty so prompts work under `curl ... | bash`. +# Falls back to the supplied default when no TTY is available. +prompt() { + local message="$1" + local default="${2:-}" + local hint="" + [ -n "$default" ] && hint=" [${default}]" + + # Subshell with stderr silenced so /dev/tty redirection errors stay quiet + # in headless environments. + local answer + answer="$( + exec 2>/dev/null + if printf '%s%s: ' "$message" "$hint" >/dev/tty; then + local ans="" + IFS= read -r ans major.minor -> major -> + latest. + --ref REF Git ref to download (default: main). + --agents CSV Comma-separated agent list. Accepts a single value + too (e.g. "claude" or "claude,codex"). Required by + every subcommand. Valid values: + claude, codex, opencode, junie. + --backup-existing-files Rename overwritten files/dirs to + .bak- instead of deleting them. + Off by default. + --verbose, --debug Print extra diagnostic output (OS, PATH, resolved + paths, tool versions) to help troubleshoot problems. + -h, --help Show this help. + +skills options: + --scope global|local Where to install skills. "global" (default) writes to + the per-agent user-home dir; "local" writes to the + matching dir under the current project (e.g. + ./.claude/skills). + +mcp-context7 options: + --context7-key K Context7 API key. Prompted interactively when missing. + +playwright options: + (uses common --agents flag; requires `npx` (Node.js) on PATH) +EOF +} + +# ================================================================= +# Tarball + version resolution +# ================================================================= + +version_sort_key() { + printf '%s' "$1" | awk -F'[.-]' '{ + for (i = 1; i <= 5; i++) { + v = (i <= NF) ? $i : 0 + if (v ~ /^[0-9]+$/) printf "%05d", v + else printf "%05d", 0 + } + print "" + }' +} + +find_latest_skills_dir() { + local extracted="$1" + local best_key="" + local best_path="" + for dir in "$extracted"/v*/; do + [ -d "${dir}skills" ] || continue + local name="${dir%/}" + name="${name##*/v}" + [ -n "$name" ] || continue + local key + key="$(version_sort_key "$name")" + if [ -z "$best_key" ] || [ "$key" \> "$best_key" ]; then + best_key="$key" + best_path="${dir}skills" + fi + done + [ -n "$best_path" ] || return 1 + printf '%s\n' "$best_path" +} + +# Resolves skills dir using tiered match (exact, major.minor, major) with +# latest-version fallback. Exit codes: +# 0 - matched (or no-version default) +# 2 - fallback used (requested didn't match any tier) +# 1 - no v*/skills dir found +resolve_skills_dir() { + local extracted="$1" + local requested="$2" + + if [ -z "$requested" ]; then + find_latest_skills_dir "$extracted" + return $? + fi + + if [ -d "${extracted}/v${requested}/skills" ]; then + printf '%s\n' "${extracted}/v${requested}/skills" + return 0 + fi + + local major_minor + major_minor="$(printf '%s' "$requested" | awk -F'[.-]' '{ if (NF >= 2 && $1 != "" && $2 != "") print $1"."$2 }')" + if [ -n "$major_minor" ] && [ "$major_minor" != "$requested" ] && [ -d "${extracted}/v${major_minor}/skills" ]; then + printf '%s\n' "${extracted}/v${major_minor}/skills" + return 0 + fi + + local major + major="$(printf '%s' "$requested" | awk -F'[.-]' '{print $1}')" + if [ -n "$major" ] && [ "$major" != "$requested" ] && [ -d "${extracted}/v${major}/skills" ]; then + printf '%s\n' "${extracted}/v${major}/skills" + return 0 + fi + + local fallback_path + fallback_path="$(find_latest_skills_dir "$extracted")" || return 1 + printf '%s\n' "$fallback_path" + return 2 +} + +# Downloads and extracts the tarball, resolves the version folder, and populates +# SOURCE_SKILLS_DIR / SOURCE_AGENTS_MD / RESOLVED_VERSION_DIR. Idempotent. +ensure_tarball() { + [ "$TARBALL_READY" -eq 1 ] && return 0 + + require_tool curl + require_tool tar + + STAGING="$(mktemp -d 2>/dev/null || mktemp -d -t jmix-install)" + trap 'rm -rf "$STAGING"' INT TERM EXIT + + local tarball_url="https://codeload.github.com/${REPO_OWNER}/${REPO_NAME}/tar.gz/${REF}" + local tarball_path="${STAGING}/source.tar.gz" + vlog "staging dir: ${STAGING}" + vlog "requested version: '${VERSION}', ref: '${REF}'" + + log "Downloading ${tarball_url}" + local http_status + http_status="$(curl -sSL --retry 3 --retry-delay 2 --connect-timeout 30 --max-time 300 -w '%{http_code}' -o "$tarball_path" "$tarball_url" || echo "000")" + if [ "$http_status" != "200" ]; then + die "failed to download ${tarball_url} (HTTP ${http_status})" + fi + + tar -xzf "$tarball_path" -C "$STAGING" + EXTRACTED_DIR="$(find "$STAGING" -maxdepth 1 -type d -name "${REPO_NAME}-*" | head -n 1)" + [ -n "$EXTRACTED_DIR" ] || die "extracted source directory not found in ${STAGING}" + + local resolve_status=0 + SOURCE_SKILLS_DIR="$(resolve_skills_dir "$EXTRACTED_DIR" "$VERSION")" || resolve_status=$? + if [ "$resolve_status" -eq 1 ] || [ -z "$SOURCE_SKILLS_DIR" ]; then + local available + available="$(find "$EXTRACTED_DIR" -maxdepth 1 -mindepth 1 -type d -exec basename {} \; | tr '\n' ' ')" + die "no v*/skills directory found in ${REF}. Available top-level entries: ${available}" + fi + + RESOLVED_VERSION_DIR="$(basename "$(dirname "$SOURCE_SKILLS_DIR")")" + SOURCE_AGENTS_MD="$(dirname "$SOURCE_SKILLS_DIR")/AGENTS.md" + vlog "extracted dir: ${EXTRACTED_DIR}" + vlog "resolved version dir: ${RESOLVED_VERSION_DIR}" + vlog "source skills dir: ${SOURCE_SKILLS_DIR}" + + if [ "$resolve_status" -eq 2 ]; then + log "Version '${VERSION}' did not match any folder, falling back to latest available (${RESOLVED_VERSION_DIR})" + fi + log "Using guidelines from ${SOURCE_SKILLS_DIR#"${EXTRACTED_DIR}"/}" + + TARBALL_READY=1 +} + +# ================================================================= +# skills install (global, per agent) +# ================================================================= + +# Validates the install scope. Emits the normalized value ("global"/"local"). +# $1 - raw scope string (may be empty -> defaults to global) +parse_scope() { + case "${1:-global}" in + global|local) printf '%s' "${1:-global}" ;; + *) die "skills: --scope must be 'global' or 'local' (got '$1')" ;; + esac +} + +# Relative skills dir each agent reads, used as a whole-dir symlink to the store. +# claude -> .claude/skills ; codex & opencode -> .agents/skills (open standard) ; +# junie -> .junie/skills. Rooted at $HOME (global) or the project dir (local). +agent_symlink_rel() { + case "$1" in + claude) printf '.claude/skills' ;; + codex|opencode) printf '.agents/skills' ;; + junie) printf '.junie/skills' ;; + *) die "unknown agent '$1'" ;; + esac +} + +# Removes a path only when it is a dangling (broken) symlink, so a later +# `mkdir -p` does not fail with ENOENT on macOS/BSD when a path component points +# at a missing target (e.g. a leftover ~/.junie symlink). A symlink that resolves +# to an existing directory is left untouched. +clear_dangling_symlink() { + local p="$1" + [ -L "$p" ] && [ ! -e "$p" ] || return 0 + if [ "$BACKUP_EXISTING" -eq 1 ]; then + mv "$p" "${p}.bak-${TIMESTAMP}" 2>/dev/null || rm -f "$p" + else + rm -f "$p" + fi +} + +# Creates (or refreshes) a whole-dir symlink $1 -> $2. Replaces an existing +# symlink; an existing real dir is backed up (when --backup-existing-files) or +# removed. Requires symlink support; fails otherwise. +create_symlink() { + local link="$1" + local target="$2" + if [ -L "$link" ]; then + rm -f "$link" || die "cannot replace symlink ${link}" + elif [ -e "$link" ]; then + if [ "$BACKUP_EXISTING" -eq 1 ]; then + mv "$link" "${link}.bak-${TIMESTAMP}" || die "cannot back up ${link}" + else + rm -rf "$link" || die "cannot remove ${link}" + fi + fi + mkdir -p "$(dirname "$link")" || die "cannot create parent of ${link}" + ln -s "$target" "$link" \ + || die "cannot create symlink ${link} -> ${target}. Your filesystem/OS may not permit symlinks." +} + +# Copies each source skill folder into the canonical store (overwrite or backup +# via write_dest). +install_skills_to_store() { + local store_dir="$1" + log "" + log "Installing skills into store ${store_dir}" + mkdir -p "$store_dir" || die "cannot create store ${store_dir}" + local skill name dest + for skill in "$SOURCE_SKILLS_DIR"/*/; do + [ -d "$skill" ] || continue + name="$(basename "$skill")" + dest="${store_dir}/${name}" + write_dest "$skill" "$dest" "$name" + done +} + +# Per-skill symlinks: link each store skill folder into the agent skills dir, +# so Jmix skills coexist with other skills already present there. +# $1 - agent skills dir (kept as a real dir) +# $2 - store dir holding the skill folders +link_skills_into_dir() { + local agent_dir="$1" + local store_dir="$2" + # Clear a broken-symlink agent base/dir (e.g. ~/.junie -> missing) so mkdir works. + clear_dangling_symlink "$(dirname "$agent_dir")" + clear_dangling_symlink "$agent_dir" + mkdir -p "$agent_dir" || die "cannot create ${agent_dir}" + local skill name + for skill in "$store_dir"/*/; do + [ -d "$skill" ] || continue + name="$(basename "$skill")" + create_symlink "${agent_dir}/${name}" "${store_dir}/${name}" + done +} + +agent_label() { + case "$1" in + claude) printf 'Claude Code' ;; + codex) printf 'Codex' ;; + opencode) printf 'OpenCode' ;; + junie) printf 'Junie' ;; + *) printf '%s' "$1" ;; + esac +} + +cmd_skills() { + local agents_csv="" + local scope="global" + + local _argc=-1 + while [ $# -gt 0 ]; do + [ "$#" -ne "$_argc" ] || die "argument parser made no progress near: $1" + _argc=$# + case "$1" in + --agents) + [ $# -ge 2 ] || die "--agents requires an argument" + agents_csv="$2"; shift 2 ;; + --scope) + [ $# -ge 2 ] || die "--scope requires an argument" + scope="$2"; shift 2 ;; + --backup-existing-files) + BACKUP_EXISTING=1; shift ;; + --version) + [ $# -ge 2 ] || die "--version requires an argument" + VERSION="$2"; shift 2 ;; + --ref) + [ $# -ge 2 ] || die "--ref requires an argument" + REF="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac + done + + local agents + agents="$(parse_agents_csv "$agents_csv" "skills")" + scope="$(parse_scope "$scope")" + + ensure_tarball + + local root store_dir + if [ "$scope" = "local" ]; then + root="$(pwd -P)" + store_dir="${root}/.skills" + else + root="${HOME}" + store_dir="${HOME}/.agents/.jmix/skills/${RESOLVED_VERSION_DIR}" + fi + + vlog "scope=${scope} root=${root} store=${store_dir}" + install_skills_to_store "$store_dir" + + log "" + log "Linking store skills into agent dirs" + local agent rel agent_dir seen=" " + for agent in $agents; do + rel="$(agent_symlink_rel "$agent")" + case "$seen" in + *" ${rel} "*) continue ;; + esac + seen="${seen}${rel} " + agent_dir="${root}/${rel}" + link_skills_into_dir "$agent_dir" "$store_dir" + log " Linked skills into ${agent_dir}" + done + + log "" + log "Done. Installed ${scope} skills store at ${store_dir} and linked: $(printf '%s' "$agents" | tr ' ' ',' | sed 's/,/, /g')" +} + +# ================================================================= +# agents-md install (project-level) +# ================================================================= + +agents_md_dest_for_agent() { + local agent="$1" + local pwd_ + pwd_="$(pwd -P)" + case "$agent" in + claude) printf '%s/CLAUDE.md' "$pwd_" ;; + codex) printf '%s/AGENTS.md' "$pwd_" ;; + opencode) printf '%s/AGENTS.md' "$pwd_" ;; + junie) printf '%s/.junie/guidelines.md' "$pwd_" ;; + *) die "unknown agent '$1'" ;; + esac +} + +install_agents_md_for() { + local agent="$1" + local dest + dest="$(agents_md_dest_for_agent "$agent")" + local label + label="$(agent_label "$agent")" + + [ -f "$SOURCE_AGENTS_MD" ] || die "AGENTS.md not found in ${RESOLVED_VERSION_DIR}" + + local dest_dir + dest_dir="$(dirname "$dest")" + mkdir -p "$dest_dir" || die "cannot create directory ${dest_dir}" + + write_dest "$SOURCE_AGENTS_MD" "$dest" "$dest" + log " Project guidelines installed for ${label}" +} + +cmd_agents_md() { + local agents_csv="" + + local _argc=-1 + while [ $# -gt 0 ]; do + [ "$#" -ne "$_argc" ] || die "argument parser made no progress near: $1" + _argc=$# + case "$1" in + --agents) + [ $# -ge 2 ] || die "--agents requires an argument" + agents_csv="$2"; shift 2 ;; + --backup-existing-files) + BACKUP_EXISTING=1; shift ;; + --version) + [ $# -ge 2 ] || die "--version requires an argument" + VERSION="$2"; shift 2 ;; + --ref) + [ $# -ge 2 ] || die "--ref requires an argument" + REF="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac + done + + local agents + agents="$(parse_agents_csv "$agents_csv" "agents-md")" + + log "Project guidelines target directory: $(pwd -P)" + ensure_tarball + + local agent + for agent in $agents; do + install_agents_md_for "$agent" + done +} + +# ================================================================= +# MCP install - JetBrains +# ================================================================= + +mcp_jetbrains_for_claude() { + require_tool claude + log "Adding JetBrains MCP for Claude Code..." + claude mcp add --transport sse jetbrains --scope user http://localhost:64342/sse +} + +mcp_jetbrains_for_codex() { + require_tool codex + log "Adding JetBrains MCP for Codex (Streamable HTTP; requires IntelliJ 2026.1+)..." + log "For older IntelliJ versions, follow the STDIO setup in the README manually." + codex mcp add jetbrains --url http://localhost:64342/stream +} + +mcp_jetbrains_for_opencode() { + local config_dir="${HOME}/.config/opencode" + local config_file="${config_dir}/opencode.json" + mkdir -p "$config_dir" + [ -f "$config_file" ] || echo '{}' > "$config_file" + + if ! command -v jq >/dev/null 2>&1; then + log "OpenCode requires jq to edit ${config_file}. Add this block manually:" + cat <<'EOF' + "mcp": { + "jetbrains": { + "type": "remote", + "url": "http://localhost:64342/sse", + "enabled": true + } + } +EOF + return 1 + fi + + local tmp + tmp="$(mktemp)" + jq '.mcp = (.mcp // {}) | .mcp.jetbrains = {"type":"remote","url":"http://localhost:64342/sse","enabled":true}' "$config_file" > "$tmp" + mv "$tmp" "$config_file" + log "Updated ${config_file} with JetBrains MCP entry." +} + +mcp_jetbrains_for_junie() { + log "Junie runs inside IntelliJ and already has native IDE access. No JetBrains MCP needed." +} + +install_jetbrains_for() { + local agent="$1" + log "" + log "[JetBrains MCP] $(agent_label "$agent")" + case "$agent" in + claude) mcp_jetbrains_for_claude ;; + codex) mcp_jetbrains_for_codex ;; + opencode) mcp_jetbrains_for_opencode ;; + junie) mcp_jetbrains_for_junie ;; + *) die "unknown agent '$1'" ;; + esac +} + +cmd_mcp_jetbrains() { + local agents_csv="" + + local _argc=-1 + while [ $# -gt 0 ]; do + [ "$#" -ne "$_argc" ] || die "argument parser made no progress near: $1" + _argc=$# + case "$1" in + --agents) + [ $# -ge 2 ] || die "--agents requires an argument" + agents_csv="$2"; shift 2 ;; + --backup-existing-files) + BACKUP_EXISTING=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac + done + + local agents + agents="$(parse_agents_csv "$agents_csv" "mcp-jetbrains")" + + local agent rc=0 + for agent in $agents; do + install_jetbrains_for "$agent" || rc=1 + done + return $rc +} + +# ================================================================= +# MCP install - Context7 +# ================================================================= + +mcp_context7_for_claude() { + local key="$1" + require_tool claude + log "Adding Context7 MCP for Claude Code..." + claude mcp add context7 --scope user -- npx -y @upstash/context7-mcp --api-key "$key" +} + +mcp_context7_for_codex() { + local key="$1" + require_tool codex + log "Adding Context7 MCP for Codex..." + codex mcp add context7 -- npx -y @upstash/context7-mcp --api-key "$key" +} + +mcp_context7_for_opencode() { + local key="$1" + local config_dir="${HOME}/.config/opencode" + local config_file="${config_dir}/opencode.json" + mkdir -p "$config_dir" + [ -f "$config_file" ] || echo '{}' > "$config_file" + + if ! command -v jq >/dev/null 2>&1; then + log "OpenCode requires jq to edit ${config_file}. Add this block manually:" + cat < "$tmp" + mv "$tmp" "$config_file" + log "Updated ${config_file} with Context7 MCP entry." +} + +mcp_context7_for_junie() { + local key="$1" + log "Junie does not support automated MCP setup." + log "Open IntelliJ Settings -> Tools -> Junie -> MCP Settings, click Add, then paste:" + cat </.claude/skills/. + # Run it inside a private staging dir so nothing leaks into the project or a + # real agent dir, then copy the produced skill folders into the store. + PW_STAGING="$(mktemp -d 2>/dev/null || mktemp -d -t jmix-playwright)" \ + || die "cannot create temp dir for Playwright install" + trap 'rm -rf ${PW_STAGING:+"$PW_STAGING"} ${STAGING:+"$STAGING"}' INT TERM EXIT + + log "Installing Playwright skills via npx (@playwright/cli)..." + ( cd "$PW_STAGING" && npx -y @playwright/cli@latest install --skills ) \ + || die "@playwright/cli install --skills failed" + + local produced="${PW_STAGING}/.claude/skills" + [ -d "$produced" ] || die "@playwright/cli produced no skills under ${produced}" + + log "" + log "Installing Playwright skills into store ${store_dir}" + mkdir -p "$store_dir" || die "cannot create store ${store_dir}" + local skill name dest count=0 + for skill in "$produced"/*/; do + [ -d "$skill" ] || continue + name="$(basename "$skill")" + dest="${store_dir}/${name}" + write_dest "$skill" "$dest" "$name" + count=$((count + 1)) + done + [ "$count" -gt 0 ] || die "no Playwright skill folders found under ${produced}" + + log "" + log "Linking store skills into agent dirs" + local agent rel agent_dir seen=" " + for agent in $agents; do + rel="$(agent_symlink_rel "$agent")" + case "$seen" in + *" ${rel} "*) continue ;; + esac + seen="${seen}${rel} " + agent_dir="${root}/${rel}" + link_skills_into_dir "$agent_dir" "$store_dir" + log " Linked skills into ${agent_dir}" + done + + log "" + log "Done. Installed Playwright skills store at ${store_dir} and linked: $(printf '%s' "$agents" | tr ' ' ',' | sed 's/,/, /g')" +} + +# ================================================================= +# Wizard +# ================================================================= + +wizard_pick_agent() { + local default_choice="$1" + shift + local prompt_label="$1" + shift + local options="$*" + + { + log "" + log "$prompt_label" + printf ' a) For all agents\n' + local i=1 + local opt + for opt in $options; do + printf ' %d) %s\n' "$i" "$(agent_label "$opt")" + i=$((i + 1)) + done + printf ' s) Skip\n' + } >&2 + + local answer + answer="$(prompt 'Choice' "$default_choice")" + case "$answer" in + s|S|skip|SKIP) printf 'skip'; return 0 ;; + a|A|all|ALL) printf '%s' "$options"; return 0 ;; + esac + if ! printf '%s' "$answer" | grep -Eq '^[0-9]+$'; then + log "Unrecognized choice '${answer}'. Skipping." >&2 + printf 'skip' + return 0 + fi + local idx=1 + for opt in $options; do + if [ "$idx" -eq "$answer" ]; then + printf '%s' "$opt" + return 0 + fi + idx=$((idx + 1)) + done + log "Unrecognized choice '${answer}'. Skipping." >&2 + printf 'skip' +} + +cmd_wizard() { + local _argc=-1 + while [ $# -gt 0 ]; do + [ "$#" -ne "$_argc" ] || die "argument parser made no progress near: $1" + _argc=$# + case "$1" in + --version) + [ $# -ge 2 ] || die "--version requires an argument" + VERSION="$2"; shift 2 ;; + --ref) + [ $# -ge 2 ] || die "--ref requires an argument" + REF="$2"; shift 2 ;; + --backup-existing-files) + BACKUP_EXISTING=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac + done + + log "=== Jmix AI Agents Toolkit ===" + [ -n "$VERSION" ] && log "Jmix version: ${VERSION}" + log "Working directory: $(pwd -P)" + + local summary_skills="skipped" + local summary_guidelines="skipped" + local summary_jetbrains="skipped" + local summary_context7="skipped" + local summary_playwright="skipped" + + # Step 1: skills + local sel + sel="$(wizard_pick_agent all '[1/5] Install Jmix skills?' "$ALL_AGENTS")" + if [ "$sel" != "skip" ]; then + local scope_answer scope="local" + scope_answer="$(prompt 'Install scope: (l)ocal project dir or (g)lobal user home' 'l')" + case "$scope_answer" in g|G|global|GLOBAL) scope="global" ;; esac + ensure_tarball + local root store_dir + if [ "$scope" = "local" ]; then + root="$(pwd -P)"; store_dir="${root}/.skills" + else + root="${HOME}"; store_dir="${HOME}/.agents/.jmix/skills/${RESOLVED_VERSION_DIR}" + fi + install_skills_to_store "$store_dir" || true + local agent rel agent_dir seen=" " + for agent in $sel; do + rel="$(agent_symlink_rel "$agent")" + case "$seen" in *" ${rel} "*) continue ;; esac + seen="${seen}${rel} " + agent_dir="${root}/${rel}" + link_skills_into_dir "$agent_dir" "$store_dir" || true + done + summary_skills="$sel (${scope})" + fi + + # Step 2: agents-md + sel="$(wizard_pick_agent all '[2/5] Add Jmix coding guidelines to this directory?' "$ALL_AGENTS")" + if [ "$sel" != "skip" ]; then + if prompt_yes_no "Target directory: $(pwd -P). Proceed?" "y"; then + ensure_tarball + local agent + for agent in $sel; do + install_agents_md_for "$agent" || true + done + summary_guidelines="$sel" + else + summary_guidelines="skipped (declined)" + fi + fi + + # Step 3: JetBrains MCP + sel="$(wizard_pick_agent skip '[3/5] Connect agent to IntelliJ IDEA via JetBrains MCP?' "$JETBRAINS_AGENTS")" + if [ "$sel" != "skip" ]; then + local agent + for agent in $sel; do + install_jetbrains_for "$agent" || true + done + summary_jetbrains="$sel" + fi + + # Step 4: Context7 MCP + sel="$(wizard_pick_agent skip '[4/5] Connect agent to library docs via Context7 MCP?' "$CONTEXT7_AGENTS")" + if [ "$sel" != "skip" ]; then + local key + key="$(prompt 'Context7 API key' '')" + if [ -n "$key" ]; then + local agent + for agent in $sel; do + install_context7_for "$agent" "$key" || true + done + summary_context7="$sel" + else + log "API key not provided, skipping Context7 setup." + summary_context7="skipped (no key)" + fi + fi + + # Step 5: Playwright + sel="$(wizard_pick_agent skip '[5/5] Install Playwright? (requires npx)' "$ALL_AGENTS")" + if [ "$sel" != "skip" ]; then + local pw_csv + pw_csv="$(printf '%s' "$sel" | tr ' ' ',' | sed 's/^,//;s/,$//')" + cmd_playwright --agents "$pw_csv" || true + summary_playwright="$sel" + fi + + log "" + log "=== Setup complete ===" + log " Skills: ${summary_skills}" + log " Guidelines: ${summary_guidelines}" + log " JetBrains: ${summary_jetbrains}" + log " Context7: ${summary_context7}" + log " Playwright: ${summary_playwright}" +} + +# ================================================================= +# Main dispatch +# ================================================================= + +# Pull global --verbose/--debug out of the args so every subcommand benefits. +_args=() +for _a in "$@"; do + case "$_a" in + --verbose|--debug) VERBOSE=1 ;; + *) _args+=("$_a") ;; + esac +done +set -- ${_args[@]+"${_args[@]}"} +debug_env + +if [ $# -eq 0 ]; then + cmd_wizard + exit $? +fi + +case "$1" in + skills) shift; cmd_skills "$@" ;; + agents-md) shift; cmd_agents_md "$@" ;; + mcp-jetbrains) shift; cmd_mcp_jetbrains "$@" ;; + mcp-context7) shift; cmd_mcp_context7 "$@" ;; + playwright) shift; cmd_playwright "$@" ;; + -h|--help) usage ;; + --*) cmd_wizard "$@" ;; + *) die "unknown subcommand: $1" ;; +esac