diff --git a/RPG-Kit/.gitignore b/RPG-Kit/.gitignore index 75fae69..3bcc672 100644 --- a/RPG-Kit/.gitignore +++ b/RPG-Kit/.gitignore @@ -7,7 +7,6 @@ __pycache__/ # --- RPG-Kit generated data & temp --- .rpgkit/data/ .rpgkit/tmp/ -.rpgkit/scripts/**/__pycache__/ # --- Logs --- *.log @@ -227,6 +226,9 @@ plans/ # RPG-Kit ignores (managed by `rpgkit init/update`) .rpgkit/ +# But DO commit the workspace AI config so collaborators get a sane default. +# Plan: plans/01-package-bundle-and-ai-config.md decision 15. +!.rpgkit/config.toml .vscode/mcp.json .vscode/tasks.json .mcp.json diff --git a/RPG-Kit/.markdownlint-cli2.jsonc b/RPG-Kit/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..27f3ac6 --- /dev/null +++ b/RPG-Kit/.markdownlint-cli2.jsonc @@ -0,0 +1,37 @@ +{ + // Mirror of the workspace-root `.markdownlint-cli2.jsonc` so running + // `markdownlint-cli2` from `RPG-Kit/` picks up the same rules. + // The slash-command templates under `templates/commands/` are prompt + // material consumed verbatim by Coding Agents — wrapping at 80 cols + // or forcing an H1 would damage their semantics, so the corresponding + // rules are disabled here. + // + // https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md + "config": { + "default": true, + "MD003": { "style": "atx" }, + "MD007": { "indent": 2 }, + "MD013": false, + "MD024": { "siblings_only": true }, + "MD033": false, + "MD041": false, + "MD049": { "style": "asterisk" }, + "MD050": { "style": "asterisk" }, + // MD060 cannot count double-width CJK / emoji characters correctly, + // so visually-aligned tables containing ✅ / ⌛ etc. trigger false + // positives. Disable. + "MD060": false + }, + "ignores": [ + ".genreleases/", + ".pytest_cache/", + "**/__pycache__/", + ".venv/", + "node_modules/", + // Internal design notes / WIP plans — not user-facing docs. + "plans/", + // The entire workspace/ tree is for e2e fixtures + backups; not + // part of rpgkit's published docs. + "workspace/" + ] +} diff --git a/RPG-Kit/README.hi-IN.md b/RPG-Kit/README.hi-IN.md index a78bf62..86b24fc 100644 --- a/RPG-Kit/README.hi-IN.md +++ b/RPG-Kit/README.hi-IN.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit वास्तविक उपयोग में -नीचे दी गई छवि इस रिपॉज़िटरी के लिए जनरेट किए गए ग्राफ़ विज़ुअलाइज़ेशन का एक भाग है। `/rpgkit.encode` चलाएँ और पूर्ण इंटरैक्टिव ग्राफ़ देखने के लिए `.rpgkit/data/rpg.html` खोलें। +नीचे दी गई छवि इस रिपॉज़िटरी के लिए जनरेट किए गए ग्राफ़ विज़ुअलाइज़ेशन का एक भाग है। `/rpgkit.encode` चलाने के बाद, पूर्ण इंटरैक्टिव ग्राफ़ देखने के लिए `/.rpgkit/reports/rpg.html` खोलें। वर्तमान वर्कस्पेस के हल किए गए पथ देखने के लिए `rpgkit version` चलाएँ। ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -103,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +`0.1.3` से, wheel pipeline scripts और slash-command templates को packaged assets के रूप में शामिल करता है, इसलिए `rpgkit init` ऑफ़लाइन वातावरणों (जैसे air-gapped या corporate proxy वातावरण) में भी काम करता है। + ## Quick Start: नई रिपॉज़िटरी जब आप RPG-Kit से आवश्यकताओं को एक नए कोडबेस में बदलवाना चाहते हैं, तब इस मार्ग का उपयोग करें। @@ -122,7 +124,6 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[वैकल्पिक]** अपने आवश्यकता दस्तावेज़ `my-project/docs/` में रखें। @@ -145,7 +146,13 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` -RPG-Kit क्रमिक रूप से `.rpgkit/data/rpg.json` बनाता है और इसका उपयोग आवश्यकताओं, प्लानिंग आउटपुट, जनरेटेड कोड और dependency जानकारी को संरेखित रखने के लिए करता है। +> [!IMPORTANT] +> **हर Coding Agent का इनवोकेशन थोड़ा अलग होता है**: +> +> - **Claude Code**: चैट में सीधे `/rpgkit.feature_spec ...` टाइप करें — slash command पहचाने जाते हैं और संबंधित workflow ट्रिगर हो जाता है। +> - **GitHub Copilot CLI**: slash command समर्थित नहीं हैं (कस्टम agent समर्थित हैं), इसलिए पहले `/agent rpgkit.feature_spec` से लक्ष्य agent पर स्विच करें, फिर `start` टाइप करके इसका अंतर्निहित workflow चलाएँ। + +RPG-Kit क्रमिक रूप से `~/.rpgkit/workspaces//data/rpg.json` बनाता है और इसका उपयोग आवश्यकताओं, प्लानिंग आउटपुट, जनरेटेड कोड और dependency जानकारी को संरेखित रखने के लिए करता है। आपके वर्कस्पेस की स्रोत फ़़ाइलें दूषित नहीं होंगी। ## Quick Start: मौजूदा रिपॉज़िटरी @@ -157,10 +164,8 @@ RPG-Kit क्रमिक रूप से `.rpgkit/data/rpg.json` बनात 1. रिपॉज़िटरी रूट में RPG-Kit को आरंभीकृत करें और प्रारंभिक ग्राफ़ बनाएँ: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode वर्तमान कोड से RPG उत्पन्न करता है ``` यदि आप गैर-खाली निर्देशिका के लिए पुष्टि संकेत को छोड़ना चाहते हैं: @@ -171,7 +176,7 @@ RPG-Kit क्रमिक रूप से `.rpgkit/data/rpg.json` बनात 2. रिपॉज़िटरी में अपना AI कोडिंग एजेंट लॉन्च करें। -3. MCP टूल्स और स्लैश कमांड्स के माध्यम से जनरेटेड RPG का उपयोग करें: +3. **[वैकल्पिक]** MCP टूल्स और स्लैश कमांड्स के माध्यम से जनरेटेड RPG का उपयोग करें। नीचे दिए गए कमांड केवल मैन्युअल रूप से चलाने पर आवश्यक हैं: ```text /rpgkit.encode # आवश्यकता पड़ने पर पूर्ण RPG को पुनर्निर्मित करें @@ -179,37 +184,53 @@ RPG-Kit क्रमिक रूप से `.rpgkit/data/rpg.json` बनात /rpgkit.rpg_edit # ग्राफ़-जागरूक कोड संपादन ``` -4. कमिट के बाद, RPG-Kit hooks `.rpgkit/data/rpg.json`, `.rpgkit/data/dep_graph.json` और `.rpgkit/data/rpg.html` को कोड परिवर्तनों के साथ संरेखित रखते हैं। यदि hook विफल हो जाता है या छोड़ दिया जाता है, तो `/rpgkit.update_rpg` चलाएँ। +4. हर commit के बाद, RPG-Kit द्वारा इंस्टॉल किया गया git hook स्वचालित रूप से `rpgkit hook ` dispatcher को कॉल करता है, RPG को अपडेट करता है और उसे कोड परिवर्तनों के साथ संरेखित रखता है। यदि hook विफल हो जाता है या छोड़ दिया जाता है, तो `/rpgkit.update_rpg` मैन्युअल रूप से चलाएँ। ## `rpgkit init` के बाद क्या होता है -`rpgkit init` आपकी स्रोत फ़ाइलों को संशोधित नहीं करता है। यह आपके कोड के साथ-साथ कमांड परिभाषाएँ, रनटाइम स्क्रिप्ट्स, MCP कॉन्फ़िगरेशन और जनरेटेड ग्राफ़ डेटा जोड़ता है। +`rpgkit init` आपकी स्रोत फ़़ाइलों को संशोधित नहीं करता है, **और आपके वर्कस्पेस में रनटाइम स्टेट नहीं लिखता है**। यह आपके वर्कस्पेस में केवल command definitions, MCP कॉन्फ़़िगरेशन और hooks जोड़ता है। RPG-Kit का रनटाइम डेटा (outputs और logs) home-side निर्देशिका `~/.rpgkit/workspaces//` के अंतर्गत रखा जाता है, जहाँ `` वर्कस्पेस के absolute path से जनित एक पठनीय slug है (उदाहरण: `home-hys-projects-myrepo`)। ```text my-project/ ├── docs/ # /rpgkit.feature_spec के लिए वैकल्पिक आवश्यकता दस्तावेज़ -├── .github/ or .claude/ # AI सहायक कमांड परिभाषाएँ और सेटिंग्स +├── .github/ or .claude/ # Coding Agent कमांड परिभाषाएँ और सेटिंग्स ├── .vscode/ # लागू होने पर Copilot/VS Code MCP कॉन्फ़िगरेशन -└── .rpgkit/ # RPG-Kit रनटाइम - ├── scripts/ # पाइपलाइन स्क्रिप्ट्स और सहायक पैकेज - ├── data/ # जनरेटेड आउटपुट, जिसमें rpg.json और dep_graph.json शामिल हैं - ├── logs/ # प्रति-चरण निष्पादन लॉग - └── reports/ # जनरेट होने पर समीक्षा और निदान रिपोर्ट +├── .rpgkit/ # जनरेटेड रिपोर्ट और कॉन्फ़िगरेशन फ़ाइलें +└── .git/hooks/ # rpgkit init द्वारा इंस्टॉल किए गए post-commit / post-merge (प्रत्येक hook केवल एक पंक्ति: `rpgkit hook `) ``` पूर्ण लेआउट और डेटा फ़ाइल संदर्भ के लिए [docs/project-structure.md](docs/project-structure.md) देखें। +## RPG-Kit अपडेट करें + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# किसी मौजूदा वर्कस्पेस को अपडेट करें +cd +rpgkit update +``` + ## समर्थित प्लेटफ़ॉर्म्स -| प्लेटफ़ॉर्म | Claude Code | GitHub Copilot | Codex | -| ------------------------ | ----------- | -------------- | ----- | -| CLI उपयोग | ✅ | ✅ (No MCP) | ⌛ | -| VS Code एक्सटेंशन उपयोग | ✅ | ✅ | ⌛ | +**Coding Agent समर्थन**: + +| Agent | CLI उपयोग | VS Code एक्सटेंशन उपयोग | +| -------------- | --------- | ----------------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**ऑपरेटिंग सिस्टम समर्थन**: -| स्क्रिप्ट | Linux | Windows | Mac | -| --------- | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| ऑपरेटिंग सिस्टम | स्थिति | +| ---------------- | ------ | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## दस्तावेज़ीकरण @@ -228,12 +249,6 @@ my-project/ **AI सहायक CLI नहीं मिला:** `rpgkit check` चलाएँ, चयनित सहायक CLI को इंस्टॉल और प्रमाणित करें, फिर `rpgkit init` या `rpgkit update` पुनः चलाएँ। -**MCP टूल्स `rpg_unavailable` की रिपोर्ट करते हैं:** `.rpgkit/data/rpg.json` बनाने के लिए `/rpgkit.encode` चलाएँ। - -**वृद्धिशील अपडेट विफल:** `.rpgkit/logs/update_rpg.log` की जाँच करें, फिर `/rpgkit.update_rpg` चलाएँ। - -**रेट लिमिट्स या निजी रिपॉज़िटरी एक्सेस के कारण टेम्पलेट डाउनलोड विफल:** `--github-token $GITHUB_TOKEN` पास करें या `GH_TOKEN` / `GITHUB_TOKEN` सेट करें। - ## लाइसेंस MIT License — विवरण के लिए [LICENSE](LICENSE) देखें। diff --git a/RPG-Kit/README.ja-JP.md b/RPG-Kit/README.ja-JP.md index f43136a..6da085b 100644 --- a/RPG-Kit/README.ja-JP.md +++ b/RPG-Kit/README.ja-JP.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit の実例 -下の図は、本リポジトリに対して生成されたグラフ可視化の一部です。`/rpgkit.encode` を実行し、`.rpgkit/data/rpg.html` を開くと完全なインタラクティブグラフを閲覧できます。 +下の図は、本リポジトリに対して生成されたグラフ可視化の一部です。`/rpgkit.encode` を実行した後、`/.rpgkit/reports/rpg.html` を開くと完全なインタラクティブグラフを閲覧できます。現在のワークスペースの解決済みパスを見るには `rpgkit version` を実行してください。 ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -103,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +`0.1.3` 以降、wheel には pipeline scripts と slash-command templates が packaged assets として同梱されるため、`rpgkit init` はオフライン環境(air-gapped 環境や企業プロキシ環境など)でも動作します。 + ## クイックスタート: 新規リポジトリ 要件から新しいコードベースを生成したい場合は、こちらの手順を使います。 @@ -122,7 +124,6 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[任意]** 要件ドキュメントを `my-project/docs/` に配置します。 @@ -145,7 +146,13 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` -RPG-Kit は `.rpgkit/data/rpg.json` を段階的に作成し、それを使って要件・計画成果物・生成コード・依存情報を整合した状態に保ちます。 +> [!IMPORTANT] +> **コーディングエージェントごとに呼び出し方が異なります**: +> +> - **Claude Code**:チャットにそのまま `/rpgkit.feature_spec ...` と入力します。slash command が認識され、対応する workflow がトリガーされます。 +> - **GitHub Copilot CLI**:slash command はサポートされません(カスタム agent はサポート)。まず `/agent rpgkit.feature_spec` で目的の agent に切り替え、その後 `start` と入力して内蔵の workflow を実行します。 + +RPG-Kit は `~/.rpgkit/workspaces//data/rpg.json` を段階的に作成し、それを使って要件・計画成果物・生成コード・依存情報を整合した状態に保ちます。ワークスペースのソースファイルは汚染されません。 ## クイックスタート: 既存リポジトリ @@ -157,10 +164,8 @@ RPG-Kit は `.rpgkit/data/rpg.json` を段階的に作成し、それを使っ 1. リポジトリのルートで RPG-Kit を初期化し、初期グラフを構築します: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode は現在のコードから RPG を生成します ``` 空でないディレクトリでの確認プロンプトをスキップしたい場合: @@ -171,7 +176,7 @@ RPG-Kit は `.rpgkit/data/rpg.json` を段階的に作成し、それを使っ 2. リポジトリで AI コーディングエージェントを起動します。 -3. 生成された RPG を MCP ツールおよびスラッシュコマンド経由で利用します: +3. **[任意]** 生成された RPG を MCP ツールおよびスラッシュコマンド経由で利用します。以下のコマンドは手動で実行する場合にのみ必要です: ```text /rpgkit.encode # 必要に応じて完全な RPG を再構築 @@ -179,37 +184,53 @@ RPG-Kit は `.rpgkit/data/rpg.json` を段階的に作成し、それを使っ /rpgkit.rpg_edit # グラフ認識型のコード編集 ``` -4. コミット後、RPG-Kit のフックが `.rpgkit/data/rpg.json`、`.rpgkit/data/dep_graph.json`、`.rpgkit/data/rpg.html` をコード変更に合わせて整合します。フックが失敗したりスキップされた場合は `/rpgkit.update_rpg` を実行してください。 +4. 各 commit の後、RPG-Kit がインストールした git hook が `rpgkit hook ` ディスパッチャを自動的に呼び出し、RPG を更新してコード変更と整合した状態に保ちます。hook が失敗したりスキップされたりした場合は、`/rpgkit.update_rpg` を手動で実行してください。 ## `rpgkit init` の後に起きること -`rpgkit init` はソースファイルを変更しません。コードのそばに、コマンド定義・ランタイムスクリプト・MCP 設定・生成されたグラフデータを追加します。 +`rpgkit init` はソースファイルを変更しません。また、**ワークスペースにランタイム状態を書き込みません**。ワークスペースには command 定義、MCP 設定、および hooks のみを追加します。RPG-Kit のランタイムデータ(成果物、ログ)は home-side ディレクトリ `~/.rpgkit/workspaces//` 下に配置されます。`` はワークスペースの絶対パスから導出される可読な slug です(例: `home-hys-projects-myrepo`)。 ```text my-project/ ├── docs/ # /rpgkit.feature_spec 用の任意の要件ドキュメント -├── .github/ or .claude/ # AI アシスタントのコマンド定義と設定 +├── .github/ or .claude/ # Coding Agent のコマンド定義と設定 ├── .vscode/ # 該当する場合の Copilot/VS Code MCP 設定 -└── .rpgkit/ # RPG-Kit ランタイム - ├── scripts/ # パイプラインスクリプトおよびサポートパッケージ - ├── data/ # 生成成果物(rpg.json と dep_graph.json を含む) - ├── logs/ # ステージごとの実行ログ - └── reports/ # 生成時のレビュー・診断レポート +├── .rpgkit/ # 生成されたレポートと設定ファイル +└── .git/hooks/ # rpgkit init が設置する post-commit / post-merge(各 hook は 1 行のみ: `rpgkit hook `) ``` 完全なレイアウトとデータファイルのリファレンスは [docs/project-structure.md](docs/project-structure.md) を参照してください。 +## RPG-Kit の更新 + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# 既存のワークスペースを更新 +cd +rpgkit update +``` + ## 対応プラットフォーム -| プラットフォーム | Claude Code | GitHub Copilot | Codex | -| --------------------- | ----------- | -------------- | ----- | -| CLI 使用 | ✅ | ✅ (No MCP) | ⌛ | -| VS Code 拡張使用 | ✅ | ✅ | ⌛ | +**Coding Agent サポート**: + +| Agent | CLI 使用 | VS Code 拡張使用 | +| -------------- | -------- | ---------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**オペレーティングシステムサポート**: -| スクリプト | Linux | Windows | Mac | -| ---------- | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| OS | 状態 | +| ------- | ---- | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## ドキュメント @@ -228,12 +249,6 @@ my-project/ **AI アシスタント CLI が見つからない:** `rpgkit check` を実行し、選択したアシスタント CLI をインストールおよび認証し、`rpgkit init` または `rpgkit update` を再実行してください。 -**MCP ツールが `rpg_unavailable` を報告する:** `/rpgkit.encode` を実行して `.rpgkit/data/rpg.json` を作成してください。 - -**増分更新が失敗する:** `.rpgkit/logs/update_rpg.log` を確認し、`/rpgkit.update_rpg` を実行してください。 - -**レート制限またはプライベートリポジトリのアクセス権でテンプレートのダウンロードに失敗する:** `--github-token $GITHUB_TOKEN` を渡すか、`GH_TOKEN` / `GITHUB_TOKEN` を設定してください。 - ## ライセンス MIT License — 詳細は [LICENSE](LICENSE) を参照してください。 diff --git a/RPG-Kit/README.ko-KR.md b/RPG-Kit/README.ko-KR.md index d399691..c770511 100644 --- a/RPG-Kit/README.ko-KR.md +++ b/RPG-Kit/README.ko-KR.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit 실제 사용 예 -아래 이미지는 이 저장소에서 생성된 그래프 시각화의 일부입니다. `/rpgkit.encode` 를 실행하고 `.rpgkit/data/rpg.html` 을 열면 전체 인터랙티브 그래프를 탐색할 수 있습니다. +아래 이미지는 이 저장소에서 생성된 그래프 시각화의 일부입니다. `/rpgkit.encode` 를 실행한 후 `/.rpgkit/reports/rpg.html` 을 열면 전체 인터랙티브 그래프를 탐색할 수 있습니다. 현재 워크스페이스의 해결된 경로를 보려면 `rpgkit version` 을 실행하세요. ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -103,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +`0.1.3` 부터 wheel은 pipeline scripts와 slash-command templates를 packaged assets로 함께 제공하므로, `rpgkit init` 은 오프라인 환경(air-gapped 환경, 회사 프록시 환경 등)에서도 동작합니다. + ## Quick Start: 새 저장소 요구사항을 새 코드베이스로 만들고 싶을 때 이 경로를 사용하세요. @@ -122,7 +124,6 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[선택]** 요구사항 문서를 `my-project/docs/` 에 둡니다. @@ -145,7 +146,13 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` -RPG-Kit은 `.rpgkit/data/rpg.json` 을 점진적으로 생성하고, 이를 사용해 요구사항, 계획 산출물, 생성된 코드, 의존성 정보를 정합 상태로 유지합니다. +> [!IMPORTANT] +> **Coding Agent마다 호출 방식이 조금씩 다릅니다**: +> +> - **Claude Code**: 채팅에 직접 `/rpgkit.feature_spec ...` 을 입력하면 slash command가 인식되어 해당 workflow가 트리거됩니다. +> - **GitHub Copilot CLI**: slash command는 지원하지 않으나(커스텀 agent는 지원), 먼저 `/agent rpgkit.feature_spec` 으로 대상 agent로 전환한 다음 `start` 를 입력해 내장된 workflow를 실행합니다. + +RPG-Kit은 `~/.rpgkit/workspaces//data/rpg.json` 을 점진적으로 생성하고, 이를 사용해 요구사항, 계획 산출물, 생성된 코드, 의존성 정보를 정합 상태로 유지합니다. 워크스페이스의 소스 파일은 오염되지 않습니다. ## Quick Start: 기존 저장소 @@ -157,10 +164,8 @@ RPG-Kit은 `.rpgkit/data/rpg.json` 을 점진적으로 생성하고, 이를 사 1. 저장소 루트에서 RPG-Kit을 초기화하고 초기 그래프를 생성합니다: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode 는 현재 코드로부터 RPG를 생성합니다 ``` 비어 있지 않은 디렉터리에 대한 확인 프롬프트를 건너뛰려면: @@ -171,7 +176,7 @@ RPG-Kit은 `.rpgkit/data/rpg.json` 을 점진적으로 생성하고, 이를 사 2. 저장소에서 AI 코딩 에이전트를 실행합니다. -3. MCP 도구와 슬래시 커맨드를 통해 생성된 RPG를 사용합니다: +3. **[선택]** MCP 도구와 슬래시 커맨드를 통해 생성된 RPG를 사용합니다. 아래 명령은 수동으로 실행할 때만 필요합니다: ```text /rpgkit.encode # 필요할 때 전체 RPG 재구축 @@ -179,37 +184,53 @@ RPG-Kit은 `.rpgkit/data/rpg.json` 을 점진적으로 생성하고, 이를 사 /rpgkit.rpg_edit # 그래프 인식 코드 편집 ``` -4. 커밋 후, RPG-Kit 훅이 `.rpgkit/data/rpg.json`, `.rpgkit/data/dep_graph.json`, `.rpgkit/data/rpg.html` 을 코드 변경에 맞춰 동기화합니다. 훅이 실패하거나 건너뛰어진 경우 `/rpgkit.update_rpg` 를 실행하세요. +4. 각 commit 후, RPG-Kit이 설치한 git hook이 `rpgkit hook ` 디스패처를 자동으로 호출해 RPG를 업데이트하고 코드 변경과 정합된 상태로 유지합니다. hook이 실패하거나 건너뛰어진 경우 `/rpgkit.update_rpg` 를 수동으로 실행하세요. ## `rpgkit init` 이후 일어나는 일 -`rpgkit init` 은 소스 파일을 수정하지 않습니다. 코드 옆에 커맨드 정의, 런타임 스크립트, MCP 구성, 생성된 그래프 데이터를 추가합니다. +`rpgkit init` 은 소스 파일을 수정하지 않습니다. 또한 **워크스페이스에 런타임 상태를 기록하지도 않습니다**. 워크스페이스에는 command 정의, MCP 구성, hooks만 추가합니다. RPG-Kit의 런타임 데이터(산출물, 로그)는 home-side 디렉터리 `~/.rpgkit/workspaces//` 아래에 배치되며, `` 는 워크스페이스의 절대 경로에서 파생된 가독성 있는 slug입니다 (예: `home-hys-projects-myrepo`). ```text my-project/ ├── docs/ # /rpgkit.feature_spec 용 선택적 요구사항 문서 -├── .github/ or .claude/ # AI 어시스턴트 커맨드 정의 및 설정 +├── .github/ or .claude/ # Coding Agent 커맨드 정의 및 설정 ├── .vscode/ # 해당하는 경우 Copilot/VS Code MCP 구성 -└── .rpgkit/ # RPG-Kit 런타임 - ├── scripts/ # 파이프라인 스크립트와 지원 패키지 - ├── data/ # 생성된 산출물 (rpg.json과 dep_graph.json 포함) - ├── logs/ # 단계별 실행 로그 - └── reports/ # 생성 시의 리뷰 및 진단 리포트 +├── .rpgkit/ # 생성된 리포트와 설정 파일 +└── .git/hooks/ # rpgkit init 이 설치하는 post-commit / post-merge (각 hook은 단 한 줄: `rpgkit hook `) ``` 전체 레이아웃과 데이터 파일 참조는 [docs/project-structure.md](docs/project-structure.md) 를 참조하세요. +## RPG-Kit 업데이트 + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# 기존 워크스페이스 업데이트 +cd +rpgkit update +``` + ## 지원 플랫폼 -| 플랫폼 | Claude Code | GitHub Copilot | Codex | -| ------------------- | ----------- | -------------- | ----- | -| CLI 사용 | ✅ | ✅ (No MCP) | ⌛ | -| VS Code 확장 사용 | ✅ | ✅ | ⌛ | +**Coding Agent 지원**: + +| Agent | CLI 사용 | VS Code 확장 사용 | +| -------------- | -------- | ----------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**운영 체제 지원**: -| 스크립트 | Linux | Windows | Mac | -| -------- | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| 운영 체제 | 상태 | +| --------- | ---- | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## 문서 @@ -228,12 +249,6 @@ my-project/ **AI 어시스턴트 CLI를 찾을 수 없음:** `rpgkit check` 를 실행하고, 선택한 어시스턴트 CLI를 설치 및 인증한 다음 `rpgkit init` 또는 `rpgkit update` 를 다시 실행하세요. -**MCP 도구가 `rpg_unavailable` 을 보고함:** `/rpgkit.encode` 를 실행해 `.rpgkit/data/rpg.json` 을 생성하세요. - -**증분 업데이트 실패:** `.rpgkit/logs/update_rpg.log` 를 확인한 다음 `/rpgkit.update_rpg` 를 실행하세요. - -**속도 제한 또는 비공개 저장소 접근 권한으로 인해 템플릿 다운로드 실패:** `--github-token $GITHUB_TOKEN` 을 전달하거나 `GH_TOKEN` / `GITHUB_TOKEN` 을 설정하세요. - ## 라이선스 MIT License — 자세한 내용은 [LICENSE](LICENSE) 참조. diff --git a/RPG-Kit/README.md b/RPG-Kit/README.md index f44847c..ea45078 100644 --- a/RPG-Kit/README.md +++ b/RPG-Kit/README.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit in action -Below is part of the graph visualization generated for this repository. Run `/rpgkit.encode` and open `.rpgkit/data/rpg.html` to explore the full interactive graph. +Below is part of the graph visualization generated for this repository. After running `/rpgkit.encode`, you can open `/.rpgkit/reports/rpg.html` to browse the full interactive graph. Run `rpgkit version` to see the resolved paths for the current workspace. ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -103,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +Since `0.1.3`, the wheel ships the pipeline scripts and slash-command templates as packaged assets, so `rpgkit init` works offline (for example in air-gapped or corporate proxy environments). + ## Quick Start: New Repository Use this path when you want RPG-Kit to turn requirements into a new codebase. @@ -122,7 +124,6 @@ Use this path when you want RPG-Kit to turn requirements into a new codebase. ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[Optional]** place your requirement documents in `my-project/docs/`. @@ -145,7 +146,13 @@ Use this path when you want RPG-Kit to turn requirements into a new codebase. [Optional] /rpgkit.rpg_edit ``` -RPG-Kit progressively creates `.rpgkit/data/rpg.json` and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. +> [!IMPORTANT] +> **Coding Agents are invoked slightly differently**: +> +> - **Claude Code**: type `/rpgkit.feature_spec ...` directly in the chat — slash commands are recognised and dispatch the matching workflow. +> - **GitHub Copilot CLI**: slash commands are not supported (custom agents are), so first run `/agent rpgkit.feature_spec` to switch to the target agent, then type `start` to run its built-in workflow. + +RPG-Kit progressively builds `rpg.json` in the home-side runtime directory (`~/.rpgkit/workspaces//data/rpg.json`) and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. Your workspace source files are not polluted. ## Quick Start: Existing Repository @@ -157,10 +164,8 @@ Use this path when you already have a repository and want an AI agent to underst 1. Initialize RPG-Kit in the repository root and build the initial graph: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode builds the RPG from the current code ``` If you want to skip the confirmation prompt for a non-empty directory: @@ -171,7 +176,7 @@ Use this path when you already have a repository and want an AI agent to underst 2. Launch your AI coding agent in the repository. -3. Use the generated RPG through MCP tools and slash commands: +3. **[Optional]** Use the generated RPG through MCP tools and slash commands. The following commands are only needed when run manually: ```text /rpgkit.encode # rebuild the full RPG when needed @@ -179,37 +184,53 @@ Use this path when you already have a repository and want an AI agent to underst /rpgkit.rpg_edit # graph-aware code edit ``` -4. After commits, RPG-Kit hooks keep `.rpgkit/data/rpg.json`, `.rpgkit/data/dep_graph.json`, and `.rpgkit/data/rpg.html` aligned with code changes. If the hook fails or is skipped, run `/rpgkit.update_rpg`. +4. After each commit, the git hook installed by RPG-Kit automatically calls the `rpgkit hook ` dispatcher to update the RPG and keep it aligned with code changes. If the hook fails or is skipped, run `/rpgkit.update_rpg` manually. ## What happens after `rpgkit init` -`rpgkit init` does not modify your source files. It adds command definitions, runtime scripts, MCP configuration, and generated graph data alongside your code. +`rpgkit init` does not modify your source files, **and it does not write runtime state into your workspace**. It only adds command definitions, MCP configuration, and hooks to your workspace. RPG-Kit runtime data (artifacts and logs) lives under the home-side directory `~/.rpgkit/workspaces//`, where `` is a slug derived from the workspace's absolute path (e.g. `home-hys-projects-myrepo`). ```text my-project/ ├── docs/ # Optional requirement docs for /rpgkit.feature_spec -├── .github/ or .claude/ # AI assistant command definitions and settings +├── .github/ or .claude/ # Coding Agent command definitions and settings ├── .vscode/ # Copilot/VS Code MCP configuration when applicable -└── .rpgkit/ # RPG-Kit runtime - ├── scripts/ # Pipeline scripts and support packages - ├── data/ # Generated artifacts, including rpg.json and dep_graph.json - ├── logs/ # Per-stage execution logs - └── reports/ # Review and diagnostic reports when generated +├── .rpgkit/ # Generated reports and configuration files +└── .git/hooks/ # post-commit / post-merge installed by rpgkit init (each hook is one line: `rpgkit hook `) ``` See [docs/project-structure.md](docs/project-structure.md) for the full layout and data file reference. +## Updating RPG-Kit + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# Update an existing workspace +cd +rpgkit update +``` + ## Supported Platforms -| Platform | Claude Code | GitHub Copilot | Codex | -| ----------------------- | ----------- | -------------- | ----- | -| CLI usage | ✅ | ✅ (No MCP) | ⌛ | -| VS Code extension usage | ✅ | ✅ | ⌛ | +**Coding Agent support**: + +| Agent | CLI usage | VS Code extension usage | +| -------------- | --------- | ----------------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**Operating system support**: -| Script | Linux | Windows | Mac | -| ------ | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| Operating system | Status | +| ---------------- | ------ | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## Documentation @@ -228,12 +249,6 @@ See [docs/project-structure.md](docs/project-structure.md) for the full layout a **AI assistant CLI not found:** run `rpgkit check`, install and authenticate the selected assistant CLI, then rerun `rpgkit init` or `rpgkit update`. -**MCP tools report `rpg_unavailable`:** run `/rpgkit.encode` to create `.rpgkit/data/rpg.json`. - -**Incremental update failed:** inspect `.rpgkit/logs/update_rpg.log`, then run `/rpgkit.update_rpg`. - -**Template download fails due to rate limits or private repo access:** pass `--github-token $GITHUB_TOKEN` or set `GH_TOKEN` / `GITHUB_TOKEN`. - ## License MIT License - See [LICENSE](LICENSE) for details. diff --git a/RPG-Kit/README.zh-CN.md b/RPG-Kit/README.zh-CN.md index f46a9c3..90ffcdb 100644 --- a/RPG-Kit/README.zh-CN.md +++ b/RPG-Kit/README.zh-CN.md @@ -22,11 +22,11 @@ RPG-Kit 为 Claude Code 和 GitHub Copilot 提供一个面向仓库级编码的* ### 选择你的工作流 -| 目标 | 工作流 | 从这里开始 | -|---|---|---| -| 从需求构建一个新仓库 | Build 工作流(requirements → RPG → code) | [`快速开始:新仓库`](#快速开始新仓库) | -| 理解一个已有仓库 | Understand 工作流(repository → RPG → search/explore) | [`快速开始:已有仓库`](#快速开始已有仓库) | -| 更新一个已有仓库 | Update 工作流(change request → affected RPG nodes → edit plan → code/RPG update) | [`快速开始:已有仓库`](#快速开始已有仓库) | +| 目标 | 工作流 | 从这里开始 | +| -------------------- | ------------------------------------------------------------ | ----------------------------------------- | +| 从需求构建一个新仓库 | Build 工作流(requirements → RPG → code) | [`快速开始:新仓库`](#快速开始新仓库) | +| 理解一个已有仓库 | Understand 工作流(repository → RPG → search/explore) | [`快速开始:已有仓库`](#快速开始已有仓库) | +| 更新一个已有仓库 | Update 工作流(change request → affected RPG nodes → edit plan → code/RPG update) | [`快速开始:已有仓库`](#快速开始已有仓库) | ### 详细流水线 @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit 实际效果 -下图是为本仓库生成的图可视化的一部分。运行 `/rpgkit.encode`,然后打开 `.rpgkit/data/rpg.html` 浏览完整的交互式图。 +下图是为本仓库生成的图可视化的一部分。运行 `/rpgkit.encode` 后,可以打开 `/.rpgkit/reports/rpg.html` 浏览完整的交互式图。运行 `rpgkit version` 可以看到当前工作区的具体路径。 ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -90,7 +90,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree - Python 3.12+ - [uv](https://docs.astral.sh/uv/) - Git -- 一个已安装并完成身份验证的 AI 编码智能体 CLI:[GitHub Copilot](https://docs.github.com/en/copilot) 或 [Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup) +- 一个已安装并完成身份验证的 Coding Agent CLI:[GitHub Copilot](https://docs.github.com/en/copilot) 或 [Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup) ### 安装 RPG-Kit @@ -103,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +从 `0.1.3` 开始,wheel 会把 pipeline scripts 和 slash-command templates 作为打包资源一起发布,因此 `rpgkit init` 可以离线工作(例如 air-gapped 环境、公司代理环境等)。 + ## 快速开始:新仓库 当你希望 RPG-Kit 把需求转换为新代码库时,使用此路径。 @@ -122,7 +124,6 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[可选]** 把你的需求文档放在 `my-project/docs/`。 @@ -145,7 +146,13 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` -RPG-Kit 会渐进式地创建 `.rpgkit/data/rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。 +> [!IMPORTANT] +> **不同 Coding Agent 的调用方式略有不同**: +> +> - **Claude Code**:直接在对话中输入 `/rpgkit.feature_spec ...`,slash command 会被识别并触发对应 workflow。 +> - **GitHub Copilot CLI**:不支持 slash command(但支持自定义 agent),需要先 `/agent rpgkit.feature_spec` 切换到目标 agent,然后输入 `start` 让它执行内置的 workflow。 + +RPG-Kit 会渐进式地在 home-side 运行时目录(`~/.rpgkit/workspaces//data/rpg.json`)里创建 `rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。你的工作区源文件不会被污染。 ## 快速开始:已有仓库 @@ -157,10 +164,8 @@ RPG-Kit 会渐进式地创建 `.rpgkit/data/rpg.json`,并用它把需求、规 1. 在仓库根目录初始化 RPG-Kit 并构建初始图: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode 会根据当前的代码生成 RPG ``` 如果你想跳过非空目录的确认提示: @@ -171,7 +176,7 @@ RPG-Kit 会渐进式地创建 `.rpgkit/data/rpg.json`,并用它把需求、规 2. 在仓库里启动你的 AI 编码智能体。 -3. 通过 MCP 工具和 slash 命令使用生成的 RPG: +3. 【可选】通过 MCP 工具和 slash 命令使用生成的 RPG,以下命令只在手动运行时需要: ```text /rpgkit.encode # 需要时重建完整 RPG @@ -179,37 +184,53 @@ RPG-Kit 会渐进式地创建 `.rpgkit/data/rpg.json`,并用它把需求、规 /rpgkit.rpg_edit # 图感知的代码编辑 ``` -4. 提交后,RPG-Kit hook 会把 `.rpgkit/data/rpg.json`、`.rpgkit/data/dep_graph.json` 和 `.rpgkit/data/rpg.html` 与代码变更保持对齐。如果 hook 失败或被跳过,运行 `/rpgkit.update_rpg`。 +4. 每次 commit 后,RPG-Kit 安装的 git hook 会自动调用 `rpgkit hook ` 调度器,更新 RPG,与代码变更保持对齐。如果 hook 失败或被跳过,可以手动运行 `/rpgkit.update_rpg`。 ## `rpgkit init` 之后会发生什么 -`rpgkit init` 不会修改你的源文件。它会在你的代码旁边添加命令定义、运行时脚本、MCP 配置和生成的图数据。 +`rpgkit init` 不会修改你的源文件,**也不会在你的工作区写入运行时状态**。它只在你的工作区添加命令定义、MCP 配置和 hooks,所有 RPG-Kit 的运行时数据(产物、日志)都放在 home-side 目录 `~/.rpgkit/workspaces//` 下,其中 `` 是根据工作区绝对路径生成的可读 slug(例如 `home-hys-projects-myrepo`)。 ```text my-project/ ├── docs/ # /rpgkit.feature_spec 的可选需求文档 ├── .github/ or .claude/ # AI 助手的命令定义和设置 ├── .vscode/ # 适用时的 Copilot/VS Code MCP 配置 -└── .rpgkit/ # RPG-Kit 运行时 - ├── scripts/ # 流水线脚本和支持包 - ├── data/ # 生成的产物,包括 rpg.json 和 dep_graph.json - ├── logs/ # 各阶段执行日志 - └── reports/ # 生成时的审查与诊断报告 +├── .rpgkit/ # 包含生成的报告和配置文件 +└── .git/hooks/ # rpgkit init 装的 post-commit / post-merge(每个 hook 仅一行:`rpgkit hook `) ``` 完整的目录布局和数据文件参考见 [docs/project-structure.md](docs/project-structure.md)。 +## 更新 RPG-Kit + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# 对已有工作区进行更新 +cd +rpgkit update +``` + ## 支持的平台 -| 平台 | Claude Code | GitHub Copilot | Codex | -| ------------------- | ----------- | -------------- | ----- | -| CLI 使用 | ✅ | ✅ (No MCP) | ⌛ | -| VS Code 扩展使用 | ✅ | ✅ | ⌛ | +**Coding Agent 支持**: + +| Agent | CLI 使用 | VS Code 扩展使用 | +| -------------- | -------- | ---------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**操作系统支持**: -| 脚本 | Linux | Windows | Mac | -| ---- | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| 操作系统 | 状态 | +| -------- | ---- | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## 文档 @@ -228,12 +249,6 @@ my-project/ **找不到 AI 助手 CLI**:运行 `rpgkit check`,安装并完成所选助手 CLI 的身份验证,然后重新运行 `rpgkit init` 或 `rpgkit update`。 -**MCP 工具报告 `rpg_unavailable`**:运行 `/rpgkit.encode` 来创建 `.rpgkit/data/rpg.json`。 - -**增量更新失败**:检查 `.rpgkit/logs/update_rpg.log`,然后运行 `/rpgkit.update_rpg`。 - -**因为速率限制或私有仓库访问导致模板下载失败**:传递 `--github-token $GITHUB_TOKEN`,或设置 `GH_TOKEN` / `GITHUB_TOKEN`。 - ## 许可证 MIT License —— 详情见 [LICENSE](LICENSE)。 diff --git a/RPG-Kit/docs/cli-reference.md b/RPG-Kit/docs/cli-reference.md index bb6ab41..0385716 100644 --- a/RPG-Kit/docs/cli-reference.md +++ b/RPG-Kit/docs/cli-reference.md @@ -17,15 +17,12 @@ rpgkit init . [options] | Option | Description | | ------ | ----------- | | `--ai ` | AI assistant: `copilot` or `claude` | -| `--script ` | Script type: `sh` (POSIX) or `ps` (PowerShell) | +| `--script ` | Script type: `sh` (POSIX). `ps` (PowerShell) is not yet supported and will be added in a future release. | | `--here` | Initialize in current directory | | `--force` | Skip confirmation for non-empty current directory | | `--no-git` | Skip git initialization | | `--no-mcp` | Skip MCP server configuration | | `--ignore-agent-tools` | Skip checks for AI agent CLI tools | -| `--github-token ` | GitHub token for private repos or higher rate limits | -| `--pre` | Download the latest pre-release template | -| `--skip-tls` | Skip SSL/TLS verification | | `--encode/--no-encode` | Run or skip initial RPG encoding at the end of init | | `--debug` | Show verbose diagnostic output | @@ -47,7 +44,6 @@ rpgkit init . --force rpgkit init . --encode rpgkit init . --force --encode rpgkit init --here --ai copilot -rpgkit init --here --github-token $GITHUB_TOKEN ``` ## `rpgkit update` @@ -57,9 +53,8 @@ Update RPG-Kit template files, scripts, command definitions, MCP configuration, ```bash rpgkit update rpgkit update --ai claude -rpgkit update --pre rpgkit update --no-mcp -rpgkit update --github-token $GITHUB_TOKEN +rpgkit update --no-upgrade ``` ### Options @@ -67,22 +62,43 @@ rpgkit update --github-token $GITHUB_TOKEN | Option | Description | | ------ | ----------- | | `--ai ` | AI assistant, auto-detected if not specified | -| `--script ` | Script type: `sh` (POSIX) or `ps` (PowerShell) | -| `--github-token ` | GitHub token for private repos or higher rate limits | -| `--pre` | Download the latest pre-release template | +| `--script ` | Script type: `sh` (POSIX). `ps` (PowerShell) is not yet supported and will be added in a future release. | +| `--no-upgrade` | Skip the default-on CLI self-upgrade and only sync workspace files. | | `--no-mcp` | Skip MCP server configuration | -| `--skip-tls` | Skip SSL/TLS verification | | `--debug` | Show verbose diagnostic output | +### Auto-upgrade behaviour + +Since the global-install layout, `rpgkit update` performs a **best-effort silent self-upgrade by default** when the install source is safe to refresh (git+URL or PyPI). After upgrading the CLI it re-executes itself once to continue the workspace sync with the new code. Editable installs, local-file installs, and unknown sources are skipped silently. + +- Pass `--no-upgrade` to skip the upgrade entirely (useful for offline or pinned environments). +- A loop guard environment variable (`RPGKIT_UPGRADE_DONE`) is set across the re-exec to guarantee at most one upgrade attempt per invocation. + +### Provisioning sources + +As of `0.1.4`, `rpgkit init` and `rpgkit update` provision exclusively +from the **packaged assets bundle** shipped inside the installed +`rpgkit-cli` wheel (under `rpgkit_cli/core_pack/`). No network access +is required at provisioning time. + +To pick up newer prompts and templates, upgrade the CLI itself +(e.g. `uv tool upgrade rpgkit-cli`). `rpgkit update` does this +automatically by default (see *Auto-upgrade behaviour* above); pass +`--no-upgrade` to opt out. + ## `rpgkit check` -Verify that required tools are installed. +Verify that the local environment has the tools RPG-Kit relies on. ```bash rpgkit check ``` -Run this after installation to confirm Python, Git, uv, and the selected AI assistant CLI are available. +Probes for Git, the supported AI assistant CLIs (GitHub Copilot, +Claude Code), and optional editors (VS Code / VS Code Insiders), and +prints a tree of which ones are available. Run this after +installation to confirm the environment is ready, or whenever a +pipeline step complains about a missing tool. ## `rpgkit version` @@ -92,20 +108,44 @@ Display version and system information. rpgkit version ``` -## Network and Release Options +## `rpgkit script` + +Execute one of the bundled RPG-Kit pipeline scripts. After install +(`uv tool install rpgkit-cli`) the scripts live inside the wheel under +`rpgkit_cli/core_pack/scripts/` and are no longer copied into each +workspace; this command is the supported way to invoke them. ```bash -rpgkit init my-project --github-token $GITHUB_TOKEN -rpgkit init my-project --pre -rpgkit init my-project --skip-tls -rpgkit init my-project --debug +rpgkit script [args...] ``` -| Option | Description | -| ------ | ----------- | -| `--github-token ` | Uses a GitHub token for API requests, useful for private repos or rate limits | -| `--pre` | Downloads the latest pre-release template instead of the latest stable release | -| `--skip-tls` | Skips SSL/TLS verification; use only for constrained environments | -| `--debug` | Prints verbose diagnostic output for network and extraction failures | +Arguments after `` are forwarded verbatim to the target +script. Standard input/output/error and exit code are inherited. + +### Options + +- `--list` — print every available script (relative path) and exit. +- `--where ` — print the absolute filesystem path of one script + and exit; pipeable into `$(...)` for ad-hoc inspection. + +The `.py` suffix on `` is optional. Path traversal (`..`) +and absolute paths are rejected for safety. + +### Examples + +```bash +rpgkit script smoke_test.py --json +rpgkit script rpg_edit/validate.py +rpgkit script --list +rpgkit script --where mcp_server.py +``` + +The slash-command templates installed by `rpgkit init` (in +`.claude/commands/` or `.github/agents/`) all use `rpgkit script …` +under the hood, so AI agents invoke the pipeline through the same +contract. -`GH_TOKEN` and `GITHUB_TOKEN` are also recognized for GitHub API requests. +A companion console script, `rpgkit-mcp`, is the MCP server entry +point and is what `.mcp.json` / `.vscode/mcp.json` register as the +`rpg-tools` command — no absolute paths in the config, no per-machine +edits. diff --git a/RPG-Kit/docs/commands.md b/RPG-Kit/docs/commands.md index e94858f..20f44ef 100644 --- a/RPG-Kit/docs/commands.md +++ b/RPG-Kit/docs/commands.md @@ -6,6 +6,8 @@ RPG-Kit provides 13 slash commands that work in three paths: - **Reverse encoder:** Existing code → RPG - **Surgical edit:** Natural-language changes applied to code, RPG, and dependency graph together +> **Note on data paths.** Throughout this document, paths shown as `.rpgkit/data/...` and `.rpgkit/logs/...` are stable logical names. The actual files live **outside the workspace** under `~/.rpgkit/workspaces//{data,logs}/` so that runtime artefacts never enter the user's git repository. Reports (`rpg.html`, review HTML, etc.) stay in the workspace at `/.rpgkit/reports/` because they are small user-facing artefacts users may want to commit. Run `rpgkit version` from inside the workspace to see the resolved Data / Logs paths. See [project-structure.md](project-structure.md) for the full layout. + ## Command Overview ### Phase 1: Feature Specification @@ -92,8 +94,8 @@ Generate and iteratively refine the feature tree from `.rpgkit/data/feature_spec **Current workflow:** -1. **Validate status** — runs `.rpgkit/scripts/feature_build_validation.py` to verify that `feature_spec.json` exists and decide whether this is a first build or an expansion. -2. **Build or expand** — runs `.rpgkit/scripts/feature_build.py --mode step1`. +1. **Validate status** — runs `rpgkit script feature_build_validation.py` to verify that `feature_spec.json` exists and decide whether this is a first build or an expansion. +2. **Build or expand** — runs `rpgkit script feature_build.py --mode step1`. - If `feature_build.json` does not exist, RPG-Kit builds the feature tree from the specification and iterates until requirements are covered. - If `feature_build.json` already exists, RPG-Kit switches to beyond-spec expansion mode and adds production-relevant features not described by the original spec. 3. **Review** — validates coverage, duplicates, and MIU constraints. Coverage review uses a default threshold of `98.0` and up to `3` review iterations. @@ -201,9 +203,9 @@ Build inter-component data flow as a directed acyclic graph (DAG). 2. **Iteration choice** — asks for max iterations: - `Y` uses the default of 5 iterations. - A number sets a custom iteration budget. -3. **DAG design** — runs `.rpgkit/scripts/build_data_flow.py --max-iterations `. -4. **Validation** — runs `.rpgkit/scripts/check_data_flow.py --verbose`. -5. **Visualization** — runs `.rpgkit/scripts/generate_viz.py` when a new data flow is built. +3. **DAG design** — runs `rpgkit script build_data_flow.py --max-iterations `. +4. **Validation** — runs `rpgkit script check_data_flow.py --verbose`. +5. **Visualization** — runs `rpgkit script generate_viz.py` when a new data flow is built. **Example:** @@ -356,9 +358,9 @@ This command is independent from `/rpgkit.feature_edit` and `/rpgkit.update_rpg` **Workflow:** -1. **Pre-check** — runs `.rpgkit/scripts/rpg_edit/validate.py --json` and stops if the RPG or dependency graph is unavailable. -2. **Locate target nodes** — runs `.rpgkit/scripts/rpg_edit/locate.py --query "" --json` and selects existing nodes or nearest parent nodes for new features. -3. **Analyze impact** — runs `.rpgkit/scripts/rpg_edit/impact.py --node-id ... --json` to identify affected nodes, callers, callees, and files. +1. **Pre-check** — runs `rpgkit script rpg_edit/validate.py --json` and stops if the RPG or dependency graph is unavailable. +2. **Locate target nodes** — runs `rpgkit script rpg_edit/locate.py --query "" --json` and selects existing nodes or nearest parent nodes for new features. +3. **Analyze impact** — runs `rpgkit script rpg_edit/impact.py --node-id ... --json` to identify affected nodes, callers, callees, and files. 4. **Optional visual reconnaissance** — for UI/layout/style edits, probes the app with the browser helper when available. 5. **Mandatory code reconnaissance** — reads affected files and searches related patterns before producing a plan. 6. **Generate and confirm plan** — writes `.rpgkit/data/rpg_edit_plan.json` and asks the user to apply, cancel, revise, or inspect a node. @@ -392,8 +394,8 @@ Encode the current repository into an RPG from scratch. **Process:** -1. **Pre-check** — runs `.rpgkit/scripts/rpg_encoder/check_encode.py --json`. -2. **Full encode** — runs `.rpgkit/scripts/rpg_encoder/run_encode.py --json`. +1. **Pre-check** — runs `rpgkit script rpg_encoder/check_encode.py --json`. +2. **Full encode** — runs `rpgkit script rpg_encoder/run_encode.py --json`. 3. **Next steps** — suggests `/rpgkit.update_rpg` for incremental updates and MCP tools for exploration. If `rpg.json` already exists, the command asks whether to full re-encode, switch to `/rpgkit.update_rpg`, or quit. @@ -418,9 +420,9 @@ Under normal use, RPG-Kit installs a post-commit hook that updates the RPG in th **Process:** -1. **Pre-check** — runs `.rpgkit/scripts/rpg_encoder/check_encode.py --json` and stops if `rpg.json` is missing or corrupt. +1. **Pre-check** — runs `rpgkit script rpg_encoder/check_encode.py --json` and stops if `rpg.json` is missing or corrupt. 2. **Commit baseline check** — verifies `HEAD~1` exists. If there is no previous commit, run `/rpgkit.encode` instead. -3. **Incremental update** — runs `.rpgkit/scripts/update_graphs.py update-rpg --json`, comparing the current workspace against `HEAD~1`, the same baseline used by the hook. +3. **Incremental update** — runs `rpgkit script update_graphs.py update-rpg --json`, comparing the current workspace against `HEAD~1`, the same baseline used by the hook. 4. **Report result** — displays node/edge deltas, functional areas, alignment status, and output path. Use this command when: diff --git a/RPG-Kit/docs/configuration.md b/RPG-Kit/docs/configuration.md index 9b5d36b..4230622 100644 --- a/RPG-Kit/docs/configuration.md +++ b/RPG-Kit/docs/configuration.md @@ -2,6 +2,8 @@ This document covers RPG-Kit configuration that is useful after installation: AI assistant setup, MCP registration, auto-approval, hooks, and initial encoding. +> **Data paths.** References below such as `.rpgkit/data/rpg.json` and `.rpgkit/logs/...` are logical names. Runtime files actually live under `~/.rpgkit/workspaces//{data,logs}/` so they stay outside your git repo. Reports stay in the workspace at `/.rpgkit/reports/`. The MCP server, hooks, and pipeline scripts all resolve the home-dir location automatically from the workspace root. Run `rpgkit version` from inside the workspace to see the resolved Data / Logs paths; see [project-structure.md](project-structure.md) for the full layout. + ## AI Assistant CLI Requirements RPG-Kit slash commands are executed by an AI coding agent. Before running `rpgkit init`, install and authenticate at least one supported AI assistant CLI. @@ -21,6 +23,55 @@ rpgkit check If the selected AI assistant is not found, install and authenticate it, then rerun `rpgkit init` or `rpgkit update`. +## Workspace Configuration (`.rpgkit/config.toml`) + +Since `0.1.3`, every workspace owns a `.rpgkit/config.toml` file that records which AI CLI command the pipeline scripts should invoke. This decouples the scripts from a single AI at packaging time — the same packaged scripts now serve any AI you pick. + +```toml +# .rpgkit/config.toml +[rpgkit] +ai_cli_cmd = "claude" +``` + +The file is created automatically by `rpgkit init --ai `. Edit it any time to switch the workspace to a different AI; no need to re-run `init`. + +### Resolution priority + +When a pipeline script (or a hook, or the MCP server) needs to invoke the AI CLI, it resolves the command via the following chain. The first non-empty value wins: + +| # | Source | Use case | +| - | ------ | -------- | +| P1 | `LLMClient(tool="...")` constructor argument | Programmatic override (rare) | +| P2 | `RPGKIT_AI_CLI_CMD` environment variable | CI runs, one-off experiments | +| P3 | `.rpgkit/config.toml` `[rpgkit].ai_cli_cmd` | Normal default (per workspace) | +| P4 | Release-zip baked-in literal | Legacy workspaces provisioned before v0.1.4 | + +If all four resolve to empty, the next `LLMClient.generate()` call raises a `RuntimeError` instructing the user to run `rpgkit init` or set the env var. + +### Supported AI CLI commands + +The values written to `ai_cli_cmd` mirror the per-AI substitutions performed by the GitHub release-zip CI: + +| `--ai` value | `ai_cli_cmd` | +| ------------ | ------------ | +| `copilot` | `copilot` | +| `claude` | `claude` | +| `gemini` | `gemini -p` | +| `qwen` | `qwen -p` | +| `cursor-agent` | `agent -p` | +| `auggie` | `augment -p` | +| `codex` | `codex exec` | +| `codebuddy` | `codebuddy -p` | +| `qoder` | `qodercli -p` | +| `opencode` | `opencode run` | +| `amp` | `amp --execute` | + +Only `copilot` and `claude` are currently verified end-to-end; the others are scaffolded but may need integration adjustments. + +### Other config keys + +The `[rpgkit]` table currently holds only `ai_cli_cmd`. Future releases will add timeouts, retry budgets, and model overrides under the same namespace; older keys remain forward-compatible. + ## Initialization Options ### AI assistant selection @@ -167,30 +218,12 @@ Run `rpgkit update` from the project root to refresh scripts, command definition ```bash rpgkit update rpgkit update --ai claude -rpgkit update --pre +rpgkit update --no-upgrade rpgkit update --no-mcp ``` `rpgkit update` auto-detects the existing assistant configuration when possible. -## Network and Release Options - -```bash -rpgkit init my-project --github-token $GITHUB_TOKEN -rpgkit init my-project --pre -rpgkit init my-project --skip-tls -rpgkit init my-project --debug -``` - -| Option | Description | -| ------ | ----------- | -| `--github-token ` | Uses a GitHub token for API requests, useful for private repos or rate limits | -| `--pre` | Downloads the latest pre-release template instead of the latest stable release | -| `--skip-tls` | Skips SSL/TLS verification; use only for constrained environments | -| `--debug` | Prints verbose diagnostic output for network and extraction failures | - -`GH_TOKEN` and `GITHUB_TOKEN` are also recognized for GitHub API requests. - ## Troubleshooting ### AI assistant CLI not found @@ -234,14 +267,14 @@ If the graph is corrupted or too stale, run `/rpgkit.encode` for a full rebuild. ### Template download hits rate limits or private repo access errors -Use a token: +As of v0.1.4 `rpgkit init` and `rpgkit update` no longer fetch templates +from GitHub releases — templates are bundled inside the installed +`rpgkit-cli` wheel, so this class of error should no longer occur during +provisioning. To pick up newer templates, upgrade the CLI itself: ```bash -rpgkit init my-project --github-token $GITHUB_TOKEN +uv tool upgrade rpgkit-cli ``` -or set an environment variable: - -```bash -export GH_TOKEN=your_token -``` +`rpgkit update` does this automatically by default; pass `--no-upgrade` +to opt out. diff --git a/RPG-Kit/docs/project-structure.md b/RPG-Kit/docs/project-structure.md index 8a3e466..5685770 100644 --- a/RPG-Kit/docs/project-structure.md +++ b/RPG-Kit/docs/project-structure.md @@ -4,9 +4,9 @@ RPG-Kit installs alongside your project code: the directory you run `rpgkit init` in, also called the workspace root, **is** the project repository root. There is no separate `repo/` subdirectory. This means: -- `rpgkit init my-project` creates `my-project/` containing both your source code (`src/`, `tests/`, `docs/`) and RPG-Kit's runtime files (`.rpgkit/`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). +- `rpgkit init my-project` creates `my-project/` containing both your source code (`src/`, `tests/`, `docs/`) and RPG-Kit's in-workspace configuration files (`.rpgkit/config.toml`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). - `rpgkit init --here` inside an existing git repository adds RPG-Kit on top of the existing code without moving the repository. -- A single `.git` repository tracks user-owned code and any RPG-Kit files the user chooses to commit. Runtime data under `.rpgkit/data/` is gitignored by default. +- A single `.git` repository tracks user-owned code and any RPG-Kit files the user chooses to commit. **Runtime data, logs, and the inner-git snapshot repo all live outside the workspace** under `~/.rpgkit/workspaces//`, so generated artefacts don't pollute your repo or accidentally get committed. Only a small set of user-facing files (`.rpgkit/config.toml`, `.rpgkit/reports/*.html`) stay inside the workspace. ## After `rpgkit init` @@ -40,58 +40,58 @@ my-project/ │ ├── mcp.json # MCP server registration │ └── tasks.json # Optional workspace tasks └── .rpgkit/ - ├── scripts/ # Pipeline scripts and support packages - │ ├── feature_spec_to_json.py # Feature specification - │ ├── feature_build.py - │ ├── feature_build_validation.py - │ ├── feature_refactor.py - │ ├── feature_refactor_validation.py - │ ├── feature_edit.py - │ ├── feature_edit_validation.py - │ ├── build_skeleton.py # RPG construction - │ ├── check_skeleton.py - │ ├── summary_skeleton.py - │ ├── build_data_flow.py - │ ├── check_data_flow.py - │ ├── generate_viz.py - │ ├── design_base_classes.py - │ ├── check_base_classes.py - │ ├── design_interfaces.py - │ ├── check_interfaces.py - │ ├── plan_tasks.py - │ ├── check_tasks.py - │ ├── init_codebase.py # Code generation - │ ├── run_batch.py # TDD batch executor, final test, global review - │ ├── check_code_gen.py - │ ├── update_graphs.py # Incremental RPG and dependency graph updates - │ ├── mcp_server.py # rpg-tools MCP server - │ ├── code_gen/ # Code generation subpackage - │ ├── common/ # Shared utilities and path definitions - │ ├── feature/ # Feature processing - │ ├── func_design/ # Function/interface design agents - │ ├── skeleton/ # Skeleton building - │ ├── rpg/ # RPG models, services, graph query engine - │ ├── rpg_edit/ # Surgical RPG/code edit pipeline - │ └── rpg_encoder/ # Reverse encoder - │ ├── check_encode.py # Pre-check rpg.json state - │ ├── run_encode.py # Full encode - │ ├── run_update_rpg.py # Incremental update implementation - │ ├── rpg_encoding.py # RPG encoding pipeline - │ ├── rpg_evolution.py # Incremental RPG evolution - │ ├── semantic_parsing.py # Semantic feature extraction - │ └── refactor_tree.py # Feature tree refactoring - ├── data/ # Runtime artifacts, populated by commands - ├── logs/ # Per-stage logs - └── reports/ # Review and diagnostic reports when generated +│ ├── config.toml # Workspace AI / config (committed). See docs/configuration.md +│ ├── .source # Provisioning channel marker: "bundle" or "legacy" +│ └── reports/ # User-facing HTML reports (rpg.html, review HTML, ...) +└── .git/ # Your existing git repo + └── hooks/ # Installed by `rpgkit init` + ├── post-commit # Single line: `rpgkit hook post-commit` + └── post-merge # Single line: `rpgkit hook post-merge` ``` +### Out-of-workspace runtime store + +Starting from the global-install layout, all runtime state lives under your home directory, keyed by a path-derived **slug** (the workspace's absolute path, lowercased, with non-alphanumeric runs collapsed to `-`): + +```text +~/.rpgkit/workspaces// +├── .git/ # Inner-git snapshot repo (per-stage auto-commits) +├── .gitignore # Excludes logs/copilot/ only — other logs are tracked for debug +├── .meta.toml # Back-pointer to the workspace path + metadata +├── data/ # Runtime artifacts (rpg.json, dep_graph.json, ...) +└── logs/ # Per-stage logs (tracked by inner-git; LLM session traces under logs/copilot/ are excluded) +``` + +Reports (`rpg.html`, review HTML, …) stay **inside** the workspace at `/.rpgkit/reports/` because they are small, user-facing artefacts that benefit from sitting next to the code (and may be committed). + +`` is normally the slug itself (e.g. `home-hys-projects-myrepo`); paths whose slug exceeds 200 characters are truncated and given a 6-char base36 SHA-256 suffix so the directory name fits comfortably under POSIX `NAME_MAX` (255). Same shape as Claude Code's `~/.claude/projects/`. Moving or renaming the workspace yields a different id, so each clone has independent state. Run `rpgkit version` from inside the workspace to see the resolved paths (the **Data**, **Logs**, and **Inner git** lines). For backward compatibility, workspaces created before 0.1.4 (which used a 12-hex-char SHA-256 hash directory) continue to resolve correctly. + +> Pipeline scripts (formerly materialised into `.rpgkit/scripts/`) now live inside the installed `rpgkit-cli` wheel under `rpgkit_cli/core_pack/scripts/` and are invoked via the global [`rpgkit script `](cli-reference.md) command. They are no longer copied into each workspace, so `rpgkit init` produces a much smaller footprint and a single source of truth per CLI install. + The agent configuration directory varies by the selected AI assistant and release package. For the verified CLI path, `--ai claude` installs `.claude/commands/`, while `--ai copilot` installs `.github/agents/`, `.github/prompts/`, and `.vscode/mcp.json`. -Command definitions are installed into the AI-agent-specific folder. Normal users should not need to edit `.rpgkit/scripts/` or `.rpgkit/data/` manually. +Command definitions are installed into the AI-agent-specific folder. Normal users should not need to inspect `~/.rpgkit/workspaces//data/` directly—run `rpgkit version` from the workspace to see all relevant paths. + +### Quick reference: where does each file live? + +| Artefact | Location | +|---|---| +| Your source code | `/` | +| Workspace AI config | `/.rpgkit/config.toml` | +| User-facing HTML reports (`rpg.html`, …) | `/.rpgkit/reports/` | +| Agent command definitions | `/.claude/` or `/.github/` | +| MCP / VS Code config | `/.vscode/` | +| Git hooks (`post-commit`, `post-merge`) | `/.git/hooks/` | +| Generated data (`rpg.json`, `dep_graph.json`, …) | `~/.rpgkit/workspaces//data/` | +| Per-stage logs | `~/.rpgkit/workspaces//logs/` | +| Inner-git snapshot repo | `~/.rpgkit/workspaces//.git/` | +| Pipeline scripts (read-only) | inside the installed `rpgkit-cli` wheel | + +To see the resolved paths for the current workspace, run `rpgkit version` from anywhere inside it. ## Generated Data Files -As you run `/rpgkit.*` commands, `.rpgkit/data/` is progressively populated: +As you run `/rpgkit.*` commands, `~/.rpgkit/workspaces//data/` is progressively populated (paths below are shown relative to that directory): | Generated file | Command | Description | | -------------- | ------- | ----------- | @@ -144,11 +144,13 @@ Typical producers and updaters: ## Runtime Logs and Reports -Runtime logs are written under `.rpgkit/logs/`, for example: +Runtime logs are written under `~/.rpgkit/workspaces//logs/`, for example: + +- `~/.rpgkit/workspaces//logs/encode.log` +- `~/.rpgkit/workspaces//logs/update_rpg.log` +- `~/.rpgkit/workspaces//logs/feature_build.log` +- `~/.rpgkit/workspaces//logs/build_data_flow.log` -- `.rpgkit/logs/encode.log` -- `.rpgkit/logs/update_rpg.log` -- `.rpgkit/logs/feature_build.log` -- `.rpgkit/logs/build_data_flow.log` +Execution traces are written under `~/.rpgkit/workspaces//data/trajectory/`. Review or diagnostic artifacts may be written under `/.rpgkit/reports/` when a command generates them. -Execution traces are written under `.rpgkit/data/trajectory/`. Review or diagnostic artifacts may be written under `.rpgkit/reports/` when a command generates them. +To discover the home-side paths (data / logs / inner-git) for the current workspace, run `rpgkit version` from anywhere inside it—the relevant lines are labelled **Workspace**, **Data**, **Logs**, and **Inner git**. diff --git a/RPG-Kit/pyproject.toml b/RPG-Kit/pyproject.toml index 545a3a5..fc5238b 100644 --- a/RPG-Kit/pyproject.toml +++ b/RPG-Kit/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rpgkit-cli" -version = "0.1.2" +version = "0.1.3" description = "RPG-Kit CLI - A tool to generate feature trees for repository planning and code generation." requires-python = ">=3.12" dependencies = [ @@ -29,6 +29,7 @@ dependencies = [ [project.scripts] rpgkit = "rpgkit_cli:main" +rpgkit-mcp = "rpgkit_cli.entries:mcp_main" [project.urls] Repository = "https://github.com/microsoft/RPG-ZeroRepo" @@ -39,3 +40,12 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/rpgkit_cli"] + +# Bundle core assets (scripts + slash-command templates) into the wheel under +# `rpgkit_cli/core_pack/` so that `rpgkit init` works offline (air-gapped / +# corporate-proxy / enterprise environments). These are the SAME source files +# the GitHub Release zip workflow packages; bundling them in the wheel just +# gives users a network-free fast path. Plan: plans/01-package-bundle-and-ai-config.md +[tool.hatch.build.targets.wheel.force-include] +"scripts" = "rpgkit_cli/core_pack/scripts" +"templates/commands" = "rpgkit_cli/core_pack/commands" diff --git a/RPG-Kit/scripts/__init__.py b/RPG-Kit/scripts/__init__.py new file mode 100644 index 0000000..1c7afd0 --- /dev/null +++ b/RPG-Kit/scripts/__init__.py @@ -0,0 +1,17 @@ +# This file is intentionally empty. +# +# It exists so that hatch treats ``scripts/`` as a discoverable subtree +# for the ``force-include`` directive in ``pyproject.toml`` (see +# ``[tool.hatch.build.targets.wheel.force-include]``). Some hatch +# versions skip top-level directories that lack an ``__init__.py`` when +# walking the source tree, even though ``force-include`` should not +# require Python-package semantics. Keeping this empty marker file +# avoids surprising build differences across hatch releases. +# +# At runtime ``scripts/`` is NOT imported as ``rpgkit_cli.scripts`` +# — the wheel's ``force-include`` rewrites the install target to +# ``rpgkit_cli/core_pack/scripts/``, and that path is also not imported +# as a Python module. Scripts are executed directly from the packaged +# location via the ``rpgkit script `` dispatcher, which resolves +# them through ``rpgkit_cli._assets.scripts_dir()``. +# diff --git a/RPG-Kit/scripts/build_data_flow.py b/RPG-Kit/scripts/build_data_flow.py index 1294e81..1c4fd6b 100644 --- a/RPG-Kit/scripts/build_data_flow.py +++ b/RPG-Kit/scripts/build_data_flow.py @@ -106,9 +106,9 @@ def update_rpg_with_data_flow(data_flow_data: Dict[str, Any], rpg_path: Path): svc.save(rpg_path) if added > 0: - print(f"[OK] Added {added} data flow edges to: {rpg_path}") + print(f"[OK] Added {added} data flow edges to: {rpg_path.name}") else: - print(f"No new data flow edges to add to: {rpg_path}") + print(f"No new data flow edges to add to: {rpg_path.name}") # ============================================================================ @@ -354,7 +354,7 @@ def main(): logger.info(f"[OK] Data flow saved to: {output_path}") builder.print_summary(result) - print(f"\n[OK] Data flow saved to: {output_path}") + print(f"\n[OK] Data flow saved to: {output_path.name}") # Add data flow edges to repo_rpg.json update_rpg_with_data_flow(result, Path(args.repo_rpg)) @@ -370,7 +370,7 @@ def main(): "components": len(result.get("components", [])), "edges": len(result.get("data_flow", [])) }) - print(f"[OK] Trajectory saved to: {trajectory.trajectory_file}") + print(f"[OK] Trajectory saved to: {trajectory.trajectory_file.name}") return 0 diff --git a/RPG-Kit/scripts/build_skeleton.py b/RPG-Kit/scripts/build_skeleton.py index 0cf9b71..30df76c 100644 --- a/RPG-Kit/scripts/build_skeleton.py +++ b/RPG-Kit/scripts/build_skeleton.py @@ -200,7 +200,7 @@ def build(self, input_data: Dict[str, Any]) -> Dict[str, Any]: # Save updated RPG (with directory assignments) self.rpg.save_json(str(REPO_RPG_FILE), indent=2) - print(f" [OK] Updated RPG saved to: {REPO_RPG_FILE}") + print(f" [OK] Updated RPG saved to: {REPO_RPG_FILE.name}") self._print_summary() @@ -226,7 +226,7 @@ def _step1_build_rpg(self) -> bool: print(f" - Total nodes: {stats['total_nodes']}") print(f" - Node types: {dict(stats['node_types'])}") print(f" - Level distribution: {dict(stats['levels'])}") - print(f" [OK] RPG saved to: {REPO_RPG_FILE}") + print(f" [OK] RPG saved to: {REPO_RPG_FILE.name}") return True @@ -688,11 +688,11 @@ def main(): json.dump(result, f, indent=2, ensure_ascii=False) logger.info(f"[OK] Skeleton saved to: {output_path}") - print(f"\n[OK] Skeleton saved to: {output_path}") + print(f"\n[OK] Skeleton saved to: {output_path.name}") # Save RPG as well if REPO_RPG_FILE.exists(): - print(f"[OK] RPG saved to: {REPO_RPG_FILE}") + print(f"[OK] RPG saved to: {REPO_RPG_FILE.name}") # Mark trajectory as complete if trajectory: @@ -701,7 +701,7 @@ def main(): "assigned_features": builder.stats["assigned_features"], "total_files": builder.stats["total_files"] }) - print(f"[OK] Trajectory saved to: {trajectory.trajectory_file}") + print(f"[OK] Trajectory saved to: {trajectory.trajectory_file.name}") return 0 diff --git a/RPG-Kit/scripts/check_code_gen.py b/RPG-Kit/scripts/check_code_gen.py index d0d7669..66bbee0 100644 --- a/RPG-Kit/scripts/check_code_gen.py +++ b/RPG-Kit/scripts/check_code_gen.py @@ -21,6 +21,7 @@ TASKS_FILE, CODE_GEN_STATE_FILE as STATE_FILE, get_scripts_dir, + cmd_for, REPO_DIR, ) from common.execution_state import load_code_gen_state @@ -155,8 +156,8 @@ def determine_state( "remaining": total_tasks } result["next_action"] = ( - f"Run: python3 {scripts}/init_codebase.py --json to initialize the repository, " - f"then run: python3 {scripts}/run_batch.py --next --json to start the first batch." + f"Run: {cmd_for('init_codebase.py')} --json to initialize the repository, " + f"then run: {cmd_for('run_batch.py')} --next --json to start the first batch." ) result["workflow_hint"] = ( "run_batch.py --next dispatches a sub-agent that autonomously " @@ -262,7 +263,7 @@ def determine_state( result["auto_recovery_error"] = str(e) result["next_action"] = ( f"Tests passed but auto-recovery failed ({e}). " - f"Run: python3 {scripts}/run_batch.py --resume --json to retry." + f"Run: {cmd_for('run_batch.py')} --resume --json to retry." ) return result else: @@ -280,14 +281,14 @@ def determine_state( if phase == "failed": result["next_action"] = ( f"Batch {current_batch_id} has failed. " - f"Run: python3 {scripts}/run_batch.py --retry {current_batch_id} --json " - f"to retry, or python3 {scripts}/run_batch.py --next --json to skip " + f"Run: {cmd_for('run_batch.py')} --retry {current_batch_id} --json " + f"to retry, or {cmd_for('run_batch.py')} --next --json to skip " f"it and move on." ) else: result["next_action"] = ( f"Resume the current batch (phase: {phase}). " - f"Run: python3 {scripts}/run_batch.py --resume --json" + f"Run: {cmd_for('run_batch.py')} --resume --json" ) result["workflow_hint"] = ( "run_batch.py --resume dispatches a sub-agent that autonomously " @@ -307,7 +308,7 @@ def determine_state( result["message"] = f"Ready to continue ({remaining} tasks remaining)" result["next_batch"] = next_batch result["next_action"] = ( - f"Run: python3 {scripts}/run_batch.py --next --json " + f"Run: {cmd_for('run_batch.py')} --next --json " f"to start the next batch." ) result["workflow_hint"] = ( @@ -344,11 +345,11 @@ def determine_state( if not ft_passed: result["next_action"] = ( - f"Run: python3 {scripts}/run_batch.py --final-test --json" + f"Run: {cmd_for('run_batch.py')} --final-test --json" ) elif not gr_passed: result["next_action"] = ( - f"Final test passed. Run: python3 {scripts}/run_batch.py --global-review --json" + f"Final test passed. Run: {cmd_for('run_batch.py')} --global-review --json" ) else: result["next_action"] = ( diff --git a/RPG-Kit/scripts/check_skeleton.py b/RPG-Kit/scripts/check_skeleton.py index 95e5dd4..8c2c692 100644 --- a/RPG-Kit/scripts/check_skeleton.py +++ b/RPG-Kit/scripts/check_skeleton.py @@ -357,9 +357,9 @@ def inspect_state() -> Dict[str, Any]: # Add next_action for clear guidance if type_value == "init": - result["next_action"] = "python3 .rpgkit/scripts/build_skeleton.py --max-iterations 10" + result["next_action"] = "rpgkit script build_skeleton.py --max-iterations 10" elif type_value == "warning": - result["next_action"] = "python3 .rpgkit/scripts/build_skeleton.py --patch" + result["next_action"] = "rpgkit script build_skeleton.py --patch" else: result["next_action"] = "Skeleton is consistent. Proceed to next step." diff --git a/RPG-Kit/scripts/code_gen/__init__.py b/RPG-Kit/scripts/code_gen/__init__.py index 527eb57..3e22e4e 100644 --- a/RPG-Kit/scripts/code_gen/__init__.py +++ b/RPG-Kit/scripts/code_gen/__init__.py @@ -12,7 +12,7 @@ * :mod:`scripts.code_gen.static_checks` — lightweight pre-LLM checks * :mod:`scripts.code_gen.subtree_review` — LLM review of completed subtrees -The package deliberately exposes **no** re-exports. Callers import from +The package exposes no re-exports. Callers import from the specific submodule (``from code_gen.prompts import ...``) to keep dependency edges explicit and to avoid lying about which functions are really part of a stable public API. diff --git a/RPG-Kit/scripts/code_gen/batch_prompts.py b/RPG-Kit/scripts/code_gen/batch_prompts.py index 29a4cee..b74a2c1 100644 --- a/RPG-Kit/scripts/code_gen/batch_prompts.py +++ b/RPG-Kit/scripts/code_gen/batch_prompts.py @@ -208,7 +208,7 @@ [FAIL] You MUST NOT: - Modify or read files under `.rpgkit/` -- Run any `.rpgkit/scripts/*.py` commands +- Run any `rpgkit script ...` or `rpgkit-mcp` commands - Run arbitrary shell commands beyond pytest/pip/git listed above - Install packages that are not genuinely needed by the source code - Delete files that are not part of your task @@ -311,7 +311,7 @@ [FAIL] You MUST NOT: - Modify existing source code or test files - Modify or read files under `.rpgkit/` -- Run any `.rpgkit/scripts/*.py` commands +- Run any `rpgkit script ...` or `rpgkit-mcp` commands ## Task Details diff --git a/RPG-Kit/scripts/code_gen/global_review.py b/RPG-Kit/scripts/code_gen/global_review.py index 2939849..e44a99f 100644 --- a/RPG-Kit/scripts/code_gen/global_review.py +++ b/RPG-Kit/scripts/code_gen/global_review.py @@ -1056,7 +1056,7 @@ class _HeartbeatLogger: Designed to wrap a single long-running blocking call (typically ``dispatch_sub_agent`` inside a global_review iteration). Exits cleanly via context manager — the daemon thread stops as soon as - ``__exit__`` runs, even when the wrapped call raises (plan E2). + ``__exit__`` runs, even when the wrapped call raises. """ def __init__(self, label: str, interval_s: int = 60) -> None: @@ -1157,7 +1157,7 @@ def global_review( # 3. Dispatch sub-agent (with retries for transient failures). # Wrap with a heartbeat so the operator sees the iteration is # still alive even if the sub-agent runs for many minutes - # without producing output (plan E2). + # without producing output. with _HeartbeatLogger( label=f"global_review[{iteration}/{max_iterations}]", interval_s=60, diff --git a/RPG-Kit/scripts/code_gen/result_builders.py b/RPG-Kit/scripts/code_gen/result_builders.py index bb19025..2709a42 100644 --- a/RPG-Kit/scripts/code_gen/result_builders.py +++ b/RPG-Kit/scripts/code_gen/result_builders.py @@ -17,6 +17,7 @@ from common.execution_state import BatchExecutionState, CodeGenState, load_code_gen_state from common.task_batch import PlannedTask, load_tasks_from_tasks_json +from common.paths import cmd_for def _error(message: str, scripts: str) -> Dict[str, Any]: @@ -24,7 +25,7 @@ def _error(message: str, scripts: str) -> Dict[str, Any]: return { "success": False, "error": message, - "next_action": f"Fix the issue, then run: python3 {scripts}/run_batch.py --next --json", + "next_action": f"Fix the issue, then run: {cmd_for('run_batch.py')} --next --json", } @@ -39,12 +40,12 @@ def _all_done(global_state: CodeGenState, tasks_path: Path, scripts: str) -> Dic msg = f"All batches processed: {completed} completed, {failed} failed out of {total}." next_act = ( f"Some batches failed. You can retry them with: " - f"python3 {scripts}/run_batch.py --retry --json, " - f"or run final validation: python3 {scripts}/run_batch.py --final-test --json" + f"{cmd_for('run_batch.py')} --retry --json, " + f"or run final validation: {cmd_for('run_batch.py')} --final-test --json" ) else: msg = f"All {completed} batches completed successfully!" - next_act = f"Run final validation: python3 {scripts}/run_batch.py --final-test --json" + next_act = f"Run final validation: {cmd_for('run_batch.py')} --final-test --json" return { "success": True, @@ -100,10 +101,10 @@ def _success_result( }, "next_action": ( f"Batch completed. {remaining} tasks remaining. " - f"Run: python3 {scripts}/run_batch.py --next --json" + f"Run: {cmd_for('run_batch.py')} --next --json" if remaining > 0 else - f"All batches done! Run: python3 {scripts}/run_batch.py --final-test --json\n" - f"Then run: python3 {scripts}/run_batch.py --global-review --json" + f"All batches done! Run: {cmd_for('run_batch.py')} --final-test --json\n" + f"Then run: {cmd_for('run_batch.py')} --global-review --json" ), } @@ -146,7 +147,7 @@ def _failure_result( "next_action": ( f"Batch failed after {len(attempts)} attempts. " f"Branch '{batch_state.branch_name}' preserved for inspection. " - f"Retry: python3 {scripts}/run_batch.py --retry {batch_id} --json, " - f"or continue: python3 {scripts}/run_batch.py --next --json" + f"Retry: {cmd_for('run_batch.py')} --retry {batch_id} --json, " + f"or continue: {cmd_for('run_batch.py')} --next --json" ), } diff --git a/RPG-Kit/scripts/code_gen/rpg_updater.py b/RPG-Kit/scripts/code_gen/rpg_updater.py index e4ff69e..e4e03b1 100644 --- a/RPG-Kit/scripts/code_gen/rpg_updater.py +++ b/RPG-Kit/scripts/code_gen/rpg_updater.py @@ -726,7 +726,7 @@ def run_rpg_update( # filesystem path. The path is only used to populate ``source_file`` in # edge metadata, which feeds into edge ``description`` text injected into # LLM prompts. Absolute paths leak host-specific prefixes - # (e.g. /home/.../RPG-Kit-backup/...) and mislead agents (plan A4). + # (e.g. /home/.../RPG-Kit-backup/...) and mislead agents. analyzer.analyze_file(Path(batch.file_path), code) analyzed_deps = analyzer.get_all_edges() diff --git a/RPG-Kit/scripts/common/execution_state.py b/RPG-Kit/scripts/common/execution_state.py index 1e3e5b3..85154e4 100644 --- a/RPG-Kit/scripts/common/execution_state.py +++ b/RPG-Kit/scripts/common/execution_state.py @@ -392,7 +392,7 @@ def _count_total_tasks_from_tasks_json(state_path: Path = STATE_FILE) -> int: Returns 0 if tasks.json doesn't exist or cannot be parsed. Used to backfill ``CodeGenState.total_tasks`` since nothing else writes that field after - ``plan_tasks`` runs (see plan A2). + ``plan_tasks`` runs. The tasks.json path is derived from ``state_path`` (assumed to live in the same ``.rpgkit/data/`` directory) so callers passing a custom @@ -419,7 +419,7 @@ def _maybe_backfill_total_tasks( The field defaults to 0 because ``CodeGenState`` is constructed before ``plan_tasks`` produces tasks.json. Backfilling on each load keeps the persisted state in sync with the actual task count without requiring - every call site to remember to update it (see plan A2). + every call site to remember to update it. """ if state.total_tasks > 0: return state @@ -555,7 +555,7 @@ def save_code_gen_state(state: CodeGenState, state_path: Path = STATE_FILE) -> N # which never triggered in practice, leaving the state file at ~880 KB # after a single 100-batch run because every save dumps the full state. # Lowered to 200 KB and we keep the last 20 snapshots so debugging can - # still walk back a few steps without bloating the file (plan E3). + # still walk back a few steps without bloating the file. _COMPACT_THRESHOLD = 200 * 1024 # 200 KB _KEEP_LAST_N = 20 # snapshots retained after compact try: @@ -657,8 +657,7 @@ def skip_current_batch(batch_id: str, state_path: Path = STATE_FILE) -> bool: from being merged, but is not a code-quality failure. The batch_id is recorded in ``skipped_task_ids`` for observability, yet remains absent from ``completed_task_ids`` and ``failed_task_ids`` so the next - ``--next`` invocation re-attempts it without consuming a retry slot - (see plan A3). + ``--next`` invocation re-attempts it without consuming a retry slot. Loop guard: ``batch_prepare_counts[batch_id]`` is incremented on each skip; once it reaches ``_MAX_BATCH_PREPARES`` the batch is recorded diff --git a/RPG-Kit/scripts/common/llm_client.py b/RPG-Kit/scripts/common/llm_client.py index a6f16b5..6a875d8 100644 --- a/RPG-Kit/scripts/common/llm_client.py +++ b/RPG-Kit/scripts/common/llm_client.py @@ -14,6 +14,7 @@ import signal as _signal import subprocess import time +import tomllib from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -21,6 +22,7 @@ from common.llm_types import Memory from common.session_manager import create_session_manager +from . import paths as _paths from .paths import REPO_DIR as _REPO_DIR, WORKSPACE_ROOT as _WORKSPACE_ROOT @@ -33,8 +35,75 @@ def _set_pdeathsig() -> None: pass -# Default AI assistant command -AI_CLI_CMD = "" +# ---------------------------------------------------------------------------- +# AI CLI command resolution +# ---------------------------------------------------------------------------- +# +# Resolution priority: +# P1. LLMClient(tool="...") constructor argument +# P2. RPGKIT_AI_CLI_CMD env var +# P3. /.rpgkit/config.toml [rpgkit].ai_cli_cmd +# P4. _BAKED_IN_VALUE (release-zip builds substitute it at packaging time; +# bundle builds leave the placeholder unchanged) +# +# An unresolved value is reported lazily by LLMClient.generate, not here, +# so importing the module and constructing an LLMClient without calling +# the LLM both succeed. + +# Built via concatenation so the release-zip's ``sed s||...|`` +# does not rewrite this sentinel. +_PLACEHOLDER_LITERAL = "<" + "AI_CLI_CMD" + ">" + +_BAKED_IN_VALUE = "" + + +def _load_ai_cli_cmd() -> str: + """Resolve the AI CLI command string via the P1-P4 priority chain. + + P1 is handled by :class:`LLMClient.__init__` (constructor argument). + This function implements P2-P4 and returns ``""`` if none of them + yield a usable value — callers decide how to react. + + The workspace root is *re-resolved at every invocation* via + :func:`paths._find_workspace_root`, not via the import-frozen + :data:`paths.WORKSPACE_ROOT` constant. This matters for long-lived + processes that may serve more than one workspace (e.g. a future + global MCP server). + """ + # P2: env var (highest non-P1 priority — useful in tests and one-off + # overrides without editing the workspace config). + env_val = _os.environ.get("RPGKIT_AI_CLI_CMD", "").strip() + if env_val: + return env_val + + # P3: workspace config.toml. + try: + workspace = _paths._find_workspace_root() + cfg_path = workspace / ".rpgkit" / "config.toml" + if cfg_path.exists(): + with open(cfg_path, "rb") as f: + data = tomllib.load(f) + cfg_val = (data.get("rpgkit") or {}).get("ai_cli_cmd", "") + if isinstance(cfg_val, str): + cfg_val = cfg_val.strip() + if cfg_val: + return cfg_val + except Exception: + # paths resolution, missing tomllib, or malformed TOML must not + # crash LLMClient construction. + pass + + # P4: legacy baked-in value (release-zip-substituted at build time). + if _BAKED_IN_VALUE and _BAKED_IN_VALUE != _PLACEHOLDER_LITERAL: + return _BAKED_IN_VALUE + + return "" + + +# Resolved once at import for backward-compat with callers that referenced +# the module-level constant directly. New code should call ``_load_ai_cli_cmd()`` +# or use ``LLMClient.tool`` (already populated through the same chain). +AI_CLI_CMD = _load_ai_cli_cmd() # Mapping from the first token of AI_CLI_CMD to the canonical agent name @@ -53,19 +122,23 @@ def _set_pdeathsig() -> None: } -def detect_agent_type() -> str: - """Detect which AI coding agent is being used based on AI_CLI_CMD. +def detect_agent_type(cmd: Optional[str] = None) -> str: + """Detect which AI coding agent is being used. - AI_CLI_CMD is a placeholder that gets replaced per-agent during - release packaging (e.g. "claude -p", "copilot -p", "codex exec"). + Args: + cmd: Optional explicit CLI command string. When omitted we + resolve dynamically via :func:`_load_ai_cli_cmd` so this + function reflects the current workspace's configuration, + not whatever was in effect at module import time. Returns one of: claude, gemini, copilot, cursor, codex, auggie, amp, opencode, codebuddy, qoder, qwen, unknown """ - if not AI_CLI_CMD: + cmd = cmd if cmd is not None else _load_ai_cli_cmd() + if not cmd or cmd == _PLACEHOLDER_LITERAL: return "unknown" - first_token = AI_CLI_CMD.strip().split()[0] + first_token = cmd.strip().split()[0] return _CLI_TO_AGENT.get(first_token, "unknown") @@ -148,18 +221,25 @@ def __init__( step_id: Current step ID in the trajectory logger: Logger instance """ - self.tool = tool or AI_CLI_CMD + # P1 (explicit arg) wins; otherwise P2-P4 chain via _load_ai_cli_cmd. + # The empty-string case is tolerated here so unit tests / utilities + # that construct an LLMClient without intending to invoke the LLM + # keep working. The actual error is raised in :meth:`generate` if + # the tool is still empty when a call is attempted. + self.tool = tool if tool is not None else _load_ai_cli_cmd() self.trajectory = trajectory self.step_id = step_id self.logger = logger or logging.getLogger(__name__) - # Session manager — auto-determined from AI_CLI_CMD. + # Session manager — driven by detect_agent_type(self.tool) so the + # right subclass is chosen even when self.tool came from the + # workspace config (not the import-time AI_CLI_CMD constant). # project_dir must match the subprocess cwd (workspace root == REPO_DIR) # so that Claude CLI's session file path # (~/.claude/projects//) can be correctly located by # the session manager. self._session_manager = create_session_manager( - agent_type=detect_agent_type(), + agent_type=detect_agent_type(self.tool), project_dir=_REPO_DIR, trace_filename_builder=self._build_trace_filename, logger=self.logger, @@ -246,6 +326,17 @@ def generate( Raises: RuntimeError: If LLM call fails after all retries """ + # Lazy validation: the constructor tolerates an empty/placeholder + # ``self.tool`` so that tests and tools can build an LLMClient + # without triggering an LLM invocation, but the moment we are + # actually asked to call out, the configuration must be valid. + if not self.tool or self.tool == _PLACEHOLDER_LITERAL: + raise RuntimeError( + "AI CLI command not configured. Run " + "`rpgkit init --ai ` in this workspace, or set the " + "RPGKIT_AI_CLI_CMD environment variable." + ) + # Create call record self._call_counter += 1 call_record = LLMCallRecord( @@ -345,9 +436,15 @@ def generate( call_record.success = response is not None call_record.error = error if not response else None if captured_path: - call_record.metadata["session_trace"] = str( - captured_path.relative_to(self._INFERRED_PROJECT_DIR) - ) + try: + rel = captured_path.relative_to(self._INFERRED_PROJECT_DIR) + call_record.metadata["session_trace"] = str(rel) + except ValueError: + # captured_path lives outside the workspace (e.g. Claude + # writes traces under ~/.claude/projects//sessions/). + # session_trace is purely informational — never let a + # bookkeeping error abort the LLM call. + call_record.metadata["session_trace"] = str(captured_path) # Store in history self._call_history.append(call_record) @@ -459,7 +556,25 @@ def generate_with_memory( Returns: LLM response text, or None if all retries failed. + + Raises: + RuntimeError: only when ``self.tool`` is not configured. + Genuine LLM-call failures (subprocess errors, timeouts, + bad responses) are swallowed and surface as ``None``; + missing configuration is an unrecoverable user-action + error and must not be silently masked. """ + # Eagerly surface the "AI CLI not configured" condition. This is + # not a transient failure that ``None`` should represent — the + # user needs an actionable error. Genuine LLM failures continue + # to be caught below. + if not self.tool or self.tool == _PLACEHOLDER_LITERAL: + raise RuntimeError( + "AI CLI command not configured. Run " + "`rpgkit init --ai ` in this workspace, or set the " + "RPGKIT_AI_CLI_CMD environment variable." + ) + prompt = self._flatten_memory(memory) try: return self.generate( diff --git a/RPG-Kit/scripts/common/paths.py b/RPG-Kit/scripts/common/paths.py index 5480f1f..1ea9e6a 100644 --- a/RPG-Kit/scripts/common/paths.py +++ b/RPG-Kit/scripts/common/paths.py @@ -3,22 +3,58 @@ This module contains all file path constants used across RPG-Kit scripts. -Directory layout (workspace == repo): - / ← user's source repo + RPG-Kit data - ├── .rpgkit/ ← scripts, data, state (machine-local) +Directory layout (``~/.rpgkit/`` home storage): + + / ← user's source repo + ├── .rpgkit/ ← minimal marker tree (in workspace) + │ ├── config.toml ← team-shared AI config (committable) + │ └── reports/ ← user-facing artefacts (rpg.html, …) ├── .claude/ or .vscode/ ← agent instructions ├── src/ tests/ … ← project code (user-owned) └── .git/ ← single git repo at the workspace root -All paths under ``.rpgkit/`` and ``.claude/`` are relative to -``WORKSPACE_ROOT``. ``REPO_DIR`` is an alias for ``WORKSPACE_ROOT`` kept for -backwards-compatibility with call sites that use "project repo root" -phrasing; both refer to the same directory. + ~/.rpgkit/ ← user-global storage + └── workspaces// + ├── .meta.toml ← channel, timestamps, version + ├── .git/ ← Plan-03 inner snapshot repo + ├── data/ ← rpg.json, dep_graph.json, … + │ └── trajectory/ + └── logs/ ← *.log, mcp_calls.jsonl, … + +Machine-local data (``data/``, ``logs/``, the inner snapshot ``.git/``) +lives under ``~/.rpgkit/workspaces//`` so it survives independently +of the workspace, never gets accidentally committed, and stays scoped +to one user. The workspace dir keeps only the lightweight, team-shared +files that benefit from being version-controlled alongside the code. + +All constants below resolve at module-import time. ``WORKSPACE_ROOT`` +is discovered once; if you need it to track a different workspace +later in the same process (rare), spawn a subprocess instead of +monkey-patching the module. + +``REPO_DIR`` is an alias for ``WORKSPACE_ROOT`` kept for backwards +compatibility with call sites that use "project repo root" phrasing; +both refer to the same directory. """ import os from pathlib import Path +# Import the home-storage helpers. rpgkit_cli is always installed in +# the same Python environment as the scripts (the wheel ships the +# scripts under ``rpgkit_cli/core_pack/scripts/``), so the import is +# robust to where the script gets invoked from. We keep a fallback +# that mirrors the legacy in-workspace layout in case someone imports +# this module from a standalone python install that doesn't have +# rpgkit_cli on sys.path — e.g. a third-party tool dropping in for +# inspection. +try: + from rpgkit_cli import _storage as _rpgkit_storage # type: ignore[import-not-found] + _HOME_STORAGE_AVAILABLE = True +except Exception: # pragma: no cover - defensive + _rpgkit_storage = None # type: ignore[assignment] + _HOME_STORAGE_AVAILABLE = False + # ============================================================================ # Workspace Root (absolute) @@ -49,8 +85,21 @@ def _find_workspace_root() -> Path: # parent process's environment. This matters for git hooks, which # are spawned by ``git`` (cwd = repo root) from arbitrary parent # contexts that may have set RPGKIT_WORKSPACE long ago. + # + # Use the ``.rpgkit/config.toml`` marker (the canonical workspace + # signal of "this is an rpgkit workspace"). Falling back to just + # ``.rpgkit/`` would still work for newly-init'd workspaces, but + # using ``config.toml`` matches :func:`rpgkit_cli._storage + # .find_workspace_root_from` exactly so the MCP server and pipeline + # scripts agree on the boundary. cwd = Path.cwd().absolute() for cand in [cwd, *cwd.parents]: + if (cand / ".rpgkit" / "config.toml").is_file(): + return cand + # Belt-and-braces fallback: also accept a bare ``.rpgkit/`` + # directory. This lets a freshly-cloned workspace whose + # ``config.toml`` was somehow missing still be discovered + # rather than silently degrading to the env-var path below. if (cand / ".rpgkit").is_dir(): return cand @@ -83,28 +132,87 @@ def _find_workspace_root() -> Path: # ============================================================================ -# Scripts Directory (absolute, for embedding in next_action messages) +# Scripts Directory (absolute path on the filesystem) # ============================================================================ +# +# Anchor SCRIPTS_DIR to ``__file__``'s parent so the constant resolves +# correctly regardless of how the scripts were deployed. Scripts live +# inside the installed wheel at +# ``/rpgkit_cli/core_pack/scripts/`` and are invoked via +# ``rpgkit script ``. +# +# The surrounding ``common/`` package is at +# ``SCRIPTS_DIR/common/``, so ``Path(__file__).parent.parent`` is the +# scripts root. Callers that need to spawn or sys.path-insert sibling +# code (e.g. ``rpg_edit/impact.py``) get a working path automatically. +# +# For *user-facing hints* embedded in ``next_action`` messages, prefer +# :func:`cmd_for` instead of stringifying ``SCRIPTS_DIR`` — the former +# emits the supported ``rpgkit script `` invocation rather than a +# raw filesystem path the user can't easily re-run. -# Anchor SCRIPTS_DIR to WORKSPACE_ROOT so that paths embedded in -# next_action messages (read by the AI agent) reference the user's -# workspace path — not the symlink target. -SCRIPTS_DIR = WORKSPACE_ROOT / ".rpgkit" / "scripts" +SCRIPTS_DIR = Path(__file__).resolve().parent.parent TOOLS_DIR = SCRIPTS_DIR / "tools" def get_scripts_dir() -> str: - """Get the scripts directory path as string for use in next_action messages.""" + """Return the scripts directory as a string (filesystem path). + + Kept for backward compatibility with code that uses this as a + base path for sibling-script Path/sys.path operations. Do NOT + use this to build invocation strings shown to the user — use + :func:`cmd_for` instead. + """ return str(SCRIPTS_DIR) +def cmd_for(script_relpath: str) -> str: + """Return the canonical ``rpgkit script`` invocation for a script. + + Args: + script_relpath: Path relative to the scripts root, e.g. + ``"run_batch.py"`` or ``"rpg_edit/validate.py"``. Leading + slashes are stripped; ``.py`` suffix is preserved. + + Returns: + A shell-ready string such as ``"rpgkit script run_batch.py"``. + + Use this for any ``next_action`` hint or error message that + suggests the user run a script. The workspace no + longer hosts a ``.rpgkit/scripts/`` copy, so the historic + ``python3 .rpgkit/scripts/X.py`` form would fail; ``rpgkit script + X.py`` works regardless of workspace layout. + """ + return f"rpgkit script {script_relpath.lstrip('/')}" + + # ============================================================================ -# .rpgkit Directory Structure (absolute, derived from WORKSPACE_ROOT) -# ============================================================================ +# .rpgkit Directory Structure (runtime state in user home) +# ========================================================================== +# +# Layout: +# +# RPGKIT_DIR = /.rpgkit/ (minimal marker tree: config.toml + .source) +# DATA_DIR = ~/.rpgkit/workspaces//data/ +# LOGS_DIR = ~/.rpgkit/workspaces//logs/ +# REPORTS_DIR = /.rpgkit/reports/ (kept in workspace by +# design: small, user-facing, may be git-tracked) +# +# Falling back to the legacy in-workspace paths when ``_storage`` is +# unavailable keeps this module importable from third-party tools that +# don't ship rpgkit_cli in the same env. RPGKIT_DIR = WORKSPACE_ROOT / ".rpgkit" -DATA_DIR = RPGKIT_DIR / "data" -LOGS_DIR = RPGKIT_DIR / "logs" + +if _HOME_STORAGE_AVAILABLE and _rpgkit_storage is not None: + DATA_DIR = _rpgkit_storage.workspace_data_dir(WORKSPACE_ROOT) + LOGS_DIR = _rpgkit_storage.workspace_logs_dir(WORKSPACE_ROOT) + REPORTS_DIR = _rpgkit_storage.workspace_reports_dir(WORKSPACE_ROOT) +else: + DATA_DIR = RPGKIT_DIR / "data" + LOGS_DIR = RPGKIT_DIR / "logs" + REPORTS_DIR = RPGKIT_DIR / "reports" + COPILOT_LOGS_DIR = LOGS_DIR / "copilot" CLAUDE_LOGS_DIR = LOGS_DIR / "claude" @@ -158,6 +266,14 @@ def get_scripts_dir() -> str: DEP_GRAPH_FILE = DATA_DIR / "dep_graph.json" REPO_INFO_FILE = DATA_DIR / "repo_info.json" +# rpg.html lives in REPORTS_DIR (workspace-side) rather than next to +# rpg.json (home-side) because the HTML is a *user-facing* artefact - +# something the developer opens in a browser and may want to share / +# commit alongside the source. Keeping it in ``.rpgkit/reports/`` also +# means double-clicking it from a file explorer "just works" without +# having to dig into ``~/.rpgkit/workspaces//``. +RPG_HTML_FILE = REPORTS_DIR / "rpg.html" + # ============================================================================ # Task Planning & Execution @@ -167,6 +283,19 @@ def get_scripts_dir() -> str: CODE_GEN_STATE_FILE = DATA_DIR / "code_gen_state.jsonl" +# ============================================================================ +# RPG Edit (surgical edit pipeline) — well-known artefact locations under +# ``DATA_DIR``. Scripts default their ``--plan`` / ``--impact`` arguments +# to these paths so slash-command templates don't need to know the +# physical (home-dir) location of the workspace. +# ============================================================================ + +RPG_EDIT_PLAN_FILE = DATA_DIR / "rpg_edit_plan.json" +RPG_EDIT_IMPACT_FILE = DATA_DIR / "rpg_edit_impact.json" +RPG_EDIT_CODE_RESULT_FILE = DATA_DIR / "rpg_edit_code_result.json" +RPG_EDIT_REVIEW_RESULT_FILE = DATA_DIR / "rpg_edit_review_result.json" + + # ============================================================================ # Trajectory & Logging # ============================================================================ @@ -180,7 +309,7 @@ def get_scripts_dir() -> str: MCP_CALLS_LOG = LOGS_DIR / "mcp_calls.jsonl" HOOK_CALLS_LOG = LOGS_DIR / "hook_calls.jsonl" -REPORTS_DIR = RPGKIT_DIR / "reports" +# REPORTS_DIR is defined above (workspace-local in the new layout). # ============================================================================ @@ -188,7 +317,18 @@ def get_scripts_dir() -> str: # ============================================================================ def ensure_rpgkit_dir() -> Path: - """Ensure .rpgkit/data directory exists and return its path.""" + """Ensure ``DATA_DIR`` exists and return its path. + + In the home-storage layout, ``DATA_DIR`` lives under + ``~/.rpgkit/workspaces//data/``. We only create the leaf + directory here; full home-layout bootstrap (including + ``.meta.toml``) is the responsibility of ``rpgkit init`` / + ``rpgkit update``. Calling this from a script that lands in a + workspace without a meta file is supported — the data dir still + gets created and the script can write its output — but the + workspace won't be properly registered until the user runs + ``rpgkit update`` (or ``init``). + """ DATA_DIR.mkdir(parents=True, exist_ok=True) return DATA_DIR diff --git a/RPG-Kit/scripts/common/project_types.py b/RPG-Kit/scripts/common/project_types.py index 03e144d..c705ece 100644 --- a/RPG-Kit/scripts/common/project_types.py +++ b/RPG-Kit/scripts/common/project_types.py @@ -6,8 +6,6 @@ tokens to decide whether to inject web-specific guidance, GUI tooling, data-pipeline checks, etc. -See ``plans/20260508-1-rpgkit-optimization*.md`` § B3 for the full design -and acceptance criteria. This module is intentionally tiny — no dependency on RPG/dataflow code so it stays cheap to import from validation utilities. diff --git a/RPG-Kit/scripts/common/rpg_io.py b/RPG-Kit/scripts/common/rpg_io.py new file mode 100644 index 0000000..98899e1 --- /dev/null +++ b/RPG-Kit/scripts/common/rpg_io.py @@ -0,0 +1,272 @@ +"""Atomic write and corruption-recovery helpers for ``rpg.json``. + +``rpg.json`` is the central pipeline artefact; corruption blocks every +downstream stage. Two failure modes have hurt users in the past: + +1. **Interrupted writes** — encoder dumps the full JSON in one + ``json.dump`` call. If the process is killed (Ctrl-C, OOM, power + loss) mid-write, the file is left half-truncated and every + subsequent read raises ``JSONDecodeError``. The workspace is + effectively bricked until the user re-runs the encoder. + +2. **Silent corruption with no recovery path** — once truncated, the + only "fix" was to re-encode from scratch. But the inner-git + snapshot repo already holds the previous good state at + ``~/.rpgkit/workspaces//.git/``; we just weren't using it. + +This module fixes both with two complementary primitives: + +* :func:`atomic_write_rpg` — serialise to ``.tmp`` first, then + ``os.replace()`` into place. POSIX (and Windows since 2018) + guarantee the rename is atomic, so any reader either sees the + complete previous version or the complete new one — never a partial + write. +* :func:`safe_load_rpg` — on ``JSONDecodeError`` (corruption), walk + the inner-git history of the workspace looking for the most recent + commit where the file parsed cleanly, restore it on disk (so + subsequent callers don't pay the recovery cost), emit a single + warning to ``logging``, and return the recovered data. If no good + snapshot exists, the original ``JSONDecodeError`` is re-raised so + callers can decide how to degrade. + +Design constraints +------------------ + +* No new dependencies; uses ``os`` + ``subprocess`` + ``json``. +* Recovery is best-effort: a failure to invoke git, a missing inner + repo, or a missing good snapshot all fall through cleanly to + re-raising the original parse error. +* The recovered file is written back atomically (same + :func:`atomic_write_rpg` path) so the workspace doesn't relapse on + the next read. +* Logging uses ``logging.getLogger(__name__)`` so calls from scripts + that have configured logging will surface the warning, while + callers in quiet contexts (e.g. MCP server with stderr-redirect) + won't be perturbed. +""" +from __future__ import annotations + +import json +import logging +import os +import subprocess +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Public: atomic write +# --------------------------------------------------------------------------- + +def atomic_write_rpg( + path: Path | str, + data: Any, + *, + indent: int = 2, + ensure_ascii: bool = False, +) -> None: + """Serialise ``data`` to ``path`` atomically as JSON. + + Writes to ``.tmp`` first then renames into place. If the + write fails mid-way (e.g. disk full), the original file (if any) + remains intact and we clean up the partial ``.tmp``. + + The signature matches ``json.dump`` for indent / ensure_ascii so + callers swapping ``open(path, "w") + json.dump`` for this helper + don't have to rethink their JSON formatting choices. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=indent, ensure_ascii=ensure_ascii) + f.write("\n") + # fsync gives us strong durability guarantees: an os.replace + # immediately after a crash could otherwise expose the + # rename without the bytes if the kernel hadn't flushed. + # On filesystems that don't support fsync (rare), the call + # is a harmless no-op. + try: + f.flush() + os.fsync(f.fileno()) + except OSError: + pass + os.replace(tmp, path) + except Exception: + # Clean up a stray .tmp so the next attempt isn't confused by + # a leftover. Swallowing this secondary error preserves the + # original traceback for the caller. + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass + raise + + +# --------------------------------------------------------------------------- +# Public: safe load with inner-git recovery +# --------------------------------------------------------------------------- + +def safe_load_rpg(path: Path | str) -> Any: + """Parse the JSON at ``path``, with automatic recovery on corruption. + + Behaviour: + + * Success path — file parses cleanly: return the deserialised data, + no side effects. + * Corruption path — ``json.JSONDecodeError`` from the read attempt + triggers :func:`_try_restore_from_inner_git`, which scans the + inner-git repo looking for the most recent commit where + the file was valid JSON. If one is found, the file is rewritten + on disk (atomically) with that content, a warning is logged, and + the recovered data is returned. + * Unrecoverable path — no inner git, no valid history, or git + unavailable: the original ``JSONDecodeError`` is re-raised. + * Missing file: ``FileNotFoundError`` is propagated unchanged + (recovery is for *corruption*, not for never-encoded + workspaces). + """ + path = Path(path) + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError as exc: + recovered = _try_restore_from_inner_git(path, exc) + if recovered is None: + # Recovery failed — surface the original parse error so the + # caller can decide how to react (MCP server returns + # ``rpg_unavailable``; scripts may want to abort). + raise + return recovered + + +# --------------------------------------------------------------------------- +# Internal: inner-git recovery +# --------------------------------------------------------------------------- + +# Filenames inside the inner-git repo that we know how to recover. +# Mirrors the layout produced by :mod:`rpgkit_cli._inner_git`: +# ``data/rpg.json``, ``data/dep_graph.json``, etc. +def _git_relpath_for(path: Path) -> Optional[str]: + """Return the path relative to the home-workspace dir for git lookup. + + ``rpg.json`` lives at ``~/.rpgkit/workspaces//data/rpg.json``; + the inner git repo is rooted at ``~/.rpgkit/workspaces//``, + so the path we ``git checkout`` is ``data/rpg.json``. Falls back + to ``None`` when ``path`` doesn't look like it lives under such a + home dir (e.g. test fixtures passing absolute paths into ``/tmp``). + """ + parts = path.resolve().parts + # Look for ".rpgkit/workspaces//..." in the path's components. + try: + idx = parts.index(".rpgkit") + if ( + idx + 2 < len(parts) + and parts[idx + 1] == "workspaces" + # parts[idx+2] is the hash + ): + return "/".join(parts[idx + 3 :]) + except ValueError: + pass + return None + + +def _inner_git_dir_for(path: Path) -> Optional[Path]: + """Find the home-workspace dir (containing ``.git/``) for ``path``.""" + cur = path.resolve().parent + while True: + if (cur / ".git").is_dir() and cur.parent.name == "workspaces": + return cur + if cur.parent == cur: + return None + cur = cur.parent + + +def _try_restore_from_inner_git( + path: Path, original_exc: json.JSONDecodeError +) -> Optional[Any]: + """Recover ``path`` from inner-git; return data or None on failure. + + Walks the linear history of the inner repo from HEAD backwards, + fetching the file content at each commit via ``git show``. The + first commit where the content parses as valid JSON wins. When + a winner is found we also re-write the file on disk (atomically) + so subsequent reads don't pay the recovery cost. + """ + git_dir = _inner_git_dir_for(path) + if git_dir is None: + return None + relpath = _git_relpath_for(path) + if relpath is None: + return None + from shutil import which + if which("git") is None: + return None + + # Force English git messages (consistent with _inner_git.py). + # Strip any inherited ``GIT_*`` vars (e.g. ``GIT_DIR``, + # ``GIT_INDEX_FILE``) that would point ``git`` at the **outer** + # repository when this recovery runs inside a hook context. This + # mirrors the env-sanitisation done in ``rpgkit_cli._inner_git._run_git``. + env = {k: v for k, v in os.environ.items() + if k not in ("GIT_INDEX_FILE", "GIT_DIR", + "GIT_WORK_TREE", "GIT_OBJECT_DIRECTORY")} + env["LC_ALL"] = "C" + env["LANG"] = "C" + + # Walk linear history (most recent first). ``--follow`` keeps + # working when a script ever renames data files in the future. + try: + log = subprocess.run( + ["git", "-C", str(git_dir), "log", "--follow", + "--format=%H", "--", relpath], + capture_output=True, text=True, env=env, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + return None + if log.returncode != 0: + return None + + commits = [c.strip() for c in log.stdout.splitlines() if c.strip()] + for commit in commits: + try: + show = subprocess.run( + ["git", "-C", str(git_dir), "show", f"{commit}:{relpath}"], + capture_output=True, text=True, env=env, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + continue + if show.returncode != 0: + continue + try: + data = json.loads(show.stdout) + except json.JSONDecodeError: + # Older snapshot also broken — skip and keep walking. + continue + + # Found a good snapshot — restore it on disk + return. + try: + atomic_write_rpg(path, data) + except OSError: + # If we can't write back (read-only fs?), still return the + # recovered data so the caller can proceed; the next + # successful write will heal the file on disk. + pass + + logger.warning( + "rpg-io: %s was corrupted (%s at line %d col %d); auto-restored " + "from inner-git snapshot %s. Run `rpgkit version` to see the " + "exact inner-git path.", + path, + original_exc.msg, + original_exc.lineno, + original_exc.colno, + commit[:8], + ) + return data + + return None diff --git a/RPG-Kit/scripts/design_base_classes.py b/RPG-Kit/scripts/design_base_classes.py index 37f1d4a..b6f6ad2 100644 --- a/RPG-Kit/scripts/design_base_classes.py +++ b/RPG-Kit/scripts/design_base_classes.py @@ -521,7 +521,7 @@ def main(): logger.info(f"[OK] Base classes saved to: {output_path}") designer.print_summary(result) - print(f"\n[OK] Base classes saved to: {output_path}") + print(f"\n[OK] Base classes saved to: {output_path.name}") # Update RPG with base classes if result.get("success", True): @@ -540,7 +540,7 @@ def main(): "data_structure_files": len(result.get("data_structures", [])), "data_structure_names": result.get("data_structure_names", []), }) - print(f"[OK] Trajectory saved to: {trajectory.trajectory_file}") + print(f"[OK] Trajectory saved to: {trajectory.trajectory_file.name}") return 0 diff --git a/RPG-Kit/scripts/design_interfaces.py b/RPG-Kit/scripts/design_interfaces.py index 5de9201..a1b70bc 100644 --- a/RPG-Kit/scripts/design_interfaces.py +++ b/RPG-Kit/scripts/design_interfaces.py @@ -1208,7 +1208,7 @@ def main(): logger.info(f"[OK] Interfaces saved to: {output_path}") designer.print_summary(result) - print(f"\n[OK] Interfaces saved to: {output_path}") + print(f"\n[OK] Interfaces saved to: {output_path.name}") # RPG update is now handled inside InterfaceDesigner.build() via InterfacesStore @@ -1233,7 +1233,7 @@ def main(): "invocation_edges": len(enhanced_data_flow.get("invocation_edges", [])), "reference_edges": len(enhanced_data_flow.get("reference_edges", [])) }) - print(f"[OK] Trajectory saved to: {trajectory.trajectory_file}") + print(f"[OK] Trajectory saved to: {trajectory.trajectory_file.name}") return 0 diff --git a/RPG-Kit/scripts/feature_build_validation.py b/RPG-Kit/scripts/feature_build_validation.py index 1837f02..09e7259 100644 --- a/RPG-Kit/scripts/feature_build_validation.py +++ b/RPG-Kit/scripts/feature_build_validation.py @@ -146,7 +146,7 @@ def validate_input_file() -> Dict[str, Any]: "project_notes": meta_dict.get("project_notes"), } - # Validate project_types / project_notes (plan B3). Soft-fail with + # Validate project_types / project_notes. Soft-fail with # an error entry so the operator regenerates feature_spec, but # don't prevent legacy specs (without these fields) from running # through downstream stages — they will simply miss the project- @@ -167,7 +167,7 @@ def validate_input_file() -> Dict[str, Any]: logger = logging.getLogger(__name__) logger.warning( "feature_spec.meta is missing project_types/project_notes " - "(plan B3); downstream prompts will lack project-type context" + "; downstream prompts will lack project-type context" ) if result["fields"]["functional_requirements"]: diff --git a/RPG-Kit/scripts/feature_spec_to_json.py b/RPG-Kit/scripts/feature_spec_to_json.py index 21a0f68..a9ae48f 100644 --- a/RPG-Kit/scripts/feature_spec_to_json.py +++ b/RPG-Kit/scripts/feature_spec_to_json.py @@ -8,7 +8,7 @@ Output: A structured JSON file with all parsed content. Usage: - python .rpgkit/scripts/feature_spec_to_json.py [--input-dir DIR] [--output FILE] [--no-evidence] + rpgkit script feature_spec_to_json.py [--input-dir DIR] [--output FILE] [--no-evidence] Arguments: --input-dir Directory containing feature_spec.md and features/ folder @@ -25,6 +25,15 @@ from pathlib import Path from typing import Optional +# Use the canonical paths from common.paths so the output location +# matches what downstream stages (feature_build, feature_build_validation, +# ...) expect. That resolves to +# ``~/.rpgkit/workspaces//data/feature_spec.json`` rather than the +# workspace-local ``.rpgkit/data/feature_spec.json`` this script used +# to compute on its own — a mismatch that previously broke the +# feature_spec → feature_build handoff. +from common.paths import FEATURE_SPEC_FILE + def parse_evidence_line(line: str) -> Optional[dict]: """Parse an evidence reference line. @@ -390,12 +399,14 @@ def main(): if args.output: output_file = args.output else: - # Default output is in parent directory of input_dir - output_file = input_dir.parent / "feature_spec.json" + # Default to the canonical location from common.paths so + # downstream stages (feature_build) can find it. The output + # lives in the home-side data dir. + output_file = FEATURE_SPEC_FILE include_evidence = not args.no_evidence - print(f"Parsing feature specification from: {input_dir}") + print(f"Parsing feature specification from: {input_dir.name}") print(f"Include evidence: {include_evidence}") try: @@ -406,8 +417,9 @@ def main(): with open(output_file, "w", encoding="utf-8") as f: json.dump(spec, f, indent=2, ensure_ascii=False) - # Print summary - print(f"\nOutput written to: {output_file}") + # Print summary — use only the file name so stdout stays + # workspace-independent; the agent cannot access home-side paths. + print(f"\nOutput written to: {output_file.name}") print(f" - Repository: {spec.get('repository_name', 'N/A')}") print(f" - Background items: {len(spec.get('background_and_overview', []))}") print(f" - NFR items: {len(spec.get('non_functional_requirements', []))}") diff --git a/RPG-Kit/scripts/init_codebase.py b/RPG-Kit/scripts/init_codebase.py index 9f1f843..bba85c6 100644 --- a/RPG-Kit/scripts/init_codebase.py +++ b/RPG-Kit/scripts/init_codebase.py @@ -36,7 +36,7 @@ REPO_RPG_FILE, FEATURE_BUILD_FILE, CODE_GEN_STATE_FILE as STATE_FILE, - get_scripts_dir, + cmd_for, REPO_DIR, ) from common.execution_state import load_code_gen_state, save_code_gen_state @@ -213,7 +213,7 @@ def _gitignore_has_rpgkit_block(existing: str) -> bool: # Agent Detection & Persistent Instructions # ============================================================================ # -# Removed in commit C4 (see plans/20260508-1-rpgkit-optimization*.md): the +# Removed: the # previously-generated `repo/.claude/rules/rpgkit-codegen.md` and # `repo/.github/instructions/rpgkit-codegen.instructions.md` files were # auto-loaded by Claude Code / Copilot for **every** session, contaminating @@ -521,14 +521,13 @@ def init_codebase( # IS the project repo root, so ``.claude`` is already at the right # location and the symlink is unnecessary (and would point at # ``/.claude``, i.e. outside the workspace). - # Block removed deliberately; do NOT reintroduce. + # Block removed on purpose; do not reintroduce. # Check if already initialized if state_path.exists(): try: state = load_code_gen_state(state_path) if state.initialized: - scripts = get_scripts_dir() return { "success": False, "error": "Codebase already initialized", @@ -536,7 +535,7 @@ def init_codebase( "initialized_at": state.initialized_at, "suggestion": "Run run_batch.py to start codegen", "next_action": ( - f"Already initialized. Run: python3 {scripts}/run_batch.py --next --json " + f"Already initialized. Run: {cmd_for('run_batch.py')} --next --json " f"to start the next batch." ) } @@ -591,7 +590,6 @@ def init_codebase( state.initialized = True state.initialized_at = datetime.now().isoformat() save_code_gen_state(state, state_path) - scripts = get_scripts_dir() return { "success": True, "message": "Repository already set up, no changes needed", @@ -599,7 +597,7 @@ def init_codebase( "gitignore_created": False, "base_class_files": 0, "next_action": ( - f"Codebase already set up. Run: python3 {scripts}/run_batch.py --next --json " + f"Codebase already set up. Run: {cmd_for('run_batch.py')} --next --json " f"to start the first batch." ) } @@ -632,7 +630,7 @@ def init_codebase( "commit_hash": commit_hash, "message": "Repository initialized successfully" if not dry_run else "Dry run complete", "next_action": ( - f"Codebase initialized. Run: python3 {get_scripts_dir()}/run_batch.py --next --json " + f"Codebase initialized. Run: {cmd_for('run_batch.py')} --next --json " f"to start the first batch." ) if not dry_run else "Dry run complete. Re-run without --dry-run to apply changes." } diff --git a/RPG-Kit/scripts/mcp_server.py b/RPG-Kit/scripts/mcp_server.py index 8ae8d01..a0e8e88 100644 --- a/RPG-Kit/scripts/mcp_server.py +++ b/RPG-Kit/scripts/mcp_server.py @@ -10,14 +10,16 @@ - ``list_rpg_tree`` -- browse RPG feature tree structure The server communicates over stdio (the standard MCP transport for -CLI-based servers). It is designed to be deployed under -``/.rpgkit/scripts/`` by ``rpgkit init`` / ``rpgkit update``, -and registered automatically in ``.mcp.json`` (Claude) or -``.vscode/mcp.json`` (VS Code Copilot). +CLI-based servers). It ships inside the ``rpgkit-cli`` wheel and is +launched by MCP clients via the ``rpgkit-mcp`` console script (which +``.mcp.json`` / ``.vscode/mcp.json`` register as the ``rpg-tools`` +command — see ``rpgkit_cli.entries:mcp_main``). -Run directly:: +Run directly (for debugging):: - python /.rpgkit/scripts/mcp_server.py [--rpg-file PATH] + rpgkit-mcp [--rpg-file PATH] + # or equivalently: + rpgkit script mcp_server.py [--rpg-file PATH] """ import json @@ -73,7 +75,17 @@ def _log_tool_call(tool_name: str, params: dict, result_summary: dict, duration_ # --------------------------------------------------------------------------- def _resolve_rpg_path() -> str: - """Resolve RPG file path from CLI args or default (.rpgkit/data/rpg.json).""" + """Resolve the RPG file path from CLI args, falling back to the default. + + The default (``RPG_FILE``) is provided by + :mod:`common.paths`, which resolves to + ``~/.rpgkit/workspaces//data/rpg.json`` for the current + workspace (discovered by walking up from cwd looking for + ``.rpgkit/config.toml``). Callers running ``rpgkit-mcp`` from any + subdirectory of a workspace therefore get the right RPG file + automatically; ``--rpg-file`` is reserved for explicit overrides + (test fixtures, alternative graphs, …). + """ rpg_path = str(RPG_FILE) args = sys.argv[1:] for i, arg in enumerate(args): @@ -84,11 +96,13 @@ def _resolve_rpg_path() -> str: # Standard message returned to the AI agent when the RPG graph isn't ready # (e.g. ``rpgkit init`` ran, but the encoder hasn't been run yet so -# ``.rpgkit/data/rpg.json`` doesn't exist). Kept short + actionable so -# the agent will relay it verbatim to the user. +# the resolved ``rpg.json`` doesn't exist). Kept short + actionable so +# the agent will relay it verbatim to the user. The hint omits the +# concrete directory path; the actual location is reported as the +# ``rpg_file`` field of :func:`_unavailable_payload`. _ENCODE_HINT = ( "RPG graph not generated yet. Ask the user to run **`/rpgkit.encode`** " - "in this AI agent to build `.rpgkit/data/rpg.json`. Once it finishes, " + "in this AI agent to build the workspace's `rpg.json`. Once it finishes, " "RPG tools will start working automatically on the next call — no need " "to restart the MCP server." ) @@ -97,7 +111,7 @@ def _resolve_rpg_path() -> str: def _unavailable_payload(rpg_path: str, reason: str) -> str: """Render a uniform 'graph not available' JSON response for every tool. - The shape is deliberately identical across all 4 tools so the AI agent + The shape is identical across all 4 tools so the AI agent can reliably detect the condition (``error == "rpg_unavailable"``) and surface the ``next_step`` field to the user. """ @@ -179,7 +193,7 @@ def _unavailable_reason() -> str: "Program Graph (RPG) for the current workspace \u2014 a " "pre-computed, queryable index of the codebase built by " "`/rpgkit.encode` and kept in sync with HEAD by a " - "pre-commit hook.\n\n" + "post-commit hook.\n\n" "What the RPG knows about this repository:\n" " \u2022 The feature hierarchy: functional areas \u2192 " "feature groups \u2192 individual features, each linked to " @@ -377,10 +391,18 @@ def list_rpg_tree( # --------------------------------------------------------------------------- -# Entry point: python .rpgkit/scripts/mcp_server.py [--rpg-file PATH] +# Entry point: ``rpgkit-mcp`` console script (via rpgkit_cli.entries:mcp_main) +# or direct ``python /mcp_server.py [--rpg-file PATH]`` for +# debugging. # --------------------------------------------------------------------------- -if __name__ == "__main__": +def main() -> None: + """Run the MCP server over stdio. + + Used by both the ``rpgkit-mcp`` console-script entry (which sets up + ``sys.path`` then imports and calls this function) and the direct + ``python mcp_server.py`` invocation under ``__main__``. + """ rpg_path = _resolve_rpg_path() # NOTE: do NOT sys.exit when the file is missing. The MCP transport # must stay up so the client can actually receive the @@ -396,3 +418,7 @@ def list_rpg_tree( server = create_mcp_server(rpg_file=rpg_path) server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/RPG-Kit/scripts/plan_tasks.py b/RPG-Kit/scripts/plan_tasks.py index 4861149..d205c06 100644 --- a/RPG-Kit/scripts/plan_tasks.py +++ b/RPG-Kit/scripts/plan_tasks.py @@ -675,7 +675,7 @@ def plan_subtree_tasks( # ============================================================================ def _format_entry_file_hint(project_types: List[str]) -> str: - """Render a per-project-type hint about the entry filename (plan B4). + """Render a per-project-type hint about the entry filename. Returns a single-line string appended to the "main.py" bullet of the main-entry task description. The wording stays advisory — the agent @@ -1286,7 +1286,7 @@ def _build_main_entry_task(self) -> str: # Read project_types so we can hint at the right entry-file shape # without locking the agent into "main.py" for SERVICE/PIPELINE - # projects (plan B4). Failure to load is non-fatal — fall back to + # projects. Failure to load is non-fatal — fall back to # the generic guidance. project_types = self._load_project_types() entry_hint = _format_entry_file_hint(project_types) @@ -1365,7 +1365,7 @@ def main(args: Optional[list] = None) -> int: """ def _load_project_types(self) -> List[str]: - """Load ``feature_spec.meta.project_types`` if available (plan B4). + """Load ``feature_spec.meta.project_types`` if available. Returns an empty list when the file is missing, malformed, or has no valid tokens. Callers must handle the empty case. @@ -1599,7 +1599,7 @@ def main(): with open(args.output, 'w', encoding='utf-8') as f: json.dump(result, f, indent=2) - print(f"\n [OK] Tasks saved to: {args.output}") + print(f"\n [OK] Tasks saved to: {args.output.name}") # Complete trajectory if trajectory: diff --git a/RPG-Kit/scripts/rpg/graph_query.py b/RPG-Kit/scripts/rpg/graph_query.py index d56733b..734d07f 100644 --- a/RPG-Kit/scripts/rpg/graph_query.py +++ b/RPG-Kit/scripts/rpg/graph_query.py @@ -21,6 +21,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from common.rpg_io import safe_load_rpg + try: from rapidfuzz import fuzz, process as rf_process _HAS_RAPIDFUZZ = True @@ -96,10 +98,14 @@ def from_rpg_file(cls, rpg_path: str) -> "GraphQueryEngine": """Load from a single rpg.json file. Handles both embedded dep_graph and external dep_graph_file reference. + + Uses :func:`common.rpg_io.safe_load_rpg` so a corrupted ``rpg.json`` + (e.g. an encoder that was killed mid-write) is silently recovered + from the inner-git snapshot history rather than blocking every + downstream read with ``JSONDecodeError``. """ rpg_dir = Path(rpg_path).resolve().parent - with open(rpg_path, "r", encoding="utf-8") as f: - rpg_data = json.load(f) + rpg_data = safe_load_rpg(rpg_path) # Try embedded dep_graph first, then external file dep_graph_data = rpg_data.get("dep_graph", {}) @@ -108,8 +114,7 @@ def from_rpg_file(cls, rpg_path: str) -> "GraphQueryEngine": if dep_graph_file: dep_path = rpg_dir / dep_graph_file if dep_path.is_file(): - with open(dep_path, "r", encoding="utf-8") as f: - dep_graph_data = json.load(f) + dep_graph_data = safe_load_rpg(dep_path) logger.info("Loaded dep_graph from %s", dep_path) else: logger.warning("dep_graph_file not found: %s", dep_path) @@ -119,11 +124,9 @@ def from_rpg_file(cls, rpg_path: str) -> "GraphQueryEngine": @classmethod def from_files(cls, rpg_path: str, dep_graph_path: str = "") -> "GraphQueryEngine": """Load from JSON files. If dep_graph_path is empty, uses embedded dep_graph.""" - with open(rpg_path, "r", encoding="utf-8") as f: - rpg_data = json.load(f) + rpg_data = safe_load_rpg(rpg_path) if dep_graph_path: - with open(dep_graph_path, "r", encoding="utf-8") as f: - dep_graph_data = json.load(f) + dep_graph_data = safe_load_rpg(dep_graph_path) else: dep_graph_data = rpg_data.get("dep_graph", {}) return cls(rpg_data, dep_graph_data) diff --git a/RPG-Kit/scripts/rpg/models.py b/RPG-Kit/scripts/rpg/models.py index 90564ef..d399d6f 100644 --- a/RPG-Kit/scripts/rpg/models.py +++ b/RPG-Kit/scripts/rpg/models.py @@ -712,9 +712,9 @@ def _infer_missing_node_type(self, node: Node) -> Optional[str]: 2) Feature-tree shape (category/subcategory/feature_group/feature) Note: ``meta.type_name`` (file/class/function/method/directory) is - the *code entity type*, NOT the tree-level role. We never use it - to set ``node_type`` — that would mix two orthogonal concepts. - Instead we rely on tree structure (leaf vs non-leaf, parent type). + the *code entity type*, not the tree-level role. ``node_type`` + is derived from tree structure (leaf vs non-leaf, parent type) + because the two concepts are orthogonal. """ if node.id == self.repo_node.id: return "repo" diff --git a/RPG-Kit/scripts/rpg_edit/__init__.py b/RPG-Kit/scripts/rpg_edit/__init__.py index a831f09..11b899a 100644 --- a/RPG-Kit/scripts/rpg_edit/__init__.py +++ b/RPG-Kit/scripts/rpg_edit/__init__.py @@ -1,7 +1,7 @@ """RPG edit pipeline — CLI entry points for the ``/rpgkit.rpg_edit`` flow. Each module is a standalone script meant to be invoked as -``python3 .rpgkit/scripts/rpg_edit/.py [args]``. They share the +``rpgkit script rpg_edit/.py [args]``. They share the ``common.paths`` / ``rpg`` / ``run_batch`` infrastructure that lives at ``scripts/`` and add ``scripts/`` (i.e. ``parent.parent``) to ``sys.path`` on import so the relative-import path stays predictable regardless of cwd. diff --git a/RPG-Kit/scripts/rpg_edit/apply.py b/RPG-Kit/scripts/rpg_edit/apply.py index c9bc0c4..deddd54 100644 --- a/RPG-Kit/scripts/rpg_edit/apply.py +++ b/RPG-Kit/scripts/rpg_edit/apply.py @@ -21,7 +21,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, REPO_DIR # noqa: E402 +from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, REPO_DIR, RPG_EDIT_PLAN_FILE # noqa: E402 def _backup(rpg_path: Path, dep_graph_path: Path, ts: str) -> Dict[str, str]: @@ -112,8 +112,8 @@ def apply_feature_changes(svc, changes: list) -> list: def main(): parser = argparse.ArgumentParser(description="Apply EditPlan to RPG + code") - parser.add_argument("--plan", type=Path, required=True, - help="Path to rpg_edit_plan.json") + parser.add_argument("--plan", type=Path, default=RPG_EDIT_PLAN_FILE, + help="Path to rpg_edit_plan.json (default: %(default)s)") parser.add_argument("--rpg", type=Path, default=REPO_RPG_FILE) parser.add_argument("--dep-graph", type=Path, @@ -142,7 +142,6 @@ def main(): args = parser.parse_args() # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") diff --git a/RPG-Kit/scripts/rpg_edit/code.py b/RPG-Kit/scripts/rpg_edit/code.py index 7b67bbc..6ce192d 100644 --- a/RPG-Kit/scripts/rpg_edit/code.py +++ b/RPG-Kit/scripts/rpg_edit/code.py @@ -41,9 +41,11 @@ from common.paths import ( # noqa: E402 RPG_FILE, + REPO_DIR, + RPG_EDIT_PLAN_FILE, DATA_DIR, WORKSPACE_ROOT, - REPO_DIR, + cmd_for, ) from common.logging_setup import setup_file_logging # noqa: E402 @@ -174,7 +176,7 @@ def _build_validation_cmds(code_changes: List[dict]) -> Tuple[str, str]: we still use absolute paths to keep the prompt cwd-agnostic — it must work no matter where the user runs the slash command from. """ - smoke = f"python3 {WORKSPACE_ROOT}/.rpgkit/scripts/smoke_test.py --json" + smoke = f"{cmd_for('smoke_test.py')} --json" patterns = _derive_test_files(code_changes) if patterns: @@ -645,8 +647,8 @@ def main() -> int: description="Apply EditPlan code_changes via SubAgent (RPG-driven)", ) parser.add_argument( - "--plan", type=Path, required=True, - help="Path to rpg_edit_plan.json", + "--plan", type=Path, default=RPG_EDIT_PLAN_FILE, + help="Path to rpg_edit_plan.json (default: %(default)s)", ) parser.add_argument( "--rpg", type=Path, default=RPG_FILE, diff --git a/RPG-Kit/scripts/rpg_edit/impact.py b/RPG-Kit/scripts/rpg_edit/impact.py index cf39897..2253199 100644 --- a/RPG-Kit/scripts/rpg_edit/impact.py +++ b/RPG-Kit/scripts/rpg_edit/impact.py @@ -17,7 +17,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE # noqa: E402 +from common.paths import REPO_RPG_FILE, RPG_EDIT_IMPACT_FILE # noqa: E402 def analyze_impact(svc, node_ids: List[str]) -> Dict: @@ -117,10 +117,13 @@ def main(): parser.add_argument("--rpg", type=Path, default=REPO_RPG_FILE) parser.add_argument("--json", action="store_true") + parser.add_argument("--save", action="store_true", + help=f"Also write the JSON result to " + f"{RPG_EDIT_IMPACT_FILE} so downstream " + f"steps (review.py) can pick it up.") args = parser.parse_args() # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") @@ -129,6 +132,11 @@ def main(): results = analyze_impact(svc, args.node_id) output = {"type": "impact_analysis", "results": results} + if args.save: + RPG_EDIT_IMPACT_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_IMPACT_FILE.write_text( + json.dumps(output, indent=2, ensure_ascii=False) + ) if args.json: print(json.dumps(output, indent=2, ensure_ascii=False)) else: diff --git a/RPG-Kit/scripts/rpg_edit/locate.py b/RPG-Kit/scripts/rpg_edit/locate.py index ca9333e..082b819 100644 --- a/RPG-Kit/scripts/rpg_edit/locate.py +++ b/RPG-Kit/scripts/rpg_edit/locate.py @@ -159,7 +159,6 @@ def main(): args = parser.parse_args() # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") diff --git a/RPG-Kit/scripts/rpg_edit/review.py b/RPG-Kit/scripts/rpg_edit/review.py index 7ae478a..c5ac4ef 100644 --- a/RPG-Kit/scripts/rpg_edit/review.py +++ b/RPG-Kit/scripts/rpg_edit/review.py @@ -6,7 +6,7 @@ data (callers, affected_files), NOT a full global review. Usage: - python3 .rpgkit/scripts/rpg_edit/review.py \ + rpgkit script rpg_edit/review.py \ --plan .rpgkit/data/rpg_edit_plan.json \ --impact .rpgkit/data/rpg_edit_impact.json \ --json @@ -34,7 +34,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import WORKSPACE_ROOT, REPO_DIR # noqa: E402 +from common.paths import REPO_DIR, cmd_for, RPG_EDIT_PLAN_FILE, RPG_EDIT_IMPACT_FILE # noqa: E402 logger = logging.getLogger(__name__) @@ -105,8 +105,8 @@ For **web apps**, use `inspect` on EVERY affected route to capture screenshots and saved HTML: ```bash -python $BROWSER_TOOL inspect http://localhost:/ -python $BROWSER_TOOL inspect http://localhost:/ +$BROWSER_TOOL inspect http://localhost:/ +$BROWSER_TOOL inspect http://localhost:/ ``` Read the saved HTML files to understand the full page content, CSS layout, and element structure. Check for: @@ -119,7 +119,7 @@ Don't just view pages — **interact** with them like a real user: ```bash -python $BROWSER_TOOL run-script http://localhost:/ --script ' +$BROWSER_TOOL run-script http://localhost:/ --script ' page.click("a:has-text(\\"Some Link\\")") page.wait_for_load_state("networkidle") ' @@ -128,10 +128,10 @@ For **GUI apps**, use the GUI tool: ```bash -python $GUI_TOOL start-display -python $GUI_TOOL launch "python main.py" --wait 3 -python $GUI_TOOL status -python $GUI_TOOL screenshot +$GUI_TOOL start-display +$GUI_TOOL launch "python main.py" --wait 3 +$GUI_TOOL status +$GUI_TOOL screenshot ``` Click every relevant button, fill forms, and screenshot after each action. @@ -340,11 +340,12 @@ def build_impact_review_prompt( pattern = " or ".join(test_patterns) pytest_cmd += f' -k "{pattern}" --timeout=30' - # Use absolute paths so the prompt is cwd-agnostic. - tools_dir = WORKSPACE_ROOT / ".rpgkit" / "scripts" / "tools" - browser_tool = str(tools_dir / "browser.py") - gui_tool = str(tools_dir / "gui.py") - smoke_test_cmd = f"python3 {WORKSPACE_ROOT}/.rpgkit/scripts/smoke_test.py --json" + # Tool invocations route through the global ``rpgkit`` CLI (the + # scripts no longer live in the workspace). See ``rpgkit script`` + # in docs/cli-reference.md. + browser_tool = cmd_for("tools/browser.py") + gui_tool = cmd_for("tools/gui.py") + smoke_test_cmd = f"{cmd_for('smoke_test.py')} --json" # Start instructions depend on project type start_instructions = ( @@ -546,10 +547,10 @@ def main(): parser = argparse.ArgumentParser( description="Impact-scoped review for rpg_edit changes" ) - parser.add_argument("--plan", type=Path, required=True, - help="Path to rpg_edit_plan.json") - parser.add_argument("--impact", type=Path, default=None, - help="Path to rpg_edit_impact.json") + parser.add_argument("--plan", type=Path, default=RPG_EDIT_PLAN_FILE, + help="Path to rpg_edit_plan.json (default: %(default)s)") + parser.add_argument("--impact", type=Path, default=RPG_EDIT_IMPACT_FILE, + help="Path to rpg_edit_impact.json (default: %(default)s)") parser.add_argument("--repo", type=Path, default=None, help="Repository root path") parser.add_argument("--max-iterations", type=int, default=3, @@ -566,7 +567,6 @@ def main(): ) # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") diff --git a/RPG-Kit/scripts/rpg_edit/save_plan.py b/RPG-Kit/scripts/rpg_edit/save_plan.py new file mode 100644 index 0000000..956870c --- /dev/null +++ b/RPG-Kit/scripts/rpg_edit/save_plan.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Save an EditPlan JSON document to ``RPG_EDIT_PLAN_FILE``. + +Reads JSON from stdin, validates that it parses, and writes it to +``~/.rpgkit/workspaces//data/rpg_edit_plan.json``. Slash-command +templates use this so they never need to know the physical (home-dir) +location of the workspace. + +Usage (typical AI-agent invocation):: + + cat << 'PLAN_EOF' | rpgkit script rpg_edit/save_plan.py + { "feature_changes": [...], "code_changes": [...] } + PLAN_EOF + +On success prints the absolute path of the saved file (one line) on +stdout and exits 0. On JSON parse error exits 2 with the parser +message on stderr. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).resolve().parent.parent +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from common.paths import RPG_EDIT_PLAN_FILE # noqa: E402 + + +def main() -> int: + if any(arg in ("-h", "--help") for arg in sys.argv[1:]): + print(__doc__) + print(f"Output path: {RPG_EDIT_PLAN_FILE}") + return 0 + raw = sys.stdin.read() + if not raw.strip(): + print("save_plan: stdin is empty", file=sys.stderr) + return 2 + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + print(f"save_plan: invalid JSON on stdin: {exc}", file=sys.stderr) + return 2 + RPG_EDIT_PLAN_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_PLAN_FILE.write_text( + json.dumps(parsed, indent=2, ensure_ascii=False) + ) + print(str(RPG_EDIT_PLAN_FILE)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/RPG-Kit/scripts/rpg_edit/validate.py b/RPG-Kit/scripts/rpg_edit/validate.py index ca1ccf6..8ad5a96 100644 --- a/RPG-Kit/scripts/rpg_edit/validate.py +++ b/RPG-Kit/scripts/rpg_edit/validate.py @@ -25,7 +25,6 @@ def main(): args = parser.parse_args() # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") @@ -48,7 +47,7 @@ def main(): if not has_dep_graph and not args.dep_graph.exists(): result = {"type": "error", "error_code": "dep_graph_not_found", "message": f"dep_graph.json not found: {args.dep_graph}. " - "Run `python3 .rpgkit/scripts/update_graphs.py sync` " + "Run `rpgkit script update_graphs.py sync` " "to build it from the current code."} print(json.dumps(result) if args.json else f"Error: {result['message']}") return 1 diff --git a/RPG-Kit/scripts/rpg_encoder/run_encode.py b/RPG-Kit/scripts/rpg_encoder/run_encode.py index 3ab16b3..3b8cc84 100644 --- a/RPG-Kit/scripts/rpg_encoder/run_encode.py +++ b/RPG-Kit/scripts/rpg_encoder/run_encode.py @@ -7,8 +7,8 @@ Prints a single JSON result to stdout with status and statistics. Usage: - python3 .rpgkit/scripts/rpg_encoder/run_encode.py --json - python3 .rpgkit/scripts/rpg_encoder/run_encode.py --repo-dir ./my-project + rpgkit script rpg_encoder/run_encode.py --json + rpgkit script rpg_encoder/run_encode.py --repo-dir ./my-project """ import json @@ -25,7 +25,7 @@ if str(_script_dir) not in sys.path: sys.path.insert(0, str(_script_dir)) -from common.paths import RPG_FILE, DEP_GRAPH_FILE, WORKSPACE_ROOT, ensure_rpgkit_dir # noqa: E402 +from common.paths import RPG_FILE, DEP_GRAPH_FILE, RPG_HTML_FILE, WORKSPACE_ROOT, ensure_rpgkit_dir # noqa: E402 from common.trajectory import Trajectory # noqa: E402 @@ -164,8 +164,12 @@ def run_encode( viz_data = load_rpg(output) html_content = generate_html(viz_data) - viz_output = str(Path(output).with_suffix(".html")) - Path(viz_output).write_text(html_content, encoding="utf-8") + # rpg.html is a user-facing artefact: keep it in the + # workspace's .rpgkit/reports/ rather than next to the + # machine-side rpg.json under ~/.rpgkit/workspaces//. + RPG_HTML_FILE.parent.mkdir(parents=True, exist_ok=True) + viz_output = str(RPG_HTML_FILE) + RPG_HTML_FILE.write_text(html_content, encoding="utf-8") traj.complete_step(step_viz.step_id, {"viz_path": viz_output}) except Exception as viz_exc: logger.warning("Failed to generate visualization: %s", viz_exc) diff --git a/RPG-Kit/scripts/rpg_encoder/run_update_rpg.py b/RPG-Kit/scripts/rpg_encoder/run_update_rpg.py index 02f3712..67a6071 100644 --- a/RPG-Kit/scripts/rpg_encoder/run_update_rpg.py +++ b/RPG-Kit/scripts/rpg_encoder/run_update_rpg.py @@ -7,7 +7,7 @@ Prints a single JSON result to stdout with status and diff statistics. Usage: - python3 .rpgkit/scripts/rpg_encoder/run_update_rpg.py --json \\ + rpgkit script rpg_encoder/run_update_rpg.py --json \\ --rpg-file .rpgkit/data/rpg.json --last-repo-dir ./old-version """ diff --git a/RPG-Kit/scripts/rpg_encoder/version_control.py b/RPG-Kit/scripts/rpg_encoder/version_control.py index b48a757..d2c7252 100644 --- a/RPG-Kit/scripts/rpg_encoder/version_control.py +++ b/RPG-Kit/scripts/rpg_encoder/version_control.py @@ -26,6 +26,8 @@ from typing import Any, Dict, List, Optional from rpg import RPG +from pathlib import Path +from common.rpg_io import atomic_write_rpg logger = logging.getLogger(__name__) @@ -173,11 +175,12 @@ def rollback(self, version: int) -> RPG: rpg = RPG.from_dict(payload["rpg"]) - # Also write to the main rpg.json so it becomes the "current" RPG + # Also write to the main rpg.json so it becomes the "current" RPG. + # Atomic write: a kill mid-rollback can't leave a half-truncated + # rpg.json that bricks future reads. main_rpg_path = os.path.join(self.data_dir, RPG_FILE_NAME) os.makedirs(self.data_dir, exist_ok=True) - with open(main_rpg_path, "w", encoding="utf-8") as fh: - json.dump(payload["rpg"], fh, indent=2, ensure_ascii=False) + atomic_write_rpg(Path(main_rpg_path), payload["rpg"]) logger.info( "Rolled back to version %d (%s)", diff --git a/RPG-Kit/scripts/rpg_encoder/workflow.py b/RPG-Kit/scripts/rpg_encoder/workflow.py index 798e4f7..56981ed 100644 --- a/RPG-Kit/scripts/rpg_encoder/workflow.py +++ b/RPG-Kit/scripts/rpg_encoder/workflow.py @@ -53,6 +53,7 @@ from .config import RPGKitConfig from .version_control import RPGVersionControl, RPG_FILE_NAME +from common.rpg_io import atomic_write_rpg logger = logging.getLogger(__name__) @@ -333,8 +334,11 @@ def save_rpg( rpg_dict["repo_info"] = getattr(rpg, "repo_info", "") rpg_dict["excluded_files"] = getattr(rpg, "excluded_files", []) - with open(rpg_path, "w", encoding="utf-8") as fh: - json.dump(rpg_dict, fh, indent=2, ensure_ascii=False) + # Atomic write: a partial encoder run (Ctrl-C, OOM, power loss) + # can no longer brick the workspace with a truncated rpg.json + # — we write to .tmp then os.replace into place. See + # ``common.rpg_io.atomic_write_rpg`` for the recovery side. + atomic_write_rpg(Path(rpg_path), rpg_dict) result: Dict[str, Any] = {"rpg_path": rpg_path} diff --git a/RPG-Kit/scripts/run_batch.py b/RPG-Kit/scripts/run_batch.py index 0a26304..86bbf88 100644 --- a/RPG-Kit/scripts/run_batch.py +++ b/RPG-Kit/scripts/run_batch.py @@ -61,6 +61,7 @@ LOGS_DIR as _LOGS_DIR, WORKSPACE_ROOT, get_scripts_dir, + cmd_for, REPO_DIR, ) from code_gen.context_collector import build_dependency_context @@ -660,7 +661,7 @@ def run_batch( pass if merge_error == "branch_missing": # Sub-agent didn't use the batch branch — skip without - # consuming a retry slot (see plan A3). The helper + # consuming a retry slot. The helper # promotes to failed after _MAX_BATCH_PREPARES skips. skipped = state_skip_batch(batch_id, state_path) if skipped: @@ -679,7 +680,7 @@ def run_batch( return _error( f"Tests pass but branch merge failed: {merge_error}. " f"Branch '{branch_name}' preserved. " - f"Retry: python3 {scripts}/run_batch.py --retry {batch_id} --json", + f"Retry: {cmd_for('run_batch.py')} --retry {batch_id} --json", scripts, ) state_complete_batch(batch_id, True, state_path, rpg_backup_path=rpg_backup) @@ -797,7 +798,7 @@ def run_batch( pass if merge_error == "branch_missing": # Sub-agent didn't use the batch branch — skip without - # consuming a retry slot (see plan A3). The helper + # consuming a retry slot. The helper # promotes to failed after _MAX_BATCH_PREPARES skips. skipped = state_skip_batch(batch_id, state_path) if skipped: @@ -816,7 +817,7 @@ def run_batch( return _error( f"Tests passed but branch merge failed: {merge_error}. " f"Branch '{branch_name}' preserved. " - f"Retry: python3 {scripts}/run_batch.py --retry {batch_id} --json", + f"Retry: {cmd_for('run_batch.py')} --retry {batch_id} --json", scripts, ) diff --git a/RPG-Kit/scripts/smoke_test.py b/RPG-Kit/scripts/smoke_test.py index cd0a80a..86e3233 100644 --- a/RPG-Kit/scripts/smoke_test.py +++ b/RPG-Kit/scripts/smoke_test.py @@ -34,7 +34,7 @@ # --------------------------------------------------------------------------- sys.path.insert(0, str(Path(__file__).parent)) -from common.paths import DEV_VENV_DIR, REPO_DIR, get_scripts_dir +from common.paths import DEV_VENV_DIR, REPO_DIR, get_scripts_dir, cmd_for logger = logging.getLogger(__name__) @@ -405,7 +405,7 @@ def main() -> int: scripts = get_scripts_dir() if not result.success: print("\n Fix the issues above, then re-run:") - print(f" python3 {scripts}/smoke_test.py --json") + print(f" {cmd_for('smoke_test.py')} --json") return 0 if result.success else 1 diff --git a/RPG-Kit/scripts/summary_skeleton.py b/RPG-Kit/scripts/summary_skeleton.py index ddb9ad4..34899b5 100644 --- a/RPG-Kit/scripts/summary_skeleton.py +++ b/RPG-Kit/scripts/summary_skeleton.py @@ -438,7 +438,7 @@ def main() -> int: with open(output_path, "w", encoding="utf-8") as f: generate_summary(skeleton_data, use_color=False, output=f) - print(f"Summary saved to: {output_path}") + print(f"Summary saved to: {output_path.name}") return 0 diff --git a/RPG-Kit/scripts/update_graphs.py b/RPG-Kit/scripts/update_graphs.py index 0deacb3..df0f981 100644 --- a/RPG-Kit/scripts/update_graphs.py +++ b/RPG-Kit/scripts/update_graphs.py @@ -13,11 +13,11 @@ full AST scan + mappings + edges (legacy, use 'sync' instead) Usage: - python3 .rpgkit/scripts/update_graphs.py dep --json - python3 .rpgkit/scripts/update_graphs.py enrich --json - python3 .rpgkit/scripts/update_graphs.py enrich --file models/user.py --dry-run --json - python3 .rpgkit/scripts/update_graphs.py sync --json - python3 .rpgkit/scripts/update_graphs.py update-rpg --json + rpgkit script update_graphs.py dep --json + rpgkit script update_graphs.py enrich --json + rpgkit script update_graphs.py enrich --file models/user.py --dry-run --json + rpgkit script update_graphs.py sync --json + rpgkit script update_graphs.py update-rpg --json """ import argparse @@ -31,7 +31,8 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, HOOK_CALLS_LOG # noqa: E402 +from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, RPG_HTML_FILE, HOOK_CALLS_LOG # noqa: E402 +from common.rpg_io import safe_load_rpg # noqa: E402 # Shared message used by every subcommand that requires an existing @@ -102,9 +103,12 @@ def _refresh_rpg_html(rpg_path: Path) -> dict: data = load_rpg(str(rpg_path)) html_content = generate_html(data) - viz_path = rpg_path.with_suffix(".html") - viz_path.write_text(html_content, encoding="utf-8") - result["viz_path"] = str(viz_path) + # rpg.html is a user-facing artefact: write it to the + # workspace's .rpgkit/reports/ (the home-side data/ holds + # only machine-consumed JSON). This mirrors run_encode.py. + RPG_HTML_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_HTML_FILE.write_text(html_content, encoding="utf-8") + result["viz_path"] = str(RPG_HTML_FILE) except Exception as exc: # pragma: no cover — defensive result["viz_error"] = str(exc) return result @@ -386,7 +390,7 @@ def cmd_update_rpg( Designed for post-commit background invocation via ``setsid``:: setsid env -u GIT_INDEX_FILE -u GIT_DIR sh -c \ - "cd ; python update_graphs.py update-rpg --json >> log 2>&1" & + "cd ; rpgkit script update_graphs.py update-rpg --json >> log 2>&1" & Requires: - rpg.json exists (encode has been run) @@ -525,8 +529,10 @@ def cmd_status(rpg_path: Path, dep_graph_path: Path) -> dict: if rpg_path.exists(): try: - with open(rpg_path, "r", encoding="utf-8") as f: - rpg_data = json.load(f) + # Use safe_load_rpg so a corrupted rpg.json doesn't crash + # the cheap status command — it'll silently restore from + # inner-git history when possible. + rpg_data = safe_load_rpg(rpg_path) # RPG stores features in a hierarchical tree rooted at "root". # Walk it lazily to count nodes without loading the full # rpg.service module (the status command must stay cheap). @@ -594,9 +600,7 @@ def _format_status_for_agent(status: dict) -> str: For Claude Code ``SessionStart`` hooks, stdout is injected verbatim into the agent's context. For VS Code tasks running on folderOpen, the user sees this text in a terminal; Copilot can read it on - request. The text intentionally mirrors the ``code-review-graph`` - pattern: state what's available + a short list of MCP tools to - prefer over raw file scans. + request. """ lines = [] rpg_broken = "rpg_error" in status diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 62447ac..9e421ec 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -54,6 +54,8 @@ import importlib.metadata import tomllib +from . import _storage + ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -63,6 +65,23 @@ _RPGKIT_RELEASE_TAG_PREFIX = "rpgkit-v" +# --------------------------------------------------------------------------- +# DEPRECATED: GitHub release-zip provisioning helpers. +# +# As of v0.1.4 ``rpgkit init`` / ``rpgkit update`` are bundle-only and no +# longer fetch templates from GitHub releases at runtime — users upgrade +# the CLI itself to pick up newer prompts. The helpers below +# (``_parse_github_owner_repo``, ``_github_token``, +# ``_github_auth_headers``, ``_parse_rate_limit_headers``, +# ``_format_rate_limit_error``, ``_is_private_repo``, +# ``_get_asset_download_url``, ``_fetch_latest_rpgkit_release``, +# ``download_template_from_github``, ``_download_and_extract_release_zip``) +# are kept temporarily so the change is reversible and so any third-party +# callers don't break on upgrade. They are slated for removal in v0.2.0 +# along with the ``httpx`` dependency they bring in. +# --------------------------------------------------------------------------- + + def _parse_github_owner_repo(url: str) -> Tuple[str, str] | None: """Extract (owner, repo) from a GitHub remote URL. @@ -298,6 +317,280 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" + +# --------------------------------------------------------------------------- +# Bundle mode (packaged assets) — added in 0.1.3 +# --------------------------------------------------------------------------- +# +# rpgkit-cli ships ``scripts/`` and ``templates/commands/`` as packaged +# assets under ``rpgkit_cli/core_pack/`` so that ``rpgkit init`` works +# offline. This block exposes: +# +# _AI_TO_CLI_CMD — single source of truth for "selected AI" → +# "AI CLI command to invoke from scripts". +# Must stay in sync with the corresponding case +# statement in +# ``.github/workflows/scripts/rpgkit/create-release-packages.sh`` +# (the release-zip pipeline) and with +# ``scripts/common/llm_client.py:_CLI_TO_AGENT`` +# (the reverse mapping consumed by detect_agent_type()). +# +# _SOURCE_BUNDLE / _SOURCE_LEGACY — provisioning channel; persisted as +# ``channel`` in ``~/.rpgkit/workspaces/ +# /.meta.toml`` so subsequent +# ``rpgkit update`` calls honour the +# user's original choice. Mirrors the +# constants in :mod:`rpgkit_cli._storage`. + +_AI_TO_CLI_CMD = { + # NOTE: values below are copied verbatim from + # .github/workflows/scripts/rpgkit/create-release-packages.sh lines ~142-169 + # to guarantee bundle mode and legacy-download mode behave identically. + "copilot": "copilot", + "claude": "claude", + "gemini": "gemini -p", + "qwen": "qwen -p", + "cursor-agent": "agent -p", + "auggie": "augment -p", + "codex": "codex exec", + "codebuddy": "codebuddy -p", + "qoder": "qodercli -p", + "opencode": "opencode run", + "amp": "amp --execute", +} + +# Re-exported (under the older names) to minimise churn at call sites; +# the canonical strings now live in :mod:`rpgkit_cli._storage`. +_SOURCE_BUNDLE = _storage.CHANNEL_BUNDLE +_SOURCE_LEGACY = _storage.CHANNEL_LEGACY +_CONFIG_RELPATH = _storage.WORKSPACE_MARKER_RELPATH + + +def _current_cli_version() -> str: + """Return the installed ``rpgkit-cli`` version, or ``"dev"`` on failure. + + Used to stamp ``.meta.toml`` with the version that last touched a + given workspace. Failures (editable install, missing METADATA, + namespace package weirdness) are silently swallowed -- the version + field is purely informational. + """ + try: + return importlib.metadata.version("rpgkit-cli") + except importlib.metadata.PackageNotFoundError: + return "dev" + + +def _read_source_marker(project_path: Path) -> str | None: + """Return the recorded provisioning channel for ``project_path``. + + Reads ``channel`` from ``~/.rpgkit/workspaces//.meta.toml``. + Returns ``None`` when no meta file exists (fresh workspace) or the + channel field is missing. + """ + meta = _storage.read_meta(project_path) + if meta is None: + return None + channel = meta.get("channel") + if isinstance(channel, str) and channel: + return channel + return None + + +def _write_source_marker(project_path: Path, source: str) -> None: + """Persist the provisioning channel in the home-side ``.meta.toml``. + + Replaces the legacy ``workspace/.rpgkit/.source`` text file with a + structured TOML record under ``~/.rpgkit/workspaces//`` that + also carries timestamps and the version of rpgkit-cli that last + touched the workspace. See :mod:`rpgkit_cli._storage` for the + layout rationale. + """ + _storage.write_meta( + project_path, + channel=source, + rpgkit_cli_version=_current_cli_version(), + ) + + +def _write_workspace_config(project_path: Path, selected_ai: str) -> None: + """Materialise ``.rpgkit/config.toml`` with the selected AI's CLI command. + + Idempotent: if the file already exists and already contains + ``ai_cli_cmd``, leave it alone (the user may have customised it). + Only writes a fresh file when one is missing. + """ + cfg_path = project_path / _CONFIG_RELPATH + cli_cmd = _AI_TO_CLI_CMD.get(selected_ai, selected_ai) + + if cfg_path.exists(): + # Don't clobber user edits. We could merge here, but plain + # workspaces don't need the complexity and a stale value is a + # supported configuration (env var override remains available). + return + + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + "# RPG-Kit workspace configuration\n" + "# Managed by `rpgkit init` / `rpgkit update`. Safe to commit.\n" + "# See: https://github.com/microsoft/RPG-ZeroRepo (RPG-Kit/docs/configuration.md)\n" + "\n" + "[rpgkit]\n" + f'ai_cli_cmd = "{cli_cmd}"\n', + encoding="utf-8", + ) + + +def _detect_install_method() -> str: + """Best-effort detection of how ``rpgkit-cli`` was installed. + + Returns one of ``"uv"``, ``"pipx"``, ``"pip-user"``, ``"pip-system"``, + ``"editable"``, ``"unknown"``. Used by ``rpgkit update`` to pick the + right self-upgrade command. + """ + try: + # Do not call ``.resolve()`` here. The python + # interpreter inside a uv tool venv is typically a symlink to + # the system python (``/usr/bin/python3.12`` on Linux); resolving + # it discards the ``~/.local/share/uv/tools/rpgkit-cli/`` prefix + # we depend on for installer detection. We want the path *as* + # the kernel saw it for ``sys.executable``, not the underlying + # interpreter binary it points to. + exe = Path(sys.executable) + exe_str = str(exe) + except Exception: + return "unknown" + + exe_posix = exe_str.replace("\\", "/") + + # IMPORTANT: editable detection must run FIRST. An editable install + # placed inside a uv-managed venv would otherwise be reported as + # "uv" and ``rpgkit update`` would try to upgrade from the + # registry instead of leaving the local checkout alone. + try: + import importlib.metadata as _im + + dist = _im.distribution("rpgkit-cli") + durl = dist.read_text("direct_url.json") + if durl and '"editable": true' in durl: + return "editable" + except Exception: + pass + + # uv tool install creates venvs under ~/.local/share/uv/tools// + # (or %LOCALAPPDATA%\uv\tools\\ on Windows). + if "/uv/tools/" in exe_posix: + return "uv" + try: + # uv writes a receipt file at the venv root one level above bin/. + # Newer uv versions use ``uv-receipt.toml``; older releases used + # ``uv-receipt.json``. Check both so the heuristic stays robust + # across the version most users have installed at any given time. + receipt_parent = exe.parent.parent + if ( + (receipt_parent / "uv-receipt.toml").exists() + or (receipt_parent / "uv-receipt.json").exists() + ): + return "uv" + except Exception: + pass + + # pipx puts each tool's venv under ~/.local/share/pipx/venvs// + if "/pipx/venvs/" in exe_posix: + return "pipx" + + # Plain pip: distinguish user-site vs system-site by path prefix. + try: + import site + + if site.ENABLE_USER_SITE and exe_str.startswith(site.getuserbase()): + return "pip-user" + except Exception: + pass + + return "pip-system" + + +def _upgrade_command(method: str) -> list[str] | None: + """Return the shell command argv that upgrades the installed CLI. + + Returns ``None`` when no automatic command is appropriate (editable + install, or unknown installer). + """ + if method == "uv": + return ["uv", "tool", "upgrade", "rpgkit-cli"] + if method == "pipx": + return ["pipx", "upgrade", "rpgkit-cli"] + if method == "pip-user": + return [sys.executable, "-m", "pip", "install", "-U", "--user", "rpgkit-cli"] + if method == "pip-system": + return [sys.executable, "-m", "pip", "install", "-U", "rpgkit-cli"] + return None + + +def _install_source() -> str: + """Identify *where* the installed ``rpgkit-cli`` came from. + + Used by the default-on auto-upgrade flow to skip dev-mode installs + (local checkout, editable) that the user is actively iterating on — + blindly running ``uv tool upgrade`` on those would either no-op + (uv complains it's not a registry release) or, worse, replace the + user's local working copy with the registry build. + + Returns: + * ``"git"`` — installed from a ``git+https://...`` URL. + Safe to auto-upgrade. + * ``"pypi"`` — installed from a PyPI release (no + ``direct_url.json`` recorded). Safe to auto-upgrade. + * ``"file"`` — installed from a local path + (``uv tool install .``). Skip auto-upgrade — the user is + developing. + * ``"editable"`` — installed with ``--editable``. Skip. + * ``"unknown"`` — couldn't determine source. Skip (conservative). + + The detection reads PEP 610's ``direct_url.json`` from the + installed distribution's metadata. We never shell out to ``uv`` + or ``pip`` for this — the local metadata is the single source of + truth and works in offline environments. + """ + try: + import importlib.metadata as _im + dist = _im.distribution("rpgkit-cli") + raw = dist.read_text("direct_url.json") + except Exception: + return "unknown" + + if raw is None: + # No direct_url.json file recorded -> installed from a PyPI + # release (PEP 610 mandates this file only for non-registry + # installs). + return "pypi" + + try: + info = json.loads(raw) + except Exception: + return "unknown" + + # Editable installs always set ``dir_info.editable: true``. + dir_info = info.get("dir_info") or {} + if isinstance(dir_info, dict) and dir_info.get("editable") is True: + return "editable" + + url = info.get("url") + if isinstance(url, str): + if url.startswith("git+") or info.get("vcs_info"): + return "git" + if url.startswith("file://"): + return "file" + + return "unknown" + + +#: Sources where auto-upgrade is safe to run by default in +#: ``rpgkit update``. Matches the values returned by +#: :func:`_install_source`. +_AUTO_UPGRADE_SOURCES: frozenset[str] = frozenset({"git", "pypi"}) + + # ── Default .gitignore template ────────────────────────────────────────── # Split into three parts so init can compose the right output depending on # project state: @@ -305,12 +598,9 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) # absent (greenfield), so we don't impose Python # conventions on an existing repo that already has # its own .gitignore preferences. -# * RPGKIT_COMMON → always injected — these files MUST be ignored +# * RPGKIT_COMMON → always injected; these files must be ignored # (runtime data, machine-specific config). -# * RPGKIT_AI[ai] → always injected for the selected AI assistant — -# RPG-Kit regenerates slash command files on every -# `rpgkit init/update`, so they are build artifacts, -# not source. +# * RPGKIT_AI[ai] → always injected for the selected AI assistant. # # The Python template is a verbatim copy of GitHub's official # ``github/gitignore/Python.gitignore`` (220-line community baseline). @@ -550,9 +840,9 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) _GITIGNORE_RPGKIT_COMMON = """\ # Runtime workspace (logs, generated data, trajectory) .rpgkit/ - -# RPG-Kit Python environment -.venv_rpgkit/ +# but DO track the workspace AI config so collaborators see the same +# default — see docs/configuration.md +!.rpgkit/config.toml # Codegen dev environments .venv_dev/ @@ -565,10 +855,10 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) """ # AI-specific slash-command directories that RPG-Kit regenerates each time -# `rpgkit init/update` runs. We deliberately scope each entry to a sub- -# directory rather than the whole agent folder so unrelated assets in -# ``.github/`` (workflows, CODEOWNERS, …) or ``.claude/`` (settings.json -# with team-shared permissions) remain trackable. +# `rpgkit init/update` runs. Each entry covers only a sub-directory of +# the agent folder so unrelated assets in ``.github/`` (workflows, +# CODEOWNERS, …) or ``.claude/`` (settings.json with team-shared +# permissions) remain trackable. _GITIGNORE_RPGKIT_AI = { "copilot": """\ # Copilot slash command definitions (regenerated by rpgkit) @@ -931,19 +1221,19 @@ def is_git_repo(path: Path = None) -> bool: def _setup_gitignore(project_path: Path, selected_ai: str) -> None: """Materialize ``.gitignore`` with RPG-Kit's required rules. - This is the **single injection point** for all RPG-Kit gitignore + This is the single injection point for all RPG-Kit gitignore management. Other init steps (``_generate_mcp_config``, - ``_install_copilot_hooks``) MUST NOT modify ``.gitignore`` - themselves — all rules they used to inject have been folded into + ``_install_copilot_hooks``) must not modify ``.gitignore`` + themselves; all rules they used to inject have been folded into ``_GITIGNORE_RPGKIT_COMMON`` / ``_GITIGNORE_RPGKIT_AI``. - Behavior (decided by the user via interactive design review): + Behavior: * **Greenfield** — both ``.git/`` and ``.gitignore`` are absent: write Python standard template + RPG-Kit common + AI-specific rules. Gives new projects a complete, sensible default. - * **Existing repo or existing ``.gitignore``** — *do not* overwrite + * **Existing repo or existing ``.gitignore``** — do not overwrite the user's Python conventions. Only append RPG-Kit rules (deduplicated by exact line match) under a single ``# RPG-Kit ignores`` header. @@ -1212,8 +1502,7 @@ def _cleanup_legacy_codegen_persistent(project_path: Path) -> list[str]: Earlier versions of ``rpgkit init`` (pre-C4 cleanup) wrote a codegen-specific instructions file that AI agents would auto-load on every session, polluting unrelated commands (rpg_edit, encode, plain - Q&A) with codegen workflow noise. See ``plans/20260508-1-rpgkit- - optimization*.md`` § C4. + Q&A) with codegen workflow noise. This helper: @@ -1269,37 +1558,26 @@ def _generate_mcp_config( ) -> None: """Generate MCP server configuration for the selected AI assistant. - Both Claude and VS Code Copilot launch the MCP server via the current - Python interpreter (``sys.executable``) running - ``/.rpgkit/scripts/mcp_server.py`` — this guarantees the - interpreter that has ``rpgkit-cli``'s dependencies (mcp, rapidfuzz, …) - installed is used to host the server. + Both Claude and VS Code Copilot launch the MCP server via the + ``rpgkit-mcp`` console script installed alongside ``rpgkit-cli``. + This keeps the config portable across machines (no absolute paths + to a workspace-local copy) and ensures the server always runs + against the bundled scripts that match the installed CLI version. - Claude: ``.mcp.json`` (key ``mcpServers.rpg-tools``) - Copilot: ``.vscode/mcp.json`` (key ``servers.rpg-tools``, VS Code 1.102+ standard layout) - Generated paths are absolute and machine-specific; the corresponding - files are ignored via :func:`_setup_gitignore` (called earlier in the - init flow), not by this function. + The ``rpgkit-mcp`` command must be on ``PATH``. ``rpgkit init`` + emits a warning at the end of the run when it isn't, so MCP + clients fail with a clear cause rather than the opaque + ``Connection closed`` error. """ - # Resolve absolute paths up-front so we never write a stale/relative path. project_path = project_path.resolve() - server_script = (project_path / ".rpgkit" / "scripts" / "mcp_server.py").resolve() - - if not server_script.is_file(): - # Should not happen — extraction step runs before us — but bail out - # cleanly instead of writing a config that would fail at runtime. - msg = f"mcp_server.py not found at {server_script}" - if tracker: - tracker.error("mcp", msg) - else: - console.print(f"[yellow]Warning: {msg}[/yellow]") - return mcp_server_config = { - "command": sys.executable, - "args": [str(server_script)], + "command": "rpgkit-mcp", + "args": [], } try: @@ -1315,18 +1593,11 @@ def _generate_mcp_config( elif selected_ai == "copilot": # VS Code Copilot (1.102+): .vscode/mcp.json with top-level "servers". - # - # We deliberately do NOT write a ``sandbox`` block here. VS - # Code's MCP sandbox requires ``bubblewrap`` (bwrap) and - # ``socat`` on PATH; most Linux desktops, WSL, minimal Docker - # images and fresh macOS installs lack these, causing the - # server to crash on startup with the opaque ``Connection - # closed`` error. The only thing sandbox gained us was - # auto-approving tool confirmations — a one-click setting in - # VS Code's MCP UI ("Always allow this server") covers the - # same UX without the dependency landmine. RPG-Kit's MCP - # server is also read-only and offline, so sandbox added no - # security value. + # No ``sandbox`` block: VS Code's MCP sandbox requires bwrap + + # socat which are absent on most Linux desktops, WSL, minimal + # Docker images, and fresh macOS installs, causing the server + # to crash with "Connection closed". Tool auto-approval is + # handled by VS Code's "Always allow this server" setting. vscode_dir = project_path / ".vscode" vscode_dir.mkdir(parents=True, exist_ok=True) mcp_file = vscode_dir / "mcp.json" @@ -1359,6 +1630,175 @@ def _generate_mcp_config( console.print(f"[yellow]Warning: Could not generate MCP config: {e}[/yellow]") +# --------------------------------------------------------------------------- +# Copilot CLI: global MCP registration +# --------------------------------------------------------------------------- + +_COPILOT_CLI_MCP_CONFIG = Path.home() / ".copilot" / "mcp-config.json" + + +def _register_copilot_cli_global_mcp(tracker=None) -> None: + """Register ``rpg-tools`` in ``~/.copilot/mcp-config.json`` (global). + + The GitHub Copilot CLI (``copilot``) — unlike the VS Code Copilot + extension — does NOT read workspace-local ``.vscode/mcp.json``. It + only reads the global ``~/.copilot/mcp-config.json`` (or accepts + inline JSON via ``--additional-mcp-config``). + + To make ``copilot`` find ``rpg-tools`` automatically in any + rpgkit-initialised workspace, we register the server globally on + first ``rpgkit init --ai copilot`` (or ``rpgkit update``). + + This is safe because ``rpgkit-mcp`` is cwd-aware (it walks up to + find ``rpg.json``) and stateless across workspaces — one global + registration serves every workspace the user ``cd``-s into. In + workspaces without ``rpg.json`` the server starts in degraded mode + and tool calls return a ``rpg_unavailable`` hint instructing the + user to run ``/rpgkit.encode``. + + Safety rules (see audit decisions D-globalmcp-1..4): + - **No-op when in-sync.** If the file already contains exactly + the entry we'd write, we don't touch it at all (no mtime bump, + no .bak). This makes ``rpgkit update`` cheap to run repeatedly. + - **Refuse to wipe a malformed config.** If the file exists but + isn't valid JSON we abort with a clear error instead of + overwriting; the user is expected to fix it (or run with + ``--no-copilot-cli-mcp``). Without this guard a stray comma + in the user's config would have us silently drop every + non-rpg-tools server. + - **Atomic write.** We serialise to ``mcp-config.json.tmp`` + first and then ``os.replace()`` into place, so a Ctrl-C or + crash mid-write can't leave the file half-written. + - **Respect user-customised entries.** If an existing + ``rpg-tools`` entry uses a different ``command`` (the user has + intentionally pointed it elsewhere, e.g. to a dev checkout) we + leave it alone and ask them to use ``--no-copilot-cli-mcp``. + - **One-shot .bak.** Only created on the first modification we + actually perform — never on no-op runs, never overwritten. + """ + config_path = _COPILOT_CLI_MCP_CONFIG + bak_path = config_path.with_suffix(".json.bak") + tmp_path = config_path.with_suffix(".json.tmp") + desired = { + "type": "stdio", + "command": "rpgkit-mcp", + "args": [], + } + + def _report_skip(detail: str) -> None: + if tracker: + tracker.skip("copilot-cli-mcp", detail) + + def _report_error(detail: str) -> None: + if tracker: + tracker.error("copilot-cli-mcp", detail) + else: + console.print( + f"[yellow]Warning: could not register rpg-tools in " + f"{config_path}: {detail}[/yellow]" + ) + + def _report_done(detail: str) -> None: + if tracker: + tracker.complete("copilot-cli-mcp", detail) + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + + # ----- Parse existing file (strictly, so we can refuse to + # clobber a malformed user config). An empty/missing file is + # fine — we treat that as "start fresh". + if config_path.exists(): + raw = config_path.read_text(encoding="utf-8") + if raw.strip() == "": + existing: Dict[str, Any] = {} + else: + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + _report_error( + f"{config_path} is not valid JSON ({exc.msg} " + f"at line {exc.lineno} col {exc.colno}); refusing to " + f"overwrite. Fix the file or re-run with " + f"--no-copilot-cli-mcp." + ) + return + if not isinstance(parsed, dict): + _report_error( + f"{config_path} top-level is not a JSON object; " + f"refusing to overwrite. Re-run with " + f"--no-copilot-cli-mcp." + ) + return + existing = parsed + else: + existing = {} + + servers = existing.get("mcpServers") + if servers is None: + existing["mcpServers"] = {} + servers = existing["mcpServers"] + elif not isinstance(servers, dict): + _report_error( + f"{config_path}: `mcpServers` is not a JSON object; " + f"refusing to overwrite. Re-run with --no-copilot-cli-mcp." + ) + return + + current = servers.get("rpg-tools") + # No-op fast path: file already contains exactly what we'd write. + if current == desired: + _report_skip(f"already up-to-date at {config_path}") + return + + # Respect a user-customised entry — only touch entries that + # either don't exist or already point at our `rpgkit-mcp` + # console script (the latter happens on a version bump where + # we'd want to e.g. add new default args). + if ( + isinstance(current, dict) + and current.get("command") + and current.get("command") != "rpgkit-mcp" + ): + _report_skip( + f"existing entry uses custom command " + f"{current.get('command')!r}; leaving alone " + f"(use --no-copilot-cli-mcp to silence)" + ) + return + + # We're going to write — back up the original (one-shot). + if config_path.exists() and not bak_path.exists(): + try: + shutil.copy2(config_path, bak_path) + except OSError: + pass # backup is best-effort + + servers["rpg-tools"] = desired + + # Atomic write: serialise to .tmp then rename. ``os.replace`` + # is atomic on POSIX and Windows. + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(existing, f, indent=2) + f.write("\n") + os.replace(tmp_path, config_path) + except Exception: + # Clean up a stray .tmp on failure so the next run isn't + # confused by a leftover. + try: + if tmp_path.exists(): + tmp_path.unlink() + except OSError: + pass + raise + + action = "updated" if current is not None else "registered" + _report_done(f"{action} at {config_path}") + except Exception as exc: + _report_error(f"failed: {exc}") + + # --------------------------------------------------------------------------- # Optional initial encode # --------------------------------------------------------------------------- @@ -1371,14 +1811,13 @@ def _workspace_has_python_code(project_path: Path) -> bool: code) skip the prompt because the encoder would produce an empty graph and waste LLM tokens. - The walk prunes the ``.rpgkit`` directory in-place so we don't - accidentally count the runtime scripts we just extracted (every - workspace has ``.rpgkit/scripts/*.py`` after init). Common - boilerplate dirs (``.git``, ``.venv``, ``node_modules``, + The walk prunes the ``.rpgkit`` directory in-place so workspace + runtime state (``data/``, ``logs/``) doesn't influence the detection. + Common boilerplate dirs (``.git``, ``.venv``, ``node_modules``, ``__pycache__``) are pruned too — a ``*.py`` under any of them would not indicate user code. """ - PRUNE = {".rpgkit", ".git", ".venv", ".venv_rpgkit", "venv", "node_modules", + PRUNE = {".rpgkit", ".git", ".venv", "venv", "node_modules", "__pycache__", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build"} for dirpath, dirnames, filenames in os.walk(project_path): @@ -1537,8 +1976,8 @@ def _run_initial_encode(project_path: Path) -> bool: of lines of ``RPGParser - INFO - ...``), so instead we: * Capture stderr in a reader thread and write it verbatim to - ``.rpgkit/logs/encode.log`` — power users can ``tail -f`` it - for the full firehose. + ``~/.rpgkit/workspaces//logs/encode.log`` — power users + can ``tail -f`` it for the full firehose. * Parse a handful of phase markers off each line to drive a :class:`rich.progress.Progress` bar with a spinner + current phase + (when known) an M/N batch counter. @@ -1552,13 +1991,27 @@ def _run_initial_encode(project_path: Path) -> bool: """ encoder = project_path / ".rpgkit" / "scripts" / "rpg_encoder" / "run_encode.py" if not encoder.is_file(): - console.print( - f"[yellow]Encoder script not found at {encoder}; " - f"run [cyan]/rpgkit.encode[/] in your AI agent later.[/yellow]" - ) - return False + # Scripts live inside the installed wheel under + # ``rpgkit_cli/core_pack/scripts/``. Resolve the encoder + # from there so the optional initial-encode kickoff works after + # ``rpgkit init`` — which no longer copies scripts into the + # workspace. + from . import _assets + candidate = _assets.scripts_dir() / "rpg_encoder" / "run_encode.py" + if candidate.is_file(): + encoder = candidate + else: + console.print( + f"[yellow]Encoder script not found at {candidate}; " + f"run [cyan]/rpgkit.encode[/] in your AI agent later.[/yellow]" + ) + return False - log_dir = project_path / ".rpgkit" / "logs" + # Keep all generated artefacts (logs/data/inner-git) in the + # per-workspace home dir under ~/.rpgkit/workspaces//. The + # workspace tree should stay clean — no .rpgkit/logs/ written here. + from . import _storage + log_dir = _storage.workspace_logs_dir(project_path) try: log_dir.mkdir(parents=True, exist_ok=True) except OSError as exc: @@ -1570,8 +2023,8 @@ def _run_initial_encode(project_path: Path) -> bool: console.print( Panel( "[cyan]Running the encoder now…[/]\n\n" - "Building [cyan].rpgkit/data/rpg.json[/] from your code via the " - "LLM. Verbose logs stream to [cyan].rpgkit/logs/encode.log[/] — " + "Building [cyan]rpg.json[/] from your code via the LLM. " + "Verbose logs stream to [cyan]" + str(log_path) + "[/] — " "`tail -f` it in another terminal for the gory details. " "Press Ctrl-C to abort; re-run later with [cyan]/rpgkit.encode[/].", title="[bold]Initial encode[/bold]", @@ -1768,8 +2221,8 @@ def _stderr_reader() -> None: console.print( Panel( "[green]Encoder finished successfully.[/]\n\n" - "The RPG graph is now available at " - "[cyan].rpgkit/data/rpg.json[/]. The post-commit hook will " + "The RPG graph is now available under your home-dir " + "workspace store ([cyan]rpg.json[/]). The post-commit hook will " "keep it in sync on every commit; the MCP tools " "([cyan]search_rpg[/], [cyan]explore_rpg[/], …) are now usable.", title="[bold green]Encode complete[/bold green]", @@ -1892,13 +2345,10 @@ def _install_claude_hooks(project_path: Path) -> None: if settings_path.exists(): shutil.copy2(settings_path, settings_dir / "settings.json.bak") - # Shell form: ``command`` is passed to ``sh -c``. Use shlex.quote so - # paths containing spaces or special characters survive shell - # tokenisation (json.dumps is JSON-safe but not shell-safe). - update_script = shlex.quote( - str((project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve()) - ) - python = shlex.quote(sys.executable) + # The command is executed by Claude Code via ``sh -c``, so we inline + # the same PATH-fallback used by git hooks (see _HOOK_PATH_FALLBACK). + # Use ``;`` rather than ``&&`` so the rpgkit call always runs after + # the (possibly no-op) PATH adjustment. marker = "update_graphs.py" # used for idempotent dedupe across upgrades rpg_session_entry = { @@ -1907,7 +2357,8 @@ def _install_claude_hooks(project_path: Path) -> None: { "type": "command", "command": ( - f"{python} {update_script} status 2>/dev/null" + f"{_HOOK_PATH_FALLBACK}; " + "rpgkit script update_graphs.py status 2>/dev/null" " || echo '[RPG-Kit] RPG status unavailable'" ), "timeout": 10, @@ -2141,6 +2592,27 @@ def _strip_hook_block( return "\n".join(out) +# --------------------------------------------------------------------------- +# PATH fallback for hook bodies +# --------------------------------------------------------------------------- +# +# Hooks invoke ``rpgkit`` (the globally-installed CLI) rather than a +# workspace-local script copy. When the hook is triggered from a GUI +# editor's source-control panel (VS Code, IntelliJ, GitHub Desktop, ...) +# the process environment may not include the user's shell PATH, so +# ``rpgkit`` is unresolvable and the hook silently fails. +# +# This snippet is prepended to every hook body. When ``rpgkit`` is +# already on PATH (terminal invocations) the test short-circuits and +# the ``export`` is skipped — zero overhead. When it isn't, we +# prepend ``$HOME/.local/bin`` which is ``uv tool install``'s default +# bin directory. +_HOOK_PATH_FALLBACK = ( + 'command -v rpgkit >/dev/null 2>&1 || ' + 'export PATH="$HOME/.local/bin:$PATH"' +) + + def _install_hook_snippet( hooks_dir: Path, hook_name: str, @@ -2197,39 +2669,48 @@ def _install_hook_snippet( return True -def _install_git_pre_commit_hook(project_path: Path) -> bool: - """Install the RPG incremental-sync command into ``pre-commit``. +def _uninstall_git_pre_commit_hook(project_path: Path) -> bool: + """Remove any previously-installed RPG-Kit ``pre-commit`` block. - Returns ``True`` when the hook is active on disk, ``False`` only - when no git checkout was found at all. + Pre-commit was retired in favour of ``post-commit`` only: the + pre-commit sync ran ``--staged-only`` and was immediately followed + by the full post-commit sync, so its output had a ~1 sec lifetime + and added latency to every ``git commit`` for no observable benefit. + Existing workspaces upgraded via ``rpgkit init`` / ``rpgkit update`` + have their pre-commit block stripped here; user-authored hook + content (and other tools' blocks such as husky / pre-commit / + lefthook) is preserved untouched. - The hook passes ``--staged-only`` so only files the user - ``git add``'d contribute to the diff — working-tree-but-not-staged - changes are out of scope for the imminent commit. + Returns ``True`` when the workspace had a hooks dir to clean, + ``False`` only when no git checkout was found at all. """ hooks_dir = _resolve_git_hooks_dir(project_path) if hooks_dir is None: return False - python = shlex.quote(sys.executable) - update_script = shlex.quote( - str((project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve()) - ) - marker = "# RPG-Kit: incremental RPG sync on commit" - body = ( - f"{marker}\n" - f"{python} {update_script} sync --staged-only 2>/dev/null || true" - ) - # Legacy: pre-Step-3 pre-commit shipped a 2-line snippet under the - # marker below. Removed on upgrade so users don't end up running - # both the old full-sync and the new staged-only path. - return _install_hook_snippet( - hooks_dir, - "pre-commit", - "pre-commit", - body, - legacy_blocks=(("# RPG-Kit: full RPG sync on commit", 2),), + hook_path = hooks_dir / "pre-commit" + if not hook_path.is_file(): + return True + + existing = hook_path.read_text(encoding="utf-8") + legacy = ( + ("# RPG-Kit: pre-commit dispatcher", 3), + ("# RPG-Kit: full RPG sync on commit", 2), + ("# RPG-Kit: incremental RPG sync on commit", 3), ) + cleaned = _strip_hook_block(existing, "pre-commit", legacy).rstrip("\n") + + # If nothing user-authored remains, delete the hook file so git + # falls back to its default no-hook behaviour. + if not cleaned.strip() or cleaned.strip() == "#!/bin/sh": + try: + hook_path.unlink() + except OSError: + pass + else: + hook_path.write_text(cleaned + "\n", encoding="utf-8") + hook_path.chmod(0o755) + return True def _install_git_post_merge_hook(project_path: Path) -> bool: @@ -2248,119 +2729,71 @@ def _install_git_post_merge_hook(project_path: Path) -> bool: if hooks_dir is None: return False - python = shlex.quote(sys.executable) - update_script = shlex.quote( - str((project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve()) - ) - marker = "# RPG-Kit: incremental RPG sync after merge / pull" + # Level-1 hook: stub delegates to ``rpgkit hook post-merge``. + marker = "# RPG-Kit: post-merge dispatcher" body = ( f"{marker}\n" - f"{python} {update_script} sync 2>/dev/null || true" + f"{_HOOK_PATH_FALLBACK}\n" + f"rpgkit hook post-merge 2>/dev/null || true" + ) + return _install_hook_snippet( + hooks_dir, + "post-merge", + "post-merge", + body, + legacy_blocks=( + ("# RPG-Kit: incremental RPG sync after merge / pull", 3), + ), ) - # post-merge was introduced with the sentinel-block design already - # in mind, so no legacy migration is needed here. - return _install_hook_snippet(hooks_dir, "post-merge", "post-merge", body) def _install_git_post_commit_hook(project_path: Path) -> bool: - """Install sync + background RPG update into ``post-commit``. - - Two phases run after every commit: - - 1. **Synchronous** (foreground): ``update_graphs.py sync`` advances - ``meta.git`` to the new HEAD (~50ms). The pre-commit hook already - updated dep_graph for the staged files, so this is a cheap - hash-verify pass. - - 2. **Asynchronous** (background): ``update_graphs.py update-rpg`` - creates a git worktree for ``HEAD~1``, runs the LLM-driven - ``RPGEvolution.process_diff`` to update the feature graph, and - cleans up the worktree. Detached via ``nohup ... &`` (POSIX, - portable to macOS where ``setsid`` is absent). Output goes to - ``.rpgkit/logs/update_rpg.log``. - - Concurrency is serialised by a *directory* lock at - ``.rpgkit/logs/.update_rpg.lock`` \u2014 ``mkdir`` is the only - POSIX-atomic exclusive-create primitive available from shell, - so two commits firing in the same second reliably get one and - only one worker. Stale locks left by a SIGKILL'd previous run - are auto-recovered after 60 minutes. - - Both phases are best-effort: failures are swallowed so they never - block a commit. + """Install the Level-1 ``post-commit`` dispatcher stub. + + The on-disk hook is now a 3-line shell snippet that ``exec``s + ``rpgkit hook post-commit``. All orchestration lives in the + :func:`hook` Python command: + + * **Phase 1 (foreground)**: ``update_graphs.py sync`` advances + ``meta.git`` to the new HEAD. Output is teed into + ``~/.rpgkit/workspaces//logs/hooks.log``. + + * **Phase 2 (background)**: ``update_graphs.py update-rpg`` is + detached via ``subprocess.Popen(start_new_session=True)``. A + mkdir-based directory lock at + ``~/.rpgkit/workspaces//logs/.update_rpg.lock`` serialises + overlapping commits; locks older than 60 minutes are treated as + orphaned and removed. The worker's stdout/stderr land in + ``~/.rpgkit/workspaces//logs/update_rpg.log``. + + Both phases are best-effort: every failure path is swallowed inside + :func:`hook` so a hook misbehaviour never blocks ``git commit``. + + Legacy multi-line shell bodies from earlier releases (pre-Level-1) + are stripped on upgrade -- the ``legacy_blocks`` tuple below covers + every shape we've shipped. """ hooks_dir = _resolve_git_hooks_dir(project_path) if hooks_dir is None: return False - python = shlex.quote(sys.executable) - update_script = shlex.quote( - str((project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve()) - ) - log_file = shlex.quote( - str((project_path / ".rpgkit" / "logs" / "update_rpg.log").resolve()) - ) - lock_file = shlex.quote( - str((project_path / ".rpgkit" / "logs" / ".update_rpg.lock").resolve()) - ) - marker = "# RPG-Kit: advance meta.git + background feature graph update" - workspace_dir = shlex.quote(str(project_path.resolve())) + marker = "# RPG-Kit: post-commit dispatcher" body = ( f"{marker}\n" - # Phase 1: synchronous meta.git advance - f"{python} {update_script} sync 2>/dev/null || true\n" - # Phase 2: background full RPG update. - # - # Lock semantics (v4): - # The lock is a *directory* created with ``mkdir`` — the only - # POSIX-atomic exclusive-create primitive available from shell. - # Two commits firing within the same second (interactive rebase, - # squash merge) reliably get serialised: exactly one wins the - # ``mkdir`` and spawns the background worker; the other no-ops. - # - # Lock recovery: - # (a) Pre-v4 installs used a *file* at this path. ``rm -f`` - # removes that file but silently no-ops on a directory - # ("Is a directory" error swallowed), so an active v4 lock - # is preserved. - # (b) Any v4 lock directory older than 60 minutes is assumed - # orphaned (worker SIGKILL'd, OOM, machine rebooted) and - # wiped. Without this, a single crashed run would silently - # disable all future background updates. - # - # Detach strategy: - # ``nohup ... &`` is POSIX-portable. We previously used - # ``setsid`` which is util-linux-only and absent from default - # macOS installs, leaving every macOS commit's phase-2 silently - # dead. - # - # env -u GIT_INDEX_FILE -u GIT_DIR: - # git sets these during hooks; if they leak into the background - # worker, ``git worktree add`` fails with cryptic index errors. - f"rm -f {lock_file} 2>/dev/null\n" - f"find {lock_file} -maxdepth 0 -mmin +60 -exec rm -rf {{}} + 2>/dev/null || true\n" - f"if mkdir {lock_file} 2>/dev/null; then\n" - f" nohup env -u GIT_INDEX_FILE -u GIT_DIR " - f'sh -c "cd {workspace_dir}; sleep 2; ' - f'{python} {update_script} update-rpg --json >> {log_file} 2>&1; ' - f'rmdir {lock_file}" /dev/null 2>&1 &\n' - f"fi" + f"{_HOOK_PATH_FALLBACK}\n" + f"rpgkit hook post-commit 2>/dev/null || true" ) - # Legacy shapes that may exist in users' .git/hooks/post-commit from - # earlier releases. Both are stripped before the new sentinel block - # is written so the upgrade is a true replace, not an append. - # v1 (pre-Step-3 polish): 2-line sync-only snippet. - # v3 (release 0576393): 5-line snippet with the same first-line - # marker we use today plus phase-1 sync, - # phase-2 setsid background, and the - # wrapping ``if/fi`` lock check. return _install_hook_snippet( hooks_dir, "post-commit", "post-commit", body, legacy_blocks=( + # v1 (pre-Step-3): two-line sync-only snippet. ("# RPG-Kit: advance meta.git after commit", 2), + # v3 (release 0576393): five-line snippet with phase-1 sync + # + phase-2 setsid background under the same marker we used + # before Level-1. ("# RPG-Kit: advance meta.git + background feature graph update", 5), ), ) @@ -2393,15 +2826,15 @@ def _install_copilot_hooks(project_path: Path) -> None: # Backup is best-effort; never block installation on it. pass - update_script = str( - (project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve() - ) - rpg_status_task = { "label": "RPG-Kit: load status", "type": "shell", - "command": sys.executable, - "args": [update_script, "status"], + # Invoke the globally-installed CLI rather than a workspace + # script copy (which no longer exists). Same + # rationale as the git-hook bodies: portable command name, + # auto-tracks the installed wheel's scripts. + "command": "rpgkit", + "args": ["script", "update_graphs.py", "status"], "presentation": { "echo": False, "reveal": "silent", @@ -2452,12 +2885,13 @@ def _install_hooks( ``.vscode/tasks.json`` that runs the same status command on workspace open — VS Code's closest analogue to a SessionStart hook for GitHub Copilot. - - All: appends an RPG incremental sync (``update_graphs.py sync``) - to ``.git/hooks/pre-commit`` AND ``.git/hooks/post-merge``. - The pre-commit hook uses ``--staged-only`` so it sees only what's - about to be committed; the post-merge hook (fired after - ``git pull`` / ``git merge``) considers the whole working tree - so teammate-incoming changes get picked up immediately. + - All: installs an RPG sync trigger on ``.git/hooks/post-commit`` + (fired after every successful commit) AND ``.git/hooks/post-merge`` + (fired after ``git pull`` / ``git merge`` so teammate-incoming + changes get picked up immediately). Any legacy ``pre-commit`` + block from earlier releases is stripped on upgrade — the design + now relies on post-commit only, so commit latency stays low and + the inner-git history is cleaner. Complements the MCP server already registered in ``.mcp.json`` / ``.vscode/mcp.json``. """ @@ -2470,8 +2904,8 @@ def _install_hooks( _install_copilot_hooks(project_path) installed.append("copilot") - if _install_git_pre_commit_hook(project_path): - installed.append("git:pre-commit") + # Strip any leftover pre-commit block from older installs. + _uninstall_git_pre_commit_hook(project_path) if _install_git_post_commit_hook(project_path): installed.append("git:post-commit") if _install_git_post_merge_hook(project_path): @@ -2756,117 +3190,180 @@ def download_template_from_github( return zip_path, metadata -def _resolve_rpgkit_source_root(source: Path) -> Path: - source = source.expanduser().resolve() - candidates = [source] - if (source / "RPG-Kit").is_dir(): - candidates.insert(0, source / "RPG-Kit") +def download_and_extract_template( + project_path: Path, + ai_assistant: str, + script_type: str, + is_current_dir: bool = False, + *, + verbose: bool = True, + tracker: StepTracker | None = None, + client: httpx.Client = None, + debug: bool = False, + # DEPRECATED params (kept for source-compat; CLI no longer passes + # them as of v0.1.4 and they are slated for removal in v0.2.0). + github_token: str = None, + pre: bool = False, + legacy_download: bool = False, +) -> Path: + """Provision the workspace with scripts + command templates. - for candidate in candidates: - if ( - (candidate / "templates" / "commands").is_dir() - and (candidate / "scripts").is_dir() - and (candidate / "pyproject.toml").is_file() - ): - return candidate + Bundle-only as of v0.1.4: templates are always sourced from the + packaged assets shipped inside ``rpgkit_cli/core_pack/``. To pick + up newer prompts the user upgrades the CLI itself (``uv tool + upgrade rpgkit-cli`` etc.), which ``rpgkit update`` does + automatically by default. - raise RuntimeError( - f"Invalid RPG-Kit source path: {source}. Expected the RPG-Kit directory " - "or the repository root containing RPG-Kit/." + The ``github_token`` / ``pre`` / ``legacy_download`` parameters and + the underlying ``_download_and_extract_release_zip`` path are kept + for now as dead code so the change is reversible, but they are no + longer reachable from the CLI surface. + + Returns ``project_path``. Uses the supplied :class:`StepTracker` + to report progress when provided. + """ + return _install_from_bundle( + project_path, + ai_assistant, + script_type, + is_current_dir, + verbose=verbose, + tracker=tracker, ) -def _build_local_template_package( - source: Path, +def _install_from_bundle( + project_path: Path, ai_assistant: str, script_type: str, -) -> Tuple[Path, dict]: - source_root = _resolve_rpgkit_source_root(source) - repo_root = source_root.parent - project_dir = source_root.relative_to(repo_root).as_posix() - scripts_root = repo_root / ".github" / "workflows" / "scripts" / "rpgkit" - version = "v0.0.0-local" - env = os.environ.copy() - env.update( - { - "GITHUB_WORKSPACE": str(repo_root), - "PROJECT_DIR": project_dir, - "AGENTS": ai_assistant, - "SCRIPTS": script_type, - "PYTHON": sys.executable, - } - ) + is_current_dir: bool, + *, + verbose: bool = True, + tracker: StepTracker | None = None, +) -> Path: + """Materialise per-AI command templates into the workspace. + + The pipeline scripts themselves live inside the installed wheel at + ``rpgkit_cli/core_pack/scripts/`` and are invoked via ``rpgkit + script `` (and ``rpgkit-mcp`` for the MCP server) — they are + NOT copied to ``/.rpgkit/scripts/`` anymore. This gives + one source of truth per CLI install, no + risk of workspace/wheel drift, and no per-workspace scripts dir + to keep in sync. + + Only slash-command templates land in the workspace, plus the + provisioning marker that records which channel was used. + """ + from . import _assets - if os.name == "nt": - release_script = scripts_root / "create-release-packages.ps1" - runner = shutil.which("pwsh") - command = ( - [ - runner, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(release_script), - version, - "-Agents", - ai_assistant, - "-Scripts", - script_type, - ] - if runner - else None - ) - else: - release_script = scripts_root / "create-release-packages.sh" - runner = shutil.which("bash") - command = [runner, str(release_script), version] if runner else None + if tracker: + # init()/update() already registered fetch/download/extract step keys, + # so just transition them through completed states instead of + # re-adding (which would overwrite the existing label). + tracker.start("fetch", "packaged assets (offline)") + tracker.complete("fetch", "bundle ready") + tracker.skip("download", "bundle mode (no network)") - if not release_script.is_file(): - raise RuntimeError( - f"Release packaging script not found: {release_script}. " - "Pass the RPG-ZeroRepo root or its RPG-Kit/ directory to --source." - ) - if command is None: - requirement = "PowerShell 7 (pwsh)" if os.name == "nt" else "bash" - raise RuntimeError( - f"Local --source packaging requires {requirement}, but it was not found on PATH." - ) + if not is_current_dir: + project_path.mkdir(parents=True) - result = subprocess.run( - command, - cwd=repo_root, - env=env, - text=True, - capture_output=True, - ) - if result.returncode != 0: - detail = ( - result.stderr or result.stdout or "local package build failed" - ).strip() - raise RuntimeError( - f"Failed to build local RPG-Kit template package from {source_root}: {detail}" - ) + if tracker: + tracker.start("extract") - archive = ( - source_root - / ".genreleases" - / f"rpgkit-template-{ai_assistant}-{script_type}-{version}.zip" - ) - if not archive.is_file(): - raise RuntimeError( - f"Local RPG-Kit template package was not created: {archive}" + try: + rpgkit_root = project_path / ".rpgkit" + rpgkit_root.mkdir(parents=True, exist_ok=True) + + # 1. Materialise slash-command templates into the AI-specific + # directory. _materialise_commands_for_agent owns the + # per-agent file-name / folder rules. + _materialise_commands_for_agent( + ai_assistant, _assets.commands_dir(), project_path ) - return archive, { - "filename": archive.name, - "size": archive.stat().st_size, - "release": version, - "source": str(source_root), - } + # 2. Record the provisioning source so subsequent ``rpgkit update`` + # invocations default to the same channel. + _write_source_marker(project_path, _SOURCE_BUNDLE) + + if tracker: + tracker.skip("zip-list", "bundle (no archive)") + tracker.skip("extracted-summary", "templates only") + tracker.complete("extract") + tracker.skip("cleanup", "bundle mode") + except Exception as e: + if tracker: + tracker.error("extract", str(e)) + else: + console.print(f"[red]Error installing from bundle:[/red] {e}") + raise + + return project_path -def download_and_extract_template( +def _materialise_commands_for_agent( + ai_assistant: str, + src_commands_dir: Path, + project_path: Path, +) -> None: + """Place command templates into the agent-specific workspace location. + + This intentionally mirrors what the legacy release-zip path produces + (see ``.github/workflows/scripts/rpgkit/create-release-packages.sh`` + ``generate_commands`` / ``generate_copilot_prompts``), so that + downstream consumers see the same layout regardless of provisioning + source. + + Layout produced: + claude → ``.claude/commands/rpgkit..md`` + copilot → ``.github/agents/rpgkit..agent.md`` + ``.github/prompts/rpgkit..prompt.md`` (frontmatter + points at the corresponding agent) + others → fallback: ``.rpgkit/commands/rpgkit..md`` (same + ``rpgkit..md`` prefix for consistency with the + supported agents above) + + NOTE: ``claude`` and ``copilot`` are the only verified agents in + AGENT_CONFIG today. Add new agents here when AGENT_CONFIG grows. + """ + def _read_body(src: Path) -> str: + # Normalise CRLF → LF, matching what the CI's ``tr -d '\r'`` does. + return src.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + + if ai_assistant == "claude": + dest = project_path / ".claude" / "commands" + dest.mkdir(parents=True, exist_ok=True) + for src in src_commands_dir.glob("*.md"): + target = dest / f"rpgkit.{src.stem}.md" + target.write_text(_read_body(src), encoding="utf-8") + elif ai_assistant == "copilot": + agents = project_path / ".github" / "agents" + prompts = project_path / ".github" / "prompts" + agents.mkdir(parents=True, exist_ok=True) + prompts.mkdir(parents=True, exist_ok=True) + for src in src_commands_dir.glob("*.md"): + stem = f"rpgkit.{src.stem}" + body = _read_body(src) + (agents / f"{stem}.agent.md").write_text(body, encoding="utf-8") + # Copilot prompt files reference the agent by name in + # frontmatter; the body is empty so the agent prompt + # (already written above) is the source of truth. + (prompts / f"{stem}.prompt.md").write_text( + f"---\nagent: {stem}\n---\n", encoding="utf-8" + ) + else: + # Unknown agent (init() validates against AGENT_CONFIG so this + # branch is unreachable from the public CLI, but provides a + # well-defined behaviour if a future caller bypasses validation). + dest = project_path / ".rpgkit" / "commands" + dest.mkdir(parents=True, exist_ok=True) + for src in src_commands_dir.glob("*.md"): + (dest / f"rpgkit.{src.stem}.md").write_text(_read_body(src), encoding="utf-8") + + +# DEPRECATED: legacy release-zip provisioning path — no longer reachable +# from the CLI as of v0.1.4 (see top-of-file DEPRECATED block). Slated for +# removal in v0.2.0. +def _download_and_extract_release_zip( project_path: Path, ai_assistant: str, script_type: str, @@ -2878,44 +3375,34 @@ def download_and_extract_template( debug: bool = False, github_token: str = None, pre: bool = False, - source: Path | None = None, ) -> Path: - """Download or build a template archive and extract it to create a project. + """Release-zip download + extract path. - Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup). + Kept available for users that need the very latest prompts before the + next CLI release, or to bypass packaging glitches. Activated via + ``rpgkit init --legacy-download``. """ current_dir = Path.cwd() - cleanup_zip = source is None if tracker: - fetch_detail = ( - "building local template package" if source else "contacting GitHub API" - ) - tracker.start("fetch", fetch_detail) + tracker.start("fetch", "contacting GitHub API") try: - if source: - zip_path, meta = _build_local_template_package( - source, - ai_assistant, - script_type, - ) - else: - zip_path, meta = download_template_from_github( - ai_assistant, - current_dir, - script_type=script_type, - verbose=verbose and tracker is None, - show_progress=(tracker is None), - client=client, - debug=debug, - github_token=github_token, - pre=pre, - ) + zip_path, meta = download_template_from_github( + ai_assistant, + current_dir, + script_type=script_type, + verbose=verbose and tracker is None, + show_progress=(tracker is None), + client=client, + debug=debug, + github_token=github_token, + pre=pre, + ) if tracker: tracker.complete( - "fetch", f"template {meta['release']} ({meta['size']:,} bytes)" + "fetch", f"release {meta['release']} ({meta['size']:,} bytes)" ) - tracker.add("download", "Use template archive" if source else "Download template") + tracker.add("download", "Download template") tracker.complete("download", meta["filename"]) except Exception as e: if tracker: @@ -3067,178 +3554,104 @@ def download_and_extract_template( if tracker: tracker.add("cleanup", "Remove temporary archive") - if cleanup_zip and zip_path.exists(): + if zip_path.exists(): zip_path.unlink() if tracker: tracker.complete("cleanup") elif verbose: console.print(f"Cleaned up: {zip_path.name}") - elif tracker: - tracker.skip("cleanup", "local package retained") + + # Record provisioning source so a later ``rpgkit update`` defaults + # to the same channel. Counterpart to ``_install_from_bundle`` which + # writes ``bundle``. + _write_source_marker(project_path, _SOURCE_LEGACY) + + # Discard the scripts copy extracted from the zip — they're not + # used at runtime anymore (the workspace invokes ``rpgkit script + # `` which resolves to the packaged scripts dir). Keeping + # them would just be dead weight that drifts vs the installed CLI. + # Legacy zip contributes commands only. + legacy_scripts_dir = project_path / ".rpgkit" / "scripts" + if legacy_scripts_dir.is_dir(): + shutil.rmtree(legacy_scripts_dir, ignore_errors=True) return project_path -def ensure_executable_scripts( +def ensure_rpgkit_runtime_dirs( project_path: Path, tracker: StepTracker | None = None ) -> None: - """Ensure POSIX .sh scripts under .rpgkit/scripts (recursively) have execute bits (no-op on Windows).""" - if os.name == "nt": - return # Windows: skip silently - scripts_root = project_path / ".rpgkit" / "scripts" - if not scripts_root.is_dir(): - return - failures: list[str] = [] - updated = 0 - for script in scripts_root.rglob("*.sh"): - try: - if script.is_symlink() or not script.is_file(): - continue - try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: - continue - st = script.stat() - mode = st.st_mode - if mode & 0o111: - continue - new_mode = mode - if mode & 0o400: - new_mode |= 0o100 - if mode & 0o040: - new_mode |= 0o010 - if mode & 0o004: - new_mode |= 0o001 - if not (new_mode & 0o100): - new_mode |= 0o100 - os.chmod(script, new_mode) - updated += 1 - except Exception as e: - failures.append(f"{script.relative_to(scripts_root)}: {e}") - if tracker: - detail = f"{updated} updated" + ( - f", {len(failures)} failed" if failures else "" - ) - tracker.add("chmod", "Set script permissions recursively") - (tracker.error if failures else tracker.complete)("chmod", detail) - else: - if updated: - console.print( - f"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]" - ) - if failures: - console.print("[yellow]Some scripts could not be updated:[/yellow]") - for f in failures: - console.print(f" - {f}") - - -def setup_venv_rpgkit( - project_path: Path, tracker: StepTracker | None = None -) -> None: - """Create or update .venv_rpgkit with RPG-Kit Python dependencies.""" - venv_dir = project_path / ".venv_rpgkit" - rpgkit_dir = project_path / ".rpgkit" - pyproject = rpgkit_dir / "pyproject.toml" - - if tracker: - tracker.start("venv") + """Pre-create RPG-Kit runtime directories under ``~/.rpgkit/``. + + The per-workspace data, logs, and inner-git snapshot repo live + under the user's home directory at ``~/.rpgkit/workspaces//`` + rather than inside the workspace. Reports stay in the workspace + (``/.rpgkit/reports/``) because they're user-facing + artefacts. + + This function is the central bootstrap for the home layout: it's + idempotent and safe to call from both ``rpgkit init`` (when the + channel was just chosen) and ``rpgkit update`` (when the channel + is read from the existing meta file). Some early-pipeline prompts + redirect stdout/stderr to ``/.log`` via shell ``>`` + before any Python code runs, so we must create the directories + upfront rather than lazily. - if not pyproject.is_file(): - msg = ".rpgkit/pyproject.toml not found — cannot install Python dependencies" - if tracker: - tracker.skip("venv", msg) - else: - console.print(f"[yellow]Warning:[/yellow] {msg}") - return + Created (idempotent): + - ``~/.rpgkit/workspaces//data/`` + - ``~/.rpgkit/workspaces//data/trajectory/`` + - ``~/.rpgkit/workspaces//logs/`` + - ``/.rpgkit/reports/`` + - ``~/.rpgkit/workspaces//.meta.toml`` (refreshed) + + The inner ``.git/`` directory is NOT created here; that's + the responsibility of :mod:`rpgkit_cli._inner_git`, which seeds an + initial commit with a meaningful message. + """ + # Resolve channel: prefer what's already recorded, fall back to + # bundle. The caller (init) will explicitly call + # ``_write_source_marker`` afterwards to lock in the final value, + # so this lookup is just a sensible default for the first run. + existing_channel = _read_source_marker(project_path) + channel = existing_channel or _storage.CHANNEL_BUNDLE try: - is_new = not venv_dir.exists() - - if is_new: - subprocess.run( - ["uv", "venv", str(venv_dir)], - check=True, - capture_output=True, - text=True, - ) - - if os.name == "nt": - pip_python = venv_dir / "Scripts" / "python.exe" - else: - pip_python = venv_dir / "bin" / "python3" - - subprocess.run( - ["uv", "pip", "install", str(rpgkit_dir), "--python", str(pip_python)], - check=True, - capture_output=True, - text=True, + home_dir = _storage.ensure_workspace_storage( + project_path, + channel=channel, + rpgkit_cli_version=_current_cli_version(), ) - - if tracker: - tracker.complete( - "venv", - "created .venv_rpgkit" if is_new else "updated .venv_rpgkit", - ) - except FileNotFoundError: - msg = "uv not found — install uv (https://docs.astral.sh/uv/) to enable auto-setup" + except _storage.WorkspaceMetaMismatch as exc: + # Hash collision or manual rename. Surface clearly: silently + # writing into the wrong workspace would corrupt the other + # one's data. if tracker: - tracker.skip("venv", msg) - console.print(f"[yellow]Warning:[/yellow] {msg}") - except subprocess.CalledProcessError as e: - detail = e.stderr.strip() if e.stderr else str(e) - msg = f"Failed to set up .venv_rpgkit:\n{detail}" - if tracker: - tracker.error("venv", detail[:120]) - console.print(f"[red]Error:[/red] {msg}") - except Exception as e: - msg = f"Failed to set up .venv_rpgkit: {e}" + tracker.add("runtime-dirs", "Ensure ~/.rpgkit/{logs,data} directories") + tracker.error("runtime-dirs", str(exc)) + else: + console.print(f"[red]error:[/red] {exc}") + raise + except OSError as exc: + # Filesystem read-only / permission issue — non-blocking. if tracker: - tracker.error("venv", str(e)[:120]) - console.print(f"[red]Error:[/red] {msg}") - - -def ensure_rpgkit_runtime_dirs( - project_path: Path, tracker: StepTracker | None = None -) -> None: - """Pre-create RPG-Kit runtime directories under ``.rpgkit/``. - - Some early-pipeline prompts redirect stdout/stderr to - ``.rpgkit/logs/.log`` via shell ``>``, which fails with - "No such file or directory" if the parent directory does not yet exist. - The first script that calls ``setup_file_logging`` would normally - auto-create ``.rpgkit/logs/``, but that only helps stages that use - the Python logging helper — shell-redirected stages fail BEFORE the - Python process even starts. + tracker.add("runtime-dirs", "Ensure ~/.rpgkit/{logs,data} directories") + tracker.error("runtime-dirs", f"could not create: {exc}") + return - Creating the runtime directories upfront (during ``rpgkit init`` / - ``rpgkit update``) makes all stage prompts robust without each one - having to ``mkdir -p`` defensively. + # data/trajectory is a script-specific subdir; create explicitly + # so the encoder's early stages can write into it without their + # own ``mkdir -p`` dance. + try: + (home_dir / "data" / "trajectory").mkdir(parents=True, exist_ok=True) + except OSError: + pass - Created (idempotent): - - ``.rpgkit/logs/`` — per-stage log files - - ``.rpgkit/data/`` — encoder / pipeline JSON artifacts - - ``.rpgkit/data/trajectory/`` — execution trajectories - """ - subdirs = ("logs", "data", "data/trajectory") - created: list[str] = [] - for sub in subdirs: - path = project_path / ".rpgkit" / sub - existed = path.exists() - try: - path.mkdir(parents=True, exist_ok=True) - if not existed: - created.append(sub) - except OSError: - # Filesystem read-only / permission issue — non-blocking. - continue if tracker: - tracker.add("runtime-dirs", "Ensure .rpgkit/{logs,data} directories") - detail = ( - f"created {', '.join(created)}" if created else "all already present" + tracker.add("runtime-dirs", "Ensure ~/.rpgkit/{logs,data} directories") + tracker.complete( + "runtime-dirs", + f"home dir at {home_dir}", ) - tracker.complete("runtime-dirs", detail) def _detect_ai_agent(project_path: Path) -> str | None: @@ -3300,37 +3713,28 @@ def init( "--force", help="Force merge/overwrite when using --here (skip confirmation)", ), - skip_tls: bool = typer.Option( - False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)" - ), debug: bool = typer.Option( False, "--debug", - help="Show verbose diagnostic output for network and extraction failures", - ), - github_token: str = typer.Option( - None, - "--github-token", - help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)", - ), - pre: bool = typer.Option( - False, - "--pre", - help="Download the latest pre-release (dev build) instead of the latest stable release", - ), - source: Optional[Path] = typer.Option( - None, - "--source", - help=( - "Use a local RPG-Kit source checkout to build and install the " - "template package instead of downloading a release asset." - ), + help="Show verbose diagnostic output", ), no_mcp: bool = typer.Option( False, "--no-mcp", help="Skip MCP server registration (rpg-tools won't be exposed to the AI agent)", ), + no_copilot_cli_mcp: bool = typer.Option( + False, + "--no-copilot-cli-mcp", + help=( + "When --ai copilot is selected, skip also registering " + "rpg-tools globally in ~/.copilot/mcp-config.json. The " + "Copilot CLI does not read workspace .vscode/mcp.json, so " + "this global registration is what makes `copilot` find " + "rpg-tools. Pass this flag if you manage your Copilot CLI " + "MCP config by hand." + ), + ), encode: Optional[bool] = typer.Option( None, "--encode/--no-encode", @@ -3341,14 +3745,26 @@ def init( "prompt and run, or --no-encode to skip the prompt and not run." ), ), + no_rpgkit_git: bool = typer.Option( + False, + "--no-rpgkit-git", + help=( + "Skip initialising a private git repository inside .rpgkit/. " + "Default is ON: rpgkit init seeds .rpgkit/.git " + "so every subsequent `rpgkit script` invocation auto-snapshots " + "the workspace state, letting you `git log` / `git diff` " + "between pipeline stages without extra tooling. This flag " + "disables the feature for the current init only." + ), + ), ): """Initialize a new RPG-Kit project from the latest template. This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant - 3. Download the appropriate template from GitHub - 4. Extract the template to a new project directory or current directory + 3. Install command templates from the packaged bundle + 4. Place them into a new project directory or current directory 5. Initialize a fresh git repository (if not --no-git and no existing repo) 6. Optionally set up AI assistant commands @@ -3478,9 +3894,20 @@ def init( f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}" ) raise typer.Exit(1) + # PowerShell support is planned but not yet wired into the + # bundled templates / pipeline scripts. Reject explicit + # --script ps with a friendly message so users aren't surprised + # by missing files later. + if script_type == "ps": + console.print( + "[yellow]PowerShell (--script ps) is not yet supported and will " + "be added in a future release. Please use --script sh for now.[/yellow]" + ) + raise typer.Exit(1) selected_script = script_type else: - default_script = "ps" if os.name == "nt" else "sh" + # Default to sh on every platform until PowerShell templates land. + default_script = "sh" if sys.stdin.isatty(): selected_script = select_with_arrows( @@ -3493,12 +3920,6 @@ def init( console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") - if source: - console.print(f"[cyan]Template source:[/cyan] {source}") - if pre: - console.print( - "[yellow]Warning:[/yellow] --pre is ignored when --source is provided" - ) tracker = StepTracker("Initialize RPG-Kit Project") @@ -3511,23 +3932,16 @@ def init( tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) for key, label in [ - ( - "fetch", - "Build local template package" - if source - else "Fetch latest pre-release" - if pre - else "Fetch latest release", - ), - ("download", "Use local template package" if source else "Download template"), + ("fetch", "Install bundled templates"), + ("download", "Download template"), ("extract", "Extract template"), ("zip-list", "Archive contents"), ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), ("gitignore", "Configure .gitignore"), ("mcp", "Configure MCP server"), + ("copilot-cli-mcp", "Register rpg-tools in ~/.copilot/mcp-config.json"), ("legacy-cleanup", "Remove obsolete persistent rules"), - ("venv", "Set up Python environment"), ("cleanup", "Cleanup"), ("git", "Initialize git repository"), ("hooks", "Install auto-update hooks"), @@ -3543,10 +3957,6 @@ def init( ) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - verify = not skip_tls - local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template( project_path, selected_ai, @@ -3554,14 +3964,16 @@ def init( here, verbose=False, tracker=tracker, - client=local_client, debug=debug, - github_token=github_token, - pre=pre, - source=source, ) - ensure_executable_scripts(project_path, tracker=tracker) + # .rpgkit/.source is written by whichever provisioning path + # actually ran (_install_from_bundle / _download_and_extract_release_zip). + + # Materialise .rpgkit/config.toml with the resolved AI CLI + # command. llm_client.py reads this at runtime to invoke + # the right sub-agent. + _write_workspace_config(project_path, selected_ai) # Materialize .gitignore *before* MCP/hook generation so the # files those steps create (.vscode/mcp.json, .vscode/tasks.json, @@ -3581,6 +3993,19 @@ def init( else: _generate_mcp_config(project_path, selected_ai, tracker=tracker) + # Global registration for Copilot CLI (which doesn't read + # workspace .vscode/mcp.json). Skipped for non-copilot AIs, + # when --no-mcp is set, or when the user opts out explicitly. + if no_mcp: + pass + elif selected_ai != "copilot": + tracker.skip("copilot-cli-mcp", f"ai={selected_ai}") + elif no_copilot_cli_mcp: + tracker.skip("copilot-cli-mcp", "--no-copilot-cli-mcp flag") + else: + tracker.start("copilot-cli-mcp") + _register_copilot_cli_global_mcp(tracker=tracker) + # Migrate workspaces created before C4: drop the auto-loaded # rpgkit-codegen.* persistent-instruction files. tracker.start("legacy-cleanup") @@ -3596,8 +4021,6 @@ def init( except Exception as exc: tracker.error("legacy-cleanup", str(exc)) - setup_venv_rpgkit(project_path, tracker=tracker) - if not no_git: tracker.start("git") if is_git_repo(project_path): @@ -3651,6 +4074,39 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") + # PATH self-check: hooks and MCP rely on ``rpgkit`` / ``rpgkit-mcp`` + # being resolvable. If they aren't on PATH, the user will hit + # opaque failures from git hooks and MCP clients later — surface + # the actionable hint now. + import shutil as _shutil + if _shutil.which("rpgkit-mcp") is None or _shutil.which("rpgkit") is None: + reinstall_cmd: Optional[list[str]] = _upgrade_command(_detect_install_method()) + # ``--force`` reinstalls in place which fixes most PATH issues + # caused by partial installs / corrupted shim links. + if reinstall_cmd and reinstall_cmd[:3] == ["uv", "tool", "upgrade"]: + reinstall_hint = "uv tool install rpgkit-cli --force" + elif reinstall_cmd and reinstall_cmd[:2] == ["pipx", "upgrade"]: + reinstall_hint = "pipx install rpgkit-cli --force" + elif reinstall_cmd: + reinstall_hint = " ".join(reinstall_cmd) + else: + reinstall_hint = "uv tool install rpgkit-cli --force # or your installer's equivalent" + console.print() + path_panel = Panel( + "[yellow]Warning:[/yellow] [cyan]rpgkit[/cyan] / [cyan]rpgkit-mcp[/cyan] " + "not found on PATH.\n\n" + "Git hooks and the MCP server invoke these commands; they will " + "fail until PATH is fixed.\n\n" + "[bold]Fix:[/bold]\n" + " - Linux/macOS: add [cyan]~/.local/bin[/cyan] to PATH in your shell rc\n" + " - Windows: add [cyan]%USERPROFILE%\\.local\\bin[/cyan] to PATH\n" + f" - Or reinstall: [cyan]{reinstall_hint}[/cyan]", + title="[red]PATH check[/red]", + border_style="yellow", + padding=(1, 2), + ) + console.print(path_panel) + # Show git error details if initialization failed if git_error_message: console.print() @@ -3686,7 +4142,7 @@ def init( console.print(security_notice) # Pre-create runtime directories so early pipeline prompts that redirect - # to .rpgkit/logs/.log don't fail with "No such file or directory". + # to ~/.rpgkit/workspaces//logs/.log don't fail with "No such file or directory". ensure_rpgkit_runtime_dirs(project_path) steps_lines = [] @@ -3713,18 +4169,6 @@ def init( ) step_num += 1 - venv_path = project_path / ".venv_rpgkit" - if os.name == "nt": - activate_cmd = r".venv_rpgkit\Scripts\activate" - else: - activate_cmd = "source .venv_rpgkit/bin/activate" - steps_lines.append( - f"{step_num}. Activate the RPG-Kit Python environment: " - f"[cyan]{activate_cmd}[/cyan]" - ) - - step_num += 1 - steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:") steps_lines.extend([ @@ -3745,8 +4189,9 @@ def init( step_num += 1 steps_lines.append( - f"{step_num}. You can inspect each step's output under [cyan].rpgkit/data/[/cyan], " - f"and review detailed execution trajectories in [cyan].rpgkit/data/trajectory/[/cyan]." + f"{step_num}. You can inspect each step's output under [cyan]~/.rpgkit/workspaces//data/[/cyan], " + f"and review detailed execution trajectories under [cyan]~/.rpgkit/workspaces//data/trajectory/[/cyan]. " + f"Run [cyan]rpgkit version[/cyan] from inside the workspace to see the resolved Data / Logs / Inner-git paths." ) step_num += 1 @@ -3760,9 +4205,9 @@ def init( # the requirement loud-and-clear here so users don't hit the silent # "rpg_unavailable" payload on their first /rpgkit.* call. steps_lines.append( - f" [yellow]Note:[/] the MCP tools query [cyan].rpgkit/data/rpg.json[/], which is " - f"created by the encoder. For existing codebases, run [cyan]/rpgkit.encode[/] " - f"once now to populate it; the post-commit hook keeps it in sync afterwards." + " [yellow]Note:[/] the MCP tools query [cyan]rpg.json[/] in the workspace's home-dir " + "store, which is created by the encoder. For existing codebases, run [cyan]/rpgkit.encode[/] " + "once now to populate it; the post-commit hook keeps it in sync afterwards." ) steps_panel = Panel( @@ -3787,6 +4232,31 @@ def init( console.print() console.print(permissions_hint) + # Initialise the private snapshot repo inside .rpgkit/. Done BEFORE + # the optional initial encode so the encoder's output, if it runs, + # becomes a fresh commit on top of the [init] baseline — a useful + # diff target. + if not no_rpgkit_git: + from . import _inner_git + from importlib.metadata import version as _pkg_version, PackageNotFoundError + try: + ver = _pkg_version("rpgkit-cli") + except PackageNotFoundError: + ver = "dev" + channel = "bundle" + script_label = script_type if script_type else "sh" + ai_label = selected_ai if selected_ai else "?" + if _inner_git.ensure_inner_git( + project_path, + initial_msg=f"[init] v{ver} \u2014 {ai_label}/{script_label}, {channel} channel", + ): + console.print( + "[dim]Inner snapshot repo initialised at " + "[cyan]~/.rpgkit/workspaces//.git[/cyan] \u2014 " + "run [cyan]rpgkit version[/cyan] for the exact path " + "and a ready-to-paste `git -C` invocation.[/dim]" + ) + # Final step: optionally build the initial RPG by running the # encoder. Skipped silently for empty workspaces / non-tty / when # the user passes --no-encode. @@ -3803,36 +4273,47 @@ def update( script_type: str = typer.Option( None, "--script", help="Script type to use: sh or ps" ), - skip_tls: bool = typer.Option( - False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)" - ), debug: bool = typer.Option( False, "--debug", - help="Show verbose diagnostic output for network and extraction failures", + help="Show verbose diagnostic output", ), - github_token: str = typer.Option( - None, - "--github-token", - help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)", + no_mcp: bool = typer.Option( + False, + "--no-mcp", + help="Skip MCP server registration (rpg-tools won't be exposed to the AI agent)", ), - pre: bool = typer.Option( + no_copilot_cli_mcp: bool = typer.Option( False, - "--pre", - help="Download the latest pre-release (dev build) instead of the latest stable release", + "--no-copilot-cli-mcp", + help=( + "When --ai copilot is selected, skip also registering " + "rpg-tools globally in ~/.copilot/mcp-config.json. The " + "Copilot CLI does not read workspace .vscode/mcp.json, so " + "this global registration is what makes `copilot` find " + "rpg-tools. Pass this flag if you manage your Copilot CLI " + "MCP config by hand." + ), ), - source: Optional[Path] = typer.Option( - None, - "--source", + no_upgrade: bool = typer.Option( + False, + "--no-upgrade", help=( - "Use a local RPG-Kit source checkout to build and install the " - "template package instead of downloading a release asset." + "Skip the default-on CLI self-upgrade step. Use when offline, " + "on a version-pinned CI runner, or when you've just " + "installed the CLI manually." ), ), - no_mcp: bool = typer.Option( + no_rpgkit_git: bool = typer.Option( False, - "--no-mcp", - help="Skip MCP server registration (rpg-tools won't be exposed to the AI agent)", + "--no-rpgkit-git", + help=( + "Skip backfilling the private snapshot repo at .rpgkit/.git " + "for older workspaces that don't have one yet. Default is ON: " + "if the inner repo is missing, `rpgkit update` creates it and " + "commits a catch-up snapshot. Pre-existing inner repos are " + "never touched." + ), ), ): """Update RPG-Kit template files in an existing project to the latest version. @@ -3847,8 +4328,7 @@ def update( Examples: rpgkit update rpgkit update --ai claude - rpgkit update --pre - rpgkit update --github-token $GITHUB_TOKEN + rpgkit update --no-upgrade """ show_banner() @@ -3900,9 +4380,20 @@ def update( f"Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}" ) raise typer.Exit(1) + # PowerShell support is planned but not yet wired into the + # bundled templates / pipeline scripts. Reject explicit + # --script ps with a friendly message so users aren't surprised + # by missing files later. + if script_type == "ps": + console.print( + "[yellow]PowerShell (--script ps) is not yet supported and will " + "be added in a future release. Please use --script sh for now.[/yellow]" + ) + raise typer.Exit(1) selected_script = script_type else: - default_script = "ps" if os.name == "nt" else "sh" + # Default to sh on every platform until PowerShell templates land. + default_script = "sh" if sys.stdin.isatty(): selected_script = select_with_arrows( SCRIPT_TYPE_CHOICES, @@ -3914,12 +4405,123 @@ def update( console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") - if source: - console.print(f"[cyan]Template source:[/cyan] {source}") - if pre: + + # Pre-update CLI upgrade ------------------------------------------------- + # + # By default, ``rpgkit update`` first runs the appropriate upgrade + # command (``uv tool upgrade rpgkit-cli`` for uv installs etc.) so + # the workspace's prompts/scripts/templates always match the + # *latest* released version of the CLI. Without this, users who + # never re-install the CLI would silently drift behind upstream. + # + # We auto-upgrade only when: + # * the install method has a known upgrade command (uv, pipx, pip…) + # AND + # * the install source is remote (git URL or PyPI), meaning the + # user isn't actively developing the CLI from a local checkout. + # + # ``--no-upgrade`` skips this step (offline / pinned CI / freshly + # re-installed manually). + # + # After a successful upgrade we ``os.execvp`` the (now-upgraded) + # rpgkit binary so the rest of update runs against the freshly + # installed code + assets. Mixing old in-memory logic with new + # on-disk core_pack/ used to cause logic vs assets drift bugs. + # + # Loop guard: ``RPGKIT_UPGRADE_DONE`` is set on the re-exec'd + # process's environment. When present, this block skips the + # upgrade attempt unconditionally so an idempotent ``uv tool + # upgrade`` (which returns 0 even when there's nothing to upgrade) + # doesn't loop forever. + _UPGRADE_DONE_ENV = "RPGKIT_UPGRADE_DONE" + already_upgraded = bool(os.environ.get(_UPGRADE_DONE_ENV)) + + method = _detect_install_method() + source = _install_source() + cmd = _upgrade_command(method) + + if already_upgraded: + do_upgrade = False + skip_reason = "" # silent — internal marker, not user-visible + elif no_upgrade: + do_upgrade = False + skip_reason = "--no-upgrade" + elif cmd is None: + do_upgrade = False + skip_reason = ( + f"install method '{method}' has no auto-upgrade path " + f"(upgrade manually)" + ) + elif source not in _AUTO_UPGRADE_SOURCES: + do_upgrade = False + skip_reason = ( + f"local/dev install (source={source!r}); skipping auto-upgrade." + ) + else: + do_upgrade = True + skip_reason = "" + + if do_upgrade: + console.print( + f"[cyan]Upgrading rpgkit-cli via {method} (source={source})...[/cyan]" + ) + try: + rc = subprocess.call(cmd) # type: ignore[arg-type] + except FileNotFoundError: + # Upgrade tool (uv, pipx, pip) not on PATH — surface, then + # carry on with the current build. Stripping the upgrade + # is a worse user experience than failing fast here would + # be, but ``rpgkit update`` is "make my workspace match the + # installed CLI", and the installed CLI is still functional. console.print( - "[yellow]Warning:[/yellow] --pre is ignored when --source is provided" + f"[yellow]Upgrade tool {cmd[0]!r} not found on PATH; " + f"continuing with currently installed version.[/yellow]" ) + rc = -1 + except Exception as exc: # noqa: BLE001 + console.print( + f"[yellow]CLI upgrade raised an unexpected error " + f"({type(exc).__name__}: {exc}); continuing with " + f"currently installed version.[/yellow]" + ) + rc = -1 + + if rc == 0: + # Re-exec the upgraded binary so the rest of update runs + # against the freshly-installed code + assets. Set the + # loop-guard env var so the re-exec'd process doesn't + # immediately try to upgrade again. + new_argv = list(sys.argv) + rpgkit_bin = shutil.which("rpgkit") or new_argv[0] + console.print( + "[cyan]CLI upgrade complete; re-exec'ing to apply " + "new templates...[/cyan]" + ) + try: + os.environ[_UPGRADE_DONE_ENV] = "1" + os.execvp(rpgkit_bin, [rpgkit_bin, *new_argv[1:]]) + except OSError as exc: + # execvp failed — fall back to running the update + # in-process with the (now-on-disk) new code. This + # mixes old in-memory logic with new assets, but + # that's strictly better than crashing here: the user + # already paid for the upgrade and wants the result. + console.print( + f"[yellow]re-exec failed ({exc}); proceeding with " + f"in-process update.[/yellow]" + ) + os.environ.pop(_UPGRADE_DONE_ENV, None) + elif rc != -1: + console.print( + f"[yellow]CLI upgrade exited with code {rc}; " + f"continuing with currently installed version.[/yellow]" + ) + elif skip_reason: + # Surface the reason only when the user explicitly opted out; + # the default-on skip paths (editable, no upgrade cmd) stay + # quiet for the 99% case where nothing to do. + if skip_reason == "--no-upgrade": + console.print(f"[dim]update: skipping CLI upgrade ({skip_reason}).[/dim]") # Build step tracker tracker = StepTracker("Update RPG-Kit Project") @@ -3931,23 +4533,16 @@ def update( tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) for key, label in [ - ( - "fetch", - "Build local template package" - if source - else "Fetch latest pre-release" - if pre - else "Fetch latest release", - ), - ("download", "Use local template package" if source else "Download template"), + ("fetch", "Install bundled templates"), + ("download", "Download template"), ("extract", "Extract template"), ("zip-list", "Archive contents"), ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), ("gitignore", "Configure .gitignore"), ("mcp", "Configure MCP server"), + ("copilot-cli-mcp", "Register rpg-tools in ~/.copilot/mcp-config.json"), ("legacy-cleanup", "Remove obsolete persistent rules"), - ("venv", "Set up Python environment"), ("hooks", "Install auto-update hooks"), ("cleanup", "Cleanup"), ("final", "Finalize"), @@ -3959,10 +4554,6 @@ def update( ) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - verify = not skip_tls - local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template( project_path, selected_ai, @@ -3970,17 +4561,18 @@ def update( True, # is_current_dir — always merge/overwrite for update verbose=False, tracker=tracker, - client=local_client, debug=debug, - github_token=github_token, - pre=pre, - source=source, ) - ensure_executable_scripts(project_path, tracker=tracker) + # .rpgkit/.source is written by whichever provisioning path + # actually ran (_install_from_bundle / _download_and_extract_release_zip). + + # Refresh .rpgkit/config.toml only when missing (preserves + # user customisations on re-update). + _write_workspace_config(project_path, selected_ai) # Pre-create runtime directories so stage prompts that redirect - # to .rpgkit/logs/.log don't fail when the folder is + # to ~/.rpgkit/workspaces//logs/.log don't fail when the folder is # missing (e.g. user removed it, or workspace was created by an # older rpgkit init that didn't pre-create logs/). ensure_rpgkit_runtime_dirs(project_path, tracker=tracker) @@ -4002,6 +4594,17 @@ def update( else: _generate_mcp_config(project_path, selected_ai, tracker=tracker) + # Global registration for Copilot CLI (see init for rationale). + if no_mcp: + pass + elif selected_ai != "copilot": + tracker.skip("copilot-cli-mcp", f"ai={selected_ai}") + elif no_copilot_cli_mcp: + tracker.skip("copilot-cli-mcp", "--no-copilot-cli-mcp flag") + else: + tracker.start("copilot-cli-mcp") + _register_copilot_cli_global_mcp(tracker=tracker) + # Migrate workspaces created before C4: drop the auto-loaded # rpgkit-codegen.* persistent-instruction files. tracker.start("legacy-cleanup") @@ -4017,8 +4620,6 @@ def update( except Exception as exc: tracker.error("legacy-cleanup", str(exc)) - setup_venv_rpgkit(project_path, tracker=tracker) - # Re-install hooks so behavior fixes propagate to existing # workspaces. Without this, the .git/hooks/* files stay # frozen at whatever version was active during the original @@ -4065,23 +4666,441 @@ def update( f"[dim]Updated: scripts, templates, and {AGENT_CONFIG[selected_ai]['name']} " f"command definitions in [cyan]{project_path}[/cyan][/dim]" ) - console.print() - venv_path = Path(project_path) / ".venv_rpgkit" - if venv_path.exists(): - activate_cmd = ( - r".venv_rpgkit\Scripts\activate" - if os.name == "nt" - else "source .venv_rpgkit/bin/activate" - ) + + # Backfill inner snapshot repo for workspaces created before + # this feature shipped. Idempotent — does nothing if .rpgkit/.git + # already exists, and silently noops if --no-rpgkit-git was passed. + if not no_rpgkit_git: + from . import _inner_git + from importlib.metadata import version as _pkg_version, PackageNotFoundError + try: + ver = _pkg_version("rpgkit-cli") + except PackageNotFoundError: + ver = "dev" + if _inner_git.ensure_inner_git( + project_path, + initial_msg=f"[update] v{ver} \u2014 catch-up snapshot", + ): + console.print( + "[dim]Initialised inner snapshot repo at " + "[cyan]~/.rpgkit/workspaces//.git[/cyan] for this workspace.[/dim]" + ) + + +@app.command( + context_settings={ + "allow_extra_args": True, + "ignore_unknown_options": True, + # Disable click's auto-help so ``--help`` is forwarded to the + # target script. Use ``rpgkit script`` (no args) or + # ``rpgkit --help script`` to see this command's own help. + "help_option_names": [], + }, +) +def script( + ctx: typer.Context, + relpath: Optional[str] = typer.Argument( + None, + help="Script path relative to the packaged scripts directory " + "(e.g. 'smoke_test.py' or 'rpg_edit/validate.py'). " + "The '.py' suffix is optional.", + ), + list_all: bool = typer.Option( + False, + "--list", + help="List all available scripts and exit.", + ), + where: Optional[str] = typer.Option( + None, + "--where", + metavar="NAME", + help="Print the absolute filesystem path of NAME and exit.", + ), +) -> None: + """Execute a bundled RPG-Kit pipeline script. + + All arguments after ```` are forwarded verbatim to the + target script. Standard input/output/error are inherited so the + child's behaviour matches direct invocation. + + Examples:: + + rpgkit script smoke_test.py --json + rpgkit script rpg_edit/validate.py + rpgkit script --list + rpgkit script --where mcp_server.py + """ + from . import _assets + + if list_all: + for name in _assets.list_scripts(): + console.print(name) + raise typer.Exit(0) + + if where is not None: + path = _resolve_script_path(where) + if path is None: + console.print(f"[red]script not found: {where}[/red]") + raise typer.Exit(1) + # Print plain path (no markup) so it pipes cleanly into $(...) + print(str(path)) + raise typer.Exit(0) + + if not relpath: console.print( - Panel( - "Activate the RPG-Kit Python environment before using slash commands:\n\n" - f"[cyan]{activate_cmd}[/cyan]", - title="[yellow]Environment Setup[/yellow]", - border_style="yellow", - padding=(1, 2), + "[red]error:[/red] missing script path. " + "Use [cyan]rpgkit script --list[/cyan] to see available scripts." + ) + raise typer.Exit(2) + + path = _resolve_script_path(relpath) + if path is None: + console.print(f"[red]script not found: {relpath}[/red]") + raise typer.Exit(1) + + # Build child env: inherit, plus disable .pyc writes so the read-mostly + # tool-venv install dir doesn't accumulate __pycache__ noise. + env = os.environ.copy() + env.setdefault("PYTHONDONTWRITEBYTECODE", "1") + + # Tee stdout to a per-stage log file so the workspace has a persistent + # record of every script invocation. The log path is resolved from + # _storage at run time; if the home-side dir doesn't exist yet (e.g. + # rpgkit init hasn't run), skip silently — no log is better than + # crashing. + log_path: Optional[Path] = None + from . import _inner_git as _ig + ws_root = _ig.find_workspace_root() + if ws_root is not None: + from . import _storage + logs_dir = _storage.workspace_logs_dir(ws_root) + if logs_dir.is_dir(): + script_stem = path.stem # e.g. "feature_build" + log_path = logs_dir / f"{script_stem}.log" + + if log_path is not None: + log_fh = open(log_path, "a", encoding="utf-8") + cmd = [sys.executable, str(path), *ctx.args] + proc = subprocess.run( + cmd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + # Write captured output to both terminal and log file. + output = proc.stdout or b"" + sys.stdout.buffer.write(output) + sys.stdout.buffer.flush() + try: + log_fh.write(output.decode("utf-8", errors="replace")) + log_fh.flush() + except OSError: + pass + finally: + log_fh.close() + else: + cmd = [sys.executable, str(path), *ctx.args] + proc = subprocess.run(cmd, env=env) + + # Snapshot the current state of .rpgkit/ into the inner git + # repo so users can `git log` / `git diff` between pipeline stages. + # No-op (silently) when the script is read-only (check_*, *_validation), + # the inner repo is absent (--no-rpgkit-git on init), or git is busy. + # + # Use the *resolved* path (always carries .py) for the commit message + # so `rpgkit script smoke_test` and `rpgkit script smoke_test.py` + # produce identical history entries. + from . import _inner_git, _assets + ws_root = _inner_git.find_workspace_root() + if ws_root is not None: + try: + commit_relpath = str(path.relative_to(_assets.scripts_dir())).replace("\\", "/") + except ValueError: + commit_relpath = relpath.replace("\\", "/") + _inner_git.auto_commit_after_script( + ws_root, + commit_relpath, + list(ctx.args), + proc.returncode, + ) + + raise typer.Exit(proc.returncode) + + +def _resolve_script_path(relpath: str) -> Optional[Path]: + """Resolve ``relpath`` against the packaged scripts dir. + + Rejects path-traversal and absolute paths; appends ``.py`` when no + suffix is given. Returns ``None`` if the resolved path is not a + regular file inside :func:`_assets.scripts_dir`. + """ + from . import _assets + + # Normalise separators for cross-platform invocation + rel = relpath.replace("\\", "/") + # Security: reject parent-traversal and absolute paths + if rel.startswith("/") or ".." in rel.split("/"): + return None + p = Path(rel) + if p.is_absolute(): + return None + if p.suffix == "": + p = p.with_suffix(".py") + root = _assets.scripts_dir() + candidate = (root / p).resolve() + try: + candidate.relative_to(root.resolve()) + except ValueError: + # Resolved outside the scripts root (e.g. via symlink) — refuse + return None + if not candidate.is_file(): + return None + return candidate + + +# --------------------------------------------------------------------------- +# Git-hook dispatch: ``rpgkit hook `` +# --------------------------------------------------------------------------- +# +# Python entry-point for git hooks. The on-disk hook files in +# ``.git/hooks/`` are short shell stubs that ``exec`` this command; +# path resolution, logging, locking, and detach logic live here so they +# can be updated by upgrading the CLI rather than reinstalling hooks. + +_HOOK_ENV_NAME = "RPGKIT_HOOK" +_HOOK_ENV_SHA = "RPGKIT_HOOK_SHA" +_HOOK_LOG_FILENAME = "hooks.log" +_HOOK_BACKGROUND_LOG = "update_rpg.log" +_HOOK_LOCK_DIRNAME = ".update_rpg.lock" +_HOOK_LOCK_STALE_SECONDS = 60 * 60 # 60 minutes -- matches the old shell impl + + +def _hook_log_line(log_path: Path, msg: str) -> None: + """Append a timestamped line to the hook log. Best-effort.""" + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + with open(log_path, "a", encoding="utf-8") as fh: + ts = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + fh.write(f"[{ts}] {msg}\n") + except OSError: + # Logging is observability; we never fail a hook because we + # couldn't write a line. + pass + + +def _short_head_sha(workspace: Path) -> str: + """Return ``git rev-parse --short HEAD`` for ``workspace`` or ``"?"``.""" + try: + r = subprocess.run( + ["git", "-C", str(workspace), "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, timeout=5, + ) + if r.returncode == 0: + return (r.stdout or "").strip() or "?" + except (OSError, subprocess.SubprocessError): + pass + return "?" + + +def _hook_run_foreground( + workspace: Path, + log_path: Path, + env: Dict[str, str], + script_args: List[str], + label: str, +) -> int: + """Run ``rpgkit script `` and tee output into ``log_path``.""" + _hook_log_line(log_path, f"{label}: start ({' '.join(script_args)})") + try: + with open(log_path, "a", encoding="utf-8") as fh: + proc = subprocess.run( + ["rpgkit", "script", *script_args], + cwd=str(workspace), + env=env, + stdout=fh, stderr=subprocess.STDOUT, + timeout=300, ) + _hook_log_line(log_path, f"{label}: done (exit {proc.returncode})") + return proc.returncode + except (OSError, subprocess.SubprocessError) as exc: + _hook_log_line(log_path, f"{label}: ERROR {exc!r}") + return -1 + + +def _hook_spawn_background( + workspace: Path, + home_dir: Path, + hook_log: Path, + env: Dict[str, str], +) -> None: + """Acquire a directory lock and detach ``update_graphs.py update-rpg``. + + The lock is a *directory* (``mkdir`` is the only POSIX-atomic + exclusive-create primitive); a directory older than + :data:`_HOOK_LOCK_STALE_SECONDS` is treated as orphaned (worker + killed by OOM / reboot / SIGKILL) and removed before re-trying. + """ + lock_dir = home_dir / "logs" / _HOOK_LOCK_DIRNAME + bg_log = home_dir / "logs" / _HOOK_BACKGROUND_LOG + + # Stale-lock recovery -- match the 60-minute window the shell hook used. + try: + if lock_dir.is_dir(): + age = time.time() - lock_dir.stat().st_mtime + if age > _HOOK_LOCK_STALE_SECONDS: + shutil.rmtree(lock_dir, ignore_errors=True) + _hook_log_line(hook_log, f"phase2: removed stale lock (age={age:.0f}s)") + except OSError: + pass + + # Try to acquire. + try: + lock_dir.mkdir(parents=False, exist_ok=False) + except FileExistsError: + _hook_log_line(hook_log, "phase2: skipped (another worker holds the lock)") + return + except OSError as exc: + _hook_log_line(hook_log, f"phase2: lock acquire failed: {exc!r}") + return + + # Background worker: run update-rpg, then release the lock. We + # cannot use ``Popen`` alone because nothing would ``rmdir`` the + # lock after the worker completes; a tiny ``sh -c`` wrapper does + # the cleanup deterministically. + # + # ``start_new_session=True`` is the cross-platform equivalent of + # ``nohup``/``setsid`` -- the child survives the hook's exit. + bg_log.parent.mkdir(parents=True, exist_ok=True) + lock_q = shlex.quote(str(lock_dir)) + log_q = shlex.quote(str(bg_log)) + workspace_q = shlex.quote(str(workspace)) + shell_cmd = ( + f"cd {workspace_q}; sleep 2; " + f"rpgkit script update_graphs.py update-rpg --json >> {log_q} 2>&1; " + f"rmdir {lock_q}" + ) + # Strip GIT_INDEX_FILE / GIT_DIR which git sets during hooks - + # if they leak into the worker, ``git worktree add`` fails with + # cryptic index errors. + worker_env = {k: v for k, v in env.items() if k not in ("GIT_INDEX_FILE", "GIT_DIR")} + try: + subprocess.Popen( + ["sh", "-c", shell_cmd], + cwd=str(workspace), + env=worker_env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, ) + _hook_log_line(hook_log, f"phase2: dispatched -> {bg_log}") + except OSError as exc: + _hook_log_line(hook_log, f"phase2: spawn failed: {exc!r}") + # Release the lock so the next commit can retry. + try: + lock_dir.rmdir() + except OSError: + pass + + +@app.command( + "hook", + hidden=True, + help="Internal git-hook dispatcher (called from .git/hooks/*).", +) +def hook(name: str = typer.Argument(..., help="Hook name: post-commit | post-merge")) -> None: + """Dispatch from ``.git/hooks/`` to the matching Python handler. + + Resolves the current workspace via the standard cwd-walk, attaches + a hook log under ``~/.rpgkit/workspaces//logs/hooks.log``, + and runs the per-hook orchestration. Every failure path is + swallowed (logged, never raised) so a misbehaving hook never blocks + the user's git operation. + + Supported hooks: ``post-commit`` and ``post-merge``. The dispatcher + also accepts ``pre-commit`` as a deliberate no-op for backward + compatibility — old workspaces whose hook file still calls + ``rpgkit hook pre-commit`` should be cleaned up on the next + ``rpgkit init`` / ``rpgkit update`` run, which strips the block. + + All ``rpgkit script`` subprocess invocations inherit two env vars: + + * ``RPGKIT_HOOK`` -- the hook name (``post-commit`` etc.) + * ``RPGKIT_HOOK_SHA`` -- short SHA of the user-facing commit + + The inner-git snapshot's commit message picks these up + (:func:`rpgkit_cli._inner_git._build_message`) so ``git log`` in the + home-side repo reads as a timeline of *user activity*, e.g.:: + + [hook:post-commit @ a1b2c3d] sync + [hook:post-merge @ 9f8e7d6] sync + """ + from . import _storage + + try: + ws = _storage.find_workspace_root_from(Path.cwd()) + if ws is None: + # Not in an rpgkit workspace -- silently exit success; + # the hook may be running in a repo that was provisioned + # then un-init'd, and we never want to block git. + raise typer.Exit(0) + + home_dir = _storage.home_workspace_dir(ws) + log_path = _storage.workspace_logs_dir(ws) / _HOOK_LOG_FILENAME + sha = _short_head_sha(ws) + + env = os.environ.copy() + env[_HOOK_ENV_NAME] = name + env[_HOOK_ENV_SHA] = sha + # Ensure ``rpgkit`` itself is on PATH when the hook is fired + # from a GUI editor that lacks the user's interactive shell PATH. + local_bin = str(Path.home() / ".local" / "bin") + if local_bin not in env.get("PATH", ""): + env["PATH"] = local_bin + os.pathsep + env.get("PATH", "") + + _hook_log_line(log_path, f"== {name} fired @ {sha} (ws={ws})") + + if name == "pre-commit": + # Retired: pre-commit is now a deliberate no-op for backward + # compatibility with workspaces whose stub hasn't been + # stripped yet. Just log and exit success so git proceeds. + _hook_log_line(log_path, "pre-commit hook is a no-op (retired)") + elif name == "post-merge": + _hook_run_foreground( + ws, log_path, env, + ["update_graphs.py", "sync"], + "sync", + ) + elif name == "post-commit": + # Phase 1: synchronous meta.git advance (fast, ~50ms). + _hook_run_foreground( + ws, log_path, env, + ["update_graphs.py", "sync"], + "phase1-sync", + ) + # Phase 2: detached background LLM-driven RPG update. + _hook_spawn_background(ws, home_dir, log_path, env) + else: + _hook_log_line(log_path, f"unknown hook name: {name!r}") + raise typer.Exit(0) + + except typer.Exit: + raise + except Exception as exc: + # Last-ditch swallow: anything reaching here means our hook + # dispatcher itself is broken, but a broken hook must not + # break ``git commit`` -- log and exit cleanly. + try: + ws = _storage.find_workspace_root_from(Path.cwd()) + if ws is not None: + _hook_log_line( + _storage.workspace_logs_dir(ws) / _HOOK_LOG_FILENAME, + f"FATAL in hook dispatcher: {exc!r}", + ) + except Exception: + pass + raise typer.Exit(0) + + raise typer.Exit(0) + @app.command() def check(): @@ -4126,7 +5145,12 @@ def check(): @app.command() def version(): - """Display version and system information.""" + """Display version and system information. + + Also fetches the latest release tag from GitHub and reports whether + the locally installed CLI is up to date, behind, or ahead (dev + build). Network failures are swallowed and surface as "offline". + """ show_banner() # Get CLI version from package metadata @@ -4148,8 +5172,9 @@ def version(): # Fetch latest template release version repo_owner, repo_name = _get_repo_info() - template_version = "unknown" + latest_version = "unknown" release_date = "unknown" + fetch_error: str | None = None try: release_data = _fetch_latest_rpgkit_release( @@ -4158,7 +5183,7 @@ def version(): client, timeout=10, ) - template_version = _format_rpgkit_version(release_data.get("tag_name", "unknown")) + latest_version = _format_rpgkit_version(release_data.get("tag_name", "unknown")) release_date = release_data.get("published_at", "unknown") if release_date != "unknown": # Format the date nicely @@ -4167,22 +5192,112 @@ def version(): release_date = dt.strftime("%Y-%m-%d") except Exception: pass - except Exception: - pass + except Exception as exc: + fetch_error = str(exc).splitlines()[0] if str(exc) else type(exc).__name__ + + # ------------------------------------------------------------------ + # Compute the status hint: up-to-date / outdated / ahead / offline. + # Uses ``packaging.version`` (stdlib-ish — ships with setuptools and + # is a transitive dep of pip itself) so PEP 440 pre-release / dev + # suffixes are compared correctly. Falls back to a plain string + # comparison when ``packaging`` is unavailable. + # ------------------------------------------------------------------ + status_label = "[dim]unknown[/dim]" + status_hint: str | None = None + + if fetch_error is not None: + status_label = "[yellow]offline[/yellow]" + status_hint = ( + f"Could not query GitHub for the latest release: {fetch_error}. " + "Local install is still usable; rerun `rpgkit version` when " + "you have network access to compare." + ) + elif cli_version != "unknown" and latest_version != "unknown": + try: + from packaging.version import Version as _Ver + + local_v = _Ver(cli_version) + remote_v = _Ver(latest_version) + except Exception: + local_v = cli_version + remote_v = latest_version + + if local_v == remote_v: + status_label = "[green]up to date[/green]" + elif local_v < remote_v: + status_label = f"[yellow]outdated → {latest_version}[/yellow]" + status_hint = ( + f"A newer release ([cyan]{latest_version}[/cyan]) is " + f"available. Upgrade with one of:\n" + f" [cyan]uv tool upgrade rpgkit-cli[/cyan]\n" + f" [cyan]pipx upgrade rpgkit-cli[/cyan]\n" + f" [cyan]pip install -U rpgkit-cli[/cyan]\n" + f"After upgrading, run [cyan]rpgkit update[/cyan] in each " + f"existing workspace to apply the new prompts." + ) + else: + status_label = f"[cyan]ahead of release ({latest_version})[/cyan]" + status_hint = ( + f"Local CLI ({cli_version}) is newer than the latest " + f"published release ({latest_version}) — typically a dev " + f"build from git. No action needed." + ) info_table = Table(show_header=False, box=None, padding=(0, 2)) info_table.add_column("Key", style="cyan", justify="right") info_table.add_column("Value", style="white") info_table.add_row("CLI Version", cli_version) - info_table.add_row("Template Version", template_version) + info_table.add_row("Latest Release", latest_version) info_table.add_row("Released", release_date) + info_table.add_row("Status", status_label) info_table.add_row("", "") info_table.add_row("Python", platform.python_version()) info_table.add_row("Platform", platform.system()) info_table.add_row("Architecture", platform.machine()) info_table.add_row("OS Version", platform.version()) + # Surface the per-workspace home-side storage when + # invoked from inside an rpgkit workspace. Without this the user + # has no obvious way to find their generated artefacts / logs after + # we moved them out of the repo tree into ``~/.rpgkit/workspaces/ + # /`` — they'd have to derive the workspace id themselves. + try: + from . import _inner_git + ws = _inner_git.find_workspace_root() + if ws is not None: + home_dir = _storage.home_workspace_dir(ws) + data_dir = _storage.workspace_data_dir(ws) + logs_dir = _storage.workspace_logs_dir(ws) + # Annotate each row when the dir doesn't exist yet so the + # user doesn't mistake a computed path for a real artefact. + # Important after partial cleanup or before the first + # ``rpgkit init`` populates the home-side store — we used + # to print non-existent paths as if they were live. + def _tag(p: Path) -> str: + return str(p) if p.exists() else f"{p} [dim](not created yet)[/dim]" + + info_table.add_row("", "") + info_table.add_row("Workspace", str(ws)) + info_table.add_row("Data", _tag(data_dir)) + info_table.add_row("Logs", _tag(logs_dir)) + # Inner-git: distinguish absent (no .git dir) from empty + # (.git exists but zero commits). snapshot_count returns + # None for both, so probe has_inner_git directly. + if not home_dir.exists(): + inner_git_value = f"{home_dir} [dim](home-side dir not created — run `rpgkit init` here)[/dim]" + elif not _inner_git.has_inner_git(ws): + inner_git_value = f"{home_dir} [dim](no inner-git repo)[/dim]" + else: + count = _inner_git.snapshot_count(ws) + if count is None or count == 0: + inner_git_value = f"{home_dir} [dim](no snapshots yet)[/dim]" + else: + inner_git_value = f"{home_dir} [dim]({count} snapshots — git -C {home_dir} log)[/dim]" + info_table.add_row("Inner git", inner_git_value) + except Exception: + pass + panel = Panel( info_table, title="[bold cyan]RPG-Kit CLI Information[/bold cyan]", @@ -4191,6 +5306,18 @@ def version(): ) console.print(panel) + if status_hint: + console.print() + console.print( + Panel( + status_hint, + title="[bold]Upgrade tip[/bold]", + border_style="yellow" + if "outdated" in status_label or "offline" in status_label + else "cyan", + padding=(1, 2), + ) + ) console.print() diff --git a/RPG-Kit/src/rpgkit_cli/_assets.py b/RPG-Kit/src/rpgkit_cli/_assets.py new file mode 100644 index 0000000..f597867 --- /dev/null +++ b/RPG-Kit/src/rpgkit_cli/_assets.py @@ -0,0 +1,144 @@ +"""Locate bundled core_pack assets inside the installed package. + +The bundle is created at wheel-build time by hatch's ``force-include`` +(see ``pyproject.toml``). After ``uv tool install rpgkit-cli``, the +layout is:: + + /lib/python3.x/site-packages/rpgkit_cli/ + __init__.py + _assets.py + core_pack/ + scripts/ (full RPG-Kit/scripts/ tree) + commands/ (full RPG-Kit/templates/commands/ tree) + +``rpgkit init`` and ``rpgkit update`` copy from here to the workspace +when bundle mode is active (the default). When the bundle is absent +(typically in an editable install where ``force-include`` does not run), +:func:`available` returns ``False`` and callers should fall back to the +legacy GitHub-release-zip download path. + +Design notes +------------ +- Uses :func:`importlib.resources.files` rather than ``__file__`` + arithmetic so non-filesystem packaging formats (zip-imports, + in-memory loaders) keep working. +- The returned path is a *filesystem* path (not a Traversable) because + the consumers (``shutil.copytree`` etc.) need real paths. This works + for the default wheel layout; if we ever ship as a zipapp this code + will need ``as_file()`` contexts. +- All functions are pure / side-effect-free. No mutation of the bundle. +""" + +from __future__ import annotations + +from importlib.resources import files +from pathlib import Path + + +def core_pack_root() -> Path: + """Absolute path to the bundled ``core_pack/`` directory. + + Returns the path regardless of whether it exists on disk — callers + should check :func:`available` before using the path. + """ + return Path(str(files("rpgkit_cli").joinpath("core_pack"))) + + +def _dev_scripts_dir() -> Path | None: + """Locate the repo-root ``scripts/`` directory for editable/dev installs. + + When ``rpgkit-cli`` is installed in editable mode (``pip install -e .`` + or ``uv run rpgkit ...`` from the source tree), hatch's + ``force-include`` does not populate ``rpgkit_cli/core_pack/``. In + that case we fall back to the live source at ``/scripts/``, + which sits two levels above this file:: + + / + src/rpgkit_cli/_assets.py ← __file__ + scripts/ ← target + """ + here = Path(__file__).resolve() + # src/rpgkit_cli/_assets.py → repo = parents[2] + if len(here.parents) >= 3: + candidate = here.parents[2] / "scripts" + if candidate.is_dir(): + return candidate + return None + + +def _dev_commands_dir() -> Path | None: + """Counterpart to :func:`_dev_scripts_dir` for slash-command templates.""" + here = Path(__file__).resolve() + if len(here.parents) >= 3: + candidate = here.parents[2] / "templates" / "commands" + if candidate.is_dir(): + return candidate + return None + + +def available() -> bool: + """True iff a usable scripts source exists. + + Returns ``True`` when either the wheel-bundled ``core_pack/scripts/`` + OR the dev-mode ``/scripts/`` is present. Used to decide + whether the bundle path is viable; callers fall back to the legacy + GitHub-release-zip download path otherwise. + """ + return scripts_dir().is_dir() + + +def scripts_dir() -> Path: + """Directory containing the RPG-Kit pipeline scripts. + + Resolution order: + 1. Wheel bundle: ``/rpgkit_cli/core_pack/scripts/`` + 2. Dev/editable fallback: ``/scripts/`` + + Falls back to the wheel path even when missing so error messages + contain a stable, recognisable location. + """ + bundled = core_pack_root() / "scripts" + if bundled.is_dir(): + return bundled + dev = _dev_scripts_dir() + if dev is not None: + return dev + return bundled # may not exist; caller decides how to surface + + +def commands_dir() -> Path: + """Directory containing the slash-command templates. + + Same resolution order as :func:`scripts_dir`. + """ + bundled = core_pack_root() / "commands" + if bundled.is_dir(): + return bundled + dev = _dev_commands_dir() + if dev is not None: + return dev + return bundled + + +def mcp_server_path() -> Path: + """Convenience: path to the MCP server entry script.""" + return scripts_dir() / "mcp_server.py" + + +def list_scripts() -> list[str]: + """Return all script relative paths (POSIX-style) under :func:`scripts_dir`. + + Filters to ``.py`` files only, skips ``__pycache__`` directories, + and sorts alphabetically. Used by ``rpgkit script --list``. + """ + root = scripts_dir() + if not root.is_dir(): + return [] + out: list[str] = [] + for p in root.rglob("*.py"): + if "__pycache__" in p.parts: + continue + out.append(p.relative_to(root).as_posix()) + out.sort() + return out + diff --git a/RPG-Kit/src/rpgkit_cli/_inner_git.py b/RPG-Kit/src/rpgkit_cli/_inner_git.py new file mode 100644 index 0000000..85b14b8 --- /dev/null +++ b/RPG-Kit/src/rpgkit_cli/_inner_git.py @@ -0,0 +1,440 @@ +"""Inner-git snapshotting for the user-home workspace directory. + +Every successful (or failed) ``rpgkit script `` invocation +auto-commits the current state of the per-workspace home directory at +``~/.rpgkit/workspaces//`` into a dedicated git repo at +``~/.rpgkit/workspaces//.git/``. This lets ``git log`` and +``git diff`` show how pipeline stages change between runs. + +What gets tracked: + +* ``data/`` — all encoder / pipeline output (rpg.json, dep_graph.json, + feature_*.json, …) +* ``logs/`` (except ``logs/copilot/``) — per-stage text/JSONL logs; + tracking them lets users ``git log -p logs/.log`` to debug + pipeline regressions across snapshots. +* ``.meta.toml`` — captures channel + CLI version at each snapshot; + changes only on ``rpgkit init/update``. + +What is NOT tracked (see :data:`_INNER_GIT_IGNORE` below): + +* ``logs/copilot/`` — full LLM session traces, MB-scale per run, too + noisy and too large to be useful in snapshot history. +* The inner ``.git/`` itself — git's own auto-exclusion. + +Design choices: + +* No global ``git config`` writes — every commit uses per-call + ``-c user.email`` / ``-c user.name`` so the user's identity is + untouched. +* Concurrent commits (background post-commit hook vs foreground script) + are handled by a one-shot retry on ``index.lock`` failure, then a + silent skip. Data is never lost: the next successful commit folds + in whatever the dropped one would have captured. +* Failures are committed too, tagged ``— FAILED (exit N)`` so the + history shows what changed pre-failure. +* Check / validation scripts are skipped (they're read-only and would + otherwise spam the history). + +All public functions swallow their own exceptions — this module must +never be a reason ``rpgkit script`` itself fails. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import time +from pathlib import Path +from typing import Optional + +from . import _storage + + +# Environment variables set by ``rpgkit hook `` before invoking +# any ``rpgkit script`` calls. They flow through every subprocess so +# the snapshot commit message can record *which* git hook fired *which* +# user-facing commit instead of just naming the underlying script. +# +# Set only by :func:`rpgkit_cli.hook` -- never by manual invocations - +# so the presence of ``RPGKIT_HOOK`` is a reliable trigger-source flag. +_ENV_HOOK_NAME = "RPGKIT_HOOK" # e.g. "post-commit" / "pre-commit" +_ENV_HOOK_SHA = "RPGKIT_HOOK_SHA" # short SHA of the user-facing commit + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Inner repo identity. Per-call (-c user.X) so this never touches the +# user's ~/.gitconfig. +_AUTHOR_EMAIL = "rpgkit@local" +_AUTHOR_NAME = "rpgkit-snapshot" + + +def _author_args() -> list[str]: + return [ + "-c", f"user.email={_AUTHOR_EMAIL}", + "-c", f"user.name={_AUTHOR_NAME}", + # Disable system / user / xdg git config so an unusual global + # template doesn't leak (e.g. signing keys, hooks) into our + # private snapshot repo. + "-c", "init.defaultBranch=main", + ] + + +# Skip patterns — these scripts are read-only or long-running and +# shouldn't pollute the snapshot history. +_SKIP_NAMES: frozenset[str] = frozenset({ + "mcp_server.py", +}) + + +# Contents of the ``.gitignore`` written into the inner repo on init. +# +# ``logs/`` is tracked so users can run ``git log -p logs/.log`` +# to inspect how a pipeline stage's output changed between snapshots. +# +# ``logs/copilot/`` is excluded: it contains full LLM session traces +# (typically MB per session) and would dominate the snapshot history. +_INNER_GIT_IGNORE = """\ +# Managed by rpgkit-cli: do not edit. +# Logs are tracked to support `git log -p logs/.log` debugging. +# Exception: logs/copilot/ holds LLM session traces (large, not useful +# in history); inspect those files directly. +logs/copilot/ +""" + + +def _basename(relpath: str) -> str: + return relpath.rsplit("/", 1)[-1] + + +def should_skip_script(relpath: str) -> bool: + """True iff this script should NOT trigger an auto-commit.""" + base = _basename(relpath) + if base in _SKIP_NAMES: + return True + # Read-only state checkers — e.g. check_skeleton.py, check_code_gen.py + if base.startswith("check_") and base.endswith(".py"): + return True + # Pre-flight validators — e.g. feature_build_validation.py + if base.endswith("_validation.py"): + return True + return False + + +def categorise_script(relpath: str) -> str: + """Map a script relpath to a short commit-message category tag.""" + rel = relpath.replace("\\", "/") + if rel.startswith("rpg_encoder/"): + return "encoder" + if rel.startswith("rpg_edit/"): + return "rpg_edit" + base = _basename(rel) + if base == "update_graphs.py": + return "sync" + if base == "mcp_server.py": + # mcp_server.py is on the skip list, so this branch is unreachable + # today. Kept so adjusting the skip list still produces a correct + # tag instead of falling through to "decoder". + return "mcp" + return "decoder" + + +# --------------------------------------------------------------------------- +# Filesystem helpers +# --------------------------------------------------------------------------- + +def _inner_git_dir(workspace: Path) -> Path: + """Return the home directory used as ``git -C `` for the snapshots. + + The directory is ``~/.rpgkit/workspaces//``; the inner repo's + ``.git`` sits directly inside it. + """ + return _storage.home_workspace_dir(workspace) + + +# Backwards-compatible alias for the (now-misleading) historical name +# used in earlier docstrings. No external caller should rely on this; +# it stays only to keep grep-friendly when reading older commit +# messages and plan documents. +_rpgkit_dir = _inner_git_dir + + +def find_workspace_root(start: Optional[Path] = None) -> Optional[Path]: + """Walk up from ``start`` (default cwd) looking for a workspace marker. + + Returns the directory containing ``.rpgkit/config.toml`` (the + workspace marker), or ``None`` if not found. Used by + ``rpgkit script`` to figure out which workspace's inner git repo to + snapshot into when the caller's cwd is a subdirectory. + """ + return _storage.find_workspace_root_from(start) + + +def has_inner_git(workspace: Path) -> bool: + """True iff a ``.git`` directory exists under the workspace's home dir.""" + return (_inner_git_dir(workspace) / ".git").is_dir() + + +def _git_available() -> bool: + from shutil import which + return which("git") is not None + + +def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30) -> subprocess.CompletedProcess[str]: + """Run ``git -C ...`` capturing stdout/stderr. + + ``check=False`` by default — callers inspect ``returncode`` themselves so + we can silently swallow expected failures (lock, no-changes, etc.). + + The child environment forces ``LC_ALL=C`` so git's error messages + are in English regardless of the user's locale. We pattern-match + on those messages (see ``_LOCK_HINTS``) to decide whether to retry + on lock contention. + """ + import os as _os + env = {**_os.environ, "LC_ALL": "C", "LANG": "C"} + # Strip inherited git env vars: a foreground hook caller may have set + # GIT_INDEX_FILE / GIT_DIR / GIT_WORK_TREE pointing at the outer repo. + # If we leak those into the inner-git call the outer repo's index gets + # corrupted (entries from $HOME/.rpgkit get written into the outer index.lock). + for _v in ("GIT_INDEX_FILE", "GIT_DIR", "GIT_WORK_TREE", "GIT_OBJECT_DIRECTORY"): + env.pop(_v, None) + cmd = ["git", "-C", str(_inner_git_dir(workspace))] + list(args) + return subprocess.run( + cmd, + check=check, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +def ensure_inner_git(workspace: Path, *, initial_msg: Optional[str] = None) -> bool: + """Create ``~/.rpgkit/workspaces//.git`` if missing. + + Returns ``True`` when a fresh repo was created, ``False`` when it + already existed or when setup was skipped (git missing, home dir + unavailable, …). + + The home dir must already exist — it's the responsibility of + ``ensure_workspace_storage`` (called from ``rpgkit init/update`` + earlier in the bootstrap) to create it. We don't create it here + because that requires picking a ``channel`` (bundle vs legacy), + which is information only the caller has. + + When a fresh repo is created we also drop a ``.gitignore`` that + excludes ``logs/copilot/`` (LLM session traces — large, not useful + in history; see :data:`_INNER_GIT_IGNORE`), then commit the current + state of ``data/`` + ``.meta.toml`` so ``git log`` has a starting + point. + """ + home_dir = _inner_git_dir(workspace) + if not home_dir.is_dir(): + return False # ensure_workspace_storage hasn't run yet + if (home_dir / ".git").is_dir(): + return False + if not _git_available(): + return False + + try: + # Use 'main' as default branch to match the workspace default and + # avoid the noisy "hint: Using 'master'" message on fresh git. + _run_git(workspace, "init", "-q", "-b", "main", check=True) + except Exception: + return False + + # Drop the ignore file so logs don't appear in `git status` from the + # very first commit. Best-effort; failure here doesn't block. + try: + (home_dir / ".gitignore").write_text(_INNER_GIT_IGNORE, encoding="utf-8") + except OSError: + pass + + # Initial commit — even if empty, it gives `git log` a starting point. + initial_msg = initial_msg or "[init] rpgkit workspace" + _commit_all(workspace, initial_msg, allow_empty=True) + return True + + +# --------------------------------------------------------------------------- +# Commit primitives +# --------------------------------------------------------------------------- + +def _has_staged_changes(workspace: Path) -> bool: + """True iff something is staged for commit.""" + r = _run_git(workspace, "diff", "--staged", "--quiet") + # exit 1 = differences exist, 0 = none, anything else = error (treat as no) + return r.returncode == 1 + + +_LOCK_HINTS = ("index.lock", "Another git process seems") + + +def _ensure_gitignore_current(workspace: Path) -> None: + """Rewrite the inner repo's ``.gitignore`` if it drifted from the + current :data:`_INNER_GIT_IGNORE`. + + Called before every commit so existing inner repos that were + initialised under an older ignore policy (e.g. the original + "ignore all of ``logs/``" rule) silently upgrade on next snapshot. + No-op when the file is already up to date. + """ + home_dir = _inner_git_dir(workspace) + gi = home_dir / ".gitignore" + try: + current = gi.read_text(encoding="utf-8") if gi.is_file() else "" + if current != _INNER_GIT_IGNORE: + gi.write_text(_INNER_GIT_IGNORE, encoding="utf-8") + except OSError: + # Best-effort: ignore policy is not critical enough to fail a commit. + pass + + +def _commit_all(workspace: Path, message: str, *, allow_empty: bool = False) -> bool: + """Stage everything and commit. Returns True iff a commit was created. + + Concurrent-safe: if the index lock is held by a parallel git process + (e.g. the post-commit hook firing ``rpgkit script update_graphs.py`` + in the background), we retry once after a short sleep, then give up + silently. The next successful commit will fold in any deferred + changes — no data is lost. + """ + _ensure_gitignore_current(workspace) + for attempt in (1, 2): + try: + r_add = _run_git(workspace, "add", "-A") + if r_add.returncode != 0: + # Likely a lock; try again + if any(h in (r_add.stderr or "") for h in _LOCK_HINTS) and attempt == 1: + time.sleep(1.0) + continue + return False + + if not allow_empty and not _has_staged_changes(workspace): + return False # nothing to commit; not an error + + commit_args = ["commit", "-m", message, "--quiet"] + if allow_empty: + commit_args.insert(1, "--allow-empty") + r_c = _run_git(workspace, *_author_args(), *commit_args) + if r_c.returncode == 0: + return True + # Retry on lock + if any(h in (r_c.stderr or "") for h in _LOCK_HINTS) and attempt == 1: + time.sleep(1.0) + continue + return False + except Exception: + return False + return False + + +# --------------------------------------------------------------------------- +# Public entry: after a `rpgkit script ` call +# --------------------------------------------------------------------------- + +def _build_message(script_relpath: str, args: list[str], exit_code: int) -> str: + """Compose the inner-git commit message for a ``rpgkit script`` call. + + Two output shapes: + + * **Hook-triggered** (``RPGKIT_HOOK`` is set by ``rpgkit hook``):: + + [hook:post-commit @ a1b2c3d] update-rpg + [hook:pre-commit @ a1b2c3d] sync --staged-only + + Both the triggering hook name and the user-facing commit short + SHA are surfaced so ``git log`` in the inner repo reads as a + timeline of *user activity*, not a timeline of internal scripts. + + * **Manual** (no ``RPGKIT_HOOK``):: + + [decoder] feature_build.py + [encoder] rpg_encoder/run_encode.py --json + [sync] update_graphs.py update-rpg — FAILED (exit 2) + + Tagged by category (see :func:`categorise_script`) plus the full + script relpath - kept verbose so power-users running scripts by + hand can see exactly which file produced each snapshot. + """ + suffix = f" — FAILED (exit {exit_code})" if exit_code != 0 else "" + + hook = os.environ.get(_ENV_HOOK_NAME, "").strip() + if hook: + # Action = first positional arg (the script's subcommand, e.g. + # ``update-rpg``/``sync``) when present, otherwise the script + # stem. Subsequent args are appended but capped so the message + # stays one-line friendly in ``git log --oneline``. + if args: + action = args[0] + extra = " ".join(shlex.quote(a) for a in args[1:]) + extra_part = (" " + extra) if extra else "" + else: + action = _basename(script_relpath).removesuffix(".py") + extra_part = "" + if len(extra_part) > 40: + extra_part = extra_part[:37] + "..." + sha = os.environ.get(_ENV_HOOK_SHA, "").strip() + sha_part = f" @ {sha}" if sha and sha != "?" else "" + return f"[hook:{hook}{sha_part}] {action}{extra_part}{suffix}" + + # Manual / interactive path -- preserve historical format. + cat = categorise_script(script_relpath) + quoted = " ".join(shlex.quote(a) for a in args) + args_part = (" " + quoted).rstrip() if quoted else "" + if len(args_part) > 80: + args_part = args_part[:77] + "..." + return f"[{cat}] {script_relpath}{args_part}{suffix}" + + +def auto_commit_after_script( + workspace: Path, + script_relpath: str, + args: list[str], + exit_code: int, +) -> None: + """Snapshot ``.rpgkit/`` after a ``rpgkit script`` call completes. + + No-ops (silently) when any of: + * ``.rpgkit/.git`` is missing + * the script matches a skip pattern + * git is unavailable + * the index is locked and the retry still fails + * nothing actually changed + """ + try: + if should_skip_script(script_relpath): + return + if not has_inner_git(workspace): + return + message = _build_message(script_relpath, args, exit_code) + _commit_all(workspace, message, allow_empty=False) + except Exception: + # Never let snapshot machinery break the calling CLI. + return + + +# --------------------------------------------------------------------------- +# `rpgkit version` helper +# --------------------------------------------------------------------------- + +def snapshot_count(workspace: Path) -> Optional[int]: + """Return number of commits in the inner repo, or None if absent.""" + if not has_inner_git(workspace): + return None + try: + r = _run_git(workspace, "rev-list", "--count", "HEAD") + if r.returncode == 0: + return int((r.stdout or "0").strip()) + except Exception: + pass + return None diff --git a/RPG-Kit/src/rpgkit_cli/_storage.py b/RPG-Kit/src/rpgkit_cli/_storage.py new file mode 100644 index 0000000..59ad3e0 --- /dev/null +++ b/RPG-Kit/src/rpgkit_cli/_storage.py @@ -0,0 +1,554 @@ +"""Home-directory workspace storage layout for RPG-Kit. + +Replaces the legacy ``workspace/.rpgkit/{data,logs,.git}`` layout with a +centralised one rooted at ``~/.rpgkit/``: + + ~/.rpgkit/ + workspaces// + .meta.toml {workspace_path, channel, created_at, last_seen_at} + .git/ inner git snapshot repo + data/ rpg.json, dep_graph.json + logs/ *.log + +The workspace itself retains only two minimal items:: + + /.rpgkit/ + config.toml AI configuration (team-shared, committed) + reports/ user-facing reports (e.g. rpg.html) + +Workspace identity +------------------ + +Each workspace is identified by a **path-derived slug** (the resolved +absolute path with non-alphanumeric runs collapsed to ``-``). Short +paths produce a readable id like ``home-hys-projects-myrepo``; paths +whose slug would exceed 200 characters are truncated and given a +6-character base36 SHA-256 suffix so the id fits comfortably under +POSIX ``NAME_MAX`` (255). Same shape as Claude Code's +``~/.claude/projects/`` directory naming, with two improvements: no +leading dash, and a deterministic overflow strategy instead of relying +on the OS to error out. + +Slug collisions are theoretically possible (e.g. ``/foo/bar`` and +``/foo-bar`` both slug to ``foo-bar``); they are detected at read time +by comparing ``workspace_path`` recorded in ``.meta.toml``; a +collision aborts cleanly rather than silently overwriting state. + +Why a readable slug and not a flat hash? Users routinely browse +``~/.rpgkit/workspaces/`` to find logs, delete stale state, or sanity- +check which workspace a process is talking to; a slug makes that ten +times easier than an opaque hex hash. We accept a small (negligible +in practice) collision risk in exchange. + +For backward compatibility, when no slug-named directory exists, +:func:`home_workspace_dir` falls back to the pre-0.1.4 12-char hex +hash layout if one is present on disk. + +Resolution +---------- + +The "workspace root" is discovered by walking up from the caller's +current directory looking for the marker ``.rpgkit/config.toml``. Both +the MCP server and ``rpgkit script `` use the same logic so a user +who ``cd``-s into any subdirectory of a workspace gets the right home +directory automatically. + +Public surface +-------------- + +* :func:`workspace_id` - the slug (or slug+hash suffix) for a workspace path. +* :func:`home_workspace_dir` - ``~/.rpgkit/workspaces//``. +* :func:`workspace_data_dir`, :func:`workspace_logs_dir`, + :func:`workspace_inner_git_dir`, :func:`workspace_reports_dir` - + convenience wrappers for the four canonical subdirectories. +* :func:`ensure_workspace_storage` - idempotent: creates the home + layout and writes/updates ``.meta.toml``. +* :func:`find_workspace_root_from` - walks up from a starting path + looking for the workspace marker. +* :func:`read_meta`, :func:`write_meta` - typed accessors for + ``.meta.toml``. + +Design constraints +------------------ + +* No symlinks are created in the workspace (avoids Windows headaches + and accidental backup-tool double-counting). +* All path inputs are run through :py:meth:`Path.resolve` so symlinked + workspace roots map to a single canonical id. +* All filesystem mutations are best-effort idempotent so re-running + ``rpgkit init`` or ``rpgkit update`` is safe. +""" +from __future__ import annotations + +import hashlib +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +try: + # Python 3.11+ + import tomllib # type: ignore[import-not-found] +except ImportError: # pragma: no cover - fallback for older Pythons + import tomli as tomllib # type: ignore[import-not-found,no-redef] + + +# --------------------------------------------------------------------------- +# Public constants +# --------------------------------------------------------------------------- + +#: Subdirectory of the user's home where rpgkit keeps all per-workspace data. +HOME_ROOT_RELPATH = Path(".rpgkit") / "workspaces" + +#: Marker file inside the workspace that identifies it as an rpgkit +#: workspace. ``rpgkit init`` writes this; cwd-walk-up looks for it. +WORKSPACE_MARKER_RELPATH = Path(".rpgkit") / "config.toml" + +#: Standard subdirectories created under each home workspace dir. +_DATA_SUBDIR = "data" +_LOGS_SUBDIR = "logs" +_INNER_GIT_SUBDIR = ".git" +_META_FILENAME = ".meta.toml" + +#: Reports directory inside the workspace (small, user-facing artefacts +#: like ``rpg.html``). +WORKSPACE_REPORTS_SUBDIR = Path(".rpgkit") / "reports" + +#: Channel values written to ``.meta.toml``. +CHANNEL_BUNDLE = "bundle" +CHANNEL_LEGACY = "legacy" +_VALID_CHANNELS = (CHANNEL_BUNDLE, CHANNEL_LEGACY) + + +# --------------------------------------------------------------------------- +# Workspace ID + path resolution +# --------------------------------------------------------------------------- + +def _resolve(path: Path) -> Path: + """Return the canonical absolute form of ``path``. + + Symlinks are followed so that ``/home/user/proj`` and the underlying + ``/data/proj`` map to the same workspace ID. We always operate on + the resolved path internally; callers shouldn't have to think about + it. + """ + return Path(path).resolve() + + +# Tunables for :func:`workspace_id`. Picked so the worst-case id +# (truncated slug + ``-`` + 6 base36 chars) stays under ``NAME_MAX`` +# (255 on Linux/macOS, 255 UTF-16 code units on Windows NTFS) with +# plenty of headroom for downstream nested paths. +_SLUG_MAX_LEN = 200 +_HASH_SUFFIX_LEN = 6 +_LEGACY_HASH_LEN = 12 + +#: Pattern used by :func:`_slugify` to collapse non-alphanumeric runs. +_NON_ALNUM_RE = re.compile(r"[^a-zA-Z0-9]+") + +#: Pattern matching the pre-0.1.4 legacy id format (12 lowercase hex chars). +_LEGACY_HASH_RE = re.compile(r"^[0-9a-f]{12}$") + +#: Base36 alphabet used for the overflow hash suffix. Matches Claude +#: Code's convention for ``~/.claude/projects/`` and packs ~5 bits per +#: char (36**6 ≈ 2.2e9 — collision probability negligible at our scale). +_BASE36_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" + + +def _slugify_path(workspace_path: Path) -> str: + """Return the slug (no hash) for a resolved workspace path. + + Algorithm: replace every run of non-alphanumeric chars with ``-``, + strip leading/trailing ``-``, lowercase. Result is always safe to + use as a directory name on every filesystem we target. + + Examples: + ``/home/hys/projects/rpgkit`` -> ``home-hys-projects-rpgkit`` + ``C:\\Users\\foo\\bar`` -> ``c-users-foo-bar`` + ``/`` -> ``root`` + """ + canonical = str(_resolve(workspace_path)) + slug = _NON_ALNUM_RE.sub("-", canonical).strip("-").lower() + return slug or "root" + + +def _base36_hash(workspace_path: Path) -> str: + """Return the 6-char base36 SHA-256 prefix used for overflow ids.""" + canonical = str(_resolve(workspace_path)) + digest = hashlib.sha256(canonical.encode("utf-8")).digest() + n = int.from_bytes(digest[: _HASH_SUFFIX_LEN], "big") + out = [] + for _ in range(_HASH_SUFFIX_LEN): + out.append(_BASE36_ALPHABET[n % 36]) + n //= 36 + return "".join(reversed(out)) + + +def _legacy_workspace_id(workspace_path: Path) -> str: + """Compute the pre-0.1.4 workspace id (SHA-256 first 12 hex chars). + + Only used by :func:`home_workspace_dir` as a backward-compat probe + so users who upgraded across the slug-naming change keep finding + their existing on-disk state. + """ + canonical = str(_resolve(workspace_path)) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:_LEGACY_HASH_LEN] + + +def workspace_id(workspace_path: Path) -> str: + """Compute the workspace identifier for ``workspace_path``. + + Two formats: + + * **Short (preferred)** — when the path slug is ≤ 200 chars, use + the slug verbatim, e.g. ``home-hys-projects-rpgkit``. Readable + at a glance; lets users browse ``~/.rpgkit/workspaces/`` and + identify their projects without cross-referencing a hash table. + * **Truncated (overflow)** — when the slug exceeds the budget, + keep the first ~193 chars and append ``-`` where + ``hash6`` is a 6-character base36 SHA-256 prefix. Guarantees + uniqueness across arbitrarily long paths while staying under + ``NAME_MAX`` (255). + + The identifier is deterministic on a given machine: the same + resolved absolute path always yields the same id. Different + paths (including different clones of the same git repo) yield + different ids — this is intentional so each clone has independent + state. + """ + slug = _slugify_path(workspace_path) + if len(slug) <= _SLUG_MAX_LEN: + return slug + # Reserve room for ``-`` + hash suffix. + head = slug[: _SLUG_MAX_LEN - _HASH_SUFFIX_LEN - 1].rstrip("-") + return f"{head}-{_base36_hash(workspace_path)}" + + +# --------------------------------------------------------------------------- +# Home-side path helpers +# --------------------------------------------------------------------------- + +def home_root() -> Path: + """Return ``~/.rpgkit/workspaces/``. + + Does not create the directory; callers should use + :func:`ensure_workspace_storage` when they need it to exist. + """ + return Path.home() / HOME_ROOT_RELPATH + + +def home_workspace_dir(workspace_path: Path) -> Path: + """Return the home directory assigned to ``workspace_path``. + + Normally this is ``~/.rpgkit/workspaces//`` using the + slug-based id from :func:`workspace_id`. + + Backward compatibility: if a directory under the **legacy** 12-char + hex id already exists on disk (created by rpgkit < 0.1.4) and no + slug-named directory exists for the same path, the legacy directory + is returned so the user keeps reaching their existing state after + upgrading. New workspaces always use the slug-based layout. + + The directory may or may not exist on disk. + """ + new_dir = home_root() / workspace_id(workspace_path) + if new_dir.exists(): + return new_dir + legacy_dir = home_root() / _legacy_workspace_id(workspace_path) + if legacy_dir.exists(): + return legacy_dir + return new_dir + + +def workspace_data_dir(workspace_path: Path) -> Path: + return home_workspace_dir(workspace_path) / _DATA_SUBDIR + + +def workspace_logs_dir(workspace_path: Path) -> Path: + return home_workspace_dir(workspace_path) / _LOGS_SUBDIR + + +def workspace_inner_git_dir(workspace_path: Path) -> Path: + """Return the path of the inner-git ``.git/`` directory. + + Note: this is the GIT_DIR itself (a directory named ``.git`` sitting + inside the home workspace dir). Callers using ``git -C ...`` should + pass :func:`home_workspace_dir`; callers using ``--git-dir`` should + pass this path. + """ + return home_workspace_dir(workspace_path) / _INNER_GIT_SUBDIR + + +def workspace_meta_path(workspace_path: Path) -> Path: + return home_workspace_dir(workspace_path) / _META_FILENAME + + +def workspace_reports_dir(workspace_path: Path) -> Path: + """Return the workspace-local ``reports/`` directory. + + This is in the workspace (not home) because reports are small, + user-facing artefacts users may want to commit or browse alongside + the source code. + """ + return _resolve(workspace_path) / WORKSPACE_REPORTS_SUBDIR + + +# --------------------------------------------------------------------------- +# Marker discovery (cwd-walk-up) +# --------------------------------------------------------------------------- + +def _is_live_workspace_root(root: Path) -> bool: + """Return True iff a candidate workspace root is still live. + + A bare ``.rpgkit/config.toml`` is enough for a *fresh* workspace + (the marker may be planted before any home-side state is written), + so the marker alone is treated as live until proven stale. + + Staleness is detected only when ``.meta.toml`` is present: a moved + or renamed workspace records its original absolute path there, and + if that recorded path no longer matches the candidate directory the + marker is treated as stale. This guards :func:`find_workspace_root_from` + against climbing into a renamed parent and misrouting reads/writes, + while still allowing brand-new (marker-only) workspaces to be + discovered before they have any home-side state. + """ + meta = read_meta(root) + if meta is not None: + recorded = meta.get("workspace_path") + if isinstance(recorded, str) and Path(recorded) != _resolve(root): + return False + return True + + +def find_workspace_root_from(start: Optional[Path] = None) -> Optional[Path]: + """Walk up from ``start`` (default: cwd) looking for an rpgkit workspace. + + A directory qualifies as a workspace if it contains + ``.rpgkit/config.toml`` (see :data:`WORKSPACE_MARKER_RELPATH`) + **and** passes :func:`_is_live_workspace_root` — i.e. either it + has no ``.meta.toml`` (fresh workspace), or the recorded + ``workspace_path`` in meta still matches. Stale (moved/renamed) + markers on parent directories are skipped, so the walker continues + climbing rather than misrouting into a different workspace's state. + + Returns the **resolved** path of the workspace root, or ``None`` + when no live marker is found before reaching the filesystem root. + """ + cur = _resolve(start if start is not None else Path.cwd()) + while True: + if (cur / WORKSPACE_MARKER_RELPATH).is_file() and _is_live_workspace_root(cur): + return cur + if cur.parent == cur: # reached / (POSIX) or drive root (Windows) + return None + cur = cur.parent + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- + +def _utc_now_iso() -> str: + """ISO-8601 timestamp in UTC, second precision (no microseconds).""" + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _toml_escape(value: str) -> str: + """Minimal TOML basic-string escape (sufficient for our values). + + Handles the chars TOML's basic-string syntax actually forbids or + requires escaping: backslash, double quote, and the control chars + that are common in pathological inputs (newline, carriage return, + tab). Other control chars (NUL, vertical tab, etc.) would also be + invalid but are vanishingly unlikely in our inputs (paths + + version strings); we accept the small remaining risk rather than + pull in a full TOML writer dependency. + """ + return ( + value + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + +def read_meta(workspace_path: Path) -> Optional[Dict[str, Any]]: + """Read ``.meta.toml`` for ``workspace_path`` or return ``None``. + + Returns ``None`` if the file doesn't exist or fails to parse. + A separate parse-failure return value isn't useful in practice - + every caller treats both cases as "no metadata yet". + """ + meta_file = workspace_meta_path(workspace_path) + if not meta_file.is_file(): + return None + try: + with open(meta_file, "rb") as f: + return tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + return None + + +def write_meta( + workspace_path: Path, + *, + channel: str, + rpgkit_cli_version: Optional[str] = None, + preserve_created_at: bool = True, +) -> None: + """Atomically write the workspace's ``.meta.toml``. + + Args: + workspace_path: The workspace directory (resolved internally). + channel: ``"bundle"`` or ``"legacy"`` -- which provisioning + channel was used. + rpgkit_cli_version: The installed rpgkit-cli version at write + time. Stored as ``rpgkit_cli_version_at_init`` (only on + first write) and ``rpgkit_cli_version_last_seen`` (every + write). + preserve_created_at: When True (the default), keep the original + ``created_at`` from any existing meta file; otherwise + overwrite with ``utc_now()``. + + Raises: + ValueError: if ``channel`` is not a recognised value. + OSError: if the file can't be written. + """ + if channel not in _VALID_CHANNELS: + raise ValueError( + f"channel must be one of {_VALID_CHANNELS!r}, got {channel!r}" + ) + + resolved = _resolve(workspace_path) + meta_file = workspace_meta_path(workspace_path) + meta_file.parent.mkdir(parents=True, exist_ok=True) + + existing = read_meta(workspace_path) or {} + now = _utc_now_iso() + if preserve_created_at: + created_at = existing.get("created_at", now) + # On preserve, also carry forward the version recorded at init + # so re-running ``rpgkit update`` doesn't blow away that history. + init_version = existing.get( + "rpgkit_cli_version_at_init", rpgkit_cli_version or "" + ) + else: + # "Reset" semantics: created_at and init_version both refresh + # to the values supplied in this call. + created_at = now + init_version = rpgkit_cli_version or "" + + # Serialise by hand - tiny + avoids a TOML writer dep. + lines = [ + "# RPG-Kit per-workspace state. Managed by `rpgkit init/update`.", + "# Do not commit; recreated automatically if missing.", + "", + f'workspace_path = "{_toml_escape(str(resolved))}"', + f'channel = "{channel}"', + f'created_at = "{created_at}"', + f'last_seen_at = "{now}"', + ] + if init_version: + lines.append(f'rpgkit_cli_version_at_init = "{_toml_escape(init_version)}"') + if rpgkit_cli_version: + lines.append( + f'rpgkit_cli_version_last_seen = "{_toml_escape(rpgkit_cli_version)}"' + ) + payload = "\n".join(lines) + "\n" + + # Atomic write: .tmp + os.replace + tmp = meta_file.with_suffix(".toml.tmp") + try: + tmp.write_text(payload, encoding="utf-8") + os.replace(tmp, meta_file) + except Exception: + if tmp.exists(): + try: + tmp.unlink() + except OSError: + pass + raise + + +# --------------------------------------------------------------------------- +# Layout bootstrap + integrity check +# --------------------------------------------------------------------------- + +class WorkspaceMetaMismatch(RuntimeError): + """Raised when an existing ``.meta.toml`` points at a different path. + + This indicates either a hash collision (statistically very rare for + a 48-bit truncated hash on a single machine, but possible) or a + user manually moving directories under ``~/.rpgkit/``. We never + silently mix two workspaces' data; the user must investigate. + """ + + +def ensure_workspace_storage( + workspace_path: Path, + *, + channel: str, + rpgkit_cli_version: Optional[str] = None, +) -> Path: + """Create the home layout for ``workspace_path`` (idempotent). + + Creates:: + + ~/.rpgkit/workspaces// + data/ + logs/ + + Writes ``.meta.toml`` capturing the workspace path, channel, and + timestamps. If an existing ``.meta.toml`` records a *different* + workspace path (hash collision or manual rename), raises + :class:`WorkspaceMetaMismatch` -- callers must surface this clearly + rather than overwriting another workspace's data. + + The inner ``.git/`` directory is NOT created here; that's the + responsibility of :mod:`rpgkit_cli._inner_git`, which knows how to + seed an initial commit message. + + Returns: + The home workspace directory (``~/.rpgkit/workspaces//``). + """ + resolved = _resolve(workspace_path) + home_dir = home_workspace_dir(resolved) + + # Hash-collision / rename guard. + existing = read_meta(resolved) + if existing is not None: + recorded = existing.get("workspace_path") + if isinstance(recorded, str) and Path(recorded).resolve() != resolved: + raise WorkspaceMetaMismatch( + f"Workspace hash collision at {home_dir}: meta points to " + f"{recorded!r} but caller passed {str(resolved)!r}. " + f"Resolve manually (e.g., move or delete the offending " + f"directory) before retrying." + ) + + (home_dir / _DATA_SUBDIR).mkdir(parents=True, exist_ok=True) + (home_dir / _LOGS_SUBDIR).mkdir(parents=True, exist_ok=True) + workspace_reports_dir(resolved).mkdir(parents=True, exist_ok=True) + + write_meta(resolved, channel=channel, rpgkit_cli_version=rpgkit_cli_version) + return home_dir + + +# --------------------------------------------------------------------------- +# Convenience: resolve from cwd in one step +# --------------------------------------------------------------------------- + +def resolve_data_from_cwd(start: Optional[Path] = None) -> Optional[Path]: + """Find the workspace from ``start`` and return its data directory. + + Convenience for scripts that just want the canonical + ``data/rpg.json`` location without manually chaining + :func:`find_workspace_root_from` and :func:`workspace_data_dir`. + Returns ``None`` if no workspace is found. + """ + root = find_workspace_root_from(start) + if root is None: + return None + return workspace_data_dir(root) diff --git a/RPG-Kit/src/rpgkit_cli/entries.py b/RPG-Kit/src/rpgkit_cli/entries.py new file mode 100644 index 0000000..83c3d04 --- /dev/null +++ b/RPG-Kit/src/rpgkit_cli/entries.py @@ -0,0 +1,44 @@ +"""Console-script entries for ``rpgkit-cli``. + +Currently provides: + +* :func:`mcp_main` — the ``rpgkit-mcp`` console script. Sets up + ``sys.path`` so that the bundled ``scripts/`` directory is importable, + then hands off to ``mcp_server.main()``. + +This module stays small and stdout-silent because MCP uses stdio as +its transport: anything written to stdout from import-time code would +corrupt the JSON-RPC stream. All diagnostics go to stderr. +""" + +from __future__ import annotations + + +def mcp_main() -> None: + """Console-script entry for MCP clients (stdio transport).""" + import os + import sys + + from . import _assets + + os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1") + + scripts_dir = _assets.scripts_dir() + if not scripts_dir.is_dir(): + sys.stderr.write( + "rpgkit-mcp: packaged scripts directory unavailable. " + "Try reinstalling: `uv tool install rpgkit-cli --force`.\n" + ) + sys.exit(2) + + # Make ``mcp_server`` and its sibling packages (``common``, ``rpg``) + # importable from the packaged scripts dir. + sys.path.insert(0, str(scripts_dir)) + + try: + from mcp_server import main as _mcp_server_main # type: ignore[import-not-found] + except Exception as exc: # pragma: no cover - import-time failure surface + sys.stderr.write(f"rpgkit-mcp: failed to import mcp_server: {exc}\n") + sys.exit(3) + + _mcp_server_main() diff --git a/RPG-Kit/templates/commands/build_data_flow.md b/RPG-Kit/templates/commands/build_data_flow.md index 009423e..aca1014 100644 --- a/RPG-Kit/templates/commands/build_data_flow.md +++ b/RPG-Kit/templates/commands/build_data_flow.md @@ -22,7 +22,7 @@ Unless it is explicitly empty, you may assume it is always available as `$ARGUME ### Step 1: Pre-check -Run the script `python3 .rpgkit/scripts/check_data_flow.py` to verify the current state. +Run the script `rpgkit script check_data_flow.py` to verify the current state. 1. Inspect the `state` field in the output: @@ -69,7 +69,7 @@ Run the script `python3 .rpgkit/scripts/check_data_flow.py` to verify the curren 1. Display the following prompt and wait for user confirmation: ```text - Description: Run the script `.rpgkit/scripts/build_data_flow.py` to: + Description: Run the script `rpgkit script build_data_flow.py` to: - Design inter-component data flow as a DAG - Generate subtree processing order @@ -81,14 +81,11 @@ Run the script `python3 .rpgkit/scripts/check_data_flow.py` to verify the curren 2. Execute the following command with the selected iteration count: ```bash - python3 .rpgkit/scripts/build_data_flow.py --max-iterations > .rpgkit/logs/build_data_flow.log 2>&1 + rpgkit script build_data_flow.py --max-iterations ``` - Then print the output by: - - ```bash - cat .rpgkit/logs/build_data_flow.log - ``` + The script writes a structured log automatically; + stdout carries the summary you need below. 3. Upon successful completion, display: @@ -108,7 +105,7 @@ Run the script `python3 .rpgkit/scripts/check_data_flow.py` to verify the curren Run the validation script: ```bash -python3 .rpgkit/scripts/check_data_flow.py --verbose +rpgkit script check_data_flow.py --verbose ``` Display the validation results to the user: @@ -128,7 +125,7 @@ Display the validation results to the user: Run the visualization script: ```bash -python3 .rpgkit/scripts/generate_viz.py +rpgkit script generate_viz.py ``` Report: diff --git a/RPG-Kit/templates/commands/build_skeleton.md b/RPG-Kit/templates/commands/build_skeleton.md index 50f755c..695721b 100644 --- a/RPG-Kit/templates/commands/build_skeleton.md +++ b/RPG-Kit/templates/commands/build_skeleton.md @@ -20,7 +20,7 @@ Unless it is explicitly empty, you may assume it is always available as `$ARGUME ### Step 1: Pre-check -Run the script `python3 .rpgkit/scripts/check_skeleton.py` to verify the current state. +Run the script `rpgkit script check_skeleton.py` to verify the current state. 1. Inspect the `type` field in the output: @@ -57,7 +57,7 @@ Run the script `python3 .rpgkit/scripts/check_skeleton.py` to verify the current 1. Display the following prompt and wait for user confirmation: ```text - Description: Run the script `.rpgkit/scripts/build_skeleton.py` to: + Description: Run the script `rpgkit script build_skeleton.py` to: - Step 1: Design directory structure for components - Step 2: Assign features to Python files @@ -69,24 +69,19 @@ Run the script `python3 .rpgkit/scripts/check_skeleton.py` to verify the current 2. Execute the following command with the selected iteration count: ```bash - python3 .rpgkit/scripts/build_skeleton.py --max-iterations > .rpgkit/logs/build_skeleton.log 2>&1 + rpgkit script build_skeleton.py --max-iterations ``` - Then print the output by: + The script writes a structured log automatically; + stdout carries the human-readable summary you need below. - ```bash - cat .rpgkit/logs/build_skeleton.log - ``` - -3. After the command finishes, read the **entire output** from `.rpgkit/logs/build_skeleton.log`: +3. From the captured stdout, find the section containing: - * Locate the section containing: - - ```text - SKELETON BUILDING COMPLETE - ``` + ```text + SKELETON BUILDING COMPLETE + ``` - * Display the summary information in a Markdown table format showing: + Display the summary information in a Markdown table format showing: * Total components * Total features * Total files created @@ -97,7 +92,7 @@ Run the script `python3 .rpgkit/scripts/check_skeleton.py` to verify the current Run the validation script: ```bash -python3 .rpgkit/scripts/check_skeleton.py --verbose +rpgkit script check_skeleton.py --verbose ``` Display the validation results to the user: @@ -115,22 +110,21 @@ Display the validation results to the user: Run the summary script to generate a formatted report and save to file: ```bash -python3 .rpgkit/scripts/summary_skeleton.py +rpgkit script summary_skeleton.py ``` -This saves the summary (including directory structure, component paths, and statistics) to `.rpgkit/data/skeleton_summary.txt`. +The summary (including directory structure, component paths, and statistics) is +printed on stdout by `summary_skeleton.py`; the script also persists it +to the workspace's state directory for later inspection. Then prompt the user: ```text Skeleton has been generated. -Generated files: - .rpgkit/data/skeleton.json - Skeleton data (JSON format) - .rpgkit/data/skeleton_summary.txt - Human-readable summary - -To view the skeleton summary: - cat .rpgkit/data/skeleton_summary.txt +Outputs (managed by the script; consumed by downstream stages): + skeleton.json - Skeleton data (JSON format) + skeleton_summary.txt - Human-readable summary To proceed with data flow design, run: /rpgkit.build_data_flow diff --git a/RPG-Kit/templates/commands/code_gen.md b/RPG-Kit/templates/commands/code_gen.md index 334f85a..5dcaf2e 100644 --- a/RPG-Kit/templates/commands/code_gen.md +++ b/RPG-Kit/templates/commands/code_gen.md @@ -1,5 +1,5 @@ --- -mode: agent +name: rpgkit.code_gen description: Implement code using TDD workflow with iterative test-code-fix cycles --- @@ -18,7 +18,7 @@ runs pytest, and fixes issues — up to 5 iterations per attempt, 2 attempts per Run the check script to determine current state: ```bash -python3 .rpgkit/scripts/check_code_gen.py --json +rpgkit script check_code_gen.py --json ``` **If type is "error"**: @@ -31,7 +31,7 @@ python3 .rpgkit/scripts/check_code_gen.py --json **If type is "in_progress"**: -* Run `python3 .rpgkit/scripts/run_batch.py --resume --json` to resume +* Run `rpgkit script run_batch.py --resume --json` to resume **If type is "complete"**: @@ -42,7 +42,7 @@ python3 .rpgkit/scripts/check_code_gen.py --json **This step is only needed once**, before the first batch. ```bash -python3 .rpgkit/scripts/init_codebase.py --json +rpgkit script init_codebase.py --json ``` This creates README.md, .gitignore, base classes, and an initial commit. @@ -96,19 +96,19 @@ Remember both choices for the session. **Single-batch mode:** ```bash -python3 .rpgkit/scripts/run_batch.py --next --json +rpgkit script run_batch.py --next --json ``` **File-merge mode (no unit limit):** ```bash -python3 .rpgkit/scripts/run_batch.py --next --merge-file --json +rpgkit script run_batch.py --next --merge-file --json ``` **File-merge mode (with unit limit):** ```bash -python3 .rpgkit/scripts/run_batch.py --next --merge-file --max-units --json +rpgkit script run_batch.py --next --merge-file --max-units --json ``` **Read the JSON output:** @@ -129,7 +129,7 @@ Continue until `type` is `"complete"` or no tasks remain. When all batches are processed: ```bash -python3 .rpgkit/scripts/run_batch.py --final-test --json +rpgkit script run_batch.py --final-test --json ``` This runs pytest (full suite) and smoke test (import check, entry point, stub detection). @@ -140,7 +140,7 @@ If smoke test reports errors, a repair agent is dispatched automatically. After final test passes, run the global review: ```bash -python3 .rpgkit/scripts/run_batch.py --global-review --json +rpgkit script run_batch.py --global-review --json ``` This dispatches a sub-agent that: @@ -167,7 +167,7 @@ This step can be re-run independently without re-running `--final-test`. Next steps: • Review failed batches (branches preserved for inspection) - • Run: python3 .rpgkit/scripts/run_batch.py --retry --json + • Run: rpgkit script run_batch.py --retry --json ``` --- @@ -176,19 +176,19 @@ This step can be re-run independently without re-running `--final-test`. ```bash # Resume an interrupted batch -python3 .rpgkit/scripts/run_batch.py --resume --json +rpgkit script run_batch.py --resume --json # Retry a specific failed batch -python3 .rpgkit/scripts/run_batch.py --retry --json +rpgkit script run_batch.py --retry --json # Run a specific batch by ID -python3 .rpgkit/scripts/run_batch.py --batch-id --json +rpgkit script run_batch.py --batch-id --json # Repo validation (pytest + smoke) -python3 .rpgkit/scripts/run_batch.py --final-test --json +rpgkit script run_batch.py --final-test --json # Full feature review + visual QA -python3 .rpgkit/scripts/run_batch.py --global-review --json +rpgkit script run_batch.py --global-review --json ``` ## Recovery @@ -196,7 +196,7 @@ python3 .rpgkit/scripts/run_batch.py --global-review --json To resume from any state: ```bash -python3 .rpgkit/scripts/check_code_gen.py --json +rpgkit script check_code_gen.py --json ``` Follow the `next_action` field — it always tells you the exact command to run. diff --git a/RPG-Kit/templates/commands/design_base_classes.md b/RPG-Kit/templates/commands/design_base_classes.md index 6539702..4bfbafb 100644 --- a/RPG-Kit/templates/commands/design_base_classes.md +++ b/RPG-Kit/templates/commands/design_base_classes.md @@ -20,7 +20,7 @@ Unless it is explicitly empty, you may assume it is always available as `$ARGUME ### Step 1: Pre-check -Run the script `python3 .rpgkit/scripts/check_base_classes.py` to verify the current state. +Run the script `rpgkit script check_base_classes.py` to verify the current state. 1. Inspect the `state` field in the output: @@ -52,7 +52,7 @@ Run the script `python3 .rpgkit/scripts/check_base_classes.py` to verify the cur 1. Display the following prompt and wait for user confirmation: ```text - Description: Run the script `.rpgkit/scripts/design_base_classes.py` to: + Description: Run the script `rpgkit script design_base_classes.py` to: - Design functional base classes (behavioral abstractions) - Design global data structures (shared data formats) @@ -66,14 +66,11 @@ Run the script `python3 .rpgkit/scripts/check_base_classes.py` to verify the cur 2. Execute the following command with the selected iteration count: ```bash - python3 .rpgkit/scripts/design_base_classes.py --max-iterations > .rpgkit/logs/design_base_classes.log 2>&1 + rpgkit script design_base_classes.py --max-iterations ``` - Then print the output by: - - ```bash - cat .rpgkit/logs/design_base_classes.log - ``` + The script writes a structured log automatically; + stdout carries the summary the next step needs. 3. Upon successful completion, display: @@ -95,7 +92,7 @@ Run the script `python3 .rpgkit/scripts/check_base_classes.py` to verify the cur Run the validation script: ```bash -python3 .rpgkit/scripts/check_base_classes.py --verbose +rpgkit script check_base_classes.py --verbose ``` Display the validation results to the user: diff --git a/RPG-Kit/templates/commands/design_interfaces.md b/RPG-Kit/templates/commands/design_interfaces.md index c11ebf1..512e5ea 100644 --- a/RPG-Kit/templates/commands/design_interfaces.md +++ b/RPG-Kit/templates/commands/design_interfaces.md @@ -1,5 +1,5 @@ --- -mode: agent +name: rpgkit.design_interfaces description: Design interfaces (functions/classes) for repository files --- @@ -16,7 +16,7 @@ Design function and class interfaces for your repository files based on the skel Run the check script to determine current state: ```bash -python3 .rpgkit/scripts/check_interfaces.py --json +rpgkit script check_interfaces.py --json ``` **If type is "error"**: @@ -62,14 +62,11 @@ python3 .rpgkit/scripts/check_interfaces.py --json Run the interface designer: ```bash -python3 .rpgkit/scripts/design_interfaces.py > .rpgkit/logs/design_interfaces.log 2>&1 +rpgkit script design_interfaces.py ``` -Then print the output by: - -```bash -cat .rpgkit/logs/design_interfaces.log -``` +The script writes a structured log automatically; stdout carries the +summary you need below. This will: @@ -89,7 +86,7 @@ defined by the data flow DAG. This ensures dependencies are resolved correctly. After generation, run the check script again: ```bash -python3 .rpgkit/scripts/check_interfaces.py --json +rpgkit script check_interfaces.py --json ``` Verify: diff --git a/RPG-Kit/templates/commands/encode.md b/RPG-Kit/templates/commands/encode.md index 782da95..7651917 100644 --- a/RPG-Kit/templates/commands/encode.md +++ b/RPG-Kit/templates/commands/encode.md @@ -23,7 +23,7 @@ code entities) and edges (dependencies, containment). Run the check script to determine the current encode state: ```bash -python3 .rpgkit/scripts/rpg_encoder/check_encode.py --json +rpgkit script rpg_encoder/check_encode.py --json ``` Inspect the `type` field in the output: @@ -62,12 +62,13 @@ Inspect the `type` field in the output: Run the full encode script: ```bash -python3 .rpgkit/scripts/rpg_encoder/run_encode.py --json > .rpgkit/logs/encode.log 2>&1 +rpgkit script rpg_encoder/run_encode.py --json ``` This may take several minutes depending on repository size and LLM response times. -Inspect the encoding result by reading the tail of the log (`tail -n 200 .rpgkit/logs/encode.log`) -or the JSON summary written by the script. +The script prints a JSON summary on stdout and writes a structured +log automatically. +Inspect the JSON `status` field to decide next steps. **If status is "success"**: diff --git a/RPG-Kit/templates/commands/feature_build.md b/RPG-Kit/templates/commands/feature_build.md index 55328b8..c035fe6 100644 --- a/RPG-Kit/templates/commands/feature_build.md +++ b/RPG-Kit/templates/commands/feature_build.md @@ -19,7 +19,7 @@ This workflow has four steps: Execute the following command to check the current state of input/output files: ```bash -python3 .rpgkit/scripts/feature_build_validation.py +rpgkit script feature_build_validation.py ``` **After execution, parse the JSON output and display a user-friendly summary.** @@ -61,12 +61,11 @@ The script automatically detects whether the output file (`feature_build.json`) 1. **Execute the command:** ```bash - python3 .rpgkit/scripts/feature_build.py \ - --mode step1 > .rpgkit/logs/feature_build.log 2>&1 + rpgkit script feature_build.py --mode step1 ``` - Inspect the result by reading the tail of the log - (`tail -n 300 .rpgkit/logs/feature_build.log`) to capture the + The script prints its full output on stdout and also writes a + structured log automatically. Inspect the stdout to capture the `FEATURE EXPANSION SUMMARY` section described below. **Available parameters for Step 2:** @@ -116,12 +115,11 @@ After the spec-driven build is complete, ask the user whether they want to expan a. **Get expansion direction suggestions:** ```bash - python3 .rpgkit/scripts/feature_build.py \ - --mode suggest-directions > .rpgkit/logs/feature_build.log 2>&1 + rpgkit script feature_build.py --mode suggest-directions ``` - Read the log to obtain the JSON payload - (`tail -n 200 .rpgkit/logs/feature_build.log`). + The JSON payload is printed on stdout (and the full log is + written automatically). b. **Parse the JSON output** and display the directions as a numbered list to the user: @@ -150,17 +148,17 @@ After the spec-driven build is complete, ask the user whether they want to expan Then pass the normalized indices to the script: ```bash - python3 .rpgkit/scripts/feature_build.py \ + rpgkit script feature_build.py \ --mode step2 \ - --direction "" > .rpgkit/logs/feature_build.log 2>&1 + --direction "" ``` For example, if the user enters `1,3,5`: ```bash - python3 .rpgkit/scripts/feature_build.py \ + rpgkit script feature_build.py \ --mode step2 \ - --direction "1,3,5" > .rpgkit/logs/feature_build.log 2>&1 + --direction "1,3,5" ``` **What happens inside the script:** diff --git a/RPG-Kit/templates/commands/feature_edit.md b/RPG-Kit/templates/commands/feature_edit.md index ecab1d7..54640d8 100644 --- a/RPG-Kit/templates/commands/feature_edit.md +++ b/RPG-Kit/templates/commands/feature_edit.md @@ -37,7 +37,7 @@ The text typed by the user after `/rpgkit.feature_edit` **is the edit instructio Execute from repository root: ```bash -python3 .rpgkit/scripts/feature_edit_validation.py --edit_instruction "$ARGUMENTS" +rpgkit script feature_edit_validation.py --edit_instruction "$ARGUMENTS" ``` **Important:** If `$ARGUMENTS` contains a double quote (`"`), it MUST be escaped before being passed to the script. @@ -73,7 +73,7 @@ Inspect the `type` field in the output: Display the following prompt and wait for user confirmation: ```markdown -The script `.rpgkit/scripts/feature_edit.py` will be executed to edit the feature tree based on your instructions. +The script `rpgkit script feature_edit.py` will be executed to edit the feature tree based on your instructions. **File:** `.rpgkit/data/feature_tree.json` @@ -96,14 +96,11 @@ Please confirm to proceed: Execute the following command: ```bash -python3 .rpgkit/scripts/feature_edit.py > .rpgkit/logs/feature_edit.log 2>&1 +rpgkit script feature_edit.py ``` -Then print the output by: - -```bash -cat .rpgkit/logs/feature_edit.log -``` +The script writes a structured log automatically; stdout +carries the summary you need below. ### Step 4: Summarize Results diff --git a/RPG-Kit/templates/commands/feature_refactor.md b/RPG-Kit/templates/commands/feature_refactor.md index dbb4322..c307798 100644 --- a/RPG-Kit/templates/commands/feature_refactor.md +++ b/RPG-Kit/templates/commands/feature_refactor.md @@ -10,7 +10,7 @@ name: rpgkit.feature_refactor 1. Run the validation script to verify input and check output file status: ```bash - python3 .rpgkit/scripts/feature_refactor_validation.py + rpgkit script feature_refactor_validation.py ``` The script outputs a JSON object. Determine the next action based on the `status` and `action` fields: @@ -33,7 +33,7 @@ name: rpgkit.feature_refactor 1. Must display the following information and prompt the user to confirm the maximum number of iterations (default: 10). ```markdown - **description**: Run the script `.rpgkit/scripts/feature_refactor.py` to perform a two-step process: + **description**: Run the script `rpgkit script feature_refactor.py` to perform a two-step process: - Step 1: Plan the structure and number of subtrees - Step 2: Iteratively assign features to the planned subtrees @@ -47,14 +47,11 @@ name: rpgkit.feature_refactor 2. Execute the following command with the selected max iteration count (default: 10 or user-defined): ```bash - python3 .rpgkit/scripts/feature_refactor.py --max-iterations > .rpgkit/logs/feature_refactor.log 2>&1 + rpgkit script feature_refactor.py --max-iterations ``` - Then print the output by: - - ```bash - cat .rpgkit/logs/feature_refactor.log - ``` + The script writes a structured log automatically; + stdout carries the summary you need below. 3. Analyze and summarize the information printed during script execution, and present the results in a Markdown table format. diff --git a/RPG-Kit/templates/commands/feature_spec.md b/RPG-Kit/templates/commands/feature_spec.md index d13da79..7003848 100644 --- a/RPG-Kit/templates/commands/feature_spec.md +++ b/RPG-Kit/templates/commands/feature_spec.md @@ -607,7 +607,7 @@ Convert generated Markdown feature specification files to JSON format. Execute the following command: ```bash -python3 .rpgkit/scripts/feature_spec_to_json.py +rpgkit script feature_spec_to_json.py ``` #### 5.2: Verify Output diff --git a/RPG-Kit/templates/commands/plan_tasks.md b/RPG-Kit/templates/commands/plan_tasks.md index 3451392..3b1634b 100644 --- a/RPG-Kit/templates/commands/plan_tasks.md +++ b/RPG-Kit/templates/commands/plan_tasks.md @@ -1,5 +1,5 @@ --- -mode: agent +name: rpgkit.plan_tasks description: Plan implementation tasks from interface definitions --- @@ -14,7 +14,7 @@ Create implementation tasks from the interface definitions. Run the check script to determine current state: ```bash -python3 .rpgkit/scripts/check_tasks.py --json +rpgkit script check_tasks.py --json ``` **If type is "error"**: @@ -60,14 +60,11 @@ python3 .rpgkit/scripts/check_tasks.py --json Run the task planner: ```bash -python3 .rpgkit/scripts/plan_tasks.py > .rpgkit/logs/plan_tasks.log 2>&1 +rpgkit script plan_tasks.py ``` -Then print the output by: - -```bash -cat .rpgkit/logs/plan_tasks.log -``` +The script writes a structured log automatically; stdout carries the +summary you need below. This will: @@ -84,7 +81,7 @@ This will: After generation, run the check script again: ```bash -python3 .rpgkit/scripts/check_tasks.py --json +rpgkit script check_tasks.py --json ``` Verify: diff --git a/RPG-Kit/templates/commands/rpg_edit.md b/RPG-Kit/templates/commands/rpg_edit.md index ac50aed..6f5186b 100644 --- a/RPG-Kit/templates/commands/rpg_edit.md +++ b/RPG-Kit/templates/commands/rpg_edit.md @@ -41,7 +41,7 @@ The text after `/rpgkit.rpg_edit` is the edit instruction, available as `$ARGUME ### Step 1: Pre-check ```bash -python3 .rpgkit/scripts/rpg_edit/validate.py --json +rpgkit script rpg_edit/validate.py --json ``` Inspect the `type` field: @@ -52,7 +52,7 @@ Inspect the `type` field: ### Step 2: Locate Target Nodes ```bash -python3 .rpgkit/scripts/rpg_edit/locate.py --query "$ARGUMENTS" --json +rpgkit script rpg_edit/locate.py --query "$ARGUMENTS" --json ``` > **Note:** If `$ARGUMENTS` contains double quotes, escape them before passing. @@ -75,13 +75,16 @@ plus a `tree_summary` showing the full RPG structure for orientation. ### Step 3: Analyze Impact -For each selected node, run impact analysis and save the output: +For each selected node, run impact analysis and persist the result so +the Step 5d review step can pick it up automatically: ```bash -python3 .rpgkit/scripts/rpg_edit/impact.py --node-id [--node-id ...] --json | tee .rpgkit/data/rpg_edit_impact.json +rpgkit script rpg_edit/impact.py --node-id [--node-id ...] --json --save ``` -Read the output to inform the EditPlan. Do NOT present it separately — incorporate the results directly into Step 4. +The `--save` flag persists `rpg_edit_impact.json` for downstream stages; +stdout still carries the JSON for you to read. Do NOT present it +separately — incorporate the results directly into Step 4. ### Step 3.5: Visual Reconnaissance (optional, before EditPlan) @@ -104,7 +107,7 @@ If no keyword matches, skip directly to Step 4. **Step 3.5a — Probe tool availability (≤ 5s):** ```bash -python3 .rpgkit/scripts/tools/browser.py check >/dev/null 2>&1 \ +rpgkit script tools/browser.py check >/dev/null 2>&1 \ && BROWSER_OK=1 || BROWSER_OK=0 ``` @@ -126,7 +129,7 @@ Step 4. **Step 3.5c — Run inspect:** ```bash -python3 .rpgkit/scripts/tools/browser.py inspect +rpgkit script tools/browser.py inspect ``` The command prints paths to the saved HTML and screenshot. Read the @@ -161,7 +164,7 @@ assumptions from node names. Poor plans come from skipping this step. was skipped but the app is running, take a screenshot now: ```bash - python3 .rpgkit/scripts/tools/browser.py inspect http://localhost:/ + rpgkit script tools/browser.py inspect http://localhost:/ ``` 4. **Collect all files that need changes** — not just the ones from @@ -197,10 +200,12 @@ in `code_changes`: - [ ] Each `description` references specific functions/classes/lines found in the code - [ ] No generic descriptions like "update styles" — cite exact CSS properties or function names -Save to `.rpgkit/data/rpg_edit_plan.json` via shell (do NOT use the Write tool for `.rpgkit/` paths): +Save the plan via the dedicated helper, which persists +`rpg_edit_plan.json` for downstream stages and prints the absolute +path on stdout. Do NOT use the Write tool for `.rpgkit/` paths: ```bash -cat > .rpgkit/data/rpg_edit_plan.json << 'PLAN_EOF' +cat << 'PLAN_EOF' | rpgkit script rpg_edit/save_plan.py PLAN_EOF ``` @@ -263,11 +268,12 @@ do **not** silently `git stash`, as that would hide their work. **Step 5b — Update RPG feature graph:** ```bash -python3 .rpgkit/scripts/rpg_edit/apply.py --plan .rpgkit/data/rpg_edit_plan.json --phase rpg-only --json +rpgkit script rpg_edit/apply.py --phase rpg-only --json ``` -This applies `feature_changes` to the RPG and saves it. The RPG now reflects the target state. -Note the `backup_timestamp` from the output — you'll need it in Step 5c and on rollback. +This applies `feature_changes` to the RPG and saves it (reading the plan +from the default home-dir location). Note the `backup_timestamp` from +the output — you'll need it in Step 5c and on rollback. **Step 5c — Apply code changes via dedicated SubAgent + refresh dep_graph + commit on the branch:** @@ -277,15 +283,15 @@ mode, and the driver script creates a single commit on the current branch (even when multiple SubAgent iterations are needed). ```bash -python3 .rpgkit/scripts/rpg_edit/code.py \ - --plan .rpgkit/data/rpg_edit_plan.json \ - --json | tee .rpgkit/data/rpg_edit_code_result.json +rpgkit script rpg_edit/code.py --json ``` Inspect the result `success` field: - `true`: code applied, single commit made on the branch (SHA in `commit_sha`). - Continue to refresh dep_graph below. + The full result JSON is on stdout; the script also persists + `rpg_edit_code_result.json` for later inspection. Continue to refresh + dep_graph below. - `false`: report `last_error` to user, do NOT refresh dep_graph, leave the rpg-edit branch for inspection. @@ -293,9 +299,8 @@ If success, refresh the dep_graph and amend the existing commit so that code + dep_graph land together: ```bash -python3 .rpgkit/scripts/rpg_edit/apply.py \ - --plan .rpgkit/data/rpg_edit_plan.json \ - --phase dep-refresh --backup-ts --json +rpgkit script rpg_edit/apply.py --phase dep-refresh \ + --backup-ts --json git add -A && git commit --amend --no-edit ``` @@ -305,19 +310,17 @@ git add -A && git commit --amend --no-edit 1. **Smoke test** — verify imports and entry point: ```bash -python3 .rpgkit/scripts/smoke_test.py --json +rpgkit script smoke_test.py --json ``` 1. **Impact review** — run targeted tests and verify affected functionality: ```bash -python3 .rpgkit/scripts/rpg_edit/review.py \ - --plan .rpgkit/data/rpg_edit_plan.json \ - --impact .rpgkit/data/rpg_edit_impact.json \ - --json +rpgkit script rpg_edit/review.py --json ``` -The review script automatically: +The review script reads the plan and impact JSON from their default +home-dir locations and automatically: - Derives test patterns from `code_changes` in the plan - Runs pytest on matching test files @@ -354,7 +357,7 @@ visible in `git log --graph`. > Merged `rpg-edit/` into `main` (commit ``). > To revert later: > - Code: `git revert -m 1 ` - > - Graphs: `python3 .rpgkit/scripts/rpg_edit/apply.py --rollback --json` + > - Graphs: `rpgkit script rpg_edit/apply.py --rollback --json` If the review output contained `suggestions`, append: @@ -378,7 +381,7 @@ visible in `git log --graph`. > `main` is clean. Choose one of: > - Inspect: `git diff main rpg-edit/` > - Discard code + graphs together: - > `python3 .rpgkit/scripts/rpg_edit/apply.py --rollback --rollback-branch rpg-edit/ --json` + > `rpgkit script rpg_edit/apply.py --rollback --rollback-branch rpg-edit/ --json` > - Discard code only: `git branch -D rpg-edit/` > - Continue editing on the branch and re-run from Step 5d. diff --git a/RPG-Kit/templates/commands/update_rpg.md b/RPG-Kit/templates/commands/update_rpg.md index 56f865e..2cf34b2 100644 --- a/RPG-Kit/templates/commands/update_rpg.md +++ b/RPG-Kit/templates/commands/update_rpg.md @@ -22,8 +22,9 @@ This slash command is a **manual fallback** for the few cases where the automatic update didn't happen, e.g.: * You committed with `git commit --no-verify` (skipping hooks). -* The background hook errored out (network blip, LLM timeout) — check - `.rpgkit/logs/update_rpg.log`. +* The background hook errored out (network blip, LLM timeout) — run + `rpgkit version` to locate the workspace's logs directory and tail + the latest `update_rpg.log` there. * You want to force a fresh update synchronously and see the result immediately instead of waiting for the async hook. @@ -35,7 +36,7 @@ uses) and runs the LLM-driven feature graph diff + dep_graph rebuild. Run the check script: ```bash -python3 .rpgkit/scripts/rpg_encoder/check_encode.py --json +rpgkit script rpg_encoder/check_encode.py --json ``` Inspect the `type` field in the JSON output: @@ -59,22 +60,17 @@ diff against, and suggest running `/rpgkit.encode` instead. Terminate. ### Step 2: Run the Update -Make sure the log directory exists, then invoke the same script the -post-commit hook uses. It creates and cleans up its own temporary -worktree internally — **you do not need to manage `git worktree` -manually**. +Invoke the same script the post-commit hook uses. It creates and cleans +up its own temporary worktree internally — **you do not need to manage +`git worktree` manually**. ```bash -mkdir -p .rpgkit/logs -python3 .rpgkit/scripts/update_graphs.py update-rpg --json \ - > .rpgkit/logs/update_rpg.log 2>&1 +rpgkit script update_graphs.py update-rpg --json ``` -The JSON result is the last `{...}` block in the log. Read it with: - -```bash -tail -n 100 .rpgkit/logs/update_rpg.log -``` +The full JSON result is printed on stdout (single `{...}` block). The +script also writes a structured log automatically; you do +not need to redirect output. ### Step 3: Display Result @@ -94,7 +90,8 @@ RPG update complete! **If `status` is `"error"`**: * Show the `error` field. -* Suggest `tail -n 200 .rpgkit/logs/update_rpg.log` for the full trace. +* Tell the user to run `rpgkit version` to locate the logs directory + and inspect `update_rpg.log` for the full trace. * Common causes: LLM API misconfigured, network failure, dirty worktree blocking `git worktree add`. @@ -107,5 +104,6 @@ Tips: automatic update failed or was skipped. - /rpgkit.encode — Run a full re-encode if the RPG seems stale or has drifted significantly from the codebase. - - .rpgkit/logs/update_rpg.log keeps the most recent run output. + - The latest `update_rpg.log` (path shown by `rpgkit version`) keeps + the most recent run output. ``` diff --git a/RPG-Kit/tests/test_dep_graph_incremental.py b/RPG-Kit/tests/test_dep_graph_incremental.py index 9bcb1de..0c8879f 100644 --- a/RPG-Kit/tests/test_dep_graph_incremental.py +++ b/RPG-Kit/tests/test_dep_graph_incremental.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for DependencyGraph incremental update API (Step 2). -The single most important invariant these tests guard is: +Core invariant under test: After any sequence of ``add_file`` / ``remove_file`` / ``update_files`` calls, the resulting DependencyGraph must be **structurally identical** diff --git a/RPG-Kit/tests/test_e2e.py b/RPG-Kit/tests/test_e2e.py index f86038c..da3bda0 100644 --- a/RPG-Kit/tests/test_e2e.py +++ b/RPG-Kit/tests/test_e2e.py @@ -728,12 +728,6 @@ def send_sms(to: str, message: str): class TestE2ECLISimulation: """Simulate CLI-like invocations end-to-end.""" - def test_cli_encode_helpers(self, sample_repo, tmp_path): - """RPG_FILE path constant points to correct location.""" - from common.paths import RPG_FILE - - assert str(RPG_FILE).endswith(os.path.join(".rpgkit", "data", "rpg.json")) - def test_cli_rpg_stats(self, encoded_rpg): """check_encode.get_rpg_stats produces valid statistics from encoded RPG data.""" from rpg_encoder.check_encode import get_rpg_stats diff --git a/RPG-Kit/tests/test_hooks_install.py b/RPG-Kit/tests/test_hooks_install.py index 63f0d07..8a7110c 100644 --- a/RPG-Kit/tests/test_hooks_install.py +++ b/RPG-Kit/tests/test_hooks_install.py @@ -61,14 +61,24 @@ def test_install_claude_hooks_writes_session_start(project): session_start = data["hooks"]["SessionStart"] assert isinstance(session_start, list) and len(session_start) == 1 cmd = session_start[0]["hooks"][0]["command"] - assert "update_graphs.py" in cmd + # Hook now invokes the global ``rpgkit`` CLI; no embedded sys.executable. + assert "rpgkit script update_graphs.py status" in cmd + # PATH fallback for GUI-launched session starts (VS Code / IDE git UI). + assert "command -v rpgkit" in cmd assert cmd.endswith("status 2>/dev/null || echo '[RPG-Kit] RPG status unavailable'") def test_install_claude_hooks_is_idempotent_across_python_upgrades(project, monkeypatch): - """Re-installing with a different ``sys.executable`` must not stack duplicate SessionStart entries: an outdated Python path pointing to a missing interpreter would fail every session start, while still appearing alongside the new entry.""" + """Re-installing must not stack duplicate SessionStart entries. + + Hooks no longer embed ``sys.executable``; they delegate to the + globally-installed ``rpgkit`` CLI. Re-running install therefore + yields the exact same command and must remain a single entry + (not a duplicate per invocation). + """ rpgkit_cli._install_claude_hooks(project) - # Simulate a Python interpreter upgrade (path differs). + # Simulate any environment change that previously affected hook content; + # the new hook body is interpreter-independent so this should be a no-op. monkeypatch.setattr(rpgkit_cli.sys, "executable", "/opt/new-python/bin/python") rpgkit_cli._install_claude_hooks(project) data = json.loads((project / ".claude" / "settings.json").read_text()) @@ -79,15 +89,18 @@ def test_install_claude_hooks_is_idempotent_across_python_upgrades(project, monk ] assert len(rpgkit_entries) == 1 cmd = rpgkit_entries[0]["hooks"][0]["command"] - assert "/opt/new-python/bin/python" in cmd # latest interpreter wins + # Always uses the rpgkit-script form regardless of interpreter path. + assert "rpgkit script update_graphs.py" in cmd + assert "/opt/new-python/bin/python" not in cmd def test_install_claude_hooks_shell_escapes_special_chars(project, monkeypatch): - """Paths with spaces or quotes must survive ``sh -c`` tokenisation. + """Interpreter / workspace paths must not appear in the hook command. - Claude hooks run shell form, so the command field is passed verbatim - to ``sh -c``. We rely on ``shlex.quote`` for safety; json.dumps - would leave bare spaces in paths exposed. + Previously the hook embedded ``sys.executable`` and the workspace + script path, requiring ``shlex.quote`` to survive spaces. The new + hook body invokes the global ``rpgkit`` CLI directly, so paths with + special characters can't end up inside the command string. """ monkeypatch.setattr( rpgkit_cli.sys, "executable", "/path with space/python" @@ -97,8 +110,9 @@ def test_install_claude_hooks_shell_escapes_special_chars(project, monkeypatch): json.loads((project / ".claude" / "settings.json").read_text()) ["hooks"]["SessionStart"][0]["hooks"][0]["command"] ) - # shlex.quote wraps in single quotes on POSIX - assert "'/path with space/python'" in cmd + # No path leakage from the interpreter / workspace location. + assert "/path with space" not in cmd + assert "rpgkit script update_graphs.py" in cmd def test_install_claude_hooks_merges_existing(project): @@ -137,8 +151,12 @@ def test_install_copilot_hooks_writes_folder_open_task(project): t = tasks["tasks"][0] assert t["label"] == "RPG-Kit: load status" assert t["runOptions"] == {"runOn": "folderOpen"} + # Task now invokes the global ``rpgkit`` CLI; args carry the + # dispatcher subcommand + script relpath, with ``status`` last. + assert t["command"] == "rpgkit" + assert t["args"][0] == "script" + assert t["args"][1] == "update_graphs.py" assert t["args"][-1] == "status" - assert t["args"][0].endswith("update_graphs.py") # Status output should appear silently — we don't want it stealing focus. assert t["presentation"]["reveal"] == "silent" # NOTE: .gitignore management was moved to `_setup_gitignore` (called @@ -557,8 +575,11 @@ def test_setup_gitignore_is_idempotent(tmp_path): assert first == second # second call is a no-op # No duplicate RPG-Kit header assert second.count(rpgkit_cli._GITIGNORE_RPGKIT_HEADER) == 1 - # No duplicate .rpgkit/ entry - assert second.count(".rpgkit/") == 1 + # No duplicate .rpgkit/ directory entry. Count actual lines (after + # stripping) because the appended block also contains + # `!.rpgkit/config.toml` which holds .rpgkit/ as a substring. + lines = [l.strip() for l in second.splitlines()] + assert lines.count(".rpgkit/") == 1 def test_setup_gitignore_partial_existing_rules_only_appends_missing(tmp_path): @@ -567,8 +588,13 @@ def test_setup_gitignore_partial_existing_rules_only_appends_missing(tmp_path): (tmp_path / ".gitignore").write_text(".rpgkit/\n") rpgkit_cli._setup_gitignore(tmp_path, "copilot") content = (tmp_path / ".gitignore").read_text() - # .rpgkit/ must NOT be duplicated - assert content.count(".rpgkit/") == 1 + # .rpgkit/ directory entry must NOT be duplicated. Compare exact + # lines (after stripping) because the appended block also contains + # `!.rpgkit/config.toml` which holds .rpgkit/ as a substring. + lines = [l.strip() for l in content.splitlines()] + assert lines.count(".rpgkit/") == 1 + # The new managed config.toml un-ignore line is present + assert "!.rpgkit/config.toml" in lines # Missing rules are now present assert ".vscode/mcp.json" in content assert ".github/agents/" in content diff --git a/RPG-Kit/tests/test_rpg_io.py b/RPG-Kit/tests/test_rpg_io.py new file mode 100644 index 0000000..1d9784d --- /dev/null +++ b/RPG-Kit/tests/test_rpg_io.py @@ -0,0 +1,209 @@ +"""Tests for ``scripts.common.rpg_io`` (atomic write + recovery).""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +# Make sure the bundled scripts/ tree is importable. +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT / "scripts")) + +from common import rpg_io # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _has_git() -> bool: + from shutil import which + return which("git") is not None + + +requires_git = pytest.mark.skipif(not _has_git(), reason="git not on PATH") + + +def _make_home_layout(tmp_path: Path, hash_id: str = "abc123def456") -> Path: + """Create the ``~/.rpgkit/workspaces//`` layout for tests. + + Returns the home_dir (the dir that gets ``git init``). Caller is + responsible for git-initialising and snapshotting it. + """ + home_root = tmp_path / ".rpgkit" / "workspaces" / hash_id + (home_root / "data").mkdir(parents=True) + return home_root + + +def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + """Convenience wrapper for tests.""" + env = { + **os.environ, + "LC_ALL": "C", "LANG": "C", + "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "test@x", + "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "test@x", + } + return subprocess.run( + ["git", "-C", str(cwd), *args], + capture_output=True, text=True, env=env, timeout=10, check=True, + ) + + +# --------------------------------------------------------------------------- +# atomic_write_rpg +# --------------------------------------------------------------------------- + +class TestAtomicWrite: + def test_creates_file(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + rpg_io.atomic_write_rpg(target, {"hello": "world"}) + assert target.is_file() + assert json.loads(target.read_text()) == {"hello": "world"} + + def test_overwrites_existing(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + target.write_text('{"old": true}') + rpg_io.atomic_write_rpg(target, {"new": True}) + assert json.loads(target.read_text()) == {"new": True} + + def test_creates_parent_dirs(self, tmp_path: Path) -> None: + target = tmp_path / "deep" / "nested" / "rpg.json" + rpg_io.atomic_write_rpg(target, {"x": 1}) + assert target.is_file() + + def test_no_tmp_leftover_on_success(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + rpg_io.atomic_write_rpg(target, {"x": 1}) + tmp = target.with_suffix(".json.tmp") + assert not tmp.exists() + + def test_no_tmp_leftover_on_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """If ``os.replace`` fails, the partial ``.tmp`` is cleaned up.""" + target = tmp_path / "rpg.json" + # Pre-existing valid content we shouldn't lose. + target.write_text('{"existing": "data"}') + + def boom(*_a, **_kw): + raise OSError("simulated replace failure") + monkeypatch.setattr(rpg_io.os, "replace", boom) + + with pytest.raises(OSError): + rpg_io.atomic_write_rpg(target, {"new": "would-be"}) + + # Original file untouched + assert json.loads(target.read_text()) == {"existing": "data"} + # No stray .tmp + tmp = target.with_suffix(".json.tmp") + assert not tmp.exists() + + def test_preserves_unicode(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + rpg_io.atomic_write_rpg(target, {"name": "测试 \u2014 ✓"}) + loaded = json.loads(target.read_text(encoding="utf-8")) + assert loaded["name"] == "测试 \u2014 ✓" + + +# --------------------------------------------------------------------------- +# safe_load_rpg — success path + propagation of FileNotFoundError +# --------------------------------------------------------------------------- + +class TestSafeLoadBasic: + def test_returns_data_on_valid_file(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + target.write_text(json.dumps({"ok": True})) + assert rpg_io.safe_load_rpg(target) == {"ok": True} + + def test_raises_filenotfound_when_missing(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + rpg_io.safe_load_rpg(tmp_path / "absent.json") + + def test_raises_jsondecodeerror_when_no_inner_git( + self, tmp_path: Path + ) -> None: + """Without an inner-git nearby, corruption propagates as-is.""" + target = tmp_path / "rpg.json" + target.write_text("not { valid json") + with pytest.raises(json.JSONDecodeError): + rpg_io.safe_load_rpg(target) + + +# --------------------------------------------------------------------------- +# safe_load_rpg — recovery via inner git +# --------------------------------------------------------------------------- + +@requires_git +class TestSafeLoadRecovery: + def _setup_with_history(self, tmp_path: Path) -> tuple[Path, Path, dict]: + """Build a home-layout with one good snapshot of data/rpg.json. + + Returns (home_dir, target_path, good_payload). + """ + home = _make_home_layout(tmp_path) + target = home / "data" / "rpg.json" + + # Good v1 → commit + good = {"version": 1, "nodes": [{"id": "x"}]} + rpg_io.atomic_write_rpg(target, good) + _git(home, "init", "-q", "-b", "main") + _git(home, "add", "-A") + _git(home, "commit", "-q", "-m", "v1") + return home, target, good + + def test_recovers_from_last_good_snapshot(self, tmp_path: Path) -> None: + home, target, good = self._setup_with_history(tmp_path) + + # Corrupt the file (simulate interrupted write). + target.write_text('{"version": 2, "nod') # truncated + + recovered = rpg_io.safe_load_rpg(target) + assert recovered == good + + # File on disk has been healed too. + assert json.loads(target.read_text()) == good + # No stray .tmp from the heal write. + assert not (home / "data" / "rpg.json.tmp").exists() + + def test_skips_bad_snapshots(self, tmp_path: Path) -> None: + """If recent commits are also broken, walks further back.""" + home, target, good = self._setup_with_history(tmp_path) + + # Commit an invalid JSON snapshot to bury the good one. + target.write_text('{"broken') + _git(home, "add", "-A") + _git(home, "commit", "-q", "-m", "broken commit") + + recovered = rpg_io.safe_load_rpg(target) + assert recovered == good + + def test_returns_none_when_history_has_no_valid_snapshot( + self, tmp_path: Path + ) -> None: + """No valid history → original parse error re-raised.""" + home = _make_home_layout(tmp_path) + target = home / "data" / "rpg.json" + + # First commit: already broken (pathological). + target.write_text('{not json') + _git(home, "init", "-q", "-b", "main") + _git(home, "add", "-A") + _git(home, "commit", "-q", "-m", "broken from the start") + + # Read it: corruption can't be recovered. + with pytest.raises(json.JSONDecodeError): + rpg_io.safe_load_rpg(target) + + def test_works_when_target_outside_known_layout( + self, tmp_path: Path + ) -> None: + """For paths that don't look like ``~/.rpgkit/workspaces/...``, + recovery silently no-ops and the original error re-raises.""" + target = tmp_path / "rpg.json" # not in a home-layout + target.write_text("not valid") + with pytest.raises(json.JSONDecodeError): + rpg_io.safe_load_rpg(target) diff --git a/RPG-Kit/tests/test_step3_polish.py b/RPG-Kit/tests/test_step3_polish.py index c4ae0f8..5d411c3 100644 --- a/RPG-Kit/tests/test_step3_polish.py +++ b/RPG-Kit/tests/test_step3_polish.py @@ -432,8 +432,7 @@ def test_install_post_commit_hook_writes_script(tmp_path): assert "nohup" in content assert "setsid" not in content # Atomic lock via mkdir (the only POSIX-atomic exclusive-create - # primitive available from shell). Pre-v4 used ``[ ! -f ]; touch`` - # which had a 2-second race window after a commit burst. + # primitive available from shell). assert "mkdir " in content assert "rmdir " in content # Stale-lock recovery for orphaned worker runs (>60min old). diff --git a/RPG-Kit/tests/test_storage.py b/RPG-Kit/tests/test_storage.py new file mode 100644 index 0000000..9d04eb6 --- /dev/null +++ b/RPG-Kit/tests/test_storage.py @@ -0,0 +1,419 @@ +"""Unit tests for ``rpgkit_cli._storage``.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# Make ``src/`` importable when running pytest directly from a clean +# checkout (no ``pip install -e .`` step). Same pattern as the other +# rpgkit_cli unit tests in this directory. +_SRC_DIR = Path(__file__).resolve().parents[1] / "src" +if str(_SRC_DIR) not in sys.path: + sys.path.insert(0, str(_SRC_DIR)) + +from rpgkit_cli import _storage # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +@pytest.fixture +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect ``Path.home()`` to a temp dir for the duration of one test.""" + monkeypatch.setenv("HOME", str(tmp_path)) + # Some Pathlib internals also consult ``USERPROFILE`` on Windows. + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + """A throwaway workspace directory.""" + ws = tmp_path / "my-workspace" + ws.mkdir() + return ws + + +# --------------------------------------------------------------------------- +# workspace_id +# --------------------------------------------------------------------------- + +class TestWorkspaceId: + def test_deterministic(self, workspace: Path) -> None: + assert _storage.workspace_id(workspace) == _storage.workspace_id(workspace) + + def test_resolves_relative(self, workspace: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(workspace.parent) + rel = Path(workspace.name) + assert _storage.workspace_id(rel) == _storage.workspace_id(workspace) + + def test_follows_symlinks(self, tmp_path: Path) -> None: + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "via-symlink" + link.symlink_to(real) + # Symlink and target must hash to the same workspace. + assert _storage.workspace_id(link) == _storage.workspace_id(real) + + def test_different_paths_differ(self, tmp_path: Path) -> None: + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + assert _storage.workspace_id(a) != _storage.workspace_id(b) + + def test_hash_length_is_12(self, workspace: Path) -> None: + """Pre-0.1.4 legacy id is still computable for backward compat.""" + wid = _storage._legacy_workspace_id(workspace) + assert len(wid) == 12 + assert all(c in "0123456789abcdef" for c in wid) + + def test_short_path_returns_plain_slug(self, tmp_path: Path) -> None: + """Common case: slug below the budget, no hash suffix.""" + ws = tmp_path / "myrepo" + ws.mkdir() + wid = _storage.workspace_id(ws) + # The slug should include the workspace dir name and contain only + # lowercase alphanumerics + ``-``. + assert "myrepo" in wid + assert all(c.isalnum() or c == "-" for c in wid) + assert not wid.startswith("-") + assert not wid.endswith("-") + # No overflow hash suffix for a short path. + assert "-" + _storage._base36_hash(ws) not in wid + + def test_long_path_truncates_and_appends_hash( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Overflow case: id is truncated and ends with a base36 hash.""" + # Synthesise a workspace whose slug far exceeds the budget by + # monkey-patching ``_resolve`` (creating a 300-deep dir tree is + # slow and noisy on disk). + fake_path = Path("/" + "/".join("seg%02d" % i for i in range(60))) + monkeypatch.setattr(_storage, "_resolve", lambda p: fake_path) + + wid = _storage.workspace_id(tmp_path) + assert len(wid) <= _storage._SLUG_MAX_LEN, ( + "workspace_id must stay under NAME_MAX budget" + ) + # Suffix shape: ``-<6 base36 chars>``. + assert wid[-7] == "-" + suffix = wid[-_storage._HASH_SUFFIX_LEN :] + assert all(c in _storage._BASE36_ALPHABET for c in suffix) + # Deterministic across calls. + assert _storage.workspace_id(tmp_path) == wid + + def test_root_path_returns_root(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``/`` slugs to ``root`` (avoids empty directory name).""" + monkeypatch.setattr(_storage, "_resolve", lambda p: Path("/")) + assert _storage.workspace_id(Path("/")) == "root" + + +# --------------------------------------------------------------------------- +# Path helpers +# --------------------------------------------------------------------------- + +class TestPathHelpers: + def test_home_workspace_dir_under_home_root( + self, fake_home: Path, workspace: Path + ) -> None: + d = _storage.home_workspace_dir(workspace) + assert d.is_relative_to(fake_home / ".rpgkit" / "workspaces") + assert d.name == _storage.workspace_id(workspace) + + def test_data_logs_inner_git_under_home( + self, fake_home: Path, workspace: Path + ) -> None: + home = _storage.home_workspace_dir(workspace) + assert _storage.workspace_data_dir(workspace) == home / "data" + assert _storage.workspace_logs_dir(workspace) == home / "logs" + assert _storage.workspace_inner_git_dir(workspace) == home / ".git" + + def test_reports_dir_under_workspace( + self, fake_home: Path, workspace: Path + ) -> None: + """Reports stay in the workspace, not in home.""" + reports = _storage.workspace_reports_dir(workspace) + assert reports == workspace.resolve() / ".rpgkit" / "reports" + + def test_legacy_hash_dir_fallback( + self, fake_home: Path, workspace: Path + ) -> None: + """Pre-0.1.4 directories using the 12-hex-char id are honoured. + + When a user upgrades and their on-disk state lives under the old + ```` directory, ``home_workspace_dir`` must keep + returning that directory so the user doesn't silently lose state. + """ + # Plant a legacy directory but **no** slug-named one. + legacy_dir = ( + fake_home / ".rpgkit" / "workspaces" / _storage._legacy_workspace_id(workspace) + ) + legacy_dir.mkdir(parents=True) + assert _storage.home_workspace_dir(workspace) == legacy_dir + + def test_slug_dir_wins_over_legacy( + self, fake_home: Path, workspace: Path + ) -> None: + """When both legacy and slug dirs exist, the slug dir wins. + + Lets users migrate by simply creating the slug dir (or letting + the next ``rpgkit init`` do it) without manual cleanup. + """ + legacy_dir = ( + fake_home / ".rpgkit" / "workspaces" / _storage._legacy_workspace_id(workspace) + ) + legacy_dir.mkdir(parents=True) + slug_dir = ( + fake_home / ".rpgkit" / "workspaces" / _storage.workspace_id(workspace) + ) + slug_dir.mkdir(parents=True) + assert _storage.home_workspace_dir(workspace) == slug_dir + + +# --------------------------------------------------------------------------- +# find_workspace_root_from +# --------------------------------------------------------------------------- + +class TestFindWorkspaceRoot: + def _mark(self, ws: Path) -> None: + """Plant the workspace marker file.""" + (ws / ".rpgkit").mkdir(exist_ok=True) + (ws / ".rpgkit" / "config.toml").write_text("ai = 'claude'\n") + + def test_finds_at_root(self, workspace: Path) -> None: + self._mark(workspace) + assert _storage.find_workspace_root_from(workspace) == workspace.resolve() + + def test_walks_up_from_subdir(self, workspace: Path) -> None: + self._mark(workspace) + deep = workspace / "src" / "pkg" / "module" + deep.mkdir(parents=True) + assert _storage.find_workspace_root_from(deep) == workspace.resolve() + + def test_returns_none_when_outside(self, tmp_path: Path) -> None: + elsewhere = tmp_path / "no-marker" + elsewhere.mkdir() + assert _storage.find_workspace_root_from(elsewhere) is None + + def test_default_start_is_cwd( + self, workspace: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._mark(workspace) + sub = workspace / "deep" / "down" + sub.mkdir(parents=True) + monkeypatch.chdir(sub) + assert _storage.find_workspace_root_from() == workspace.resolve() + + def test_skips_stale_marker_with_mismatched_meta( + self, fake_home: Path, workspace: Path + ) -> None: + """A ``.meta.toml`` whose ``workspace_path`` doesn't match the + marker's directory is treated as stale (e.g. dir was moved or + renamed) and the walker keeps climbing rather than misrouting.""" + self._mark(workspace) + # Forge meta recording a *different* absolute path under + # ``~/.rpgkit/workspaces//.meta.toml``. + meta_path = _storage.workspace_meta_path(workspace) + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text( + 'channel = "bundle"\n' + f'workspace_path = "{workspace.parent / "elsewhere"}"\n' + 'rpgkit_cli_version_at_init = "0.1.4"\n' + 'rpgkit_cli_version_last_seen = "0.1.4"\n' + 'initialised_at = "2026-01-01T00:00:00+00:00"\n' + ) + assert _storage.find_workspace_root_from(workspace) is None + + +# --------------------------------------------------------------------------- +# .meta.toml read / write +# --------------------------------------------------------------------------- + +class TestMeta: + def test_read_returns_none_when_missing( + self, fake_home: Path, workspace: Path + ) -> None: + assert _storage.read_meta(workspace) is None + + def test_write_then_read_roundtrip( + self, fake_home: Path, workspace: Path + ) -> None: + _storage.write_meta( + workspace, + channel=_storage.CHANNEL_BUNDLE, + rpgkit_cli_version="0.1.4", + ) + data = _storage.read_meta(workspace) + assert data is not None + assert data["channel"] == "bundle" + assert data["workspace_path"] == str(workspace.resolve()) + assert data["rpgkit_cli_version_at_init"] == "0.1.4" + assert data["rpgkit_cli_version_last_seen"] == "0.1.4" + assert "created_at" in data + assert "last_seen_at" in data + + def test_write_preserves_created_at( + self, fake_home: Path, workspace: Path + ) -> None: + _storage.write_meta(workspace, channel=_storage.CHANNEL_BUNDLE) + first = _storage.read_meta(workspace) + assert first is not None + # Second write some moments later + _storage.write_meta(workspace, channel=_storage.CHANNEL_BUNDLE) + second = _storage.read_meta(workspace) + assert second is not None + assert second["created_at"] == first["created_at"] + # last_seen_at may equal or be later; either way it's a string + assert isinstance(second["last_seen_at"], str) + + def test_write_rejects_invalid_channel( + self, fake_home: Path, workspace: Path + ) -> None: + with pytest.raises(ValueError): + _storage.write_meta(workspace, channel="something-else") + + def test_atomic_write_no_tmp_leftover( + self, fake_home: Path, workspace: Path + ) -> None: + _storage.write_meta(workspace, channel=_storage.CHANNEL_BUNDLE) + meta = _storage.workspace_meta_path(workspace) + tmp = meta.with_suffix(".toml.tmp") + assert meta.is_file() + assert not tmp.exists() + + def test_handles_unparseable_meta( + self, fake_home: Path, workspace: Path + ) -> None: + # Plant a broken meta file then attempt to read. + meta = _storage.workspace_meta_path(workspace) + meta.parent.mkdir(parents=True, exist_ok=True) + meta.write_text("this is { not valid toml") + # read_meta should swallow the error and return None + assert _storage.read_meta(workspace) is None + + def test_escapes_pathological_strings( + self, fake_home: Path, tmp_path: Path + ) -> None: + """Workspace paths with backslashes / quotes / newlines round-trip.""" + # Build a workspace whose name contains characters that need + # escaping in TOML basic strings. We can't actually mkdir a + # directory with embedded newlines portably, so we exercise + # the escape function directly + a quote-bearing workspace. + ws = tmp_path / 'has "quotes" in name' + ws.mkdir() + _storage.write_meta(ws, channel=_storage.CHANNEL_BUNDLE) + data = _storage.read_meta(ws) + assert data is not None + assert data["workspace_path"] == str(ws.resolve()) + + def test_reset_resets_init_version( + self, fake_home: Path, workspace: Path + ) -> None: + """``preserve_created_at=False`` resets both timestamps AND init_version.""" + _storage.write_meta( + workspace, + channel=_storage.CHANNEL_BUNDLE, + rpgkit_cli_version="0.1.4", + ) + first = _storage.read_meta(workspace) + assert first is not None + assert first["rpgkit_cli_version_at_init"] == "0.1.4" + + _storage.write_meta( + workspace, + channel=_storage.CHANNEL_BUNDLE, + rpgkit_cli_version="0.2.0", + preserve_created_at=False, + ) + second = _storage.read_meta(workspace) + assert second is not None + # init_version should track the *current* call now, not the + # previously-recorded one. + assert second["rpgkit_cli_version_at_init"] == "0.2.0" + assert second["rpgkit_cli_version_last_seen"] == "0.2.0" + + +# --------------------------------------------------------------------------- +# ensure_workspace_storage +# --------------------------------------------------------------------------- + +class TestEnsureWorkspaceStorage: + def test_creates_layout_first_time( + self, fake_home: Path, workspace: Path + ) -> None: + home = _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + assert (home / "data").is_dir() + assert (home / "logs").is_dir() + assert _storage.workspace_meta_path(workspace).is_file() + assert _storage.workspace_reports_dir(workspace).is_dir() + + def test_idempotent(self, fake_home: Path, workspace: Path) -> None: + first = _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + second = _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + assert first == second + # No exceptions, directories still present. + assert (first / "data").is_dir() + + def test_does_not_create_inner_git( + self, fake_home: Path, workspace: Path + ) -> None: + """Inner git is owned by ``_inner_git.ensure_inner_git``, not us.""" + home = _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + assert not (home / ".git").exists() + + def test_detects_hash_collision( + self, fake_home: Path, workspace: Path + ) -> None: + """If ``.meta.toml`` records a different path, raise.""" + _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + # Tamper: rewrite meta to point at a different workspace. + meta = _storage.workspace_meta_path(workspace) + meta.write_text( + 'workspace_path = "/nowhere/else"\n' + 'channel = "bundle"\n' + 'created_at = "2024-01-01T00:00:00+00:00"\n' + 'last_seen_at = "2024-01-01T00:00:00+00:00"\n' + ) + with pytest.raises(_storage.WorkspaceMetaMismatch): + _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + + +# --------------------------------------------------------------------------- +# resolve_data_from_cwd +# --------------------------------------------------------------------------- + +class TestResolveDataFromCwd: + def test_resolves_from_subdir( + self, fake_home: Path, workspace: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (workspace / ".rpgkit").mkdir() + (workspace / ".rpgkit" / "config.toml").write_text("") + sub = workspace / "src" + sub.mkdir() + monkeypatch.chdir(sub) + data = _storage.resolve_data_from_cwd() + assert data == _storage.workspace_data_dir(workspace) + + def test_returns_none_outside_workspace( + self, fake_home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + outside = tmp_path / "outside" + outside.mkdir() + monkeypatch.chdir(outside) + assert _storage.resolve_data_from_cwd() is None diff --git a/RPG-Kit/tests/test_workspace_unified_layout.py b/RPG-Kit/tests/test_workspace_unified_layout.py index bbd52f6..d9774b9 100644 --- a/RPG-Kit/tests/test_workspace_unified_layout.py +++ b/RPG-Kit/tests/test_workspace_unified_layout.py @@ -11,7 +11,7 @@ corrupts paths). * ``GraphQueryEngine`` handles an empty ``_code_dir_prefix`` cleanly. -These tests are deliberately decoupled from the heavier +These tests are decoupled from the heavier ``test_encoder_workspace_layout.py`` so they can be run on their own during the refactor without dragging the full encoder stack along. """