Compare commits

...
14 Commits
61 changed files with 1926 additions and 2340 deletions

No files matched your search

+8 -6
View File
@@ -1,4 +1,4 @@
# AI instructions # AI instructions
This file contains maintenance instructions for this repository only. It is not This file contains maintenance instructions for this repository only. It is not
part of the generated Codex or Claude Code configuration and must not be read part of the generated Codex or Claude Code configuration and must not be read
@@ -20,12 +20,14 @@ Keep the architecture as:
## Portability ## Portability
- Design scripts, hooks, generated configuration, and installation behavior for both Windows and Linux. - Design scripts, hooks, generated configuration, and installation behavior for both PowerShell and Bash.
- Use cross-platform tools and paths where practical. - Name configuration choices and generated packages after their shell, not the operating system.
- When platform-specific behavior is necessary, provide an equivalent implementation or a safe no-op for the other supported platform. - Use portable tools and paths where practical.
- When shell-specific behavior is necessary, provide an equivalent implementation or a safe no-op for the other supported shell.
- Provide equivalent native Bash (`.sh`) and PowerShell (`.ps1`) entry points for every script and hook. - Provide equivalent native Bash (`.sh`) and PowerShell (`.ps1`) entry points for every script and hook.
- Support Windows PowerShell 5.1 and newer on Windows, and PowerShell 7 (`pwsh`) on Linux. Keep shared PowerShell syntax compatible with 5.1; Windows must not require PowerShell 7. - Keep shared PowerShell syntax compatible with 5.1 and support PowerShell 7 (`pwsh`) as well.
- Validate relevant changes on both supported platforms when practical. - Preserve operating-system API names and upstream schema keys when their exact spelling is required.
- Validate relevant changes with both supported shells when practical.
## Language ## Language
+46 -52
View File
@@ -2,21 +2,21 @@
![OpenAI Codex](https://img.shields.io/badge/OpenAI-Codex-000000) ![OpenAI Codex](https://img.shields.io/badge/OpenAI-Codex-000000)
![Claude Code](https://img.shields.io/badge/Anthropic-Claude_Code-D97757) ![Claude Code](https://img.shields.io/badge/Anthropic-Claude_Code-D97757)
![Windows](https://img.shields.io/badge/Windows-PowerShell_5.1%2B-0078D4) ![PowerShell](https://img.shields.io/badge/PowerShell-5.1%2B-0078D4)
![Linux](https://img.shields.io/badge/Linux-Bash-FCC624) ![Bash](https://img.shields.io/badge/Bash-4.3%2B-FCC624)
[![MIT License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![MIT License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
`MyAiConfiguration` is a version-controlled global user configuration for OpenAI Codex and Claude Code. `MyAiConfiguration` is a version-controlled global user configuration for OpenAI Codex and Claude Code.
It keeps rules, skills, agents, hooks, and orchestration guidance in shared source files. Client adapters turn those files It keeps rules, skills, agents, hooks, and orchestration guidance in shared source files. Client adapters turn those files
into platform-specific packages that can be inspected before they are installed. into shell-specific packages that can be inspected before they are installed.
## Quickstart ## Quickstart
From an existing checkout, with the [requirements](#requirements) installed, run pull, build, then install. From an existing checkout, with the [requirements](#requirements) installed, run pull, build, then install.
Stop if any command fails. Installation asks for the platform, client, and plugin selection. Stop if any command fails. Installation asks for the shell, client, CLI update, Flashbang, and extension choices.
PowerShell (Windows): PowerShell:
```powershell ```powershell
git pull --ff-only git pull --ff-only
@@ -24,7 +24,7 @@ git pull --ff-only
.\scripts\install.ps1 -Summary .\scripts\install.ps1 -Summary
``` ```
Bash (Linux): Bash:
```bash ```bash
git pull --ff-only git pull --ff-only
@@ -36,10 +36,10 @@ For an already-installed configuration, use Quickupdate instead.
## Quickupdate ## Quickupdate
Run these commands from the repository root. Quick reuses the platform, client, and plugin selection from the last Run these commands from the repository root. Quick reuses the shell, client, CLI update, Flashbang, and extension choices from the last
successful installation or update without opening selection menus. Stop if any command fails. successful installation or update without opening selection menus. Stop if any command fails.
PowerShell (Windows): PowerShell:
```powershell ```powershell
git pull --ff-only git pull --ff-only
@@ -47,7 +47,7 @@ git pull --ff-only
.\scripts\update.ps1 -Quick -Summary .\scripts\update.ps1 -Quick -Summary
``` ```
Bash (Linux): Bash:
```bash ```bash
git pull --ff-only git pull --ff-only
@@ -55,7 +55,7 @@ git pull --ff-only
./scripts/update.sh --quick --summary ./scripts/update.sh --quick --summary
``` ```
If no selection has been saved yet, Quick stops with a hint. Run `scripts/update.ps1` or `./scripts/update.sh` once If no selection has been saved yet, or it predates the CLI update and Flashbang options, Quick stops with a hint. Run `scripts/update.ps1` or `./scripts/update.sh` once
without Quick to save your choices. They are stored in `~/.my-ai-configuration/selection.json`; dry runs and failed without Quick to save your choices. They are stored in `~/.my-ai-configuration/selection.json`; dry runs and failed
runs do not overwrite them. To change the selection later, run a normal update again. Quick does not automatically runs do not overwrite them. To change the selection later, run a normal update again. Quick does not automatically
select newly added plugins. select newly added plugins.
@@ -77,15 +77,10 @@ Install and update also rebuild internally; the explicit build above lets you ch
| [Hook package](shared/hooks/README.md) | Overview shipped with the hook scripts. | | [Hook package](shared/hooks/README.md) | Overview shipped with the hook scripts. |
| [Repository instructions](AI-Instructions.md) | Rules for maintaining this repository. | | [Repository instructions](AI-Instructions.md) | Rules for maintaining this repository. |
Historical records, not current runbooks:
- [Token-efficiency design](docs/superpowers/specs/2026-09-16-token-efficiency-design.md)
- [Token-efficiency implementation plan](docs/superpowers/plans/2026-09-16-token-efficiency.md)
## Features ## Features
- One shared source of truth for Codex and Claude Code. - One shared source of truth for Codex and Claude Code.
- Separate generated packages for Windows and Linux. - Separate generated packages for PowerShell and Bash.
- Native PowerShell 5.1 and Bash scripts. - Native PowerShell 5.1 and Bash scripts.
- Installation for Codex, Claude Code, or both, with explicit target parameters and an interactive plugin selection. - Installation for Codex, Claude Code, or both, with explicit target parameters and an interactive plugin selection.
- Claude Code loads technology- and situation-specific rules as skills, so only their name and description sit - Claude Code loads technology- and situation-specific rules as skills, so only their name and description sit
@@ -98,8 +93,8 @@ Historical records, not current runbooks:
- Separate install and update scripts: install refuses to run against an already-installed destination, update refuses - Separate install and update scripts: install refuses to run against an already-installed destination, update refuses
to run against one that is not installed yet. to run against one that is not installed yet.
- User-level plugin installation for Ponytail, i-have-adhd, Superpowers, Context7, Caveman, Humanizer, Impeccable, and - User-level plugin installation for Ponytail, i-have-adhd, Superpowers, Context7, Caveman, Humanizer, Impeccable, and
Anthropic Frontend Design, with an interactive per-plugin toggle during both install and update; update pre-checks Anthropic Frontend Design, with an interactive extension toggle during both install and update. Only extensions
plugins that are already installed, so unchecking one uninstalls it and checking one installs or updates it. installed and recorded by this setup can be removed through deselection.
## Architecture ## Architecture
@@ -111,43 +106,43 @@ shared definitions -> client adapters -> generated packages -> user installation
| --- | --- | | --- | --- |
| `shared/` | Client-independent rules, agents, skills, hooks, status lines, and global instructions. | | `shared/` | Client-independent rules, agents, skills, hooks, status lines, and global instructions. |
| `adapters/` | Codex and Claude Code formats, settings, templates, and the plugin manifest. | | `adapters/` | Codex and Claude Code formats, settings, templates, and the plugin manifest. |
| `generated/` | Reproducible Windows and Linux packages. This directory is ignored by Git. | | `generated/` | Reproducible PowerShell and Bash packages. This directory is ignored by Git. |
| `scripts/` | Build, installation, update, diagnosis, and platform validation entry points. | | `scripts/` | Build, installation, update, diagnosis, and shell validation entry points. |
| `docs/` | Focused documentation about the configuration design. | | `docs/` | Focused documentation about the configuration design. |
The build creates: The build creates:
```text ```text
generated/ generated/
├── codex-windows/ |-- codex-powershell/
├── claude-windows/ |-- claude-powershell/
├── codex-linux/ |-- codex-bash/
└── claude-linux/ `-- claude-bash/
``` ```
## Requirements ## Requirements
- Windows PowerShell 5.1 or newer on Windows. - PowerShell 5.1 or newer; PowerShell 7 is supported as well.
- Bash 4.3 or newer on Linux, with standard utilities such as `awk`, `sed`, and `sha256sum`. - Bash 4.3 or newer, with standard utilities such as `awk`, `sed`, and `sha256sum`.
- Python 3.11 or newer for configuration validation (`python` for PowerShell scripts, `python3` for Bash scripts). - Python 3.11 or newer for configuration validation and task-state hooks/helpers (`python` for PowerShell scripts, `python3` for Bash scripts).
- Git for marketplace installation and branch display; Node.js/npm (`npx`) for Codex's external skill installers. - Git for marketplace installation and branch display. External Codex skills are downloaded from their manifest sources.
- `jq` for Claude's Bash status line; without it, that status line produces no output. - `jq` for Claude's Bash status line; without it, that status line produces no output.
- The Codex and Claude Code CLIs for the clients that should be installed or diagnosed. - The Codex and Claude Code CLIs for the clients that should be installed or diagnosed.
- Network access for plugin installation and updates. - Network access for plugin installation and updates.
The Bash scripts do not require PowerShell. The PowerShell scripts remain compatible with Windows PowerShell 5.1; The Bash scripts do not require PowerShell. The PowerShell scripts remain compatible with PowerShell 5.1;
PowerShell 7 can also run them. PowerShell 7 can also run them.
## Build ## Build
Run the matching command from the repository root: Run the matching command from the repository root:
Powershell: (Windows) PowerShell:
```powershell ```powershell
.\scripts\build.ps1 .\scripts\build.ps1
``` ```
Bash: (Linux) Bash:
```bash ```bash
./scripts/build.sh ./scripts/build.sh
``` ```
@@ -158,36 +153,36 @@ change's risk; documentation-only edits do not require a build. Building writes
## Installation ## Installation
The installer asks for the target platform and client, unless they are passed as parameters, then refuses to continue The installer asks for the target shell and client, unless they are passed as parameters, then refuses to continue
if any selected destination is already installed (`Already installed, use the update script.`). Selecting Linux or if any selected destination is already installed (`Already installed, use the update script.`). Selecting PowerShell or
Windows chooses the generated package format; installation always targets the current user's home directory. This Bash chooses the generated package format; installation always targets the current user's home directory. This
check is skipped for a dry run, so previewing always works regardless of prior install state. check is skipped for a dry run, so previewing always works regardless of prior install state.
Preview an installation without changing user files, backups, or plugins: Preview an installation without changing user files, backups, or plugins:
Powershell: (Windows) PowerShell:
```powershell ```powershell
.\scripts\install.ps1 -DryRun .\scripts\install.ps1 -DryRun
``` ```
Bash: (Linux) Bash:
```bash ```bash
./scripts/install.sh --dry-run ./scripts/install.sh --dry-run
``` ```
Install the selected configuration interactively: Install the selected configuration interactively:
Powershell: (Windows) PowerShell:
```powershell ```powershell
.\scripts\install.ps1 .\scripts\install.ps1
``` ```
Bash: (Linux) Bash:
```bash ```bash
./scripts/install.sh ./scripts/install.sh
``` ```
Omitting either target parameter opens its selection prompt. There is no automatic platform default. Omitting either target parameter opens its selection prompt. There is no automatic shell default.
For a noninteractive preview, supply both target parameters together with `-DryRun` or `--dry-run`. For a noninteractive preview, supply both target parameters together with `-DryRun` or `--dry-run`.
Every installed file is tracked in a per-destination manifest (`.ai-config-manifest.tsv`). On update, unchanged files Every installed file is tracked in a per-destination manifest (`.ai-config-manifest.tsv`). On update, unchanged files
@@ -200,10 +195,10 @@ backed up and retained with a warning. Backups are stored under `backups/<timest
Local Codex `plugins` and `marketplaces` tables are preserved when syncing `config.toml`; other local settings are Local Codex `plugins` and `marketplaces` tables are preserved when syncing `config.toml`; other local settings are
not generally merged. Dry runs still rebuild `generated/`, but do not modify installation destinations. not generally merged. Dry runs still rebuild `generated/`, but do not modify installation destinations.
Selecting Codex also installs shared skills to `.agents/skills`. Before plugins are installed, an interactive list lets Selecting Codex also installs shared skills to `.agents/skills`. An interactive extension list includes plugins and
you toggle each configured plugin on or off (all checked by default); unchecked plugins are skipped. This step is also Codex skills; unchecked extensions are skipped on installation. Normal runs also ask whether to update
skipped for a dry run. Codex-only selection omits Humanizer, Impeccable, and Anthropic Frontend Design because they the selected client CLIs and enable Flashbang. These choices are saved for Quickupdate. Dry runs preview changes
use separate skill installers and are always ensured. See [Plugins](docs/plugins.md) for client-specific behavior. without updating CLIs or extensions. See [Plugins](docs/plugins.md) for client-specific behavior.
## Update ## Update
@@ -211,32 +206,31 @@ Once a destination is installed, use the update script (not the installer) to re
to continue if any selected destination is not installed yet (`Not installed, use the install script.`), also skipped to continue if any selected destination is not installed yet (`Not installed, use the install script.`), also skipped
for a dry run. for a dry run.
Powershell: (Windows) PowerShell:
```powershell ```powershell
.\scripts\update.ps1 .\scripts\update.ps1
``` ```
Bash: (Linux) Bash:
```bash ```bash
./scripts/update.sh ./scripts/update.sh
``` ```
It accepts the same `-DryRun`/`--dry-run`, `-Client`/`--client`, and `-Platform`/`--platform` parameters as the It accepts the same `-DryRun`/`--dry-run`, `-Client`/`--client`, and `-Shell`/`--shell` parameters as the
installer, re-syncs every managed file the same way, and shows the same plugin toggle list — but pre-checks plugins installer, re-syncs every managed file, and lets you revise the CLI update, Flashbang, and extension choices.
that are already installed. Checking a plugin that is not installed installs it; checking one that is installed Selected extensions are installed or updated. Deselection removes only extensions recorded as installed by this
updates it; unchecking an installed plugin uninstalls it. Selection happens before managed files are synchronized. setup; pre-existing or manually installed extensions are retained. Quickupdate reuses the saved choices.
For `Both`, the initial selection follows Claude's installed plugins, not the union of both clients.
## Doctor ## Doctor
Check client availability, generated packages, source directories, hooks, and installed managed files: Check client availability, generated packages, source directories, hooks, and installed managed files:
Powershell: (Windows) PowerShell:
```powershell ```powershell
.\scripts\doctor.ps1 .\scripts\doctor.ps1
``` ```
Bash: (Linux) Bash:
```bash ```bash
./scripts/doctor.sh ./scripts/doctor.sh
``` ```
+1
View File
@@ -0,0 +1 @@
Read skills/rules/rules-orchestration/SKILL.md from your installed configuration before acting as a subagent.
+1
View File
@@ -5,6 +5,7 @@ decision-rule.md rules-decision-rule unresolved requirements, ambiguous behavior
definition-of-done.md rules-definition-of-done validating that a task is complete or ready for review definition-of-done.md rules-definition-of-done validating that a task is complete or ready for review
git.md rules-git Git operations such as commits, branches, or merges git.md rules-git Git operations such as commits, branches, or merges
microsoft.md rules-microsoft Microsoft 365, Azure DevOps, or Teams operations microsoft.md rules-microsoft Microsoft 365, Azure DevOps, or Teams operations
orchestration.md rules-orchestration delegation, subagent work, or long-running task state
refactoring.md rules-refactoring refactoring existing code without changing behavior refactoring.md rules-refactoring refactoring existing code without changing behavior
security.md rules-security any software development task: implementing, modifying, debugging, reviewing, testing, or configuring code, scripts, hooks, or infrastructure security.md rules-security any software development task: implementing, modifying, debugging, reviewing, testing, or configuring code, scripts, hooks, or infrastructure
security-auth.md rules-security-auth authentication, authorization, sessions, tokens, permissions, or tenant-boundary work security-auth.md rules-security-auth authentication, authorization, sessions, tokens, permissions, or tenant-boundary work
1 rule_file skill_name trigger
5 definition-of-done.md rules-definition-of-done validating that a task is complete or ready for review
6 git.md rules-git Git operations such as commits, branches, or merges
7 microsoft.md rules-microsoft Microsoft 365, Azure DevOps, or Teams operations
8 orchestration.md rules-orchestration delegation, subagent work, or long-running task state
9 refactoring.md rules-refactoring refactoring existing code without changing behavior
10 security.md rules-security any software development task: implementing, modifying, debugging, reviewing, testing, or configuring code, scripts, hooks, or infrastructure
11 security-auth.md rules-security-auth authentication, authorization, sessions, tokens, permissions, or tenant-boundary work
+1 -1
View File
@@ -17,5 +17,5 @@ hooks = true
[[hooks.Stop.hooks]] [[hooks.Stop.hooks]]
type = "command" type = "command"
command = '__HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__HOOK_SCRIPT__"' command = '__HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__HOOK_SCRIPT__"'
command_windows = '__WINDOWS_HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__WINDOWS_HOOK_SCRIPT__"' command_windows = '__POWERSHELL_HOOK_COMMAND__ "__AI_CONFIG_ROOT__/hooks/__POWERSHELL_HOOK_SCRIPT__"'
timeout = 8 timeout = 8
+1
View File
@@ -0,0 +1 @@
Read rules/orchestration.md from your installed configuration before acting as a subagent.
+2 -2
View File
@@ -4,6 +4,6 @@ i-have-adhd ayghri/i-have-adhd i-have-adhd@i-have-adhd ayghri/i-have-adhd i-have
Superpowers - superpowers@claude-plugins-official openai-curated-remote superpowers@openai-curated-remote plugin - - Superpowers - superpowers@claude-plugins-official openai-curated-remote superpowers@openai-curated-remote plugin - -
Context7 upstash/context7 context7@context7-marketplace upstash/context7 context7@context7-marketplace plugin - - Context7 upstash/context7 context7@context7-marketplace upstash/context7 context7@context7-marketplace plugin - -
Caveman JuliusBrussee/caveman caveman@caveman thinkhome-org/caveman-codex caveman@thinkhome-caveman plugin - - Caveman JuliusBrussee/caveman caveman@caveman thinkhome-org/caveman-codex caveman@thinkhome-caveman plugin - -
Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer - Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer humanizer
Impeccable pbakaus/impeccable impeccable@impeccable - - impeccable - - Impeccable pbakaus/impeccable impeccable@skill pbakaus/impeccable impeccable skill pbakaus/impeccable impeccable
Anthropic Frontend Design anthropics/claude-plugins-official frontend-design@claude-plugins-official - - skill anthropics/skills frontend-design Anthropic Frontend Design anthropics/claude-plugins-official frontend-design@claude-plugins-official - - skill anthropics/skills frontend-design
1 name claude_marketplace claude_plugin codex_marketplace codex_plugin codex_method codex_source codex_skill
4 Superpowers - superpowers@claude-plugins-official openai-curated-remote superpowers@openai-curated-remote plugin - -
5 Context7 upstash/context7 context7@context7-marketplace upstash/context7 context7@context7-marketplace plugin - -
6 Caveman JuliusBrussee/caveman caveman@caveman thinkhome-org/caveman-codex caveman@thinkhome-caveman plugin - -
7 Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer - humanizer
8 Impeccable pbakaus/impeccable impeccable@impeccable impeccable@skill - pbakaus/impeccable - impeccable impeccable skill - pbakaus/impeccable - impeccable
9 Anthropic Frontend Design anthropics/claude-plugins-official frontend-design@claude-plugins-official - - skill anthropics/skills frontend-design
+20 -2
View File
@@ -5,7 +5,25 @@ The five shared agents are logical roles, not mandatory steps.
- `implementer` executes the smallest complete engineering change and performs self-review. - `implementer` executes the smallest complete engineering change and performs self-review.
- `researcher` investigates code, patterns, evidence, alternatives, and unknowns without modifying implementation code by default. - `researcher` investigates code, patterns, evidence, alternatives, and unknowns without modifying implementation code by default.
- `architect` evaluates technical boundaries, dependencies, interfaces, constraints, and design options. - `architect` evaluates technical boundaries, dependencies, interfaces, constraints, and design options.
- `verifier` checks completed work against requested behavior, acceptance criteria, tests, generated output, and configuration. - `verifier` supplies execution evidence from relevant tests, builds, checks, generated output, and behavior reproduction. It does not repeat a general architecture review.
- `reviewer` independently checks correctness, regressions, security, maintainability, architecture, tests, and unnecessary complexity. - `reviewer` starts with the actual diff and changed symbols, then examines necessary callers, dependencies, tests, and contracts for analytical correctness. It does not automatically rerun the test suite.
Use only the roles that are likely to improve the outcome. Choose `researcher`, `reviewer`, and `verifier` based on the task's uncertainty, complexity, risk, blast radius, and verification value; none runs automatically. The agent may make evidence-based, reversible technical decisions. Ask the user when technical choices remain materially uncertain or when business behavior, user-visible behavior, UI, UX, APIs, database models, configuration formats, compatibility behavior, or other high-impact decisions are ambiguous or open to interpretation. Use only the roles that are likely to improve the outcome. Choose `researcher`, `reviewer`, and `verifier` based on the task's uncertainty, complexity, risk, blast radius, and verification value; none runs automatically. The agent may make evidence-based, reversible technical decisions. Ask the user when technical choices remain materially uncertain or when business behavior, user-visible behavior, UI, UX, APIs, database models, configuration formats, compatibility behavior, or other high-impact decisions are ambiguous or open to interpretation.
## Coordination
The main agent owns task state, decisions, and progress. Subagents report to their parent. The parent forwards only relevant conclusions, references, risks, and constraints; direct peer communication requires a concrete dependency and parent coordination. Subagents cannot create further subagents.
Assignments contain a concrete task, allowed scope and ownership, success criteria, a few starting references, and the handoff protocol reference. Use a fresh subagent context where supported. Do not preload conversations, repository summaries, or full earlier reports. Additional context is retrieved only to answer a concrete question. These are instruction-level policies; client-controlled context inheritance cannot be disabled by this repository where the client exposes no such option.
The shared [orchestration rule](../shared/rules/orchestration.md) defines the `RESULT`, `EVIDENCE`, `CHANGES`, `RISKS`, and optional `NEXT` handoff. Codex loads it as a rule file; Claude loads the generated `rules-orchestration` skill. Agent adapters add only the client-specific loading instruction. The protocol body is not copied into every role or permanent prompt.
## Task state
For long-running work, the main agent maintains one compact state containing `goal`, `decisions`, `changed`, `verified`, `open`, and `risks`. An existing task-state facility is preferred; the file fallback is `.ai-session/state.json` in the current workspace. Subagents may read it when needed, but send updates to the parent. Keep unrelated concurrent tasks in separate checkouts, and keep runtime state out of commits.
The optional `scripts/session-state.ps1` and `scripts/session-state.sh` helpers update this workspace file. Omitted fields are preserved. Their `-Workspace`/`--workspace` option selects the workspace explicitly; the default is the current working directory. Both accept goal, decisions, changed files, completed verification, open items, and risks. `-Pointer`/`--pointer` only prints the existing file's location.
Starting a different goal requires `-Reset -Goal ...` or `--reset --goal ...`, which clears the previous task's decisions and verification. Updates are atomic; legacy field names are migrated when reading existing state.
After compaction, verify that the state belongs to the current goal and workspace before using it. Claude's lifecycle hooks point to an existing workspace state without creating or summarizing it. Codex follows the same recovery instructions manually; no unsupported lifecycle events are registered.
+3 -1
View File
@@ -18,7 +18,7 @@ Codex sets `approval_policy = "on-request"`, `approvals_reviewer = "auto_review"
Codex's native `tui.status_line` contains `model`, `reasoning`, `project-name`, `git-branch`, `context-window-size`, `context-used`, and `used-tokens`, in that order. Rendering and colors are controlled by Codex; the adapter does not define custom colors per field. Codex's native `tui.status_line` contains `model`, `reasoning`, `project-name`, `git-branch`, `context-window-size`, `context-used`, and `used-tokens`, in that order. Rendering and colors are controlled by Codex; the adapter does not define custom colors per field.
Claude runs `shared/statusline/statusline.ps1` on Windows or `statusline.sh` on Linux. Its two-line display is: Claude runs `shared/statusline/statusline.ps1` for PowerShell or `statusline.sh` for Bash. Its two-line display is:
```text ```text
Opus · Effort high · MyRepo @ main Opus · Effort high · MyRepo @ main
@@ -33,4 +33,6 @@ Model and context-window size are cyan, effort and cumulative tokens magenta, re
The agent chooses the smallest sufficient checks based on behavior, affected callers, risk, reversibility, and uncertainty. Inspection can suffice for a small change. Full builds, broad test suites, and independent reviews are not automatic; explicit user and project requirements remain binding. The agent chooses the smallest sufficient checks based on behavior, affected callers, risk, reversibility, and uncertainty. Inspection can suffice for a small change. Full builds, broad test suites, and independent reviews are not automatic; explicit user and project requirements remain binding.
Delegation and long-running state use the shared orchestration rule, loaded on demand as a Codex rule or Claude skill. The coordinator owns decisions and routes concise evidence-based handoffs. Review and execution verification have separate responsibilities; see [Agents](agents.md).
Rebuild packages after changes to shared definitions or adapters. Generated defaults reach a user's configuration only through installation or update. Updates back up replaced files and preserve Codex's local `plugins` and `marketplaces` tables; other local settings are not generally merged. See [the README](../README.md) for overwrite and backup behavior. Rebuild packages after changes to shared definitions or adapters. Generated defaults reach a user's configuration only through installation or update. Updates back up replaced files and preserve Codex's local `plugins` and `marketplaces` tables; other local settings are not generally merged. See [the README](../README.md) for overwrite and backup behavior.
+4 -4
View File
@@ -7,12 +7,12 @@ The adapters register these events:
| Client | Event | Action | | Client | Event | Action |
| --- | --- | --- | | --- | --- | --- |
| Codex and Claude | `Stop` | Run the finish notification (`flashbang`). | | Codex and Claude | `Stop` | Run the finish notification (`flashbang`). |
| Claude | `PreCompact` | Append an event and UTC timestamp to `.ai-session/telemetry.jsonl` under the installed configuration root. | | Claude | `PreCompact` | Record the compaction timestamp under the installed configuration root and point to existing workspace task state. |
| Claude | `SessionStart` | Print a short pointer if `.ai-session/state.json` exists under that root. | | Claude | `SessionStart` | Print an absolute pointer if the workspace's `.ai-session/state.json` exists. |
The session-state hooks do not create a task summary or restore conversation state. Their paths are relative to the installed configuration root, not the current project. The scripts do not grant permissions, bypass approvals, or replace client permission systems. The main agent alone maintains canonical task state. Hooks never write or summarize it, print its contents, or infer completed verification. They use the hook event's `cwd`, falling back to the process working directory when no workspace is supplied. Malformed event data does not fall back to another project. An absent state file is a successful no-op. Legacy state under the installed configuration root is not automatically adopted; confirm its task before migrating it. The scripts do not grant permissions, bypass approvals, or replace client permission systems.
The flashbang runs on `Stop`, after the main agent finishes responding. The default flash lasts about 500 milliseconds. The Windows command omits `-WindowStyle Hidden`, which can hide or minimize the shared terminal. The PowerShell hook creates a low-opacity click-through overlay without activating another window. The Bash hook uses Python's `tkinter`, uses `xrandr` for multi-monitor bounds when available, and falls back to `notify-send` when the overlay is unavailable. When enabled during installation or update, Flashbang runs on `Stop`, after the main agent finishes responding. The default flash lasts about 500 milliseconds. The PowerShell command omits `-WindowStyle Hidden`, which can hide or minimize the shared terminal. The PowerShell hook creates a low-opacity click-through overlay without activating another window when its native GUI APIs are available; unsupported runtimes exit safely. The Bash hook uses Python's `tkinter`, uses `xrandr` for multi-monitor bounds when available, and falls back to `notify-send` when the overlay is unavailable.
`Validate-CommandSafety`/`validate-command-safety.sh` and `Invoke-PostChangeVerification`/`invoke-post-change-verification.sh` are unregistered utilities. The former checks command text for selected dangerous patterns and outside-workspace paths; it is not a complete security boundary. The latter explicitly runs the repository build when invoked; it is not an automatic requirement after each change. `Validate-CommandSafety`/`validate-command-safety.sh` and `Invoke-PostChangeVerification`/`invoke-post-change-verification.sh` are unregistered utilities. The former checks command text for selected dangerous patterns and outside-workspace paths; it is not a complete security boundary. The latter explicitly runs the repository build when invoked; it is not an automatic requirement after each change.
+6 -4
View File
@@ -2,18 +2,20 @@
The user-level plugin manifest is `adapters/plugins.tsv`. It maps each logical plugin to the marketplace selector understood by Claude Code and Codex. The user-level plugin manifest is `adapters/plugins.tsv`. It maps each logical plugin to the marketplace selector understood by Claude Code and Codex.
The current baseline includes Ponytail, i-have-adhd, Superpowers, Context7, Caveman, Humanizer, Impeccable, and Anthropic Frontend Design. Both scripts operate at user scope and show a plugin selector unless running a dry run. Installation copies configuration before selection; update selects plugins before copying configuration. Both rebuild generated packages first. The current baseline includes Ponytail, i-have-adhd, Superpowers, Context7, Caveman, Humanizer, Impeccable, and Anthropic Frontend Design. Both scripts operate at user scope and show a plugin selector unless running a dry run. Selections are collected before configuration changes. Both rebuild generated packages first.
`install.ps1`/`install.sh` starts every plugin checked by default; unchecking one skips it. It refuses to run against an already-installed destination (`Already installed, use the update script.`). `install.ps1`/`install.sh` starts every plugin checked by default; unchecking one skips it. It refuses to run against an already-installed destination (`Already installed, use the update script.`).
`update.ps1`/`update.sh` pre-checks plugins installed in the selected client. For `Both`, Claude is the reference client; the selection is then applied to both clients. A disabled but installed Claude plugin is checked and re-enabled when updated. Checking a missing plugin installs it; unchecking an installed plugin uninstalls it (`claude plugin uninstall --scope user`/`codex plugin remove`). Unlisted plugins are not explicitly installed or removed. Update refuses to run if any selected destination lacks an installation manifest (`Not installed, use the install script.`). `update.ps1`/`update.sh` lets you revise the saved extension selection. Checking an extension installs it if missing or updates it if owned; existing foreign installations are preserved. Deselection removes only extensions that this setup installed and recorded as owned. Pre-existing or manually installed plugins and skills are retained. Extensions removed from the manifest are also removed when recorded as owned; unrelated installations are retained. Update refuses to run if any selected destination lacks an installation manifest (`Not installed, use the install script.`).
Codex's local `plugins` and `marketplaces` tables survive configuration synchronization. Cached plugin files alone are not treated as proof of installation: selection uses the client CLI's installed list. If an older update removed the registrations, inspect the backed-up `config.toml` under `~/.codex/backups/` and reselect the affected plugins on the next update. Codex's local `plugins` and `marketplaces` tables survive configuration synchronization. Cached plugin files alone are not treated as proof of installation: selection uses the client CLI's installed list. If an older update removed the registrations, inspect the backed-up `config.toml` under `~/.codex/backups/` and reselect the affected plugins on the next update.
Codex receives Caveman through its native plugin adapter. Humanizer and Anthropic Frontend Design use the cross-agent Skills CLI; Impeccable uses its provider-aware Codex installer. The scripts do not track or uninstall these three Codex skill installations. They are always ensured and omitted from the selector on Codex-only runs. For Claude or Both they are toggleable; deselection can remove the Claude plugin but does not remove an existing Codex skill installation. Codex receives Caveman through its native plugin adapter. Humanizer, Impeccable, and Anthropic Frontend Design are downloaded as skill payloads from the GitHub sources in the manifest, without running upstream installers. All three appear in the extension selector for Codex. Ownership is recorded in `~/.my-ai-configuration/extensions.json`. Skill files are tracked by hash; modified or foreign files are retained with a warning. Existing installations from before ownership tracking are not automatically adopted.
Normal installation and update also ask whether to update the selected client CLIs and enable Flashbang. Quickupdate reuses the saved shell, client, CLI update, Flashbang, and extension choices without opening menus. Only successful runs save selections; dry runs do not update CLIs or extensions.
Plugin installation requires the corresponding client CLI, network access, and any authentication required by the plugin. Context7 may open an OAuth flow. Codex or plugin hooks may require a trust review in `/hooks`. The scripts never install plugins during a build or doctor run and never install them when `--dry-run` or `-DryRun` is used. Plugin installation requires the corresponding client CLI, network access, and any authentication required by the plugin. Context7 may open an OAuth flow. Codex or plugin hooks may require a trust review in `/hooks`. The scripts never install plugins during a build or doctor run and never install them when `--dry-run` or `-DryRun` is used.
If Codex reports that a marketplace is already registered from another source, the scripts remove only the conflicting marketplace name from the configured user-level Codex sources, add the repository source from the manifest, and continue with the Codex plugin command. Other marketplace errors remain fatal. Marketplace source conflicts stop installation; the setup does not replace a foreign marketplace registration. Failed extension operations retain the ownership records of earlier successful operations so the next run can reconcile them.
Only trusted upstream marketplace sources should be added. Plugins are executable extensions and may access the permissions granted to their client. Only trusted upstream marketplace sources should be added. Plugins are executable extensions and may access the permissions granted to their client.
File diff suppressed because it is too large. Load diff
@@ -1,149 +0,0 @@
# Token-Efficiency Improvements — Design
Historical design approved on 2026-09-16, retained to explain the original decisions. It is not a description of every current behavior. Later changes supersede its blanket validation steps; see [AI-Instructions.md](../../../AI-Instructions.md), [Configuration](../../configuration.md), and the [README](../../../README.md) for current guidance.
## Goal
Reduce unnecessary token consumption in both Claude Code and Codex — script
output copied into agent context, rule/skill content loaded when not needed,
duplicated instructions loaded on every task, and delegation that duplicates
context — without weakening correctness, security, or verification quality.
## 1. Quiet output mode for scripts
Add `-Summary` (PowerShell) / `--summary` (Bash) to `build`, `doctor`,
`install`, `update`, and the `test-*` scripts.
- Purely an output-verbosity switch. Orthogonal to `-Client`/`-Platform`
(interactivity is unaffected — a quiet run without `-Client`/`-Platform`
still prompts) and to `-DryRun` (dry-run keeps its existing defaults;
`-Summary -DryRun` just makes the preview more compact).
- On success: suppress the low-value per-item lines (`SOURCE`, `CREATE`,
`UPDATE`, `UNCHANGED`, per-plugin `PASS ... ensured/already installed`,
per-doctor-check `PASS`) and print one summary line instead:
- `Build: PASS | 4 packages`
- `Doctor: PASS | <n> checks`
- `Tests: PASS | <n>/<n>`
- `Install: PASS | <client>, <platform>` / `Update: PASS | <client>, <platform>`
- Never suppress: `WARN`, `FAIL`, thrown errors, interactive prompts, or the
plugin toggle list. Warnings/errors always print in full regardless of the
flag.
- Implementation: each script gains a `$Summary`/`summary` bool threaded into
the existing `Write-Output`/`printf` call sites in `scripts/lib/manifest.*`
and `scripts/lib/plugins.*` (guard the per-item lines), plus a tally +
one-line footer at the end of `build.*`, `doctor.*`, `install.*`,
`update.*`, and each `test-*.*`.
## 2. Progressive disclosure for large rules
Split only the three rules that are 2-3x larger than the rest and have
clearly separable, independently-invokable subtopics:
- `angular.md` (297 lines) → components, templates, signals, rxjs,
dependency-injection-and-services, forms-and-routing, testing
- `wpf.md` (375 lines) → mvvm-and-binding, commands-and-async,
collections-and-virtualization, xaml-and-layout, resources-and-lifecycle,
testing-and-quality
- `ui-ux.md` (306 lines) → navigation-and-actions, forms, content-and-feedback,
visual-and-accessibility, tables-search-dialogs, destructive-and-error-prevention
`git.md`, `typescript.md`, `csharp.md`, `refactoring.md`, `microsoft.md`
(116-167 lines) stay single-file — splitting them adds a read hop without
materially cutting tokens.
Structure per split rule: `shared/rules/<name>/index.md` (small: universal/
high-value rules + a short table telling the agent which `references/*.md`
to open for which subtopic) + `shared/rules/<name>/references/*.md`.
- Claude: `skills/rules/<skill_name>/SKILL.md` = generated frontmatter (from
`rule-skills.tsv`, unchanged mechanism) + `index.md` body; `references/`
copied alongside into the same skill directory (already documented Anthropic
pattern; `Copy-Directory` already ships arbitrary sibling files, so no new
copy mechanism needed — only the generation loop needs to read `index.md`
instead of a flat file when `rule_file` in the manifest names a directory).
- Codex: `rules/<name>/index.md` + `rules/<name>/references/*.md`; the path
in `AGENTS.md`'s rule-loading heredoc (`scripts/build.ps1`/`build.sh`)
changes from `rules/<name>.md` to `rules/<name>/index.md` for these three.
- `adapters/claude/rule-skills.tsv` stays the single source of truth for
trigger wording — no new duplication.
- Build-script change: detect whether `shared/rules/<rule_file>` resolves to
a file or a directory; directory ⇒ read `<dir>/index.md` as the body and
copy the whole `<dir>/` (including `references/`) instead of a single file.
## 3+4. Narrower triggers and canonical rule locations (same fix)
Root cause found in the current build: `general.md` is embedded in full into
`CLAUDE.md` (`build.ps1`/`build.sh`, unconditional, every task) but for Codex
is only *referenced by path* ("Always load `rules/general.md`"—Codex must
actively read it). `shared/global-instructions.md` separately re-paraphrases
Priority, Documentation/Comments, and decision-making content inline — so
`decision-rule.md`'s substance is already permanently loaded for both
clients today, never actually lazy, defeating its own skill/file-based
lazy-loading.
Changes:
- Embed `general.md` in full into `AGENTS.md` too (mirror what `CLAUDE.md`
already does). Remove the "Always load `rules/general.md`" instruction —
no longer needed. This is strictly safer, not riskier, for Codex even
though subagent AGENTS.md inheritance is undocumented/unconfirmed
(researched): today, if a Codex subagent doesn't inherit AGENTS.md, it
already can't see the *instruction* to go read `general.md` either, so
embedding changes nothing for that case, and strictly helps the case where
it does inherit (guaranteed-present vs. requires-a-read-action).
- Trim `shared/global-instructions.md` to just the rule-loading block and the
(rewritten, see §5) orchestration paragraph. Delete the restated
Priority/Documentation/Comments/decision-making prose — `general.md` (now
embedded for both clients, always) is the single canonical source.
- `general.md` gains one short pointer line to the decision-rule skill/file
for actual ambiguity, replacing the removed inline paragraph — keeps
decision-making guidance genuinely lazy (loads only when ambiguity is
actually present), which is exactly the item-3 goal for `decision-rule.md`.
- `definition-of-done.md`'s two bullets that restate the documentation rule
(lines 19-20) collapse to one line referencing `general.md`'s Documentation
section instead of repeating it.
- `shared/agents/*/instructions.md` (the 3-line per-role files) stay
unchanged. Researched: Claude subagents fully inherit CLAUDE.md (redundant
there), but Codex subagent AGENTS.md inheritance is unconfirmed/
undocumented — these files feed both clients' agent definitions from one
shared source, and for Codex the one-line "no comments" reminder inside
`developer_instructions` is the only guaranteed-present copy if inheritance
turns out not to hold. Cost is 3 lines; not worth the risk to trim.
- `security.md`'s broad trigger ("any software development task") is left
as-is — it's already the documented "short universal baseline" design
(`docs/configuration.md`), 11 lines, and narrowing it would just relocate
the same content behind a less reliable trigger. The actual "effectively
always loaded" problem was the *duplicated* Priority/decision content in
`global-instructions.md`, fixed above.
## 5. Token-aware subagent orchestration guidance
Replace the "Use one implementer for small, contained tasks. Add researcher
only for material uncertainty..." paragraph in `global-instructions.md` with:
> Delegate only when the expected improvement in correctness, independence,
> specialization, or parallelism outweighs the duplicated context and tool
> cost. Do not delegate simple repository exploration the main agent can do
> directly. Do not create a researcher for information the main agent can
> obtain cheaply itself. Use one implementer for small and medium contained
> tasks. Run reviewer and verifier together only when their responsibilities
> are materially different for the current task. Avoid multiple agents
> independently reading the same large set of files. Use parallel agents
> only for genuinely independent work. Do not let subagents create further
> subagents.
`config.toml`'s `max_concurrent_threads_per_session = 5` / Claude's five-
operation concurrency stay untouched — only the decision guidance changes,
not the ceiling.
## Validation
- `scripts/build.ps1`/`.sh`, `scripts/doctor.ps1`/`.sh` pass.
- `scripts/test-platform-packages.*` (agent/skill/AGENTS.md/CLAUDE.md content
assertions) updated for: embedded `general.md` in `AGENTS.md`, the split
rule skill/reference structure, trimmed `global-instructions.md`.
- New/updated assertions for `-Summary`/`--summary` output shape on at least
one script per language (build + doctor), covering both the summary line
and that `WARN`/`FAIL` still surface unsummarized.
- Manual read-through of the three split rules to confirm no content was
dropped, only relocated.
+31 -29
View File
@@ -116,23 +116,24 @@ Load rule files when their subject applies:
- rules/wpf/index.md for WPF work. - rules/wpf/index.md for WPF work.
- rules/ui-ux/index.md for UI or UX decisions. - rules/ui-ux/index.md for UI or UX decisions.
- rules/microsoft.md for Microsoft 365, Azure DevOps, or Teams work. - rules/microsoft.md for Microsoft 365, Azure DevOps, or Teams work.
- rules/orchestration.md for delegation, subagent work, or long-running task state.
- rules/git.md for Git operations. - rules/git.md for Git operations.
- rules/refactoring.md for refactoring work. - rules/refactoring.md for refactoring work.
- rules/definition-of-done.md when validating completion. - rules/definition-of-done.md when validating completion.
- rules/decision-rule.md when requirements are unresolved, behavior is ambiguous, or a meaningful technical choice remains. - rules/decision-rule.md when requirements are unresolved, behavior is ambiguous, or a meaningful technical choice remains.
'@ '@
foreach ($platform in @('windows','linux')) { foreach ($shell in @('powershell','bash')) {
New-Item (Join-Path $output "codex-$platform") -ItemType Directory -Force | Out-Null New-Item (Join-Path $output "codex-$shell") -ItemType Directory -Force | Out-Null
New-Item (Join-Path $output "claude-$platform") -ItemType Directory -Force | Out-Null New-Item (Join-Path $output "claude-$shell") -ItemType Directory -Force | Out-Null
Copy-Directory (Join-Path $shared 'skills') (Join-Path $output "codex-$platform/skills") Copy-Directory (Join-Path $shared 'skills') (Join-Path $output "codex-$shell/skills")
Copy-Directory (Join-Path $shared 'skills') (Join-Path $output "claude-$platform/skills") Copy-Directory (Join-Path $shared 'skills') (Join-Path $output "claude-$shell/skills")
Copy-Directory (Join-Path $shared 'rules') (Join-Path $output "codex-$platform/rules") Copy-Directory (Join-Path $shared 'rules') (Join-Path $output "codex-$shell/rules")
Copy-Directory (Join-Path $shared 'hooks') (Join-Path $output "codex-$platform/hooks") Copy-Directory (Join-Path $shared 'hooks') (Join-Path $output "codex-$shell/hooks")
Copy-Directory (Join-Path $shared 'hooks') (Join-Path $output "claude-$platform/hooks") Copy-Directory (Join-Path $shared 'hooks') (Join-Path $output "claude-$shell/hooks")
Copy-Directory (Join-Path $shared 'statusline') (Join-Path $output "claude-$platform/statusline") Copy-Directory (Join-Path $shared 'statusline') (Join-Path $output "claude-$shell/statusline")
$claudeRulesDir = Join-Path $output "claude-$platform/rules" $claudeRulesDir = Join-Path $output "claude-$shell/rules"
New-Item $claudeRulesDir -ItemType Directory -Force | Out-Null New-Item $claudeRulesDir -ItemType Directory -Force | Out-Null
$claudeRuleSkillLines = @() $claudeRuleSkillLines = @()
@@ -140,7 +141,7 @@ foreach ($platform in @('windows','linux')) {
$ruleSourcePath = Join-Path $shared "rules/$($entry.rule_file)" $ruleSourcePath = Join-Path $shared "rules/$($entry.rule_file)"
$isDirectory = Test-Path -LiteralPath $ruleSourcePath -PathType Container $isDirectory = Test-Path -LiteralPath $ruleSourcePath -PathType Container
$ruleBodyPath = if ($isDirectory) { Join-Path $ruleSourcePath 'index.md' } else { $ruleSourcePath } $ruleBodyPath = if ($isDirectory) { Join-Path $ruleSourcePath 'index.md' } else { $ruleSourcePath }
$skillDir = Join-Path $output "claude-$platform/skills/rules/$($entry.skill_name)" $skillDir = Join-Path $output "claude-$shell/skills/rules/$($entry.skill_name)"
New-Item $skillDir -ItemType Directory -Force | Out-Null New-Item $skillDir -ItemType Directory -Force | Out-Null
$ruleContent = Get-Content $ruleBodyPath -Raw $ruleContent = Get-Content $ruleBodyPath -Raw
$skillBody = "---`r`nname: $($entry.skill_name)`r`ndescription: $(Quote-Toml "Use for $($entry.trigger).")`r`n---`r`n`r`n$ruleContent" $skillBody = "---`r`nname: $($entry.skill_name)`r`ndescription: $(Quote-Toml "Use for $($entry.trigger).")`r`n---`r`n`r`n$ruleContent"
@@ -158,23 +159,24 @@ foreach ($platform in @('windows','linux')) {
$agentsContent = $sharedTemplate.Replace('__RULE_LOADING__', $codexRuleLoading) $agentsContent = $sharedTemplate.Replace('__RULE_LOADING__', $codexRuleLoading)
$agentsContent = $agentsContent.TrimEnd() + "`r`n`r`n---`r`n`r`n$generalContent" $agentsContent = $agentsContent.TrimEnd() + "`r`n`r`n---`r`n`r`n$generalContent"
Set-Content (Join-Path $output "codex-$platform/AGENTS.md") $agentsContent -Encoding UTF8 Set-Content (Join-Path $output "codex-$shell/AGENTS.md") $agentsContent -Encoding UTF8
$claudeContent = $sharedTemplate.Replace('__RULE_LOADING__', $claudeRuleLoading) $claudeContent = $sharedTemplate.Replace('__RULE_LOADING__', $claudeRuleLoading)
$claudeContent = $claudeContent.TrimEnd() + "`r`n`r`n---`r`n`r`n$generalContent" $claudeContent = $claudeContent.TrimEnd() + "`r`n`r`n---`r`n`r`n$generalContent"
Set-Content (Join-Path $output "claude-$platform/CLAUDE.md") $claudeContent -Encoding UTF8 Set-Content (Join-Path $output "claude-$shell/CLAUDE.md") $claudeContent -Encoding UTF8
Copy-Item (Join-Path $root 'adapters/codex/config/config.toml') (Join-Path $output "codex-$platform/config.toml") -Force Copy-Item (Join-Path $root 'adapters/codex/config/config.toml') (Join-Path $output "codex-$shell/config.toml") -Force
Copy-Item (Join-Path $root 'adapters/claude/config/settings.json') (Join-Path $output "claude-$platform/settings.json") -Force Copy-Item (Join-Path $root 'adapters/claude/config/settings.json') (Join-Path $output "claude-$shell/settings.json") -Force
foreach ($client in @('codex','claude')) { foreach ($client in @('codex','claude')) {
$agentsOutput = Join-Path $output "$client-$platform/agents" $agentsOutput = Join-Path $output "$client-$shell/agents"
New-Item $agentsOutput -ItemType Directory -Force | Out-Null New-Item $agentsOutput -ItemType Directory -Force | Out-Null
foreach ($agentDir in $sourceAgentDirs) { foreach ($agentDir in $sourceAgentDirs) {
$metadata = Join-Path $agentDir.FullName 'agent.yml' $metadata = Join-Path $agentDir.FullName 'agent.yml'
$name = Read-Field $metadata 'name' $name = Read-Field $metadata 'name'
$description = Read-Field $metadata 'description' $description = Read-Field $metadata 'description'
$instructions = Get-Content (Join-Path $agentDir.FullName 'instructions.md') -Raw $loader = (Get-Content (Join-Path $root "adapters/$client/orchestration-loader.txt") -Raw -Encoding UTF8).Trim()
$instructions = $loader + "`n`n" + (Get-Content (Join-Path $agentDir.FullName 'instructions.md') -Raw -Encoding UTF8)
if ($client -eq 'codex') { if ($client -eq 'codex') {
Set-Content (Join-Path $agentsOutput "$name.toml") "name = $(Quote-Toml $name)`r`ndescription = $(Quote-Toml $description)`r`ndeveloper_instructions = $(Quote-Toml $instructions)" -Encoding UTF8 Set-Content (Join-Path $agentsOutput "$name.toml") "name = $(Quote-Toml $name)`r`ndescription = $(Quote-Toml $description)`r`ndeveloper_instructions = $(Quote-Toml $instructions)" -Encoding UTF8
} else { } else {
@@ -185,21 +187,21 @@ foreach ($platform in @('windows','linux')) {
foreach ($client in @('codex','claude')) { foreach ($client in @('codex','claude')) {
$file = if ($client -eq 'codex') { 'config.toml' } else { 'settings.json' } $file = if ($client -eq 'codex') { 'config.toml' } else { 'settings.json' }
$path = Join-Path $output "$client-$platform/$file" $path = Join-Path $output "$client-$shell/$file"
$command = if ($platform -eq 'windows') { 'powershell -NoProfile -ExecutionPolicy Bypass -File' } else { 'bash' } $command = if ($shell -eq 'powershell') { '__POWERSHELL_COMMAND__' } else { 'bash' }
$script = if ($platform -eq 'windows') { 'flashbang.ps1' } else { 'flashbang.sh' } $script = if ($shell -eq 'powershell') { 'flashbang.ps1' } else { 'flashbang.sh' }
$statusLineScript = if ($platform -eq 'windows') { 'statusline.ps1' } else { 'statusline.sh' } $statusLineScript = if ($shell -eq 'powershell') { 'statusline.ps1' } else { 'statusline.sh' }
$content = Get-Content -LiteralPath $path -Raw $content = Get-Content -LiteralPath $path -Raw
$compactScript = if ($platform -eq 'windows') { 'Record-Compact.ps1' } else { 'record-compact.sh' } $compactScript = if ($shell -eq 'powershell') { 'Record-Compact.ps1' } else { 'record-compact.sh' }
$pointerScript = if ($platform -eq 'windows') { 'Show-SessionStatePointer.ps1' } else { 'show-session-state-pointer.sh' } $pointerScript = if ($shell -eq 'powershell') { 'Show-SessionStatePointer.ps1' } else { 'show-session-state-pointer.sh' }
$content = $content.Replace('__HOOK_COMMAND__', $command).Replace('__WINDOWS_HOOK_COMMAND__', $command).Replace('__HOOK_SCRIPT__', $script).Replace('__WINDOWS_HOOK_SCRIPT__', $script).Replace('__COMPACT_SCRIPT__', $compactScript).Replace('__SESSION_POINTER_SCRIPT__', $pointerScript).Replace('__STATUSLINE_COMMAND__', $command).Replace('__STATUSLINE_SCRIPT__', $statusLineScript) $content = $content.Replace('__HOOK_COMMAND__', $command).Replace('__POWERSHELL_HOOK_COMMAND__', $command).Replace('__HOOK_SCRIPT__', $script).Replace('__POWERSHELL_HOOK_SCRIPT__', $script).Replace('__COMPACT_SCRIPT__', $compactScript).Replace('__SESSION_POINTER_SCRIPT__', $pointerScript).Replace('__STATUSLINE_COMMAND__', $command).Replace('__STATUSLINE_SCRIPT__', $statusLineScript)
Set-Content -LiteralPath $path -Value $content -Encoding UTF8 Set-Content -LiteralPath $path -Value $content -Encoding UTF8
} }
$tomlPath = Join-Path $output "codex-$platform/config.toml" $tomlPath = Join-Path $output "codex-$shell/config.toml"
$settingsPath = Join-Path $output "claude-$platform/settings.json" $settingsPath = Join-Path $output "claude-$shell/settings.json"
& python (Join-Path $root 'scripts/validate-config.py') $tomlPath $settingsPath & python (Join-Path $root 'scripts/validate-config.py') $tomlPath $settingsPath
if ($LASTEXITCODE -ne 0) { throw "Generated configuration validation failed for $platform." } if ($LASTEXITCODE -ne 0) { throw "Generated configuration validation failed for $shell." }
Test-CodexSchema $tomlPath Test-CodexSchema $tomlPath
} }
@@ -207,10 +209,10 @@ $leftoverPlaceholders = @(Get-ChildItem $output -File -Recurse | ForEach-Object
# __AI_CONFIG_ROOT__ is resolved at install time, once the destination is known; it is # __AI_CONFIG_ROOT__ is resolved at install time, once the destination is known; it is
# expected to remain in generated output. -cmatch keeps this case-sensitive so Python # expected to remain in generated output. -cmatch keeps this case-sensitive so Python
# dunder names (__main__, __name__) in flashbang.sh are not mistaken for placeholders. # dunder names (__main__, __name__) in flashbang.sh are not mistaken for placeholders.
$content = (Get-Content -LiteralPath $_.FullName -Raw) -replace '__AI_CONFIG_ROOT__', '' $content = (Get-Content -LiteralPath $_.FullName -Raw) -replace '__AI_CONFIG_ROOT__|__POWERSHELL_COMMAND__', ''
if ($content -cmatch '__[A-Z0-9_]+__') { $_.FullName } if ($content -cmatch '__[A-Z0-9_]+__') { $_.FullName }
}) })
if ($leftoverPlaceholders.Count) { throw "Unresolved template placeholders in: $($leftoverPlaceholders -join ', ')" } if ($leftoverPlaceholders.Count) { throw "Unresolved template placeholders in: $($leftoverPlaceholders -join ', ')" }
if ($Summary) { Write-Output 'Build: PASS | 4 packages' } else { Write-Output 'PASS build: codex-windows, claude-windows, codex-linux, claude-linux' } if ($Summary) { Write-Output 'Build: PASS | 4 packages' } else { Write-Output 'PASS build: codex-powershell, claude-powershell, codex-bash, claude-bash' }
exit 0 exit 0
+26 -25
View File
@@ -112,22 +112,23 @@ Load rule files when their subject applies:
- rules/wpf/index.md for WPF work. - rules/wpf/index.md for WPF work.
- rules/ui-ux/index.md for UI or UX decisions. - rules/ui-ux/index.md for UI or UX decisions.
- rules/microsoft.md for Microsoft 365, Azure DevOps, or Teams work. - rules/microsoft.md for Microsoft 365, Azure DevOps, or Teams work.
- rules/orchestration.md for delegation, subagent work, or long-running task state.
- rules/git.md for Git operations. - rules/git.md for Git operations.
- rules/refactoring.md for refactoring work. - rules/refactoring.md for refactoring work.
- rules/definition-of-done.md when validating completion. - rules/definition-of-done.md when validating completion.
- rules/decision-rule.md when requirements are unresolved, behavior is ambiguous, or a meaningful technical choice remains. - rules/decision-rule.md when requirements are unresolved, behavior is ambiguous, or a meaningful technical choice remains.
BLOCK BLOCK
for platform in windows linux; do for shell in powershell bash; do
mkdir -p "$output/codex-$platform" "$output/claude-$platform" mkdir -p "$output/codex-$shell" "$output/claude-$shell"
copy_directory "$shared/skills" "$output/codex-$platform/skills" copy_directory "$shared/skills" "$output/codex-$shell/skills"
copy_directory "$shared/skills" "$output/claude-$platform/skills" copy_directory "$shared/skills" "$output/claude-$shell/skills"
copy_directory "$shared/rules" "$output/codex-$platform/rules" copy_directory "$shared/rules" "$output/codex-$shell/rules"
copy_directory "$shared/hooks" "$output/codex-$platform/hooks" copy_directory "$shared/hooks" "$output/codex-$shell/hooks"
copy_directory "$shared/hooks" "$output/claude-$platform/hooks" copy_directory "$shared/hooks" "$output/claude-$shell/hooks"
copy_directory "$shared/statusline" "$output/claude-$platform/statusline" copy_directory "$shared/statusline" "$output/claude-$shell/statusline"
mkdir -p "$output/claude-$platform/rules" mkdir -p "$output/claude-$shell/rules"
claude_rule_loading_list="" claude_rule_loading_list=""
for i in "${!rule_skill_names[@]}"; do for i in "${!rule_skill_names[@]}"; do
@@ -137,7 +138,7 @@ for i in "${!rule_skill_names[@]}"; do
rule_source="$shared/rules/$rule_file" rule_source="$shared/rules/$rule_file"
rule_body="$rule_source" rule_body="$rule_source"
[ -d "$rule_source" ] && rule_body="$rule_source/index.md" [ -d "$rule_source" ] && rule_body="$rule_source/index.md"
skill_dir="$output/claude-$platform/skills/rules/$skill_name" skill_dir="$output/claude-$shell/skills/rules/$skill_name"
mkdir -p "$skill_dir" mkdir -p "$skill_dir"
{ printf -- '---\nname: %s\ndescription: %s\n---\n\n' "$skill_name" "$(quote_toml "Use for $trigger.")"; cat "$rule_body"; } > "$skill_dir/SKILL.md" { printf -- '---\nname: %s\ndescription: %s\n---\n\n' "$skill_name" "$(quote_toml "Use for $trigger.")"; cat "$rule_body"; } > "$skill_dir/SKILL.md"
if [ -d "$rule_source/references" ]; then if [ -d "$rule_source/references" ]; then
@@ -158,22 +159,22 @@ shared_template=$(<"$shared/global-instructions.md")
general_content=$(<"$shared/rules/general.md") general_content=$(<"$shared/rules/general.md")
agents_content=${shared_template//__RULE_LOADING__/$codex_rule_loading} agents_content=${shared_template//__RULE_LOADING__/$codex_rule_loading}
printf '%s\n\n---\n\n%s\n' "${agents_content%$'\n'}" "$general_content" > "$output/codex-$platform/AGENTS.md" printf '%s\n\n---\n\n%s\n' "${agents_content%$'\n'}" "$general_content" > "$output/codex-$shell/AGENTS.md"
claude_content=${shared_template//__RULE_LOADING__/$claude_rule_loading} claude_content=${shared_template//__RULE_LOADING__/$claude_rule_loading}
printf '%s\n\n---\n\n%s\n' "${claude_content%$'\n'}" "$general_content" > "$output/claude-$platform/CLAUDE.md" printf '%s\n\n---\n\n%s\n' "${claude_content%$'\n'}" "$general_content" > "$output/claude-$shell/CLAUDE.md"
cp "$root/adapters/codex/config/config.toml" "$output/codex-$platform/config.toml" cp "$root/adapters/codex/config/config.toml" "$output/codex-$shell/config.toml"
cp "$root/adapters/claude/config/settings.json" "$output/claude-$platform/settings.json" cp "$root/adapters/claude/config/settings.json" "$output/claude-$shell/settings.json"
for client in codex claude; do for client in codex claude; do
agents_output="$output/$client-$platform/agents" agents_output="$output/$client-$shell/agents"
mkdir -p "$agents_output" mkdir -p "$agents_output"
for agent_dir in "${agent_dirs[@]}"; do for agent_dir in "${agent_dirs[@]}"; do
metadata="${agent_dir}agent.yml" metadata="${agent_dir}agent.yml"
name=$(read_field "$metadata" name) name=$(read_field "$metadata" name)
description=$(read_field "$metadata" description) description=$(read_field "$metadata" description)
instructions=$(<"${agent_dir}instructions.md") instructions=$(<"$root/adapters/$client/orchestration-loader.txt")$'\n\n'$(<"${agent_dir}instructions.md")
if [ "$client" = codex ]; then if [ "$client" = codex ]; then
printf 'name = %s\ndescription = %s\ndeveloper_instructions = %s\n' "$(quote_toml "$name")" "$(quote_toml "$description")" "$(quote_toml "$instructions")" > "$agents_output/$name.toml" printf 'name = %s\ndescription = %s\ndeveloper_instructions = %s\n' "$(quote_toml "$name")" "$(quote_toml "$description")" "$(quote_toml "$instructions")" > "$agents_output/$name.toml"
else else
@@ -190,19 +191,19 @@ for client in codex claude; do
statusline_script=statusline.sh statusline_script=statusline.sh
compact_script=record-compact.sh compact_script=record-compact.sh
pointer_script=show-session-state-pointer.sh pointer_script=show-session-state-pointer.sh
if [ "$platform" = windows ]; then if [ "$shell" = powershell ]; then
command='powershell -NoProfile -ExecutionPolicy Bypass -File' command='__POWERSHELL_COMMAND__'
script=flashbang.ps1 script=flashbang.ps1
statusline_script=statusline.ps1 statusline_script=statusline.ps1
compact_script=Record-Compact.ps1 compact_script=Record-Compact.ps1
pointer_script=Show-SessionStatePointer.ps1 pointer_script=Show-SessionStatePointer.ps1
fi fi
path="$output/$client-$platform/$file" path="$output/$client-$shell/$file"
content=$(<"$path") content=$(<"$path")
content=${content//__HOOK_COMMAND__/$command} content=${content//__HOOK_COMMAND__/$command}
content=${content//__WINDOWS_HOOK_COMMAND__/$command} content=${content//__POWERSHELL_HOOK_COMMAND__/$command}
content=${content//__HOOK_SCRIPT__/$script} content=${content//__HOOK_SCRIPT__/$script}
content=${content//__WINDOWS_HOOK_SCRIPT__/$script} content=${content//__POWERSHELL_HOOK_SCRIPT__/$script}
content=${content//__COMPACT_SCRIPT__/$compact_script} content=${content//__COMPACT_SCRIPT__/$compact_script}
content=${content//__SESSION_POINTER_SCRIPT__/$pointer_script} content=${content//__SESSION_POINTER_SCRIPT__/$pointer_script}
content=${content//__STATUSLINE_COMMAND__/$command} content=${content//__STATUSLINE_COMMAND__/$command}
@@ -210,8 +211,8 @@ for client in codex claude; do
printf '%s\n' "$content" > "$path" printf '%s\n' "$content" > "$path"
done done
toml_path="$output/codex-$platform/config.toml" toml_path="$output/codex-$shell/config.toml"
settings_path="$output/claude-$platform/settings.json" settings_path="$output/claude-$shell/settings.json"
python3 "$root/scripts/validate-config.py" "$toml_path" "$settings_path" python3 "$root/scripts/validate-config.py" "$toml_path" "$settings_path"
if command -v codex >/dev/null; then if command -v codex >/dev/null; then
schema_home=$(mktemp -d) schema_home=$(mktemp -d)
@@ -229,11 +230,11 @@ done
# __AI_CONFIG_ROOT__ is resolved at install time, once the destination is known; it is # __AI_CONFIG_ROOT__ is resolved at install time, once the destination is known; it is
# expected to remain in generated output, so it is excluded from the leftover check. A # expected to remain in generated output, so it is excluded from the leftover check. A
# single tree-wide grep (rather than one process per file) keeps this fast. # single tree-wide grep (rather than one process per file) keeps this fast.
leftover_tokens=$(grep -RohE '__[A-Z0-9_]+__' "$output" 2>/dev/null | sort -u | grep -v '^__AI_CONFIG_ROOT__$' || true) leftover_tokens=$(grep -RohE '__[A-Z0-9_]+__' "$output" 2>/dev/null | sort -u | grep -Ev '^(__AI_CONFIG_ROOT__|__POWERSHELL_COMMAND__)$' || true)
if [ -n "$leftover_tokens" ]; then if [ -n "$leftover_tokens" ]; then
offending_files=$(grep -RlE '__[A-Z0-9_]+__' "$output" 2>/dev/null | tr '\n' ' ') offending_files=$(grep -RlE '__[A-Z0-9_]+__' "$output" 2>/dev/null | tr '\n' ' ')
printf 'Unresolved template placeholders (%s) in: %s\n' "$(printf '%s' "$leftover_tokens" | tr '\n' ' ')" "$offending_files" >&2 printf 'Unresolved template placeholders (%s) in: %s\n' "$(printf '%s' "$leftover_tokens" | tr '\n' ' ')" "$offending_files" >&2
exit 1 exit 1
fi fi
if [ "$summary" = true ]; then printf 'Build: PASS | 4 packages\n'; else printf 'PASS build: codex-windows, claude-windows, codex-linux, claude-linux\n'; fi if [ "$summary" = true ]; then printf 'Build: PASS | 4 packages\n'; else printf 'PASS build: codex-powershell, claude-powershell, codex-bash, claude-bash\n'; fi
+9 -9
View File
@@ -18,26 +18,26 @@ function Result($state, $message) {
} }
foreach ($tool in @('codex','claude')) { if (Get-Command $tool -ErrorAction SilentlyContinue) { $version = & $tool --version 2>&1 | Select-Object -First 1; Result 'PASS' "$tool available ($version)" } else { Result 'WARN' "$tool unavailable" } } foreach ($tool in @('codex','claude')) { if (Get-Command $tool -ErrorAction SilentlyContinue) { $version = & $tool --version 2>&1 | Select-Object -First 1; Result 'PASS' "$tool available ($version)" } else { Result 'WARN' "$tool unavailable" } }
if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { if (Get-Command jq -ErrorAction SilentlyContinue) { Result 'PASS' 'jq available for Claude status line' } else { Result 'WARN' 'jq unavailable; Claude status line is disabled' } } if (Get-Command jq -ErrorAction SilentlyContinue) { Result 'PASS' 'jq available for Claude Bash status line' } else { Result 'WARN' 'jq unavailable; Claude Bash status line is disabled' }
$sourceAgentCount = @(Get-ChildItem (Join-Path $root 'shared/agents') -Directory -ErrorAction SilentlyContinue).Count $sourceAgentCount = @(Get-ChildItem (Join-Path $root 'shared/agents') -Directory -ErrorAction SilentlyContinue).Count
$sourceSkillCount = @(Get-ChildItem (Join-Path $root 'shared/skills') -Filter 'SKILL.md' -Recurse -ErrorAction SilentlyContinue).Count $sourceSkillCount = @(Get-ChildItem (Join-Path $root 'shared/skills') -Filter 'SKILL.md' -Recurse -ErrorAction SilentlyContinue).Count
$ruleSkillManifestPath = Join-Path $root 'adapters/claude/rule-skills.tsv' $ruleSkillManifestPath = Join-Path $root 'adapters/claude/rule-skills.tsv'
$ruleSkillCount = if (Test-Path -LiteralPath $ruleSkillManifestPath) { @(Import-Csv -LiteralPath $ruleSkillManifestPath -Delimiter ([char]9)).Count } else { 0 } $ruleSkillCount = if (Test-Path -LiteralPath $ruleSkillManifestPath) { @(Import-Csv -LiteralPath $ruleSkillManifestPath -Delimiter ([char]9)).Count } else { 0 }
foreach ($platform in @('windows','linux')) { foreach ($shell in @('powershell','bash')) {
if (Test-Path (Join-Path $root "generated/codex-$platform/AGENTS.md")) { Result 'PASS' "generated output present ($platform)" } else { Result 'FAIL' "generated output missing ($platform); run build" } if (Test-Path (Join-Path $root "generated/codex-$shell/AGENTS.md")) { Result 'PASS' "generated output present ($shell)" } else { Result 'FAIL' "generated output missing ($shell); run build" }
if ((Test-Path (Join-Path $root "generated/codex-$platform/hooks/scripts/Validate-CommandSafety.ps1")) -and (Test-Path (Join-Path $root "generated/codex-$platform/hooks/scripts/validate-command-safety.sh")) -and (Test-Path (Join-Path $root "generated/claude-$platform/hooks/scripts/Validate-CommandSafety.ps1")) -and (Test-Path (Join-Path $root "generated/claude-$platform/hooks/scripts/validate-command-safety.sh"))) { Result 'PASS' "generated hooks present ($platform)" } else { Result 'FAIL' "generated hooks missing ($platform); run build" } if ((Test-Path (Join-Path $root "generated/codex-$shell/hooks/scripts/Validate-CommandSafety.ps1")) -and (Test-Path (Join-Path $root "generated/codex-$shell/hooks/scripts/validate-command-safety.sh")) -and (Test-Path (Join-Path $root "generated/claude-$shell/hooks/scripts/Validate-CommandSafety.ps1")) -and (Test-Path (Join-Path $root "generated/claude-$shell/hooks/scripts/validate-command-safety.sh"))) { Result 'PASS' "generated hooks present ($shell)" } else { Result 'FAIL' "generated hooks missing ($shell); run build" }
foreach ($client in @('codex','claude')) { foreach ($client in @('codex','claude')) {
$package = Join-Path $root "generated/$client-$platform" $package = Join-Path $root "generated/$client-$shell"
if (-not (Test-Path $package)) { continue } if (-not (Test-Path $package)) { continue }
$agentCount = @(Get-ChildItem (Join-Path $package 'agents') -File -ErrorAction SilentlyContinue).Count $agentCount = @(Get-ChildItem (Join-Path $package 'agents') -File -ErrorAction SilentlyContinue).Count
if ($agentCount -eq $sourceAgentCount) { Result 'PASS' "$client-$platform agent count matches source ($agentCount)" } else { Result 'FAIL' "$client-$platform agent count $agentCount does not match source ($sourceAgentCount)" } if ($agentCount -eq $sourceAgentCount) { Result 'PASS' "$client-$shell agent count matches source ($agentCount)" } else { Result 'FAIL' "$client-$shell agent count $agentCount does not match source ($sourceAgentCount)" }
$expectedSkillCount = if ($client -eq 'claude') { $sourceSkillCount + $ruleSkillCount } else { $sourceSkillCount } $expectedSkillCount = if ($client -eq 'claude') { $sourceSkillCount + $ruleSkillCount } else { $sourceSkillCount }
$skillCount = @(Get-ChildItem (Join-Path $package 'skills') -Filter 'SKILL.md' -Recurse -ErrorAction SilentlyContinue).Count $skillCount = @(Get-ChildItem (Join-Path $package 'skills') -Filter 'SKILL.md' -Recurse -ErrorAction SilentlyContinue).Count
if ($skillCount -eq $expectedSkillCount) { Result 'PASS' "$client-$platform skill count matches source ($skillCount)" } else { Result 'FAIL' "$client-$platform skill count $skillCount does not match source ($expectedSkillCount)" } if ($skillCount -eq $expectedSkillCount) { Result 'PASS' "$client-$shell skill count matches source ($skillCount)" } else { Result 'FAIL' "$client-$shell skill count $skillCount does not match source ($expectedSkillCount)" }
$placeholderHits = @(Get-ChildItem $package -File -Recurse | Where-Object { ((Get-Content -LiteralPath $_.FullName -Raw) -replace '__AI_CONFIG_ROOT__', '') -cmatch '__[A-Z0-9_]+__' }) $placeholderHits = @(Get-ChildItem $package -File -Recurse | Where-Object { ((Get-Content -LiteralPath $_.FullName -Raw) -replace '__AI_CONFIG_ROOT__|__POWERSHELL_COMMAND__', '') -cmatch '__[A-Z0-9_]+__' })
if ($placeholderHits.Count -eq 0) { Result 'PASS' "$client-$platform has no unresolved placeholders" } else { Result 'FAIL' "$client-$platform has unresolved placeholders: $($placeholderHits.FullName -join ', ')" } if ($placeholderHits.Count -eq 0) { Result 'PASS' "$client-$shell has no unresolved placeholders" } else { Result 'FAIL' "$client-$shell has unresolved placeholders: $($placeholderHits.FullName -join ', ')" }
} }
} }
+9 -9
View File
@@ -29,7 +29,7 @@ result() {
for tool in codex claude; do for tool in codex claude; do
if command -v "$tool" >/dev/null; then result PASS "$tool available ($($tool --version 2>&1 | head -n 1))"; else result WARN "$tool unavailable"; fi if command -v "$tool" >/dev/null; then result PASS "$tool available ($($tool --version 2>&1 | head -n 1))"; else result WARN "$tool unavailable"; fi
done done
command -v jq >/dev/null && result PASS 'jq available for Claude status line' || result WARN 'jq unavailable; Claude status line is disabled' command -v jq >/dev/null && result PASS 'jq available for Claude Bash status line' || result WARN 'jq unavailable; Claude Bash status line is disabled'
source_agent_count=$(find "$root/shared/agents" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) source_agent_count=$(find "$root/shared/agents" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
source_skill_count=$(find "$root/shared/skills" -name SKILL.md 2>/dev/null | wc -l) source_skill_count=$(find "$root/shared/skills" -name SKILL.md 2>/dev/null | wc -l)
@@ -37,20 +37,20 @@ rule_skill_manifest="$root/adapters/claude/rule-skills.tsv"
rule_skill_count=0 rule_skill_count=0
[ -f "$rule_skill_manifest" ] && rule_skill_count=$(($(wc -l < "$rule_skill_manifest") - 1)) [ -f "$rule_skill_manifest" ] && rule_skill_count=$(($(wc -l < "$rule_skill_manifest") - 1))
for platform in windows linux; do for shell in powershell bash; do
[ -f "$root/generated/codex-$platform/AGENTS.md" ] && result PASS "generated output present ($platform)" || result FAIL "generated output missing ($platform); run build" [ -f "$root/generated/codex-$shell/AGENTS.md" ] && result PASS "generated output present ($shell)" || result FAIL "generated output missing ($shell); run build"
[ -f "$root/generated/codex-$platform/hooks/scripts/Validate-CommandSafety.ps1" ] && [ -f "$root/generated/codex-$platform/hooks/scripts/validate-command-safety.sh" ] && [ -f "$root/generated/claude-$platform/hooks/scripts/Validate-CommandSafety.ps1" ] && [ -f "$root/generated/claude-$platform/hooks/scripts/validate-command-safety.sh" ] && result PASS "generated hooks present ($platform)" || result FAIL "generated hooks missing ($platform); run build" [ -f "$root/generated/codex-$shell/hooks/scripts/Validate-CommandSafety.ps1" ] && [ -f "$root/generated/codex-$shell/hooks/scripts/validate-command-safety.sh" ] && [ -f "$root/generated/claude-$shell/hooks/scripts/Validate-CommandSafety.ps1" ] && [ -f "$root/generated/claude-$shell/hooks/scripts/validate-command-safety.sh" ] && result PASS "generated hooks present ($shell)" || result FAIL "generated hooks missing ($shell); run build"
for client in codex claude; do for client in codex claude; do
package="$root/generated/$client-$platform" package="$root/generated/$client-$shell"
[ -d "$package" ] || continue [ -d "$package" ] || continue
agent_count=$(find "$package/agents" -type f 2>/dev/null | wc -l) agent_count=$(find "$package/agents" -type f 2>/dev/null | wc -l)
if [ "$agent_count" -eq "$source_agent_count" ]; then result PASS "$client-$platform agent count matches source ($agent_count)"; else result FAIL "$client-$platform agent count $agent_count does not match source ($source_agent_count)"; fi if [ "$agent_count" -eq "$source_agent_count" ]; then result PASS "$client-$shell agent count matches source ($agent_count)"; else result FAIL "$client-$shell agent count $agent_count does not match source ($source_agent_count)"; fi
expected_skill_count=$source_skill_count expected_skill_count=$source_skill_count
[ "$client" != claude ] || expected_skill_count=$((source_skill_count + rule_skill_count)) [ "$client" != claude ] || expected_skill_count=$((source_skill_count + rule_skill_count))
skill_count=$(find "$package/skills" -name SKILL.md 2>/dev/null | wc -l) skill_count=$(find "$package/skills" -name SKILL.md 2>/dev/null | wc -l)
if [ "$skill_count" -eq "$expected_skill_count" ]; then result PASS "$client-$platform skill count matches source ($skill_count)"; else result FAIL "$client-$platform skill count $skill_count does not match source ($expected_skill_count)"; fi if [ "$skill_count" -eq "$expected_skill_count" ]; then result PASS "$client-$shell skill count matches source ($skill_count)"; else result FAIL "$client-$shell skill count $skill_count does not match source ($expected_skill_count)"; fi
leftover_tokens=$(grep -RohE '__[A-Z0-9_]+__' "$package" 2>/dev/null | sort -u | grep -v '^__AI_CONFIG_ROOT__$' || true) leftover_tokens=$(grep -RohE '__[A-Z0-9_]+__' "$package" 2>/dev/null | sort -u | grep -Ev '^(__AI_CONFIG_ROOT__|__POWERSHELL_COMMAND__)$' || true)
if [ -n "$leftover_tokens" ]; then result FAIL "$client-$platform has unresolved placeholders"; else result PASS "$client-$platform has no unresolved placeholders"; fi if [ -n "$leftover_tokens" ]; then result FAIL "$client-$shell has unresolved placeholders"; else result PASS "$client-$shell has no unresolved placeholders"; fi
done done
done done
+16 -12
View File
@@ -2,7 +2,7 @@
param( param(
[switch]$DryRun, [switch]$DryRun,
[ValidateSet('Codex','Claude','Both')][string]$Client, [ValidateSet('Codex','Claude','Both')][string]$Client,
[ValidateSet('Windows','Linux')][string]$Platform, [ValidateSet('PowerShell','Bash')][string]$Shell,
[switch]$Summary [switch]$Summary
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -11,9 +11,10 @@ $root = Split-Path $PSScriptRoot -Parent
. (Join-Path $PSScriptRoot 'lib/manifest.ps1') . (Join-Path $PSScriptRoot 'lib/manifest.ps1')
. (Join-Path $PSScriptRoot 'lib/install-targets.ps1') . (Join-Path $PSScriptRoot 'lib/install-targets.ps1')
. (Join-Path $PSScriptRoot 'lib/selection-state.ps1') . (Join-Path $PSScriptRoot 'lib/selection-state.ps1')
. (Join-Path $PSScriptRoot 'lib/install-options.ps1')
$Platform = Read-InstallPlatform -Platform $Platform $Shell = Read-InstallShell -Shell $Shell
$platform = $Platform.ToLowerInvariant() $shell = $Shell.ToLowerInvariant()
$Client = Read-InstallClient -Client $Client $Client = Read-InstallClient -Client $Client
$homePath = [Environment]::GetFolderPath('UserProfile') $homePath = [Environment]::GetFolderPath('UserProfile')
@@ -21,6 +22,8 @@ if (-not $DryRun -and (Test-AnyManifestPresent (Get-InstallDestinations -HomePat
throw 'Already installed, use the update script.' throw 'Already installed, use the update script.'
} }
$options = Read-InstallOptions -HomePath $homePath -RepositoryRoot $root -Client $Client -DryRun:$DryRun
$buildScript = Join-Path $PSScriptRoot 'build.ps1' $buildScript = Join-Path $PSScriptRoot 'build.ps1'
& $buildScript -Summary:$Summary & $buildScript -Summary:$Summary
if (-not $?) { throw 'Build failed. Installation was not started.' } if (-not $?) { throw 'Build failed. Installation was not started.' }
@@ -28,17 +31,18 @@ $generated = Join-Path $root 'generated'
if (-not (Test-Path $generated)) { throw 'Generated output is missing after a successful build.' } if (-not (Test-Path $generated)) { throw 'Generated output is missing after a successful build.' }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$shellCommand = 'pwsh -NoProfile -ExecutionPolicy Bypass -File' $powerShellCommand = if (Get-Command powershell -ErrorAction SilentlyContinue) { 'powershell -NoProfile -ExecutionPolicy Bypass -File' } else { 'pwsh -NoProfile -ExecutionPolicy Bypass -File' }
$windowsShellCommand = 'powershell -NoProfile -ExecutionPolicy Bypass -File' $shellCommand = if ($Shell -eq 'PowerShell') { $powerShellCommand } else { 'bash' }
if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { $shellCommand = $windowsShellCommand }
foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Platform $platform -Client $Client)) { $plugins = Select-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -Mode 'Install' -DryRun:$DryRun
if ($options.update_agents) { Update-SelectedAgentClis -Client $Client -DryRun:$DryRun -Summary:$Summary }
foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Shell $shell -Client $Client)) {
Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp $stamp ` Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp $stamp `
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -WindowsShellCommand $windowsShellCommand -DryRun:$DryRun -Summary:$Summary -AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -DryRun:$DryRun -Summary:$Summary
} }
$plugins = Select-ConfiguredPlugins -RepositoryRoot $root -Client $Client -Mode 'Install' -DryRun:$DryRun Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected
Install-ConfiguredPlugins -RepositoryRoot $root -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang }
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Platform $Platform -Client $Client -Plugins $plugins } if ($Summary) { Write-Output "Install: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS install dry-run' } else { 'PASS install' })) }
if ($Summary) { Write-Output "Install: PASS | $Client, $Platform$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS install dry-run' } else { 'PASS install' })) }
exit 0 exit 0
+19 -12
View File
@@ -3,14 +3,14 @@ set -euo pipefail
dry_run=false dry_run=false
client="" client=""
platform="" shell=""
summary=false summary=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--dry-run) dry_run=true; shift ;; --dry-run) dry_run=true; shift ;;
--summary) summary=true; shift ;; --summary) summary=true; shift ;;
--client) client=${2:?--client requires a value}; shift 2 ;; --client) client=${2:?--client requires a value}; shift 2 ;;
--platform) platform=${2:?--platform requires a value}; shift 2 ;; --shell) shell=${2:?--shell requires a value}; shift 2 ;;
*) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;; *) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;;
esac esac
done done
@@ -20,8 +20,9 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
. "$root/scripts/lib/manifest.sh" . "$root/scripts/lib/manifest.sh"
. "$root/scripts/lib/install-targets.sh" . "$root/scripts/lib/install-targets.sh"
. "$root/scripts/lib/selection-state.sh" . "$root/scripts/lib/selection-state.sh"
. "$root/scripts/lib/install-options.sh"
platform=$(read_install_platform "$platform") shell=$(read_install_shell "$shell")
client=$(read_install_client "$client") client=$(read_install_client "$client")
home_path=${HOME:?HOME is required} home_path=${HOME:?HOME is required}
@@ -33,6 +34,8 @@ if [ "$dry_run" = false ]; then
fi fi
fi fi
read_install_options
build_args=() build_args=()
[ "$summary" = false ] || build_args+=(--summary) [ "$summary" = false ] || build_args+=(--summary)
bash "$root/scripts/build.sh" "${build_args[@]}" bash "$root/scripts/build.sh" "${build_args[@]}"
@@ -40,23 +43,27 @@ generated="$root/generated"
[ -d "$generated" ] || { printf 'Generated output is missing after a successful build.\n' >&2; exit 1; } [ -d "$generated" ] || { printf 'Generated output is missing after a successful build.\n' >&2; exit 1; }
stamp=$(date +%Y%m%d-%H%M%S) stamp=$(date +%Y%m%d-%H%M%S)
powershell_command='pwsh -NoProfile -ExecutionPolicy Bypass -File'
if command -v powershell >/dev/null; then powershell_command='powershell -NoProfile -ExecutionPolicy Bypass -File'; fi
shell_command=bash shell_command=bash
windows_shell_command='powershell -NoProfile -ExecutionPolicy Bypass -File' [ "$shell" != powershell ] || shell_command=$powershell_command
while IFS='|' read -r source destination; do
ai_config_root=$destination
command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination")
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$windows_shell_command" "$dry_run" "$summary"
done < <(get_install_targets "$generated" "$home_path" "$platform" "$client")
selected_plugins=() selected_plugins=()
deselected_plugins=() deselected_plugins=()
select_configured_plugins "$root" "$client" Install "$dry_run" selected_plugins deselected_plugins select_configured_plugins "$root" "$client" Install "$dry_run" selected_plugins deselected_plugins
install_configured_plugins "$root" "$client" "$dry_run" false selected_plugins "$summary" [ "$update_agents" = false ] || update_selected_agent_clis
while IFS='|' read -r source destination; do
ai_config_root=$destination
command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination")
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang"
done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary"
if [ "$dry_run" = false ]; then save_update_selection; fi if [ "$dry_run" = false ]; then save_update_selection; fi
if [ "$summary" = true ]; then if [ "$summary" = true ]; then
suffix= suffix=
[ "$dry_run" = false ] || suffix=', dry-run' [ "$dry_run" = false ] || suffix=', dry-run'
printf 'Install: PASS | %s, %s%s\n' "$client" "$platform" "$suffix" printf 'Install: PASS | %s, %s%s\n' "$client" "$shell" "$suffix"
elif "$dry_run"; then printf 'PASS install dry-run\n'; else printf 'PASS install\n'; fi elif "$dry_run"; then printf 'PASS install dry-run\n'; else printf 'PASS install\n'; fi
+72
View File
@@ -0,0 +1,72 @@
function Read-InstallBoolean {
param([string]$Prompt, [bool]$Default)
$hint = if ($Default) { 'Y/n' } else { 'y/N' }
while ($true) {
Write-Host "$Prompt [$hint] " -NoNewline
$answer = [Console]::ReadLine()
if ($null -eq $answer) { throw 'Input ended before install options were confirmed.' }
switch ($answer.Trim().ToLowerInvariant()) {
'' { return $Default }
{ $_ -in @('y', 'yes') } { return $true }
{ $_ -in @('n', 'no') } { return $false }
}
}
}
function Read-InstallOptions {
param([string]$HomePath, [string]$RepositoryRoot, [string]$Client, [switch]$DryRun)
$defaults = @{ update_agents = $false; flashbang = $true }
if (Test-Path -LiteralPath (Join-Path $HomePath '.my-ai-configuration/selection.json')) {
try { $defaults = Read-UpdateSelection -HomePath $HomePath -RepositoryRoot $RepositoryRoot -AllowLegacy }
catch { Write-Warning 'Saved options could not be read; confirm new options below.' }
}
if ($DryRun) { return $defaults }
return @{
update_agents = Read-InstallBoolean -Prompt "Update selected agent CLIs ($Client)?" -Default $defaults.update_agents
flashbang = Read-InstallBoolean -Prompt 'Enable the Flashbang notification hook?' -Default $defaults.flashbang
}
}
function Test-CodexProcessActive {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return $false }
return [bool](Get-Process -Name 'codex' -ErrorAction SilentlyContinue)
}
function Test-GlobalNpmCodex {
$npm = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npm) { return $false }
$previousPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
& $npm list -g '@openai/codex' --depth=0 --json 1>$null 2>$null
return $LASTEXITCODE -eq 0
} finally {
$ErrorActionPreference = $previousPreference
}
}
function Update-SelectedAgentClis {
param([string]$Client, [switch]$DryRun, [switch]$Summary)
$clients = if ($Client -eq 'Both') { @('codex', 'claude') } else { @($Client.ToLowerInvariant()) }
$previousNonInteractive = $script:PluginNonInteractive
$previousAutoUpdate = $env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE
try {
$script:PluginNonInteractive = $true
$env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE = '1'
foreach ($selectedClient in $clients) {
if ($selectedClient -eq 'codex' -and -not $DryRun -and (Test-CodexProcessActive)) {
Write-Warning 'Codex CLI update skipped because codex.exe is running. Close all Codex sessions and run the update again to update the CLI.'
continue
}
if ($selectedClient -eq 'codex' -and (Test-GlobalNpmCodex)) {
Invoke-PluginCommand -Command 'npm' -Arguments @('install','-g','@openai/codex@latest') -DryRun:$DryRun -Summary:$Summary
} else {
Invoke-PluginCommand -Command $selectedClient -Arguments @('update') -DryRun:$DryRun -Summary:$Summary
}
if ($Summary) { Write-Output "CLI $selectedClient update: $(if ($DryRun) { 'dry-run' } else { 'PASS' })" }
}
} finally {
$script:PluginNonInteractive = $previousNonInteractive
$env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE = $previousAutoUpdate
}
}
+57
View File
@@ -0,0 +1,57 @@
import argparse
import json
from pathlib import Path
import re
import sys
import tomllib
def is_flashbang(hook):
return any(re.search(r'(?:[/\\\s"\x27]|^)flashbang\.(?:ps1|sh)(?:[\s"\x27]|$)', str(hook.get(key, '')), re.I)
for key in ('command', 'command_windows'))
def filter_flashbang(path):
content = path.read_text(encoding='utf-8-sig')
if path.name == 'settings.json':
settings = json.loads(content)
groups = settings.get('hooks', {}).get('Stop', [])
remaining = []
for group in groups:
hooks = [hook for hook in group.get('hooks', []) if not is_flashbang(hook)]
if hooks:
remaining.append(dict(group, hooks=hooks))
if remaining:
settings['hooks']['Stop'] = remaining
elif 'Stop' in settings.get('hooks', {}):
del settings['hooks']['Stop']
return json.dumps(settings, indent=2, ensure_ascii=False) + '\n'
if path.name != 'config.toml':
raise ValueError('Flashbang filtering only supports client configuration files.')
tomllib.loads(content)
sections = re.split(r'(?m)(?=^\s*\[\[?[^\r\n]+\]\]?\s*$)', content)
filtered = []
for section in sections:
if re.match(r'\s*\[\[hooks\.Stop\.hooks\]\]', section):
hook = tomllib.loads(section)['hooks']['Stop']['hooks'][0]
if is_flashbang(hook):
continue
filtered.append(section)
result = ''.join(filtered)
result = re.sub(r'(?m)^\[\[hooks\.Stop\]\][ \t]*\r?\n\s*(?=\[|\Z)(?!\[\[hooks\.Stop\.hooks\]\])', '', result)
tomllib.loads(result)
return result
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=('filter',))
parser.add_argument('--path', type=Path, required=True)
parser.add_argument('--flashbang', choices=('true', 'false'), required=True)
args = parser.parse_args()
try:
sys.stdout.reconfigure(encoding='utf-8')
print(filter_flashbang(args.path) if args.flashbang == 'false' else args.path.read_text(encoding='utf-8-sig'), end='')
except (OSError, ValueError, KeyError) as error:
print(f'Install options: {error}', file=sys.stderr)
sys.exit(1)
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
read_install_boolean() {
local prompt=$1 default=$2 hint=y/N answer
[ "$default" = false ] || hint=Y/n
while :; do
printf '%s [%s] ' "$prompt" "$hint" >&2
IFS= read -r answer || { printf 'Input ended before install options were confirmed.\n' >&2; return 1; }
answer=${answer%$'\r'}
case "$answer" in
'') printf '%s\n' "$default"; return ;;
y|Y|yes|Yes|YES) printf 'true\n'; return ;;
n|N|no|No|NO) printf 'false\n'; return ;;
esac
done
}
read_install_options() {
update_agents=false
flashbang=true
local saved key value
if [ -f "$home_path/.my-ai-configuration/selection.json" ]; then
if saved=$(python3 "$root/scripts/lib/selection-state.py" read --home "$home_path" --manifest "$root/adapters/plugins.tsv" --format tsv --allow-legacy); then
while IFS=$'\t' read -r key value; do
value=${value%$'\r'}
case "$key" in update_agents) update_agents=$value ;; flashbang) flashbang=$value ;; esac
done <<< "$saved"
else
printf 'WARN Saved options could not be read; confirm new options below.\n' >&2
fi
fi
[ "$dry_run" = false ] || return 0
update_agents=$(read_install_boolean "Update selected agent CLIs ($client)?" "$update_agents") || return
flashbang=$(read_install_boolean 'Enable the Flashbang notification hook?' "$flashbang") || return
}
is_windows_host() {
case "${OSTYPE:-}" in msys*|cygwin*|win32*) return 0 ;; *) return 1 ;; esac
}
codex_process_active() {
is_windows_host || return 1
command -v powershell.exe >/dev/null 2>&1 || return 1
powershell.exe -NoProfile -Command "if (Get-Process -Name codex -ErrorAction SilentlyContinue) { exit 0 }; exit 1" >/dev/null 2>&1
}
global_npm_codex() {
command -v npm >/dev/null 2>&1 || return 1
npm list -g '@openai/codex' --depth=0 --json >/dev/null 2>&1
}
update_selected_agent_clis() {
local selected_client clients=()
case "$client" in codex|both) clients+=(codex) ;; esac
case "$client" in claude|both) clients+=(claude) ;; esac
for selected_client in "${clients[@]}"; do
if [ "$selected_client" = codex ] && [ "$dry_run" = false ] && codex_process_active; then
printf 'WARN Codex CLI update skipped because codex.exe is running. Close all Codex sessions and run the update again to update the CLI.\n' >&2
continue
fi
if [ "$selected_client" = codex ] && global_npm_codex; then
CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 CI=true run_plugin_command "$dry_run" npm install -g '@openai/codex@latest' </dev/null || return
else
CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 CI=true run_plugin_command "$dry_run" "$selected_client" update </dev/null || return
fi
done
}
+8 -8
View File
@@ -1,7 +1,7 @@
function Read-InstallPlatform { function Read-InstallShell {
param([string]$Platform) param([string]$Shell)
if ($Platform) { return $Platform } if ($Shell) { return $Shell }
return Read-InteractiveSingle -Title 'Select shell' -Names @('PowerShell','Bash') -Values @('Windows','Linux') return Read-InteractiveSingle -Title 'Select shell' -Names @('PowerShell','Bash') -Values @('PowerShell','Bash')
} }
function Read-InstallClient { function Read-InstallClient {
param([string]$Client) param([string]$Client)
@@ -13,14 +13,14 @@ function Read-InteractiveSingle {
param([string]$Title,[string[]]$Names,[string[]]$Values) param([string]$Title,[string[]]$Names,[string[]]$Values)
if ([Console]::IsInputRedirected -or $env:CI -eq 'true' -or $env:AI_CONFIG_NO_INTERACTIVE -eq '1') { if ([Console]::IsInputRedirected -or $env:CI -eq 'true' -or $env:AI_CONFIG_NO_INTERACTIVE -eq '1') {
Write-Host $Title; for($i=0;$i -lt $Names.Count;$i++){ Write-Host ('{0}) {1}' -f ($i+1),$Names[$i]) } Write-Host $Title; for($i=0;$i -lt $Names.Count;$i++){ Write-Host ('{0}) {1}' -f ($i+1),$Names[$i]) }
do { $answer=Read-Host 'Selection' } while($answer -notmatch ('^[1-{0}]$' -f $Names.Count)); return $Values[[int]$answer-1] do { $answer=Read-Host 'Selection'; if ($null -eq $answer -or ([Console]::IsInputRedirected -and $answer -eq '')) { throw 'Selection input ended before a choice was provided.' } } while($answer -notmatch ('^[1-{0}]$' -f $Names.Count)); return $Values[[int]$answer-1]
} }
$index=0 $index=0
while($true) { while($true) {
Clear-InteractiveScreen; Write-Host $Title Clear-InteractiveScreen; Write-Host $Title
for($i=0;$i -lt $Names.Count;$i++){ $prefix=if($i -eq $index){'>'}else{' '}; if($i -eq $index -and $env:NO_COLOR -ne '1'){Write-Host ('{0} {1}' -f $prefix,$Names[$i]) -ForegroundColor Cyan}else{Write-Host ('{0} {1}' -f $prefix,$Names[$i])} } for($i=0;$i -lt $Names.Count;$i++){ $prefix=if($i -eq $index){'>'}else{' '}; if($i -eq $index -and $env:NO_COLOR -ne '1'){Write-Host ('{0} {1}' -f $prefix,$Names[$i]) -ForegroundColor Cyan}else{Write-Host ('{0} {1}' -f $prefix,$Names[$i])} }
Write-Host 'Up/Down Navigate Enter Confirm'; $key=[Console]::ReadKey($true) Write-Host 'Up/Down Navigate Enter Confirm'; $key=[Console]::ReadKey($true)
if($key.Key -eq 'Up'){ $index=($index+$Names.Count-1)%$Names.Count } elseif($key.Key -eq 'Down'){ $index=($index+1)%$Names.Count } elseif($key.Key -eq 'Enter'){ Clear-InteractiveScreen; return $Values[$index] } if($key.Key -eq 'UpArrow'){ $index=($index+$Names.Count-1)%$Names.Count } elseif($key.Key -eq 'DownArrow'){ $index=($index+1)%$Names.Count } elseif($key.Key -eq 'Enter'){ Clear-InteractiveScreen; return $Values[$index] }
} }
} }
function Get-InstallDestinations { function Get-InstallDestinations {
@@ -28,8 +28,8 @@ function Get-InstallDestinations {
$destinations=@(); if($Client -in @('Codex','Both')){$destinations+=Join-Path $HomePath '.codex'}; if($Client -in @('Claude','Both')){$destinations+=Join-Path $HomePath '.claude'}; return $destinations $destinations=@(); if($Client -in @('Codex','Both')){$destinations+=Join-Path $HomePath '.codex'}; if($Client -in @('Claude','Both')){$destinations+=Join-Path $HomePath '.claude'}; return $destinations
} }
function Get-InstallTargets { function Get-InstallTargets {
param([Parameter(Mandatory=$true)][string]$Generated,[Parameter(Mandatory=$true)][string]$HomePath,[Parameter(Mandatory=$true)][string]$Platform,[ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client) param([Parameter(Mandatory=$true)][string]$Generated,[Parameter(Mandatory=$true)][string]$HomePath,[Parameter(Mandatory=$true)][string]$Shell,[ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client)
$targets=@(); if($Client -in @('Codex','Both')){$targets+=@{Source=(Join-Path $Generated "codex-$Platform");Destination=(Join-Path $HomePath '.codex')};$targets+=@{Source=(Join-Path $Generated "codex-$Platform/skills");Destination=(Join-Path $HomePath '.agents/skills')}}; if($Client -in @('Claude','Both')){$targets+=@{Source=(Join-Path $Generated "claude-$Platform");Destination=(Join-Path $HomePath '.claude')};}; return $targets $targets=@(); if($Client -in @('Codex','Both')){$targets+=@{Source=(Join-Path $Generated "codex-$($Shell.ToLowerInvariant())");Destination=(Join-Path $HomePath '.codex')};$targets+=@{Source=(Join-Path $Generated "codex-$($Shell.ToLowerInvariant())/skills");Destination=(Join-Path $HomePath '.agents/skills')}}; if($Client -in @('Claude','Both')){$targets+=@{Source=(Join-Path $Generated "claude-$($Shell.ToLowerInvariant())");Destination=(Join-Path $HomePath '.claude')};}; return $targets
} }
function Test-AnyManifestPresent { param([Parameter(Mandatory=$true)][string[]]$Destinations); return [bool](@($Destinations|Where-Object{Test-Path(Get-ManagedManifestPath $_)}).Count) } function Test-AnyManifestPresent { param([Parameter(Mandatory=$true)][string[]]$Destinations); return [bool](@($Destinations|Where-Object{Test-Path(Get-ManagedManifestPath $_)}).Count) }
function Test-AllManifestsPresent { param([Parameter(Mandatory=$true)][string[]]$Destinations); return [bool](@($Destinations|Where-Object{-not(Test-Path(Get-ManagedManifestPath $_))}).Count -eq 0) } function Test-AllManifestsPresent { param([Parameter(Mandatory=$true)][string[]]$Destinations); return [bool](@($Destinations|Where-Object{-not(Test-Path(Get-ManagedManifestPath $_))}).Count -eq 0) }
+33 -15
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
read_install_platform() { read_install_shell() {
local platform=$1 local shell=$1
if [ -n "$platform" ]; then if [ -n "$shell" ]; then
case "$platform" in case "$shell" in
windows|Windows) printf 'windows\n'; return 0 ;; powershell|PowerShell) printf 'powershell\n'; return 0 ;;
linux|Linux) printf 'linux\n'; return 0 ;; bash|Bash) printf 'bash\n'; return 0 ;;
*) printf 'Unknown --platform value: %s (expected windows or linux)\n' "$platform" >&2; return 1 ;; *) printf 'Unknown --shell value: %s (expected powershell or bash)\n' "$shell" >&2; return 1 ;;
esac esac
fi fi
read_single_selection 'Select shell' 'PowerShell' 'windows' 'Bash' 'linux' read_single_selection 'Select shell' 'PowerShell' 'powershell' 'Bash' 'bash'
} }
read_install_client() { read_install_client() {
@@ -25,12 +25,30 @@ read_install_client() {
read_single_selection 'Select client' 'Codex' 'codex' 'Claude' 'claude' 'Both' 'both' read_single_selection 'Select client' 'Codex' 'codex' 'Claude' 'claude' 'Both' 'both'
} }
clear_interactive() { [ -t 0 ] && [ "${CI:-}" != true ] && [ "${AI_CONFIG_NO_INTERACTIVE:-}" != 1 ] && printf '\033[2J\033[H'; } clear_interactive() { [ -t 0 ] && [ "${CI:-}" != true ] && [ "${AI_CONFIG_NO_INTERACTIVE:-}" != 1 ] && printf '\033[2J\033[H' >&2; }
read_single_selection() { read_single_selection() {
local title=$1; shift; local labels=() values=() label value index=0 key local title=$1; shift; local labels=() values=() label value index=0 key
while [ $# -gt 0 ]; do labels+=("$1"); values+=("$2"); shift 2; done while [ $# -gt 0 ]; do labels+=("$1"); values+=("$2"); shift 2; done
if [ ! -t 0 ] || [ "${CI:-}" = true ] || [ "${AI_CONFIG_NO_INTERACTIVE:-}" = 1 ]; then printf '%s\n' "$title" >&2; for ((index=0;index<${#labels[@]};index++)); do printf '%d) %s\n' "$((index+1))" "${labels[$index]}" >&2; done; while read -r -p 'Selection ' value; do [[ "$value" =~ ^[1-9][0-9]*$ ]] && [ "$value" -le "${#labels[@]}" ] && { printf '%s\n' "${values[$((value-1))]}"; return; }; done; return 1; fi if [ ! -t 0 ] || [ "${CI:-}" = true ] || [ "${AI_CONFIG_NO_INTERACTIVE:-}" = 1 ]; then printf '%s\n' "$title" >&2; for ((index=0;index<${#labels[@]};index++)); do printf '%d) %s\n' "$((index+1))" "${labels[$index]}" >&2; done; while read -r -p 'Selection ' value; do [[ "$value" =~ ^[1-9][0-9]*$ ]] && [ "$value" -le "${#labels[@]}" ] && { printf '%s\n' "${values[$((value-1))]}"; return; }; done; return 1; fi
local selected=0; while :; do clear_interactive; printf '%s\n' "$title"; for ((index=0;index<${#labels[@]};index++)); do [ "$index" -eq "$selected" ] && printf '> %s\n' "${labels[$index]}" || printf ' %s\n' "${labels[$index]}"; done; printf '↑/↓ Navigate Enter Confirm\n'; IFS= read -rsn1 key || return 1; case "$key" in $'\x1b') read -rsn2 key; case "$key" in '[A') selected=$(( (selected+${#labels[@]}-1)%${#labels[@]} ));; '[B') selected=$(( (selected+1)%${#labels[@]} ));; esac;; '') clear_interactive; printf '%s\n' "${values[$selected]}"; return;; esac; done local selected=0
while :; do
clear_interactive
printf '%s\n' "$title" >&2
for ((index=0;index<${#labels[@]};index++)); do
if [ "$index" -eq "$selected" ]; then printf '> %s\n' "${labels[$index]}" >&2; else printf ' %s\n' "${labels[$index]}" >&2; fi
done
printf 'Up/Down Navigate Enter Confirm\n' >&2
IFS= read -rsn1 key || return 1
case "$key" in
$'\x1b')
read -rsn2 key || return 1
case "$key" in
'[A') selected=$(( (selected+${#labels[@]}-1)%${#labels[@]} ));;
'[B') selected=$(( (selected+1)%${#labels[@]} ));;
esac;;
'') clear_interactive; printf '%s\n' "${values[$selected]}"; return;;
esac
done
} }
# get_install_destinations <home> <client> -- prints one destination per line # get_install_destinations <home> <client> -- prints one destination per line
@@ -40,16 +58,16 @@ get_install_destinations() {
case "$client" in claude|both) printf '%s/.claude\n' "$home_path" ;; esac case "$client" in claude|both) printf '%s/.claude\n' "$home_path" ;; esac
} }
# get_install_targets <generated> <home> <platform> <client> -- prints "source|dest" per line # get_install_targets <generated> <home> <shell> <client> -- prints "source|dest" per line
get_install_targets() { get_install_targets() {
local generated=$1 home_path=$2 platform=$3 client=$4 local generated=$1 home_path=$2 shell=$3 client=$4
case "$client" in case "$client" in
codex|both) codex|both)
printf '%s/codex-%s|%s/.codex\n' "$generated" "$platform" "$home_path" printf '%s/codex-%s|%s/.codex\n' "$generated" "$shell" "$home_path"
printf '%s/codex-%s/skills|%s/.agents/skills\n' "$generated" "$platform" "$home_path" printf '%s/codex-%s/skills|%s/.agents/skills\n' "$generated" "$shell" "$home_path"
;; ;;
esac esac
case "$client" in claude|both) printf '%s/claude-%s|%s/.claude\n' "$generated" "$platform" "$home_path" ;; esac case "$client" in claude|both) printf '%s/claude-%s|%s/.claude\n' "$generated" "$shell" "$home_path" ;; esac
} }
# any_manifest_present <destination>... -- true (0) if at least one has a manifest # any_manifest_present <destination>... -- true (0) if at least one has a manifest
+399
View File
@@ -0,0 +1,399 @@
import argparse
import csv
import hashlib
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
def linked(path):
try:
metadata = path.lstat()
except FileNotFoundError:
return False
return stat.S_ISLNK(metadata.st_mode) or bool(getattr(metadata, 'st_file_attributes', 0) & 0x400)
def safe_path(home, relative):
parts = PurePosixPath(relative).parts
if not parts or PurePosixPath(relative).is_absolute() or any(p in ('.', '..') or ':' in p or '\\' in p for p in parts):
raise ValueError('Invalid managed extension path.')
target = home.joinpath(*parts)
for item in (target, *target.parents):
if item == home:
break
if linked(item):
raise ValueError('Managed extension path contains a link.')
return target
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def atomic_write(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def run_command(arguments, summary=False, home=None):
executable = shutil.which(arguments[0])
if executable is None:
raise ValueError(f'{arguments[0]} CLI is required to manage plugins.')
environment = os.environ.copy()
if home is not None:
environment.update(HOME=str(home), USERPROFILE=str(home), CODEX_HOME=str(home / '.codex'), CLAUDE_CONFIG_DIR=str(home / '.claude'))
result = subprocess.run([executable, *arguments[1:]], stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=180, env=environment)
if result.returncode:
raise ValueError(f'Extension command failed: {arguments[0]} {" ".join(arguments[1:])}\n{result.stdout}{result.stderr}')
if result.stderr:
print(result.stderr.rstrip(), file=sys.stderr)
if not summary and result.stdout:
print(result.stdout.rstrip())
elif summary:
for line in result.stdout.splitlines():
if re.search(r'warn|error|fail|deprecat', line, re.I):
print(line)
return result.stdout
def installed_plugins(client, home=None):
result = json.loads(run_command([client, 'plugin', 'list', '--json'], True, home))
if client == 'codex':
if not isinstance(result, dict) or not isinstance(result.get('installed'), list):
raise ValueError('Invalid Codex installed plugin response.')
result = result['installed']
if not isinstance(result, list):
raise ValueError('Invalid installed plugin response.')
key = 'id' if client == 'claude' else 'pluginId'
if any(not isinstance(item, dict) or not isinstance(item.get(key), str) for item in result):
raise ValueError('Invalid installed plugin record.')
return {item[key]: item for item in result}
def fetch_skill(entry):
source = entry['codex_source']
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', source):
raise ValueError('Skill source must be a GitHub owner/repository.')
skill = entry['codex_skill']
if not re.fullmatch(r'[A-Za-z0-9_-]+', skill):
raise ValueError('Skill name is invalid.')
request = urllib.request.Request(f'https://codeload.github.com/{source}/zip/HEAD', headers={'User-Agent': 'MyAiConfiguration'})
with urllib.request.urlopen(request, timeout=60) as response:
if not response.url.startswith('https://codeload.github.com/'):
raise ValueError('Unexpected skill download redirect.')
payload = response.read(32 * 1024 * 1024 + 1)
if len(payload) > 32 * 1024 * 1024:
raise ValueError('Skill archive exceeds the download limit.')
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
members = archive.infolist()
roots = {item.filename.split('/')[0] for item in members}
if len(roots) != 1:
raise ValueError('Skill archive has an invalid root.')
root = next(iter(roots)) + '/'
prefixes = [root + '.agents/skills/' + skill + '/', root + 'skills/' + skill + '/', root]
prefix = next((p for p in prefixes if p + 'SKILL.md' in archive.namelist()), None)
if prefix is None:
raise ValueError(f'Skill payload is missing: {skill}')
files = {}
total = 0
for item in members:
if not item.filename.startswith(prefix) or item.is_dir():
continue
relative = item.filename[len(prefix):]
if prefix == root and relative != 'SKILL.md' and not relative.startswith(('references/', 'scripts/', 'assets/', 'agents/')):
continue
if stat.S_ISLNK(item.external_attr >> 16):
raise ValueError('Skill archive contains a symbolic link.')
parts = PurePosixPath(relative).parts
if not parts or any(p in ('.', '..') or ':' in p or '\\' in p for p in parts) or relative.startswith('/'):
raise ValueError('Skill archive contains an unsafe path.')
total += item.file_size
if total > 64 * 1024 * 1024 or len(files) >= 10000:
raise ValueError('Skill payload exceeds extraction limits.')
if relative in files:
raise ValueError('Skill archive contains duplicate paths.')
files[relative] = (archive.read(item), bool(item.external_attr >> 16 & 0o111))
return skill, files
class Manager:
def __init__(self, home, dry_run=False, update=False, summary=False):
self.home = Path(home).absolute()
self.path = safe_path(self.home, '.my-ai-configuration/extensions.json')
self.dry_run = dry_run
self.update = update
self.summary = summary
self.state = {'version': 1, 'resources': []}
if self.path.exists():
self.state = json.loads(self.path.read_text(encoding='utf-8'))
self.validate()
self.cache = {}
def validate(self):
if not isinstance(self.state, dict) or self.state.get('version') != 1 or not isinstance(self.state.get('resources'), list):
raise ValueError('Invalid extension ownership ledger.')
identities = set()
for resource in self.state['resources']:
if not isinstance(resource, dict) or resource.get('client') not in ('codex', 'claude') or resource.get('kind') not in ('plugin', 'skill'):
raise ValueError('Invalid extension ownership record.')
if not isinstance(resource.get('name'), str) or not resource['name']:
raise ValueError('Invalid extension ownership name.')
identity = (resource['client'], resource['name'])
if identity in identities:
raise ValueError('Duplicate extension ownership record.')
identities.add(identity)
if resource['kind'] == 'plugin':
if not re.fullmatch(r'[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+', resource.get('selector', '')):
raise ValueError('Invalid owned plugin selector.')
else:
directory = resource.get('directory', '')
if not re.fullmatch(r'\.agents/skills/[A-Za-z0-9_-]+', directory) or resource['client'] != 'codex':
raise ValueError('Invalid owned skill directory.')
safe_path(self.home, directory)
if not isinstance(resource.get('files'), dict):
raise ValueError('Invalid owned skill file inventory.')
for relative, value in resource['files'].items():
if not relative.startswith(directory + '/') or not re.fullmatch(r'[a-f0-9]{64}', value):
raise ValueError('Invalid owned skill file record.')
safe_path(self.home, relative)
def save(self):
if not self.dry_run:
self.validate()
atomic_write(self.path, (json.dumps(self.state, indent=2) + '\n').encode())
def installed(self, client):
if client not in self.cache:
self.cache[client] = installed_plugins(client, self.home)
return self.cache[client]
def command(self, arguments):
if self.dry_run:
if not self.summary:
print('DRYRUN ' + ' '.join(arguments))
else:
run_command(arguments, self.summary, self.home)
def remove(self, record):
if self.dry_run:
if not self.summary:
print(f'DRYRUN remove managed {record["client"]} {record["name"]}')
return
if record['kind'] == 'plugin':
client, selector = record['client'], record['selector']
item = self.installed(client).get(selector)
if item is not None:
if client == 'claude' and item.get('scope', 'user') != 'user':
raise ValueError('Owned plugin now has a different scope; removal stopped.')
arguments = [client, 'plugin', 'uninstall', selector, '--scope', 'user'] if client == 'claude' else [client, 'plugin', 'remove', selector]
self.command(arguments)
self.cache[client].pop(selector, None)
else:
empty_candidates = set()
for relative, expected in list(record['files'].items()):
path = safe_path(self.home, relative)
if path.exists():
if not path.is_file() or digest(path) != expected:
print(f'WARN Preserving modified skill file: {relative}')
continue
path.unlink()
parent = path.parent
directory = safe_path(self.home, record['directory'])
while parent != directory.parent:
empty_candidates.add(parent)
parent = parent.parent
del record['files'][relative]
self.save()
directory = safe_path(self.home, record['directory'])
if directory.exists():
for path in sorted(empty_candidates, key=lambda p: len(p.parts), reverse=True):
if path.is_dir() and not path.is_symlink():
try:
path.rmdir()
except OSError:
pass
try:
directory.rmdir()
except OSError:
pass
if record['files']:
return
self.state['resources'].remove(record)
self.save()
def ensure(self, client, entry):
skill = client == 'codex' and entry['codex_method'] != 'plugin'
kind = 'skill' if skill else 'plugin'
record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None)
selector = entry[client + '_plugin']
if record and (record['kind'] != kind or (kind == 'plugin' and record['selector'] != selector)):
self.remove(record)
if not self.dry_run and record in self.state['resources']:
raise ValueError('Modified owned files prevent extension replacement.')
record = None
if self.dry_run:
if not self.summary:
print(f'DRYRUN ensure {client} {kind}: {entry["name"]}')
return
if skill:
self.ensure_skill(entry, record)
return
if not re.fullmatch(r'[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+', selector):
raise ValueError('Invalid plugin selector.')
item = self.installed(client).get(selector)
if item is not None and record is None:
if not self.summary:
print(f'PASS Preserving pre-existing {client} plugin: {selector}')
return
if item is not None and client == 'claude' and item.get('scope', 'user') != 'user':
raise ValueError('Owned plugin now has a different scope; update stopped.')
if item is not None and not self.update:
if client == 'claude' and item.get('enabled') is False:
self.command([client, 'plugin', 'enable', selector])
return
if item is not None and client == 'claude':
self.command([client, 'plugin', 'update', selector])
if item.get('enabled') is False:
self.command([client, 'plugin', 'enable', selector])
return
marketplace = entry[client + '_marketplace']
if marketplace not in ('', '-', 'openai-curated-remote'):
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', marketplace):
raise ValueError('Invalid plugin marketplace source.')
if item is not None and self.update and client == 'codex':
self.command([client, 'plugin', 'marketplace', 'upgrade', selector.rsplit('@', 1)[1]])
else:
self.command([client, 'plugin', 'marketplace', 'add', marketplace])
arguments = [client, 'plugin', 'install', selector, '--scope', 'user'] if client == 'claude' else [client, 'plugin', 'add', selector]
self.command(arguments)
if record is None:
self.state['resources'].append(dict(client=client, name=entry['name'], kind='plugin', selector=selector))
self.save()
self.cache[client][selector] = {'scope': 'user'}
def ensure_skill(self, entry, record):
skill = entry['codex_skill']
if not re.fullmatch(r'[A-Za-z0-9_-]+', skill):
raise ValueError('Invalid skill name.')
directory = '.agents/skills/' + skill
target = self.home / directory
alternate = self.home / '.codex' / 'skills' / skill
if record is None and (target.exists() or target.is_symlink() or alternate.exists() or alternate.is_symlink()):
if not self.summary:
print(f'PASS Preserving pre-existing Codex skill: {skill}')
return
target = safe_path(self.home, directory)
if record and record['directory'] != directory:
self.remove(record)
if record in self.state['resources']:
raise ValueError('Modified owned files prevent skill replacement.')
record = None
if record and record.get('complete') and not self.update and all(safe_path(self.home, name).is_file() for name in record['files']) and record['files']:
return
_, files = fetch_skill(entry)
if record is None:
record = dict(client='codex', name=entry['name'], kind='skill', directory=directory, files={}, complete=False)
self.state['resources'].append(record)
self.save()
record['complete'] = False
self.save()
for name, (content, executable) in files.items():
relative = directory + '/' + name
path = safe_path(self.home, relative)
if path.exists() and (relative not in record['files'] or not path.is_file() or digest(path) != record['files'][relative]):
print(f'WARN Preserving modified or foreign skill file: {relative}')
continue
atomic_write(path, content)
if executable:
path.chmod(path.stat().st_mode | stat.S_IXUSR)
record['files'][relative] = hashlib.sha256(content).hexdigest()
self.save()
for relative in list(record['files']):
if relative[len(directory) + 1:] in files:
continue
path = safe_path(self.home, relative)
if path.exists() and (not path.is_file() or digest(path) != record['files'][relative]):
print(f'WARN Preserving modified stale skill file: {relative}')
continue
path.unlink(missing_ok=True)
del record['files'][relative]
self.save()
record['complete'] = True
self.save()
def sync(self, entries, clients, selected, action='sync'):
desired = {entry['name']: entry for entry in entries if entry['name'] in selected}
for record in list(self.state['resources']):
if record['client'] not in clients:
continue
if (action == 'sync' and record['name'] not in desired) or (action == 'remove' and record['name'] in selected):
self.remove(record)
if action != 'remove':
for client in clients:
for entry in desired.values():
self.ensure(client, entry)
print('EXTENSIONS: reconciliation complete' + (' (dry run)' if self.dry_run else ''))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=('sync', 'install', 'remove'))
parser.add_argument('--home', required=True)
parser.add_argument('--manifest', required=True)
parser.add_argument('--client', choices=('codex', 'claude', 'both'), required=True)
parser.add_argument('--selected', nargs='*')
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--update', action='store_true')
parser.add_argument('--summary', action='store_true')
args = parser.parse_args()
with open(args.manifest, encoding='utf-8-sig', newline='') as stream:
entries = list(csv.DictReader(stream, delimiter='\t'))
names = {entry['name'] for entry in entries}
if len(names) != len(entries):
raise ValueError('Duplicate extension manifest entry.')
selected = names if args.selected is None else set(args.selected)
if args.action != 'remove' and selected - names:
raise ValueError('Unknown selected extension.')
clients = ('claude', 'codex') if args.client == 'both' else (args.client,)
manager = Manager(args.home, args.dry_run, args.update, args.summary)
if args.dry_run:
manager.sync(entries, clients, selected, args.action)
return
lock = safe_path(manager.home, '.my-ai-configuration/extensions.lock')
lock.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
try:
os.close(descriptor)
manager = Manager(args.home, args.dry_run, args.update, args.summary)
manager.sync(entries, clients, selected, args.action)
finally:
lock.unlink()
if __name__ == '__main__':
try:
main()
except (OSError, ValueError, KeyError, subprocess.SubprocessError, zipfile.BadZipFile) as error:
print(f'Managed extensions: {error}', file=sys.stderr)
sys.exit(1)
+11 -5
View File
@@ -11,7 +11,7 @@ function Get-Sha256Hash {
} }
function Get-Sha256HashOfBytes { function Get-Sha256HashOfBytes {
param([Parameter(Mandatory=$true)][byte[]]$Bytes) param([Parameter(Mandatory=$true)][AllowEmptyCollection()][byte[]]$Bytes)
$sha256 = [System.Security.Cryptography.SHA256]::Create() $sha256 = [System.Security.Cryptography.SHA256]::Create()
try { return ([System.BitConverter]::ToString($sha256.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } try { return ([System.BitConverter]::ToString($sha256.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() }
finally { $sha256.Dispose() } finally { $sha256.Dispose() }
@@ -55,7 +55,8 @@ function Sync-ManagedDestination {
[Parameter(Mandatory=$true)][string]$Stamp, [Parameter(Mandatory=$true)][string]$Stamp,
[string]$AiConfigRoot, [string]$AiConfigRoot,
[string]$ShellCommand, [string]$ShellCommand,
[string]$WindowsShellCommand, [string]$PowerShellCommand,
[bool]$FlashbangEnabled = $true,
[switch]$DryRun, [switch]$DryRun,
[switch]$Summary [switch]$Summary
) )
@@ -69,10 +70,15 @@ function Sync-ManagedDestination {
Get-ChildItem $Source -File -Recurse | ForEach-Object { Get-ChildItem $Source -File -Recurse | ForEach-Object {
$relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/') $relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/')
$target = Join-Path $Destination $relative $target = Join-Path $Destination $relative
$rawContent = Get-Content -LiteralPath $_.FullName -Raw $rawContent = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8
$needsSubstitution = $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__WINDOWS_HOOK_COMMAND__') $filterFlashbang = -not $FlashbangEnabled -and $relative -in @('settings.json', 'config.toml')
if ($filterFlashbang) {
$rawContent = (& python (Join-Path $PSScriptRoot 'install-options.py') filter --path $_.FullName --flashbang false | Out-String)
if ($LASTEXITCODE -ne 0) { throw 'Could not configure Flashbang.' }
}
$needsSubstitution = $filterFlashbang -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__')
if ($needsSubstitution) { if ($needsSubstitution) {
$finalContent = $rawContent.Replace('__AI_CONFIG_ROOT__', $AiConfigRoot).Replace('__HOOK_COMMAND__', $ShellCommand).Replace('__WINDOWS_HOOK_COMMAND__', $WindowsShellCommand).Replace('__HOOK_SCRIPT__', 'flashbang.ps1').Replace('__WINDOWS_HOOK_SCRIPT__', 'flashbang.ps1') $finalContent = $rawContent.Replace('__AI_CONFIG_ROOT__', $AiConfigRoot).Replace('__HOOK_COMMAND__', $ShellCommand).Replace('__POWERSHELL_HOOK_COMMAND__', $PowerShellCommand).Replace('__POWERSHELL_COMMAND__', $PowerShellCommand).Replace('__HOOK_SCRIPT__', 'flashbang.ps1').Replace('__POWERSHELL_HOOK_SCRIPT__', 'flashbang.ps1')
$newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent) $newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent)
} else { } else {
$newBytes = [System.IO.File]::ReadAllBytes($_.FullName) $newBytes = [System.IO.File]::ReadAllBytes($_.FullName)
+10 -5
View File
@@ -49,9 +49,9 @@ write_managed_manifest() {
} > "$path" } > "$path"
} }
# sync_managed_destination <source-dir> <destination-dir> <stamp> <ai-config-root> <shell-command> <windows-shell-command> <dry-run: true|false> [summary: true|false] # sync_managed_destination <source-dir> <destination-dir> <stamp> <ai-config-root> <shell-command> <powershell-command> <dry-run: true|false> [summary: true|false]
sync_managed_destination() { sync_managed_destination() {
local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 windows_shell_command=$6 dry_run=$7 summary=${8:-false} local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 powershell_command=$6 dry_run=$7 summary=${8:-false} flashbang_enabled=${9:-true}
[ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination" [ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination"
local manifest local manifest
manifest=$(manifest_path "$destination") manifest=$(manifest_path "$destination")
@@ -67,13 +67,18 @@ sync_managed_destination() {
target="$destination/$relative" target="$destination/$relative"
content=$(<"$source_file") content=$(<"$source_file")
needs_sub=false needs_sub=false
if [[ "$content" == *'__AI_CONFIG_ROOT__'* || "$content" == *'__HOOK_COMMAND__'* || "$content" == *'__WINDOWS_HOOK_COMMAND__'* ]]; then if [ "$flashbang_enabled" = false ] && [[ "$relative" = settings.json || "$relative" = config.toml ]]; then
content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter --path "$source_file" --flashbang false) || return
needs_sub=true
fi
if [[ "$needs_sub" = true || "$content" == *'__AI_CONFIG_ROOT__'* || "$content" == *'__HOOK_COMMAND__'* || "$content" == *'__POWERSHELL_HOOK_COMMAND__'* || "$content" == *'__POWERSHELL_COMMAND__'* ]]; then
needs_sub=true needs_sub=true
content=${content//__AI_CONFIG_ROOT__/$ai_config_root} content=${content//__AI_CONFIG_ROOT__/$ai_config_root}
content=${content//__HOOK_COMMAND__/$shell_command} content=${content//__HOOK_COMMAND__/$shell_command}
content=${content//__WINDOWS_HOOK_COMMAND__/$windows_shell_command} content=${content//__POWERSHELL_HOOK_COMMAND__/$powershell_command}
content=${content//__POWERSHELL_COMMAND__/$powershell_command}
content=${content//__HOOK_SCRIPT__/flashbang.sh} content=${content//__HOOK_SCRIPT__/flashbang.sh}
content=${content//__WINDOWS_HOOK_SCRIPT__/flashbang.sh} content=${content//__POWERSHELL_HOOK_SCRIPT__/flashbang.sh}
new_hash=$(sha256_of_string "$content") new_hash=$(sha256_of_string "$content")
else else
new_hash=$(sha256_of_file "$source_file") new_hash=$(sha256_of_file "$source_file")
+67 -138
View File
@@ -19,7 +19,7 @@ function Invoke-PluginCommand {
$output = if ($script:PluginNonInteractive) { @($null | & $resolvedCommand @Arguments 2>&1) } else { @(& $resolvedCommand @Arguments 2>&1) } $output = if ($script:PluginNonInteractive) { @($null | & $resolvedCommand @Arguments 2>&1) } else { @(& $resolvedCommand @Arguments 2>&1) }
$exitCode = $LASTEXITCODE $exitCode = $LASTEXITCODE
} finally { $ErrorActionPreference = $previousPreference } } finally { $ErrorActionPreference = $previousPreference }
if (-not $Summary -or $env:AI_CONFIG_VERBOSE -eq '1') { $output | Write-Output } elseif ($exitCode -eq 0) { $output | Where-Object { "$_" -match '(?i)warn|error|fail|deprecat' } } if ($exitCode -ne 0 -or -not $Summary -or $env:AI_CONFIG_VERBOSE -eq '1') { $output | Write-Output } else { $output | Where-Object { "$_" -match '(?i)warn|error|fail|deprecat' } }
if ($exitCode -ne 0) { throw "Plugin command failed: $display" } if ($exitCode -ne 0) { throw "Plugin command failed: $display" }
} }
@@ -35,50 +35,30 @@ function Get-ConfiguredPluginEntries {
} }
function Get-InstalledPlugins { function Get-InstalledPlugins {
param([ValidateSet('Claude','Codex')][string]$Client)
if ($Client -eq 'Claude') {
if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { throw 'Claude Code CLI is required to manage Claude plugins.' }
$items = (claude plugin list --json | Out-String) | ConvertFrom-Json
return @($items)
}
if (-not (Get-Command codex -ErrorAction SilentlyContinue)) { throw 'Codex CLI is required to manage Codex plugins.' }
$items = (codex plugin list --json | Out-String) | ConvertFrom-Json
return @($items.installed)
}
function Ensure-CodexMarketplace {
param( param(
[Parameter(Mandatory=$true)][string]$Source, [ValidateSet('Claude','Codex')][string]$Client,
[Parameter(Mandatory=$true)][string]$MarketplaceName, [string]$HomePath = [Environment]::GetFolderPath('UserProfile')
[switch]$DryRun,
[switch]$Summary
) )
if ($DryRun) { $command = Get-Command $Client.ToLowerInvariant() -ErrorAction Stop
if (-not $Summary) { Write-Output "DRYRUN codex plugin marketplace add $Source" } $environment = @{
return HOME = $HomePath
USERPROFILE = $HomePath
CODEX_HOME = (Join-Path $HomePath '.codex')
CLAUDE_CONFIG_DIR = (Join-Path $HomePath '.claude')
} }
$previousErrorActionPreference = $ErrorActionPreference $previousEnvironment = @{}
foreach ($name in $environment.Keys) { $previousEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') }
try { try {
$ErrorActionPreference = 'Continue' foreach ($name in $environment.Keys) { [Environment]::SetEnvironmentVariable($name, $environment[$name], 'Process') }
$output = if ($script:PluginNonInteractive) { @($null | & codex plugin marketplace add $Source 2>&1) } else { @(& codex plugin marketplace add $Source 2>&1) } $global:LASTEXITCODE = 0
$exitCode = $LASTEXITCODE $output = $null | & $command plugin list --json | Out-String
} catch { if ($LASTEXITCODE -ne 0) { throw "$Client plugin listing failed." }
$output = @($_) $items = $output | ConvertFrom-Json
$exitCode = 1 if ($Client -eq 'Claude') { return @($items) }
return @($items.installed)
} finally { } finally {
$ErrorActionPreference = $previousErrorActionPreference foreach ($name in $previousEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name], 'Process') }
} }
if (-not $Summary -or $env:AI_CONFIG_VERBOSE -eq '1') { $output | Write-Output } elseif ($exitCode -eq 0) { $output | Where-Object { "$_" -match '(?i)warn|error|fail|deprecat' } }
if ($exitCode -eq 0) { return }
$text = $output -join [Environment]::NewLine
if ($text -match 'marketplace .* already added from a different source') {
Write-Output "WARN Codex marketplace '$MarketplaceName' exists from another source; replacing it with: $Source"
Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','marketplace','remove',$MarketplaceName) -Summary:$Summary
Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','marketplace','add',$Source) -Summary:$Summary
return
}
throw "Plugin command failed: codex plugin marketplace add $Source"
} }
function Read-PluginToggleSelection { function Read-PluginToggleSelection {
@@ -91,12 +71,6 @@ function Read-PluginToggleSelection {
$index=0; while($true){ Clear-Host; Write-Host 'Select plugins'; for($i=0;$i -lt $Names.Count;$i++){ $focus=if($i -eq $index){'>'}else{' '}; $mark=if($state[$i]){'x'}else{' '}; Write-Host "$focus [$mark] $($Names[$i])" }; Write-Host 'Up/Down Navigate Space Toggle Enter Confirm'; $key=[Console]::ReadKey($true); if($key.Key -eq 'UpArrow'){$index=($index+$Names.Count-1)%$Names.Count}elseif($key.Key -eq 'DownArrow'){$index=($index+1)%$Names.Count}elseif($key.Key -eq 'Spacebar'){$state[$index]= -not $state[$index]}elseif($key.Key -eq 'Enter'){Clear-Host;return $state} } $index=0; while($true){ Clear-Host; Write-Host 'Select plugins'; for($i=0;$i -lt $Names.Count;$i++){ $focus=if($i -eq $index){'>'}else{' '}; $mark=if($state[$i]){'x'}else{' '}; Write-Host "$focus [$mark] $($Names[$i])" }; Write-Host 'Up/Down Navigate Space Toggle Enter Confirm'; $key=[Console]::ReadKey($true); if($key.Key -eq 'UpArrow'){$index=($index+$Names.Count-1)%$Names.Count}elseif($key.Key -eq 'DownArrow'){$index=($index+1)%$Names.Count}elseif($key.Key -eq 'Spacebar'){$state[$index]= -not $state[$index]}elseif($key.Key -eq 'Enter'){Clear-Host;return $state} }
} }
function Update-CodexMarketplace {
param([Parameter(Mandatory=$true)][string]$MarketplaceName,[Parameter(Mandatory=$true)][string]$Source,[switch]$DryRun,[switch]$Summary)
try { Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','marketplace','upgrade',$MarketplaceName) -DryRun:$DryRun -Summary:$Summary }
catch { Ensure-CodexMarketplace -Source $Source -MarketplaceName $MarketplaceName -DryRun:$DryRun -Summary:$Summary }
}
function Select-ConfiguredPlugins { function Select-ConfiguredPlugins {
<# <#
.SYNOPSIS .SYNOPSIS
@@ -109,20 +83,23 @@ function Select-ConfiguredPlugins {
[Parameter(Mandatory=$true)][string]$RepositoryRoot, [Parameter(Mandatory=$true)][string]$RepositoryRoot,
[ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client,
[ValidateSet('Install','Update')][Parameter(Mandatory=$true)][string]$Mode, [ValidateSet('Install','Update')][Parameter(Mandatory=$true)][string]$Mode,
[string]$HomePath = [Environment]::GetFolderPath('UserProfile'),
[switch]$DryRun [switch]$DryRun
) )
$entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot) $entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot)
$toggleable = @($entries | Where-Object { $Client -in @('Claude','Both') -or $_.codex_method -eq 'plugin' }) $toggleable = @($entries)
$fixed = @($entries | Where-Object { $toggleable -notcontains $_ }) $fixed = @($entries | Where-Object { $toggleable -notcontains $_ })
if ($DryRun -or $toggleable.Count -eq 0) { return @{ Selected = $entries; Deselected = @() } } if ($DryRun -or $toggleable.Count -eq 0) { return @{ Selected = $entries; Deselected = @() } }
$referenceClient = if ($Client -eq 'Codex') { 'Codex' } else { 'Claude' } $referenceClient = if ($Client -eq 'Codex') { 'Codex' } else { 'Claude' }
$installed = if ($Mode -eq 'Update') { @(Get-InstalledPlugins -Client $referenceClient) } else { @() } $installed = if ($Mode -eq 'Update') { @(Get-InstalledPlugins -Client $referenceClient -HomePath $HomePath) } else { @() }
$idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' } $idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' }
$checked = @($toggleable | ForEach-Object { $checked = @($toggleable | ForEach-Object {
if ($Mode -eq 'Install') { $true; return } if ($Mode -eq 'Install') { $true; return }
$selector = if ($referenceClient -eq 'Claude') { $_.claude_plugin } else { $_.codex_plugin } $selector = if ($referenceClient -eq 'Claude') { $_.claude_plugin } else { $_.codex_plugin }
[bool](@($installed | Where-Object { $_.$idField -eq $selector }).Count) if ($referenceClient -eq 'Codex' -and $_.codex_method -ne 'plugin') {
(Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $_.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $_.codex_skill)))
} else { [bool](@($installed | Where-Object { $_.$idField -eq $selector }).Count) }
}) })
$names = @($toggleable | ForEach-Object { $_.name }) $names = @($toggleable | ForEach-Object { $_.name })
$checked = Read-PluginToggleSelection -Names $names -Checked $checked $checked = Read-PluginToggleSelection -Names $names -Checked $checked
@@ -135,110 +112,62 @@ function Select-ConfiguredPlugins {
return @{ Selected = ($selected + $fixed); Deselected = $deselected } return @{ Selected = ($selected + $fixed); Deselected = $deselected }
} }
function Uninstall-DeselectedPlugins { function Invoke-ManagedExtensions {
param( param(
[Parameter(Mandatory=$true)][AllowEmptyCollection()][object[]]$Entries, [Parameter(Mandatory=$true)][string]$RepositoryRoot,
[ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, [Parameter(Mandatory=$true)][string]$Client,
[string]$HomePath = $HOME,
[ValidateSet('sync','install','remove')][string]$Action = 'sync',
[object[]]$Entries,
[switch]$DryRun, [switch]$DryRun,
[switch]$Update,
[switch]$Summary [switch]$Summary
) )
if ($Entries.Count -eq 0 -and $PSBoundParameters.ContainsKey('Entries')) { return } $arguments = @((Join-Path $PSScriptRoot 'managed-extensions.py'), $Action, '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--client', $Client.ToLowerInvariant())
$clients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) } if ($DryRun) { $arguments += '--dry-run' }
foreach ($selectedClient in $clients) { if ($Update) { $arguments += '--update' }
$installed = if ($DryRun) { @() } else { @(Get-InstalledPlugins -Client $selectedClient) } if ($Summary) { $arguments += '--summary' }
$idField = if ($selectedClient -eq 'Claude') { 'id' } else { 'pluginId' } if ($PSBoundParameters.ContainsKey('Entries')) { $arguments += '--selected'; $arguments += @($Entries | ForEach-Object { $_.name }) }
$removedCount = 0 & python @arguments
foreach ($entry in $Entries) { if ($LASTEXITCODE -ne 0) { throw 'Managed extension reconciliation failed.' }
$plugin = if ($selectedClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin }
if ([string]::IsNullOrWhiteSpace($plugin)) { continue }
$isInstalled = [bool](@($installed | Where-Object { $_.$idField -eq $plugin }).Count)
if (-not $isInstalled) { continue }
if ($selectedClient -eq 'Claude') {
Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','uninstall',$plugin,'--scope','user') -DryRun:$DryRun -Summary:$Summary
} else {
Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','remove',$plugin) -DryRun:$DryRun -Summary:$Summary
}
if (-not $Summary) { Write-Output "PASS $selectedClient plugin removed: $plugin" }
$removedCount++
}
if ($Summary -and $removedCount -gt 0) { Write-Output "PLUGINS ${selectedClient}: $removedCount removed" }
} }
function Sync-ConfiguredPlugins {
param(
[Parameter(Mandatory=$true)][string]$RepositoryRoot,
[ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client,
[string]$HomePath = $HOME,
[object[]]$Entries,
[switch]$DryRun,
[switch]$Update,
[switch]$Summary
)
Invoke-ManagedExtensions @PSBoundParameters -Action sync
} }
function Install-ConfiguredPlugins { function Install-ConfiguredPlugins {
param( param(
[Parameter(Mandatory=$true)][string]$RepositoryRoot, [Parameter(Mandatory=$true)][string]$RepositoryRoot,
[ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client,
[string]$HomePath = $HOME,
[object[]]$Entries,
[switch]$DryRun, [switch]$DryRun,
[switch]$Update, [switch]$Update,
[switch]$Summary, [switch]$Summary
[object[]]$Entries
) )
$entries = if ($PSBoundParameters.ContainsKey('Entries')) { $Entries } else { @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot) } if ($PSBoundParameters.ContainsKey('Entries') -and $Entries.Count -eq 0) { return }
if ($Entries.Count -eq 0 -and $PSBoundParameters.ContainsKey('Entries')) { return } Invoke-ManagedExtensions @PSBoundParameters -Action install
$clients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) }
foreach ($selectedClient in $clients) {
$installed = if ($DryRun) { @() } else { @(Get-InstalledPlugins -Client $selectedClient) }
$tally = @{ Ensured = 0; AlreadyInstalled = 0; Updated = 0 }
foreach ($entry in $entries) {
$marketplace = if ($selectedClient -eq 'Claude') { $entry.claude_marketplace.Trim() } else { $entry.codex_marketplace.Trim() }
if ($marketplace -eq '-') { $marketplace = '' }
$plugin = if ($selectedClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin }
if ([string]::IsNullOrWhiteSpace($plugin)) { throw ('Plugin selector missing for {0}: {1}' -f $selectedClient, $entry.name) }
if ($selectedClient -eq 'Codex' -and $entry.codex_method -ne 'plugin') {
if ($entry.codex_method -eq 'skill') {
$arguments = @('-y','skills','add',$entry.codex_source,'--global','--agent','codex')
if ($entry.codex_skill -ne '-') { $arguments = @('-y','skills','add',$entry.codex_source,'--skill',$entry.codex_skill,'--global','--agent','codex') }
} elseif ($entry.codex_method -eq 'impeccable') {
$arguments = @('-y','impeccable','install','-y','--providers=codex','--scope=global')
} else {
throw "Unknown Codex install method '$($entry.codex_method)' for $($entry.name)."
}
Invoke-PluginCommand -Command 'npx' -Arguments $arguments -DryRun:$DryRun -Summary:$Summary
if (-not $Summary) { Write-Output "PASS Codex skill ensured: $($entry.name)" }
$tally.Ensured++
continue
} }
$installedItem = if ($selectedClient -eq 'Claude') { $installed | Where-Object { $_.id -eq $plugin } | Select-Object -First 1 } else { $installed | Where-Object { $_.pluginId -eq $plugin } | Select-Object -First 1 } function Uninstall-DeselectedPlugins {
if ($Update -and $selectedClient -eq 'Claude' -and $null -ne $installedItem) { param(
Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','update',$plugin) -DryRun:$DryRun -Summary:$Summary [Parameter(Mandatory=$true)][AllowEmptyCollection()][object[]]$Entries,
if (-not $installedItem.enabled) { [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client,
Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','enable',$plugin) -DryRun:$DryRun -Summary:$Summary [string]$RepositoryRoot = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent),
[string]$HomePath = $HOME,
[switch]$DryRun,
[switch]$Summary
)
if ($Entries.Count -eq 0) { return }
Invoke-ManagedExtensions -RepositoryRoot $RepositoryRoot -Client $Client -HomePath $HomePath -Entries $Entries -Action remove -DryRun:$DryRun -Summary:$Summary
} }
$tally.Updated++
continue
}
if (-not $Update -and $null -ne $installedItem) {
if ($selectedClient -eq 'Claude' -and -not $installedItem.enabled) {
Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','enable',$plugin) -DryRun:$DryRun -Summary:$Summary
}
if (-not $Summary) { Write-Output "PASS $selectedClient plugin already installed: $plugin" }
$tally.AlreadyInstalled++
continue
}
if ($selectedClient -eq 'Claude') {
if (-not [string]::IsNullOrWhiteSpace($marketplace)) {
Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','marketplace','add',$marketplace) -DryRun:$DryRun -Summary:$Summary
}
Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','install',$plugin,'--scope','user') -DryRun:$DryRun -Summary:$Summary
} else {
if ($Update -and -not [string]::IsNullOrWhiteSpace($marketplace) -and $marketplace -ne 'openai-curated-remote') {
Update-CodexMarketplace -MarketplaceName (($plugin -split '@')[-1]) -Source $marketplace -DryRun:$DryRun -Summary:$Summary
}
if (-not [string]::IsNullOrWhiteSpace($marketplace) -and $marketplace -ne 'openai-curated-remote') {
$marketplaceName = ($plugin -split '@')[-1]
Ensure-CodexMarketplace -Source $marketplace -MarketplaceName $marketplaceName -DryRun:$DryRun -Summary:$Summary
}
Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','add',$plugin) -DryRun:$DryRun -Summary:$Summary
}
if (-not $Summary) { Write-Output "PASS $selectedClient plugin ensured: $plugin" }
if ($Update -and $null -ne $installedItem) { $tally.Updated++ } else { $tally.Ensured++ }
}
if ($Summary) { Write-Output ("PLUGINS {0}: {1} ensured, {2} already installed, {3} updated" -f $selectedClient, $tally.Ensured, $tally.AlreadyInstalled, $tally.Updated) }
}
}
+33 -141
View File
@@ -22,7 +22,7 @@ run_plugin_command() {
printf '%s\n' "$output" | grep -Ei 'warn|error|fail|deprecat' || true printf '%s\n' "$output" | grep -Ei 'warn|error|fail|deprecat' || true
else else
status=$? status=$?
[ "${AI_CONFIG_VERBOSE:-}" = 1 ] && printf '%s\n' "$output" >&2 printf '%s\n' "$output" >&2
printf 'Plugin command failed: %s\n' "$*" >&2 printf 'Plugin command failed: %s\n' "$*" >&2
return "$status" return "$status"
fi fi
@@ -31,33 +31,6 @@ run_plugin_command() {
fi fi
} }
update_codex_marketplace() {
local dry_run=$1 marketplace_name=$2 source=$3
if ! run_plugin_command "$dry_run" codex plugin marketplace upgrade "$marketplace_name"; then
ensure_codex_marketplace "$dry_run" "$source" "$marketplace_name"
fi
}
ensure_codex_marketplace() {
local dry_run=$1 source=$2 marketplace_name=$3 output
if [ "$dry_run" = true ]; then
[ "${summary:-false}" = true ] || printf 'DRYRUN codex plugin marketplace add %s\n' "$source"
return 0
fi
if output=$(codex plugin marketplace add "$source" 2>&1); then
[ -z "$output" ] || printf '%s\n' "$output"
return 0
fi
printf '%s\n' "$output" >&2
if printf '%s\n' "$output" | grep -Eiq 'marketplace .* already added from a different source'; then
printf "WARN Codex marketplace '%s' exists from another source; replacing it with: %s\n" "$marketplace_name" "$source"
run_plugin_command false codex plugin marketplace remove "$marketplace_name"
run_plugin_command false codex plugin marketplace add "$source"
return 0
fi
return 1
}
get_plugin_names() { get_plugin_names() {
local root=$1 name rest local root=$1 name rest
while IFS=$'\t' read -r name rest; do while IFS=$'\t' read -r name rest; do
@@ -80,9 +53,7 @@ plugin_field() {
} }
plugin_toggleable() { plugin_toggleable() {
local root=$1 name=$2 client=$3 return 0
case "$client" in claude|both) return 0;; esac
[ "$(plugin_field "$root" "$name" codex_method)" = plugin ]
} }
# read_plugin_toggle_selection <names-array-name> <checked-array-name> # read_plugin_toggle_selection <names-array-name> <checked-array-name>
@@ -138,7 +109,11 @@ select_configured_plugins() {
continue continue
fi fi
if [ "$reference_client" = claude ]; then selector=$(plugin_field "$root" "$name" claude_plugin); else selector=$(plugin_field "$root" "$name" codex_plugin); fi if [ "$reference_client" = claude ]; then selector=$(plugin_field "$root" "$name" claude_plugin); else selector=$(plugin_field "$root" "$name" codex_plugin); fi
if plugin_installed "$reference_client" "$selector"; then checked+=(true); else checked+=(false); fi if [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then
local skill_name
skill_name=$(plugin_field "$root" "$name" codex_skill)
if [ -d "$HOME/.agents/skills/$skill_name" ] || [ -d "$HOME/.codex/skills/$skill_name" ]; then checked+=(true); else checked+=(false); fi
elif plugin_installed "$reference_client" "$selector"; then checked+=(true); else checked+=(false); fi
done done
read_plugin_toggle_selection toggle_names checked read_plugin_toggle_selection toggle_names checked
@@ -150,118 +125,35 @@ select_configured_plugins() {
selected_ref+=("${fixed_names[@]}") selected_ref+=("${fixed_names[@]}")
} }
# uninstall_deselected_plugins <root> <client> <dry_run> <deselected-array-name> [summary] managed_extensions() {
uninstall_deselected_plugins() { local action=$1 root=$2 client=$3 home_path=$4 dry_run=$5 update=$6 selected_name=${7:-} summary=${8:-false}
local root=$1 client_selection=$2 dry_run=$3 local arguments=("$root/scripts/lib/managed-extensions.py" "$action" --home "$home_path" --manifest "$root/adapters/plugins.tsv" --client "$client")
local -n names_ref=$4 [ "$dry_run" != true ] || arguments+=(--dry-run)
local summary=${5:-false} [ "$update" != true ] || arguments+=(--update)
local clients=() [ "$summary" != true ] || arguments+=(--summary)
case "$client_selection" in
codex) clients=(codex);;
claude) clients=(claude);;
both) clients=(claude codex);;
esac
local client name plugin removed
for client in "${clients[@]}"; do
removed=0
for name in "${names_ref[@]}"; do
if [ "$client" = claude ]; then plugin=$(plugin_field "$root" "$name" claude_plugin); else plugin=$(plugin_field "$root" "$name" codex_plugin); fi
[ -n "$plugin" ] || continue
if [ "$dry_run" = false ] && plugin_installed "$client" "$plugin"; then
if [ "$client" = claude ]; then
run_plugin_command "$dry_run" claude plugin uninstall "$plugin" --scope user
else
run_plugin_command "$dry_run" codex plugin remove "$plugin"
fi
[ "$summary" = true ] || printf 'PASS %s plugin removed: %s\n' "$client" "$plugin"
removed=$((removed + 1))
fi
done
if [ "$summary" = true ] && [ "$removed" -gt 0 ]; then printf 'PLUGINS %s: %s removed\n' "$client" "$removed"; fi
done
}
# install_configured_plugins <root> <client> <dry_run> <update> [selected-names-array-name] [summary]
install_configured_plugins() {
local root=$1 client_selection=$2 dry_run=$3 update=$4 selected_name=${5:-} summary=${6:-false}
local manifest="$root/adapters/plugins.tsv"
[ -f "$manifest" ] || { printf 'Plugin manifest is missing: %s\n' "$manifest" >&2; return 1; }
local -A selected_set=()
if [ -n "$selected_name" ]; then if [ -n "$selected_name" ]; then
local -n selected_names_ref=$selected_name local -n extension_names_ref=$selected_name
local sn arguments+=(--selected "${extension_names_ref[@]}")
for sn in "${selected_names_ref[@]}"; do selected_set[$sn]=1; done
fi fi
local ensured=0 already_installed=0 updated_count=0 python3 "${arguments[@]}"
}
local clients=() sync_configured_plugins() {
case "$client_selection" in managed_extensions sync "$@"
codex) clients=(codex);; }
claude) clients=(claude);;
both) clients=(claude codex);;
*) printf 'Unknown plugin client selection: %s\n' "$client_selection" >&2; return 1;;
esac
local client name claude_marketplace claude_plugin codex_marketplace codex_plugin codex_method codex_source codex_skill marketplace plugin install_configured_plugins() {
for client in "${clients[@]}"; do local root=$1 client=$2 dry_run=$3 update=$4 selected_name=${5:-} summary=${6:-false} home_path=${7:-$HOME}
ensured=0 already_installed=0 updated_count=0 if [ -n "$selected_name" ]; then
while IFS=$'\t' read -r name claude_marketplace claude_plugin codex_marketplace codex_plugin codex_method codex_source codex_skill; do local -n install_names_ref=$selected_name
[ "$name" = name ] && continue [ "${#install_names_ref[@]}" -gt 0 ] || return 0
[ -n "$name" ] || continue
[ -n "$selected_name" ] && [ -z "${selected_set[$name]+x}" ] && continue
[ "$claude_marketplace" = - ] && claude_marketplace=
[ "$codex_marketplace" = - ] && codex_marketplace=
if [ "$client" = claude ]; then marketplace=$claude_marketplace; plugin=$claude_plugin; else marketplace=$codex_marketplace; plugin=$codex_plugin; fi
[ -n "$plugin" ] || { printf 'Plugin selector missing for %s: %s\n' "$client" "$name" >&2; return 1; }
if [ "$client" = codex ] && [ "$codex_method" != plugin ]; then
case "$codex_method" in
skill)
if [ "$codex_skill" = - ]; then
run_plugin_command "$dry_run" npx -y skills add "$codex_source" --global --agent codex
else
run_plugin_command "$dry_run" npx -y skills add "$codex_source" --skill "$codex_skill" --global --agent codex
fi fi
;; managed_extensions install "$root" "$client" "$home_path" "$dry_run" "$update" "$selected_name" "$summary"
impeccable) }
run_plugin_command "$dry_run" npx -y impeccable install -y --providers=codex --scope=global
;; uninstall_deselected_plugins() {
*) printf 'Unknown Codex install method for %s: %s\n' "$client" "$name" >&2; return 1;; local root=$1 client=$2 dry_run=$3 selected_name=$4 summary=${5:-false} home_path=${6:-$HOME}
esac local -n removal_names_ref=$selected_name
[ "$summary" = true ] || printf 'PASS Codex skill ensured: %s\n' "$name" [ "${#removal_names_ref[@]}" -gt 0 ] || return 0
ensured=$((ensured + 1)) managed_extensions remove "$root" "$client" "$home_path" "$dry_run" false "$selected_name" "$summary"
continue
fi
if [ "$dry_run" = false ] && plugin_installed "$client" "$plugin"; then
if [ "$update" = true ] && [ "$client" = claude ]; then
run_plugin_command "$dry_run" claude plugin update "$plugin"
run_plugin_command "$dry_run" claude plugin enable "$plugin"
updated_count=$((updated_count + 1))
elif [ "$update" = true ] && [ "$client" = codex ]; then
[ -z "$marketplace" ] || [ "$marketplace" = openai-curated-remote ] || update_codex_marketplace "$dry_run" "${plugin##*@}" "$marketplace"
run_plugin_command "$dry_run" codex plugin add "$plugin"
updated_count=$((updated_count + 1))
else
[ "$summary" = true ] || printf 'PASS %s plugin already installed: %s\n' "$client" "$plugin"
already_installed=$((already_installed + 1))
fi
continue
fi
if [ "$client" = claude ]; then
[ -z "$marketplace" ] || run_plugin_command "$dry_run" claude plugin marketplace add "$marketplace"
run_plugin_command "$dry_run" claude plugin install "$plugin" --scope user
else
if [ -n "$marketplace" ] && [ "$marketplace" != openai-curated-remote ]; then
marketplace_name=${plugin##*@}
ensure_codex_marketplace "$dry_run" "$marketplace" "$marketplace_name"
fi
run_plugin_command "$dry_run" codex plugin add "$plugin"
fi
[ "$summary" = true ] || printf 'PASS %s plugin ensured: %s\n' "$client" "$plugin"
ensured=$((ensured + 1))
done < "$manifest"
if [ "$summary" = true ]; then
printf 'PLUGINS %s: %s ensured, %s already installed, %s updated\n' "$client" "$ensured" "$already_installed" "$updated_count"
fi
done
} }
+7 -4
View File
@@ -1,13 +1,16 @@
function Read-UpdateSelection { function Read-UpdateSelection {
param([string]$HomePath, [string]$RepositoryRoot) param([string]$HomePath, [string]$RepositoryRoot, [switch]$AllowLegacy)
$output = & python (Join-Path $PSScriptRoot 'selection-state.py') read --home $HomePath --manifest (Join-Path $RepositoryRoot 'adapters/plugins.tsv') $arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'read', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'))
if ($AllowLegacy) { $arguments += '--allow-legacy' }
$output = & python @arguments
if ($LASTEXITCODE -ne 0) { throw 'Could not load the saved selection.' } if ($LASTEXITCODE -ne 0) { throw 'Could not load the saved selection.' }
return ($output | Out-String | ConvertFrom-Json) return ($output | Out-String | ConvertFrom-Json)
} }
function Save-UpdateSelection { function Save-UpdateSelection {
param([string]$HomePath, [string]$RepositoryRoot, [string]$Platform, [string]$Client, [hashtable]$Plugins) param([string]$HomePath, [string]$RepositoryRoot, [string]$Shell, [string]$Client, [hashtable]$Plugins,
$arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'write', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--platform', $Platform.ToLowerInvariant(), '--client', $Client.ToLowerInvariant(), '--selected') [Parameter(Mandatory=$true)][bool]$UpdateAgents, [Parameter(Mandatory=$true)][bool]$Flashbang)
$arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'write', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--shell', $Shell.ToLowerInvariant(), '--client', $Client.ToLowerInvariant(), '--update-agents', $UpdateAgents.ToString().ToLowerInvariant(), '--flashbang', $Flashbang.ToString().ToLowerInvariant(), '--selected')
$arguments += @($Plugins.Selected | ForEach-Object { $_.name }) $arguments += @($Plugins.Selected | ForEach-Object { $_.name })
$arguments += '--deselected' $arguments += '--deselected'
$arguments += @($Plugins.Deselected | ForEach-Object { $_.name }) $arguments += @($Plugins.Deselected | ForEach-Object { $_.name })
+23 -8
View File
@@ -7,11 +7,19 @@ import sys
import tempfile import tempfile
def validate(state): def validate(state, allow_legacy=False):
if not isinstance(state, dict) or state.get('version') != 1: if isinstance(state, dict) and state.get('version') == 1:
if not allow_legacy:
raise ValueError('Saved selection predates install options. Run update once without Quick.')
state = dict(state, version=2, shell={'windows': 'powershell', 'linux': 'bash'}.get(state.get('platform')),
update_agents=False, flashbang=True)
state.pop('platform', None)
if not isinstance(state, dict) or state.get('version') != 2:
raise ValueError('Unsupported saved selection.') raise ValueError('Unsupported saved selection.')
if state.get('platform') not in ('windows', 'linux') or state.get('client') not in ('codex', 'claude', 'both'): if state.get('shell') not in ('powershell', 'bash') or state.get('client') not in ('codex', 'claude', 'both'):
raise ValueError('Invalid saved platform or client.') raise ValueError('Invalid saved shell or client.')
if any(type(state.get(key)) is not bool for key in ('update_agents', 'flashbang')):
raise ValueError('Invalid saved install options. Run update once without Quick.')
for key in ('selected', 'deselected'): for key in ('selected', 'deselected'):
values = state.get(key) values = state.get(key)
if not isinstance(values, list) or any(not isinstance(value, str) or not value or any(ord(c) < 32 for c in value) for value in values): if not isinstance(values, list) or any(not isinstance(value, str) or not value or any(ord(c) < 32 for c in value) for value in values):
@@ -29,7 +37,10 @@ def main():
parser.add_argument('--home', required=True) parser.add_argument('--home', required=True)
parser.add_argument('--manifest', required=True) parser.add_argument('--manifest', required=True)
parser.add_argument('--format', choices=('json', 'tsv'), default='json') parser.add_argument('--format', choices=('json', 'tsv'), default='json')
parser.add_argument('--platform') parser.add_argument('--shell')
parser.add_argument('--allow-legacy', action='store_true')
parser.add_argument('--update-agents', choices=('true', 'false'))
parser.add_argument('--flashbang', choices=('true', 'false'))
parser.add_argument('--client') parser.add_argument('--client')
parser.add_argument('--selected', nargs='*', default=[]) parser.add_argument('--selected', nargs='*', default=[])
parser.add_argument('--deselected', nargs='*', default=[]) parser.add_argument('--deselected', nargs='*', default=[])
@@ -40,19 +51,23 @@ def main():
if args.action == 'read': if args.action == 'read':
if not path.is_file(): if not path.is_file():
raise ValueError('No saved selection. Run install or update once without Quick.') raise ValueError('No saved selection. Run install or update once without Quick.')
state = validate(json.loads(path.read_text(encoding='utf-8-sig'))) state = validate(json.loads(path.read_text(encoding='utf-8-sig')), args.allow_legacy)
for key in ('selected', 'deselected'): for key in ('selected', 'deselected'):
state[key] = [name for name in state[key] if name in names] state[key] = [name for name in state[key] if name in names]
if args.format == 'json': if args.format == 'json':
print(json.dumps(state)) print(json.dumps(state))
else: else:
print('platform\t' + state['platform']) print('shell\t' + state['shell'])
print('client\t' + state['client']) print('client\t' + state['client'])
for key in ('update_agents', 'flashbang'):
print(key + '\t' + str(state[key]).lower())
for key in ('selected', 'deselected'): for key in ('selected', 'deselected'):
for name in state[key]: for name in state[key]:
print(key + '\t' + name) print(key + '\t' + name)
return return
state = validate(dict(version=1, platform=args.platform, client=args.client, state = validate(dict(version=2, shell=args.shell, client=args.client,
update_agents=None if args.update_agents is None else args.update_agents == 'true',
flashbang=None if args.flashbang is None else args.flashbang == 'true',
selected=args.selected, deselected=args.deselected)) selected=args.selected, deselected=args.deselected))
if (set(state['selected']) | set(state['deselected'])) - names: if (set(state['selected']) | set(state['deselected'])) - names:
raise ValueError('Unknown plugin in selection.') raise ValueError('Unknown plugin in selection.')
+5 -2
View File
@@ -8,8 +8,10 @@ read_update_selection() {
while IFS=$'\t' read -r key value; do while IFS=$'\t' read -r key value; do
value=${value%$'\r'} value=${value%$'\r'}
case "$key" in case "$key" in
platform) [ -n "$platform" ] || platform=$value ;; shell) [ -n "$shell" ] || shell=$value ;;
client) [ -n "$client" ] || client=$value ;; client) [ -n "$client" ] || client=$value ;;
update_agents) update_agents=$value ;;
flashbang) flashbang=$value ;;
selected) selected_plugins+=("$value") ;; selected) selected_plugins+=("$value") ;;
deselected) deselected_plugins+=("$value") ;; deselected) deselected_plugins+=("$value") ;;
esac esac
@@ -18,5 +20,6 @@ read_update_selection() {
save_update_selection() { save_update_selection() {
python3 "$root/scripts/lib/selection-state.py" write --home "$home_path" --manifest "$root/adapters/plugins.tsv" \ python3 "$root/scripts/lib/selection-state.py" write --home "$home_path" --manifest "$root/adapters/plugins.tsv" \
--platform "$platform" --client "$client" --selected "${selected_plugins[@]}" --deselected "${deselected_plugins[@]}" --shell "$shell" --client "$client" --update-agents "$update_agents" --flashbang "$flashbang" \
--selected "${selected_plugins[@]}" --deselected "${deselected_plugins[@]}"
} }
+5 -5
View File
@@ -3,21 +3,21 @@ param([switch]$Summary)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent $root = Split-Path $PSScriptRoot -Parent
$generated = Join-Path $root 'generated' $generated = Join-Path $root 'generated'
if (-not (Test-Path (Join-Path $generated 'claude-linux/CLAUDE.md'))) { throw 'Generated output is missing; run build first.' } if (-not (Test-Path (Join-Path $generated 'claude-bash/CLAUDE.md'))) { throw 'Generated output is missing; run build first.' }
function Measure-Tree([string]$path) { function Measure-Tree([string]$path) {
if (-not (Test-Path $path)) { return 0 } if (-not (Test-Path $path)) { return 0 }
return [int64]((Get-ChildItem $path -File -Recurse | ForEach-Object { $_.Length } | Measure-Object -Sum).Sum) return [int64]((Get-ChildItem $path -File -Recurse | ForEach-Object { $_.Length } | Measure-Object -Sum).Sum)
} }
function Tokens([int64]$bytes) { return [int][Math]::Ceiling($bytes / 4.0) } function Tokens([int64]$bytes) { return [int][Math]::Ceiling($bytes / 4.0) }
$claude = (Get-Item (Join-Path $generated 'claude-linux/CLAUDE.md')).Length $claude = (Get-Item (Join-Path $generated 'claude-bash/CLAUDE.md')).Length
$codex = (Get-Item (Join-Path $generated 'codex-linux/AGENTS.md')).Length $codex = (Get-Item (Join-Path $generated 'codex-bash/AGENTS.md')).Length
$skills = Measure-Tree (Join-Path $generated 'claude-linux/skills') $skills = Measure-Tree (Join-Path $generated 'claude-bash/skills')
$triggers = (Get-Item (Join-Path $root 'adapters/claude/rule-skills.tsv')).Length $triggers = (Get-Item (Join-Path $root 'adapters/claude/rule-skills.tsv')).Length
$lazyItem = Get-ChildItem (Join-Path $root 'shared/rules') -File -Recurse | Sort-Object Length -Descending | Select-Object -First 1 $lazyItem = Get-ChildItem (Join-Path $root 'shared/rules') -File -Recurse | Sort-Object Length -Descending | Select-Object -First 1
$lazy = if ($lazyItem) { [int64]$lazyItem.Length } else { 0 } $lazy = if ($lazyItem) { [int64]$lazyItem.Length } else { 0 }
$baseline = Join-Path $root 'adapters/prompt-budget-baseline.json' $baseline = Join-Path $root 'adapters/prompt-budget-baseline.json'
$old = if (Test-Path $baseline) { Get-Content $baseline -Raw | ConvertFrom-Json } else { $null } $old = if (Test-Path $baseline) { Get-Content $baseline -Raw | ConvertFrom-Json } else { $null }
$rows = @([pscustomobject]@{Name='Claude permanent context';Bytes=$claude;Tokens=(Tokens $claude)},[pscustomobject]@{Name='Codex permanent context';Bytes=$codex;Tokens=(Tokens $codex)},[pscustomobject]@{Name='Claude skill metadata';Bytes=$skills;Tokens=(Tokens $skills)},[pscustomobject]@{Name='Rule trigger metadata';Bytes=$triggers;Tokens=(Tokens $triggers)},[pscustomobject]@{Name='Largest lazy rule';Bytes=$lazy;Tokens=(Tokens $lazy)}) $rows = @([pscustomobject]@{Name='Claude permanent context';Bytes=$claude;Tokens=(Tokens $claude)},[pscustomobject]@{Name='Codex permanent context';Bytes=$codex;Tokens=(Tokens $codex)},[pscustomobject]@{Name='Claude skill files (lazy)';Bytes=$skills;Tokens=(Tokens $skills)},[pscustomobject]@{Name='Rule trigger metadata';Bytes=$triggers;Tokens=(Tokens $triggers)},[pscustomobject]@{Name='Largest lazy rule';Bytes=$lazy;Tokens=(Tokens $lazy)})
$fail = $false $fail = $false
if ($old) { foreach ($row in $rows | Select-Object -First 2) { $previous = [double]$old.($row.Name); if ($previous -gt 0 -and $row.Bytes -gt $previous * 1.15) { $fail = $true } } } if ($old) { foreach ($row in $rows | Select-Object -First 2) { $previous = [double]$old.($row.Name); if ($previous -gt 0 -and $row.Bytes -gt $previous * 1.15) { $fail = $true } } }
if ($Summary) { Write-Output 'Prompt Budget'; $rows | ForEach-Object { Write-Output ('{0,-28} {1,8} bytes {2,6} tokens' -f $_.Name,$_.Bytes,$_.Tokens) } } else { $rows | ForEach-Object { Write-Output ('{0}: {1} bytes / {2} tokens' -f $_.Name,$_.Bytes,$_.Tokens) } } if ($Summary) { Write-Output 'Prompt Budget'; $rows | ForEach-Object { Write-Output ('{0,-28} {1,8} bytes {2,6} tokens' -f $_.Name,$_.Bytes,$_.Tokens) } } else { $rows | ForEach-Object { Write-Output ('{0}: {1} bytes / {2} tokens' -f $_.Name,$_.Bytes,$_.Tokens) } }
+4 -3
View File
@@ -4,11 +4,12 @@ summary=false
for arg in "$@"; do case "$arg" in --summary) summary=true;; *) printf 'Unknown argument: %s\n' "$arg" >&2; exit 1;; esac; done for arg in "$@"; do case "$arg" in --summary) summary=true;; *) printf 'Unknown argument: %s\n' "$arg" >&2; exit 1;; esac; done
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
generated="$root/generated" generated="$root/generated"
[ -f "$generated/claude-linux/CLAUDE.md" ] || { printf 'Generated output is missing; run build first.\n' >&2; exit 1; } [ -f "$generated/claude-bash/CLAUDE.md" ] || { printf 'Generated output is missing; run build first.\n' >&2; exit 1; }
bytes() { wc -c < "$1" | tr -d ' '; } bytes() { wc -c < "$1" | tr -d ' '; }
tree_bytes() { find "$1" -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s+0}'; } tree_bytes() { find "$1" -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s+0}'; }
tokens() { awk -v b="$1" 'BEGIN { printf "%d", (b + 3) / 4 }'; } tokens() { awk -v b="$1" 'BEGIN { printf "%d", (b + 3) / 4 }'; }
claude=$(bytes "$generated/claude-linux/CLAUDE.md"); codex=$(bytes "$generated/codex-linux/AGENTS.md"); skills=$(tree_bytes "$generated/claude-linux/skills"); triggers=$(bytes "$root/adapters/claude/rule-skills.tsv"); lazy=$(find "$root/shared/rules" -type f -printf '%s %p\n' | sort -nr | awk 'NR==1 {print $1+0}'); fail=false claude=$(bytes "$generated/claude-bash/CLAUDE.md"); codex=$(bytes "$generated/codex-bash/AGENTS.md"); skills=$(tree_bytes "$generated/claude-bash/skills"); triggers=$(bytes "$root/adapters/claude/rule-skills.tsv"); lazy=$(find "$root/shared/rules" -type f -printf '%s %p\n' | sort -nr | awk 'NR==1 {print $1+0}'); fail=false
if [ -f "$root/adapters/prompt-budget-baseline.tsv" ]; then while IFS=$'\t' read -r name old; do current=$([ "$name" = Claude ] && printf '%s' "$claude" || printf '%s' "$codex"); awk -v n="$current" -v o="$old" 'BEGIN { exit !(o > 0 && n > o * 1.15) }' && fail=true; done < "$root/adapters/prompt-budget-baseline.tsv"; fi if [ -f "$root/adapters/prompt-budget-baseline.tsv" ]; then while IFS=$'\t' read -r name old; do current=$([ "$name" = Claude ] && printf '%s' "$claude" || printf '%s' "$codex"); awk -v n="$current" -v o="$old" 'BEGIN { exit !(o > 0 && n > o * 1.15) }' && fail=true; done < "$root/adapters/prompt-budget-baseline.tsv"; fi
if [ "$summary" = true ]; then printf 'Prompt Budget\nClaude permanent context %8s bytes %6s tokens\nCodex permanent context %8s bytes %6s tokens\nClaude skill metadata %8s bytes %6s tokens\nRule trigger metadata %8s bytes %6s tokens\nLargest lazy rule %8s bytes %6s tokens\n' "$claude" "$(tokens "$claude")" "$codex" "$(tokens "$codex")" "$skills" "$(tokens "$skills")" "$triggers" "$(tokens "$triggers")" "$lazy" "$(tokens "$lazy")"; else printf 'Prompt budget: Claude %s bytes, Codex %s bytes, skills %s bytes, triggers %s bytes, largest lazy rule %s bytes\n' "$claude" "$codex" "$skills" "$triggers" "$lazy"; fi if [ "$summary" = true ]; then printf 'Prompt Budget\nClaude permanent context %8s bytes %6s tokens\nCodex permanent context %8s bytes %6s tokens\nClaude skill files (lazy) %8s bytes %6s tokens\nRule trigger metadata %8s bytes %6s tokens\nLargest lazy rule %8s bytes %6s tokens\n' "$claude" "$(tokens "$claude")" "$codex" "$(tokens "$codex")" "$skills" "$(tokens "$skills")" "$triggers" "$(tokens "$triggers")" "$lazy" "$(tokens "$lazy")"; else printf 'Prompt budget: Claude %s bytes, Codex %s bytes, skills %s bytes, triggers %s bytes, largest lazy rule %s bytes\n' "$claude" "$codex" "$skills" "$triggers" "$lazy"; fi
$fail && { printf 'Prompt budget: permanent context increased by more than 15 percent.\n' >&2; exit 1; } $fail && { printf 'Prompt budget: permanent context increased by more than 15 percent.\n' >&2; exit 1; }
exit 0
+29 -6
View File
@@ -1,8 +1,31 @@
[CmdletBinding()] [CmdletBinding()]
param([string]$Goal,[string[]]$ChangedFiles=@(),[string[]]$Verified=@(),[string[]]$Pending=@(),[string[]]$ImportantFindings=@(),[switch]$Pointer) param(
[string]$Workspace = (Get-Location).Path,
[AllowEmptyString()][string]$Goal,
[AllowEmptyCollection()][string[]]$Decisions,
[AllowEmptyCollection()][string[]]$ChangedFiles,
[AllowEmptyCollection()][string[]]$Verified,
[AllowEmptyCollection()][string[]]$Pending,
[AllowEmptyCollection()][string[]]$Risks,
[AllowEmptyCollection()][string[]]$ImportantFindings,
[switch]$Pointer,
[switch]$Reset
)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent; $dir = Join-Path $root '.ai-session'; $path = Join-Path $dir 'state.json' $helper = Join-Path (Split-Path $PSScriptRoot -Parent) 'shared/hooks/scripts/session-state.py'
if ($Pointer) { if (Test-Path $path) { Write-Output 'Task state is available in .ai-session/state.json; read it only if needed.' }; exit 0 } $values = @{ workspace = $Workspace }
function Limit([string[]]$items) { @($items | Where-Object { $_ } | Select-Object -First 12 | ForEach-Object { if ($_.Length -gt 180) { $_.Substring(0,180) } else { $_ } }) } $fields = @{ Goal = 'goal'; Decisions = 'decisions'; ChangedFiles = 'changed'; Verified = 'verified'; Pending = 'open'; Risks = 'risks' }
New-Item $dir -ItemType Directory -Force | Out-Null foreach ($parameter in $fields.Keys) {
[ordered]@{ goal=if($Goal){$Goal.Substring(0,[Math]::Min(240,$Goal.Length))}else{''}; changedFiles=Limit $ChangedFiles; verified=Limit $Verified; pending=Limit $Pending; importantFindings=Limit $ImportantFindings; updatedAt=(Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Depth 4 | Set-Content $path -Encoding UTF8 if ($PSBoundParameters.ContainsKey($parameter)) { $values[$fields[$parameter]] = $PSBoundParameters[$parameter] }
}
if ($PSBoundParameters.ContainsKey('ImportantFindings') -and -not $PSBoundParameters.ContainsKey('Decisions')) {
$values.decisions = $ImportantFindings
}
$OutputEncoding = New-Object System.Text.UTF8Encoding($false)
$arguments = @($helper, '--update-json')
if ($Pointer) { $arguments += '--pointer' }
if ($Reset) { $arguments += '--reset' }
$payload = $values | ConvertTo-Json -Depth 4 -Compress
$payload = [regex]::Replace($payload, '[^\x00-\x7F]', { param($match) '\u{0:x4}' -f [int][char]$match.Value })
$payload | & python @arguments
exit $LASTEXITCODE
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd); dir="$root/.ai-session"; path="$dir/state.json" root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
if [ "${1:-}" = --pointer ]; then [ -f "$path" ] && printf 'Task state is available in .ai-session/state.json; read it only if needed.\n'; exit 0; fi arguments=()
goal=${AI_TASK_GOAL:-}; goal=${goal:0:240}; goal_json=$(printf '%s' "$goal" | sed 's/[\\]/\\\\/g; s/"/\\"/g'); mkdir -p "$dir" if [ "${AI_TASK_GOAL+x}" = x ]; then arguments+=(--goal "$AI_TASK_GOAL"); fi
printf '{"goal":"%s","changedFiles":[],"verified":[],"pending":[],"importantFindings":[],"updatedAt":"%s"}\n' "$goal_json" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$path" python3 "$root/shared/hooks/scripts/session-state.py" "${arguments[@]}" "$@"
+208
View File
@@ -0,0 +1,208 @@
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import tomllib
import unittest
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location('install_options', ROOT / 'scripts/lib/install-options.py')
OPTIONS = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(OPTIONS)
class InstallOptionsTests(unittest.TestCase):
def test_filter_preserves_other_claude_hooks_and_status(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'settings.json'
settings = {'hooks': {'Stop': [{'hooks': [
{'command': 'bash "root/hooks/flashbang.sh"'}, {'command': 'echo keep'}]}],
'PreCompact': [{'hooks': [{'command': 'echo compact'}]}]},
'statusLine': {'command': 'echo status'}}
path.write_text(json.dumps(settings))
result = json.loads(OPTIONS.filter_flashbang(path))
self.assertEqual(result['hooks']['Stop'][0]['hooks'], [{'command': 'echo keep'}])
self.assertEqual(result['hooks']['PreCompact'], settings['hooks']['PreCompact'])
self.assertEqual(result['statusLine'], settings['statusLine'])
def test_filter_preserves_other_codex_stop_hooks_and_settings(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'config.toml'
path.write_text('approval_policy = "on-request"\n[[hooks.Stop]]\n'
'[[hooks.Stop.hooks]]\ncommand = \'bash "root/flashbang.sh"\'\n'
'[[hooks.Stop.hooks]]\ncommand = "echo keep"\n'
'[tui]\nstatus_line = ["model"]\n')
result = tomllib.loads(OPTIONS.filter_flashbang(path))
self.assertEqual(result['hooks']['Stop'][0]['hooks'], [{'command': 'echo keep'}])
self.assertEqual(result['tui']['status_line'], ['model'])
def test_native_option_helpers_select_clients_and_restore_environment(self):
shells = [('powershell', shutil.which('powershell') or shutil.which('pwsh')),
('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe')
if os.name == 'nt' else shutil.which('bash'))]
for kind, executable in shells:
if not executable or not Path(executable).exists():
continue
with self.subTest(shell=kind):
if kind == 'powershell':
command = [executable, '-NoProfile', '-Command',
"$ErrorActionPreference='Stop'; . ./scripts/lib/install-options.ps1; "
"function Test-CodexProcessActive { $false }; function Test-GlobalNpmCodex { $false }; "
"function Invoke-PluginCommand { param($Command,$Arguments,[switch]$DryRun,[switch]$Summary) "
"Write-Output ($Command + ':' + ($Arguments -join ' ')) }; "
"$env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE='original'; "
"Update-SelectedAgentClis -Client Both; "
"if ($env:CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE -ne 'original') { throw 'Environment leaked' }"]
else:
command = [executable, '-c',
'. ./scripts/lib/install-options.sh; '
'codex_process_active() { return 1; }; global_npm_codex() { return 1; }; '
'run_plugin_command() { shift; printf "%s:%s\\n" "$1" "$2"; }; '
'client=both; dry_run=false; summary=false; '
'CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=original; update_selected_agent_clis; '
'[ "$CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE" = original ]']
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(result.stdout.splitlines(), ['codex:update', 'claude:update'])
def test_running_codex_is_skipped_and_npm_install_is_used_when_available(self):
shell = shutil.which('powershell') or shutil.which('pwsh')
if shell:
skipped = subprocess.run([
shell, '-NoProfile', '-Command',
"$ErrorActionPreference='Stop'; . ./scripts/lib/install-options.ps1; "
"function Test-CodexProcessActive { $true }; function Test-GlobalNpmCodex { $true }; "
"function Invoke-PluginCommand { param($Command,$Arguments,[switch]$DryRun,[switch]$Summary) Write-Output $Command }; "
"Update-SelectedAgentClis -Client Both"
], cwd=ROOT, capture_output=True, text=True, timeout=20)
self.assertEqual(skipped.returncode, 0, skipped.stdout + skipped.stderr)
self.assertEqual(skipped.stdout.splitlines()[-1], 'claude')
self.assertIn('Codex CLI update skipped', skipped.stdout + skipped.stderr)
npm = subprocess.run([
shell, '-NoProfile', '-Command',
"$ErrorActionPreference='Stop'; . ./scripts/lib/install-options.ps1; "
"function Test-CodexProcessActive { $false }; function Test-GlobalNpmCodex { $true }; "
"function Invoke-PluginCommand { param($Command,$Arguments,[switch]$DryRun,[switch]$Summary) "
"Write-Output ($Command + ':' + ($Arguments -join ' ')) }; Update-SelectedAgentClis -Client Codex"
], cwd=ROOT, capture_output=True, text=True, timeout=20)
self.assertEqual(npm.returncode, 0, npm.stdout + npm.stderr)
self.assertEqual(npm.stdout.strip(), 'npm:install -g @openai/codex@latest')
bash = (str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe')
if os.name == 'nt' else shutil.which('bash'))
if bash and Path(bash).exists():
result = subprocess.run([
bash, '-c',
'. ./scripts/lib/install-options.sh; codex_process_active() { return 0; }; '
'global_npm_codex() { return 0; }; run_plugin_command() { shift; printf "%s\\n" "$1"; }; '
'client=both; dry_run=false; summary=false; update_selected_agent_clis'
], cwd=ROOT, capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(result.stdout.splitlines(), ['claude'])
self.assertIn('Codex CLI update skipped', result.stderr)
def test_flashbang_toggle_is_applied_by_both_manifest_writers(self):
shells = [('powershell', shutil.which('powershell') or shutil.which('pwsh')),
('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe')
if os.name == 'nt' else shutil.which('bash'))]
for kind, executable in shells:
if not executable or not Path(executable).exists():
continue
with self.subTest(shell=kind), tempfile.TemporaryDirectory() as directory:
source = Path(directory) / 'source'
source.mkdir()
destination = Path(directory) / 'destination'
settings = {'hooks': {'Stop': [{'hooks': [{'command': 'bash "__AI_CONFIG_ROOT__/flashbang.sh"'}]}],
'PreCompact': [{'hooks': [{'command': 'echo keep'}]}]}}
(source / 'settings.json').write_text(json.dumps(settings))
(source / 'config.toml').write_text('[[hooks.Stop]]\n[[hooks.Stop.hooks]]\n'
'command = \'bash "__AI_CONFIG_ROOT__/flashbang.sh"\'\n')
environment = dict(os.environ, OPTION_SOURCE=str(source), OPTION_DESTINATION=str(destination))
for enabled in (False, True):
if kind == 'powershell':
command = [executable, '-NoProfile', '-Command',
"$ErrorActionPreference='Stop'; . ./scripts/lib/manifest.ps1; "
"Sync-ManagedDestination -Source $env:OPTION_SOURCE -Destination $env:OPTION_DESTINATION "
"-Stamp test -AiConfigRoot root -ShellCommand bash -PowerShellCommand pwsh "
"-Summary -FlashbangEnabled $" + str(enabled).lower()]
else:
command = [executable, '-c',
'set -euo pipefail; . ./scripts/lib/manifest.sh; '
'sync_managed_destination "$OPTION_SOURCE" "$OPTION_DESTINATION" test root bash pwsh false true '
+ str(enabled).lower()]
environment.update(OPTION_SOURCE=source.as_posix(), OPTION_DESTINATION=destination.as_posix())
result = subprocess.run(command, cwd=ROOT, env=environment, capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
installed = json.loads((destination / 'settings.json').read_text(encoding='utf-8-sig'))
self.assertEqual('Stop' in installed['hooks'], enabled)
self.assertIn('PreCompact', installed['hooks'])
codex = tomllib.loads((destination / 'config.toml').read_text(encoding='utf-8-sig'))
self.assertEqual(bool(codex.get('hooks', {}).get('Stop')), enabled)
self.assertTrue((destination / '.ai-config-manifest.tsv').is_file())
def test_entrypoints_save_normal_choices_and_replay_quick_without_prompts(self):
executables = [('powershell', shutil.which('powershell') or shutil.which('pwsh')),
('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe')
if os.name == 'nt' else shutil.which('bash'))]
for kind, executable in executables:
if not executable or not Path(executable).exists():
continue
with self.subTest(shell=kind), tempfile.TemporaryDirectory() as directory:
root = Path(directory) / 'repo'
scripts = root / 'scripts'
shutil.copytree(ROOT / 'scripts/lib', scripts / 'lib', ignore=shutil.ignore_patterns('__pycache__'))
home = Path(directory) / 'home'
home.mkdir()
(root / 'adapters').mkdir()
shutil.copyfile(ROOT / 'adapters/plugins.tsv', root / 'adapters/plugins.tsv')
for client in ('codex', 'claude'):
package = root / 'generated' / (client + '-bash')
(package / 'skills').mkdir(parents=True)
(package / 'skills' / 'test.txt').write_text('managed')
config = ('[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ncommand = \'bash "__AI_CONFIG_ROOT__/flashbang.sh"\'\n'
if client == 'codex' else '{"hooks":{"Stop":[{"hooks":[{"command":"bash flashbang.sh"}]}]}}')
(package / ('config.toml' if client == 'codex' else 'settings.json')).write_text(config)
suffix = 'ps1' if kind == 'powershell' else 'sh'
for name in ('install', 'update'):
content = (ROOT / 'scripts' / (name + '.' + suffix)).read_text(encoding='utf-8-sig')
content = content.replace("[Environment]::GetFolderPath('UserProfile')", '$env:TEST_INSTALL_HOME')
(scripts / (name + '.' + suffix)).write_text(content)
(scripts / ('build.' + suffix)).write_text('param([switch]$Summary)' if kind == 'powershell' else 'exit 0\n')
if kind == 'powershell':
(scripts / 'lib/plugins.ps1').write_text(
'function Select-ConfiguredPlugins { @{Selected=@(); Deselected=@()} }\n'
'function Get-ConfiguredPluginEntries { @() }\n'
'function Sync-ConfiguredPlugins {}\n'
'function Invoke-PluginCommand { param($Command,$Arguments,[switch]$DryRun,[switch]$Summary) '
'Write-Output "CLI-MOCK $Command" }\n')
else:
(scripts / 'lib/plugins.sh').write_text(
'select_configured_plugins() { :; }\nsync_configured_plugins() { :; }\n'
'run_plugin_command() { printf "CLI-MOCK %s\\n" "$2"; }\n')
environment = dict(os.environ, HOME=home.as_posix(), TEST_INSTALL_HOME=str(home))
for name, quick, answers in [('install', False, 'y\nn\n'), ('update', True, '')]:
entry = str(scripts / (name + '.' + suffix))
command = ([executable, '-NoProfile', '-File', entry, '-Shell', 'Bash', '-Client', 'Both', '-Summary']
if kind == 'powershell' else [executable, entry, '--shell', 'bash', '--client', 'both', '--summary'])
if quick:
command.append('-Quick' if kind == 'powershell' else '--quick')
result = subprocess.run(command, env=environment, input=answers, capture_output=True, text=True, timeout=25)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertTrue('CLI-MOCK codex' in result.stdout or
'Codex CLI update skipped' in result.stdout + result.stderr)
self.assertIn('CLI-MOCK claude', result.stdout)
if quick:
self.assertNotIn('Enable the Flashbang', result.stdout + result.stderr)
state = json.loads((home / '.my-ai-configuration/selection.json').read_text())
self.assertTrue(state['update_agents'])
self.assertFalse(state['flashbang'])
self.assertEqual(state['shell'], 'bash')
self.assertNotIn('Stop', json.loads((home / '.claude/settings.json').read_text()).get('hooks', {}))
if __name__ == '__main__':
unittest.main()
+205
View File
@@ -0,0 +1,205 @@
import contextlib
import importlib.util
import io
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('extensions', Path(__file__).parent / 'lib' / 'managed-extensions.py')
extensions = importlib.util.module_from_spec(spec)
spec.loader.exec_module(extensions)
def plugin(name='Example'):
return dict(name=name, codex_method='plugin', codex_plugin=name.lower() + '@test', codex_marketplace='-',
claude_plugin=name.lower() + '@test', claude_marketplace='-')
def skill():
return dict(name='Humanizer', codex_method='skill', codex_source='blader/humanizer', codex_skill='humanizer', codex_plugin='-')
class ManagedExtensionTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.home = Path(self.temporary.name)
self.installed = {'codex': {}, 'claude': {}}
self.commands = []
self.addCleanup(patch.stopall)
patch.object(extensions, 'installed_plugins', side_effect=lambda client, home: dict(self.installed[client])).start()
patch.object(extensions, 'run_command', side_effect=self.command).start()
patch.object(extensions, 'fetch_skill', return_value=('humanizer', {'SKILL.md': (b'original', False), 'references/old.md': (b'old', False)})).start()
self.output = contextlib.redirect_stdout(io.StringIO())
self.output.__enter__()
self.addCleanup(self.output.__exit__, None, None, None)
def command(self, args, summary, home=None):
self.commands.append(args)
if args[2] in ('add', 'install') and args[1] == 'plugin':
self.installed[args[0]][args[3]] = {'scope': 'user'}
if args[2] in ('remove', 'uninstall'):
self.installed[args[0]].pop(args[3], None)
return ''
def manager(self, **kwargs):
return extensions.Manager(self.home, **kwargs)
def test_foreign_plugin_is_never_claimed_or_removed(self):
self.installed['codex']['example@test'] = {}
self.manager(update=True).sync([plugin()], ['codex'], {'Example'})
self.manager().sync([], ['codex'], set())
self.assertEqual([], self.commands)
self.assertEqual([], self.manager().state['resources'])
def test_owned_plugin_removed_after_manifest_entry_disappears(self):
self.manager().sync([plugin()], ['codex'], {'Example'})
self.manager().sync([], ['codex'], set())
self.assertEqual(['codex', 'plugin', 'remove', 'example@test'], self.commands[-1])
self.assertEqual([], self.manager().state['resources'])
def test_client_scope_is_preserved(self):
self.manager().sync([plugin()], ['claude', 'codex'], {'Example'})
self.manager().sync([], ['codex'], set())
self.assertEqual(['claude'], [r['client'] for r in self.manager().state['resources']])
def test_partial_plugin_failure_retains_successful_ownership(self):
with patch.object(extensions, 'run_command', side_effect=[None, ValueError('failure')]):
with self.assertRaises(ValueError):
self.manager().sync([plugin(), plugin('Second')], ['codex'], {'Example', 'Second'})
self.assertEqual(['Example'], [r['name'] for r in self.manager().state['resources']])
def test_failed_removal_retains_ownership(self):
self.manager().sync([plugin()], ['codex'], {'Example'})
with patch.object(extensions, 'run_command', side_effect=ValueError('failure')):
with self.assertRaises(ValueError):
self.manager().sync([], ['codex'], set())
self.assertEqual(1, len(self.manager().state['resources']))
def test_foreign_skill_directory_is_preserved(self):
target = self.home / '.agents/skills/humanizer/SKILL.md'
target.parent.mkdir(parents=True)
target.write_text('foreign')
self.manager(update=True).sync([skill()], ['codex'], {'Humanizer'})
self.manager().sync([], ['codex'], set())
self.assertEqual('foreign', target.read_text())
self.assertEqual([], self.manager().state['resources'])
def test_skill_removal_preserves_modified_and_foreign_files(self):
self.manager().sync([skill()], ['codex'], {'Humanizer'})
directory = self.home / '.agents/skills/humanizer'
(directory / 'SKILL.md').write_text('modified')
(directory / 'foreign.txt').write_text('foreign')
self.manager().sync([], ['codex'], set())
self.assertEqual('modified', (directory / 'SKILL.md').read_text())
self.assertTrue((directory / 'foreign.txt').exists())
self.assertFalse((directory / 'references/old.md').exists())
self.assertEqual(1, len(self.manager().state['resources'][0]['files']))
def test_skill_update_cleans_only_owned_stale_files(self):
self.manager().sync([skill()], ['codex'], {'Humanizer'})
directory = self.home / '.agents/skills/humanizer'
(directory / 'foreign.txt').write_text('foreign')
with patch.object(extensions, 'fetch_skill', return_value=('humanizer', {'SKILL.md': (b'new', False)})):
self.manager(update=True).sync([skill()], ['codex'], {'Humanizer'})
self.assertEqual('new', (directory / 'SKILL.md').read_text())
self.assertTrue((directory / 'foreign.txt').exists())
self.assertFalse((directory / 'references/old.md').exists())
def test_skill_partial_failure_records_each_successful_file(self):
original_write = extensions.atomic_write
def write(path, content):
if path.name == 'old.md':
raise OSError('simulated disk failure')
original_write(path, content)
with patch.object(extensions, 'atomic_write', side_effect=write):
with self.assertRaises(OSError):
self.manager().sync([skill()], ['codex'], {'Humanizer'})
self.assertEqual(['.agents/skills/humanizer/SKILL.md'], list(self.manager().state['resources'][0]['files']))
self.manager().sync([skill()], ['codex'], {'Humanizer'})
self.assertEqual(2, len(self.manager().state['resources'][0]['files']))
self.assertTrue(self.manager().state['resources'][0]['complete'])
def test_dry_run_has_no_commands_network_or_writes(self):
self.manager(dry_run=True).sync([plugin(), skill()], ['codex'], {'Example', 'Humanizer'})
self.assertEqual([], list(self.home.iterdir()))
self.assertEqual([], self.commands)
extensions.fetch_skill.assert_not_called()
def test_traversal_ledger_is_rejected(self):
path = self.home / '.my-ai-configuration/extensions.json'
path.parent.mkdir()
path.write_text(json.dumps({'version': 1, 'resources': [dict(client='codex', name='Bad', kind='skill', directory='.agents/skills/bad', files={'.agents/skills/bad/../../../other': '0' * 64})]}))
with self.assertRaises(ValueError):
self.manager()
def test_marketplace_failure_never_removes_existing_source(self):
entry = plugin()
entry['codex_marketplace'] = 'owner/repository'
with patch.object(extensions, 'run_command', side_effect=ValueError('different source')) as command:
with self.assertRaises(ValueError):
self.manager().sync([entry], ['codex'], {'Example'})
self.assertEqual(1, command.call_count)
self.assertEqual('add', command.call_args.args[0][3])
def test_owned_codex_update_upgrades_marketplace(self):
entry = plugin()
entry['codex_marketplace'] = 'owner/repository'
self.manager().sync([entry], ['codex'], {'Example'})
self.commands.clear()
self.manager(update=True).sync([entry], ['codex'], {'Example'})
self.assertEqual([['codex', 'plugin', 'marketplace', 'upgrade', 'test'], ['codex', 'plugin', 'add', 'example@test']], self.commands)
def test_owned_disabled_claude_is_enabled(self):
self.manager().sync([plugin()], ['claude'], {'Example'})
self.installed['claude']['example@test']['enabled'] = False
self.manager(update=True).sync([plugin()], ['claude'], {'Example'})
self.assertEqual(['claude', 'plugin', 'enable', 'example@test'], self.commands[-1])
def test_dry_run_summary_has_one_line(self):
with contextlib.redirect_stdout(io.StringIO()) as output:
self.manager(dry_run=True, summary=True).sync([plugin(), skill()], ['codex'], {'Example', 'Humanizer'})
self.assertEqual('EXTENSIONS: reconciliation complete (dry run)\n', output.getvalue())
def test_foreign_empty_directory_survives_skill_removal(self):
self.manager().sync([skill()], ['codex'], {'Humanizer'})
foreign = self.home / '.agents/skills/humanizer/foreign-empty'
foreign.mkdir()
self.manager().sync([], ['codex'], set())
self.assertTrue(foreign.is_dir())
def test_foreign_skill_link_is_preserved(self):
foreign = self.home / 'foreign'
foreign.mkdir()
target = self.home / '.agents/skills/humanizer'
target.parent.mkdir(parents=True)
try:
target.symlink_to(foreign, target_is_directory=True)
except OSError:
self.skipTest('Symbolic links are unavailable on this host.')
self.manager().sync([skill()], ['codex'], {'Humanizer'})
self.assertTrue(target.is_symlink())
extensions.fetch_skill.assert_not_called()
class CommandTests(unittest.TestCase):
def test_subprocess_uses_intended_home_and_cmd_launcher(self):
with tempfile.TemporaryDirectory(prefix='managed extensions ') as temporary:
home = Path(temporary)
executable = home / ('mock.cmd' if os.name == 'nt' else 'mock')
if os.name == 'nt':
executable.write_text('@echo off\necho %CODEX_HOME%\necho %CLAUDE_CONFIG_DIR%\n')
else:
executable.write_text('#!/bin/sh\nprintf "%s\\n" "$CODEX_HOME" "$CLAUDE_CONFIG_DIR"\n')
executable.chmod(0o700)
with contextlib.redirect_stdout(io.StringIO()):
result = extensions.run_command([str(executable), 'plugin', 'list', '--json'], True, home)
self.assertEqual([str(home / '.codex'), str(home / '.claude')], result.splitlines())
if __name__ == '__main__':
unittest.main()
+15 -9
View File
@@ -30,26 +30,32 @@ $detailed = @(Invoke-PluginCommand -Command Test-PluginOutput -Arguments @('succ
if ($detailed.Count -ne 2 -or $detailed[0] -ne 'download progress') { throw 'Detailed output must retain progress.' } if ($detailed.Count -ne 2 -or $detailed[0] -ne 'download progress') { throw 'Detailed output must retain progress.' }
$diagnostics = [System.Collections.Generic.List[string]]::new() $diagnostics = [System.Collections.Generic.List[string]]::new()
$failed = $false $failed = $false
$env:AI_CONFIG_VERBOSE = '1' $env:AI_CONFIG_VERBOSE = $null
try { Invoke-PluginCommand -Command Test-PluginOutput -Arguments @('fail') -Summary | ForEach-Object { $diagnostics.Add("$_") } } try { Invoke-PluginCommand -Command Test-PluginOutput -Arguments @('fail') -Summary | ForEach-Object { $diagnostics.Add("$_") } }
catch { $failed = $true } catch { $failed = $true }
$env:AI_CONFIG_VERBOSE = $null $env:AI_CONFIG_VERBOSE = $null
if (-not $failed -or $diagnostics -notcontains 'download progress' -or $diagnostics -notcontains 'WARN test warning') { throw 'Plugin failure must retain full diagnostics and fail.' } if (-not $failed -or $diagnostics -notcontains 'download progress' -or $diagnostics -notcontains 'WARN test warning') { throw 'Plugin failure must retain full diagnostics and fail.' }
$global:LASTEXITCODE = 0 $global:LASTEXITCODE = 0
function codex { '{"installed":[{"pluginId":"ponytail@ponytail"},{"pluginId":"i-have-adhd@i-have-adhd"},{"pluginId":"superpowers@openai-curated-remote"},{"pluginId":"context7@context7-marketplace"},{"pluginId":"caveman@thinkhome-caveman"}],"available":[]}' } function codex { $script:observedCodexHome = $env:CODEX_HOME; $script:observedProfile = $env:USERPROFILE; '{"installed":[{"pluginId":"ponytail@ponytail"},{"pluginId":"i-have-adhd@i-have-adhd"},{"pluginId":"superpowers@openai-curated-remote"},{"pluginId":"context7@context7-marketplace"},{"pluginId":"caveman@thinkhome-caveman"}],"available":[]}' }
$previousCodexHome = $env:CODEX_HOME
$previousProfile = $env:USERPROFILE
$null = Get-InstalledPlugins -Client Codex -HomePath 'C:/temporary profile'
if ($observedCodexHome -ne (Join-Path 'C:/temporary profile' '.codex') -or $observedProfile -ne 'C:/temporary profile') { throw 'Plugin inspection must use the requested user home.' }
if ($env:CODEX_HOME -ne $previousCodexHome -or $env:USERPROFILE -ne $previousProfile) { throw 'Plugin inspection must restore the process environment.' }
$script:answers.Enqueue('done') $script:answers.Enqueue('done')
$selection = Select-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Mode Update $selection = Select-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Mode Update
if ($selection.Deselected.Count -ne 0 -or $selection.Selected.Count -ne 8) { throw 'All installed Codex plugins must start checked when updating.' } if (@($selection.Selected | Where-Object { $_.codex_method -eq 'plugin' }).Count -ne 5) { throw 'All installed Codex plugins must start checked when updating.' }
$script:answers.Enqueue('4') $script:answers.Enqueue('4')
$script:answers.Enqueue('done') $script:answers.Enqueue('done')
$selection = Select-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Mode Update $selection = Select-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Mode Update
if ($selection.Deselected.Count -ne 1 -or $selection.Deselected[0].name -ne 'Context7') { throw 'Only the explicitly unchecked installed plugin must be deselected.' } if (@($selection.Deselected | Where-Object { $_.name -eq 'Context7' }).Count -ne 1) { throw 'Only the explicitly unchecked installed plugin must be deselected.' }
function Get-InstalledPlugins { [pscustomobject]@{ pluginId = 'test@market' } } $script:managedArguments = @()
$script:commands = @() function python { $script:managedArguments = @($args); $global:LASTEXITCODE = 0 }
function Invoke-PluginCommand { param($Command, $Arguments, [switch]$DryRun, [switch]$Summary) $script:commands += "$Command $($Arguments -join ' ')" }
$entry = [pscustomobject]@{ name = 'Test'; codex_method = 'plugin'; codex_marketplace = '-'; codex_plugin = 'test@market' } $entry = [pscustomobject]@{ name = 'Test'; codex_method = 'plugin'; codex_marketplace = '-'; codex_plugin = 'test@market' }
$updated = @(Install-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Update -Summary -Entries @($entry)) Sync-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -HomePath 'C:/temporary home' -Update -Summary -Entries @($entry)
if ($commands.Count -ne 1 -or $commands[0] -ne 'codex plugin add test@market' -or $updated[0] -ne 'PLUGINS Codex: 0 ensured, 0 already installed, 1 updated') { throw 'Updating an installed Codex plugin must report one update.' } if ($managedArguments -notcontains 'sync' -or $managedArguments -notcontains '--update' -or $managedArguments -notcontains 'Test' -or $managedArguments -notcontains 'C:/temporary home') { throw 'Managed reconciliation must receive selection, home, and update mode.' }
Sync-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Codex -Entries @()
if ($managedArguments[-1] -ne '--selected') { throw 'Empty selection must reach reconciliation for managed cleanup.' }
function Get-InstalledPlugins { throw 'Empty selection must not query installed plugins.' } function Get-InstalledPlugins { throw 'Empty selection must not query installed plugins.' }
function Invoke-PluginCommand { throw 'Empty selection must not run plugin commands.' } function Invoke-PluginCommand { throw 'Empty selection must not run plugin commands.' }
Install-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Both -Entries @() Install-ConfiguredPlugins -RepositoryRoot (Split-Path $PSScriptRoot -Parent) -Client Both -Entries @()
+29 -9
View File
@@ -25,7 +25,7 @@ class SelectionTests(unittest.TestCase):
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
def save(self, *arguments): def save(self, *arguments):
result = self.run_state('write', '--platform', 'windows', '--client', 'both', *arguments) result = self.run_state('write', '--shell', 'powershell', '--update-agents', 'false', '--flashbang', 'false', '--client', 'both', *arguments)
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
def test_missing_selection_fails_without_creating_state(self): def test_missing_selection_fails_without_creating_state(self):
@@ -38,7 +38,7 @@ class SelectionTests(unittest.TestCase):
self.save('--selected', 'Superpowers', 'Anthropic Frontend Design', '--deselected', 'Context7') self.save('--selected', 'Superpowers', 'Anthropic Frontend Design', '--deselected', 'Context7')
original = self.path.read_bytes() original = self.path.read_bytes()
state = json.loads(self.run_state('read').stdout) state = json.loads(self.run_state('read').stdout)
self.assertEqual(state['platform'], 'windows') self.assertEqual(state['shell'], 'powershell')
self.assertEqual(state['client'], 'both') self.assertEqual(state['client'], 'both')
self.assertEqual(state['selected'], ['Superpowers', 'Anthropic Frontend Design']) self.assertEqual(state['selected'], ['Superpowers', 'Anthropic Frontend Design'])
self.assertEqual(state['deselected'], ['Context7']) self.assertEqual(state['deselected'], ['Context7'])
@@ -51,10 +51,30 @@ class SelectionTests(unittest.TestCase):
self.save() self.save()
self.assertEqual(json.loads(self.run_state('read').stdout)['selected'], []) self.assertEqual(json.loads(self.run_state('read').stdout)['selected'], [])
def test_legacy_state_requires_normal_update_and_can_supply_defaults(self):
self.path.parent.mkdir()
self.path.write_text(json.dumps(dict(version=1, platform='windows', client='both', selected=[], deselected=[])))
result = self.run_state('read')
self.assertNotEqual(result.returncode, 0)
self.assertIn('without Quick', result.stderr)
migrated = json.loads(self.run_state('read', '--allow-legacy').stdout)
self.assertEqual(migrated['shell'], 'powershell')
self.assertFalse(migrated['update_agents'])
self.assertTrue(migrated['flashbang'])
def test_boolean_options_are_required_and_preserved(self):
self.save()
state = json.loads(self.run_state('read').stdout)
self.assertFalse(state['update_agents'])
self.assertFalse(state['flashbang'])
state['flashbang'] = 'false'
self.path.write_text(json.dumps(state))
self.assertNotEqual(self.run_state('read').returncode, 0)
def test_invalid_write_preserves_last_selection(self): def test_invalid_write_preserves_last_selection(self):
self.save('--selected', 'Superpowers') self.save('--selected', 'Superpowers')
original = self.path.read_bytes() original = self.path.read_bytes()
result = self.run_state('write', '--platform', 'linux', '--client', 'codex', '--selected', 'Unknown') result = self.run_state('write', '--shell', 'bash', '--update-agents', 'false', '--flashbang', 'true', '--client', 'codex', '--selected', 'Unknown')
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.path.read_bytes(), original) self.assertEqual(self.path.read_bytes(), original)
@@ -80,7 +100,7 @@ class SelectionTests(unittest.TestCase):
save = subprocess.run([shell, '-NoProfile', '-Command', save = subprocess.run([shell, '-NoProfile', '-Command',
"$ErrorActionPreference = 'Stop'; . (Join-Path $env:TEST_REPOSITORY_ROOT 'scripts/lib/selection-state.ps1'); " "$ErrorActionPreference = 'Stop'; . (Join-Path $env:TEST_REPOSITORY_ROOT 'scripts/lib/selection-state.ps1'); "
"Save-UpdateSelection -HomePath $env:TEST_SELECTION_HOME -RepositoryRoot $env:TEST_REPOSITORY_ROOT " "Save-UpdateSelection -HomePath $env:TEST_SELECTION_HOME -RepositoryRoot $env:TEST_REPOSITORY_ROOT "
"-Platform Windows -Client Both -Plugins @{ Selected = @([pscustomobject]@{name='Superpowers'}); Deselected = @() }"], "-Shell PowerShell -Client Both -UpdateAgents $false -Flashbang $false -Plugins @{ Selected = @([pscustomobject]@{name='Superpowers'}); Deselected = @() }"],
env=environment, capture_output=True, text=True, timeout=10) env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(save.returncode, 0, save.stdout + save.stderr) self.assertEqual(save.returncode, 0, save.stdout + save.stderr)
original = self.path.read_bytes() original = self.path.read_bytes()
@@ -95,8 +115,8 @@ class SelectionTests(unittest.TestCase):
env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90) env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn('Select plugins', result.stdout) self.assertNotIn('Select plugins', result.stdout)
self.assertIn('codex plugin add superpowers@openai-curated-remote', result.stdout) self.assertIn('DRYRUN ensure codex plugin: Superpowers', result.stdout)
self.assertNotIn('codex plugin add context7', result.stdout) self.assertNotIn('DRYRUN ensure codex plugin: Context7', result.stdout)
self.assertEqual(self.path.read_bytes(), original) self.assertEqual(self.path.read_bytes(), original)
self.assertFalse((self.home / '.codex').exists()) self.assertFalse((self.home / '.codex').exists())
failed = subprocess.run([shell, '-NoProfile', '-File', str(entry), '-Quick'], env=environment, failed = subprocess.run([shell, '-NoProfile', '-File', str(entry), '-Quick'], env=environment,
@@ -112,7 +132,7 @@ class SelectionTests(unittest.TestCase):
if not shell: if not shell:
self.skipTest('Bash unavailable') self.skipTest('Bash unavailable')
save = subprocess.run([shell, '-c', save = subprocess.run([shell, '-c',
'set -euo pipefail; root=$1; home_path=$2; platform=windows; client=both; ' 'set -euo pipefail; root=$1; home_path=$2; shell=powershell; client=both; update_agents=false; flashbang=false; '
'selected_plugins=(Superpowers); deselected_plugins=(); ' 'selected_plugins=(Superpowers); deselected_plugins=(); '
'. "$root/scripts/lib/selection-state.sh"; save_update_selection', '. "$root/scripts/lib/selection-state.sh"; save_update_selection',
'test', ROOT.as_posix(), self.home.as_posix()], 'test', ROOT.as_posix(), self.home.as_posix()],
@@ -124,8 +144,8 @@ class SelectionTests(unittest.TestCase):
env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90) env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn('Select plugins', result.stdout) self.assertNotIn('Select plugins', result.stdout)
self.assertIn('codex plugin add superpowers@openai-curated-remote', result.stdout) self.assertIn('DRYRUN ensure codex plugin: Superpowers', result.stdout)
self.assertNotIn('codex plugin add context7', result.stdout) self.assertNotIn('DRYRUN ensure codex plugin: Context7', result.stdout)
self.assertEqual(self.path.read_bytes(), original) self.assertEqual(self.path.read_bytes(), original)
self.assertFalse((self.home / '.codex').exists()) self.assertFalse((self.home / '.codex').exists())
+170
View File
@@ -0,0 +1,170 @@
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
SHELLS = [
('powershell', shutil.which('powershell') or shutil.which('pwsh')),
('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe')
if os.name == 'nt' else shutil.which('bash')),
]
class SessionStateHookTests(unittest.TestCase):
def test_writer_migrates_legacy_state_and_requires_reset_for_new_goal(self):
with tempfile.TemporaryDirectory() as directory:
workspace = Path(directory)
state_file = workspace / '.ai-session/state.json'
state_file.parent.mkdir()
state_file.write_text(json.dumps({'goal': 'Original', 'changedFiles': ['a'],
'pending': ['b'], 'importantFindings': ['c']}))
command = [sys.executable, str(ROOT / 'shared/hooks/scripts/session-state.py'),
'--workspace', str(workspace)]
result = subprocess.run(command + ['--verified', 'passed'], capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
state = json.loads(state_file.read_text())
self.assertEqual(state['changed'], ['a'])
self.assertEqual(state['open'], ['b'])
self.assertEqual(state['decisions'], ['c'])
self.assertFalse({'changedFiles', 'pending', 'importantFindings'} & state.keys())
before = state_file.read_bytes()
result = subprocess.run(command + ['--goal', 'New'], capture_output=True)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(state_file.read_bytes(), before)
result = subprocess.run(command + ['--reset', '--goal', 'New'], capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
state = json.loads(state_file.read_text())
self.assertEqual(state['goal'], 'New')
self.assertEqual(state['verified'], [])
self.assertEqual(state['changed'], [])
state_file.write_text('{"goal":"New","verified":"invalid"}')
before = state_file.read_bytes()
result = subprocess.run(command + ['--open', 'next'], capture_output=True)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(state_file.read_bytes(), before)
def test_writers_preserve_partial_updates_and_escape_values(self):
for shell, executable in SHELLS:
if not executable or not Path(executable).exists():
continue
with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory:
base = Path(directory)
repository = base / 'repository'
shutil.copytree(ROOT / 'shared/hooks/scripts', repository / 'shared/hooks/scripts')
(repository / 'scripts').mkdir()
for filename in ('session-state.ps1', 'session-state.sh'):
shutil.copy(ROOT / 'scripts' / filename, repository / 'scripts' / filename)
workspace = base / 'workspace ĂĽ'
workspace.mkdir()
environment = dict(os.environ, TASK_WORKSPACE=str(workspace),
TASK_GOAL='Repair "quotes"\n' + 'x' * 300)
if shell == 'powershell':
prefix = [executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command']
commands = [
'& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Goal $env:TASK_GOAL -Decisions @("keep", "line two") -Pending @("check")',
'& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Verified @("passed")',
'& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Pointer',
]
else:
prefix = [executable, '-c']
commands = [
'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --goal "$TASK_GOAL" --decisions keep "line two" --open check',
'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --verified passed',
'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --pointer',
]
for command in commands:
result = subprocess.run(prefix + [command], cwd=repository, capture_output=True,
encoding='utf-8', timeout=20, env=environment)
self.assertEqual(result.returncode, 0, result.stderr)
state_file = workspace / '.ai-session/state.json'
self.assertIn(str(state_file), result.stdout)
state = json.loads(state_file.read_text(encoding='utf-8-sig'))
self.assertEqual(state['goal'], environment['TASK_GOAL'])
self.assertEqual(state['decisions'], ['keep', 'line two'])
self.assertEqual(state['open'], ['check'])
self.assertEqual(state['verified'], ['passed'])
self.assertEqual(state['workspace'], str(workspace))
self.assertFalse((repository / '.ai-session').exists())
self.assertEqual([p.name for p in state_file.parent.iterdir()], ['state.json'])
def run_hook(self, shell, executable, hook, config, workspace, payload):
names = {'pointer': ('Show-SessionStatePointer.ps1', 'show-session-state-pointer.sh'),
'compact': ('Record-Compact.ps1', 'record-compact.sh')}
script = config / 'hooks/scripts' / names[hook][shell == 'bash']
command = ([executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(script)]
if shell == 'powershell' else [executable, str(script)])
result = subprocess.run(command, cwd=workspace, input=payload, capture_output=True,
encoding='utf-8', timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, '')
return result.stdout
def test_workspace_selection_and_read_only_state(self):
for shell, executable in SHELLS:
if not executable or not Path(executable).exists():
continue
with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory:
base = Path(directory)
config = base / 'installed config'
shutil.copytree(ROOT / 'shared/hooks/scripts', config / 'hooks/scripts')
workspace = base / 'working project'
selected = base / 'selected project ĂĽ'
empty = base / 'empty project'
for folder in (config, workspace, selected, empty):
folder.mkdir(exist_ok=True)
for folder in (config, workspace, selected):
(folder / '.ai-session').mkdir()
(folder / '.ai-session/state.json').write_text('private state content')
for hook in ('pointer', 'compact'):
for payload, expected in (
('', workspace),
('{}', workspace),
(json.dumps({'cwd': str(selected)}, ensure_ascii=False), selected),
(json.dumps({'cwd': str(empty)}), None),
('{"cwd":"relative/path"}', None),
('{"cwd":null}', None),
('{"cwd":12}', None),
('{"cwd":""}', None),
('{bad json', None),
('[]', None),
):
with self.subTest(hook=hook, payload=payload):
output = self.run_hook(shell, executable, hook, config, workspace, payload)
if expected is None:
self.assertEqual(output, '')
else:
self.assertIn(str(expected / '.ai-session/state.json'), output)
self.assertNotIn(str(config), output)
self.assertNotIn('private state content', output)
for folder in (config, workspace, selected):
self.assertEqual((folder / '.ai-session/state.json').read_text(), 'private state content')
self.assertFalse((empty / '.ai-session').exists())
events = (config / '.ai-session/telemetry.jsonl').read_text(encoding='utf-8-sig').splitlines()
self.assertEqual(len(events), 10)
self.assertTrue(all(json.loads(line)['event'] == 'compact' for line in events))
def test_missing_workspace_state_succeeds_without_config_fallback(self):
for shell, executable in SHELLS:
if not executable or not Path(executable).exists():
continue
with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory:
base = Path(directory)
config = base / 'config'
shutil.copytree(ROOT / 'shared/hooks/scripts', config / 'hooks/scripts')
(config / '.ai-session').mkdir()
(config / '.ai-session/state.json').write_text('{}')
workspace = base / 'workspace'
workspace.mkdir()
for hook in ('pointer', 'compact'):
self.assertEqual(self.run_hook(shell, executable, hook, config, workspace, ''), '')
self.assertEqual(list(workspace.iterdir()), [])
if __name__ == '__main__':
unittest.main()
@@ -4,7 +4,7 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent $root = Split-Path $PSScriptRoot -Parent
$powerShellHook = Get-Content -LiteralPath (Join-Path $root 'shared/hooks/flashbang.ps1') -Raw $powerShellHook = Get-Content -LiteralPath (Join-Path $root 'shared/hooks/flashbang.ps1') -Raw
$bashHook = Get-Content -LiteralPath (Join-Path $root 'shared/hooks/flashbang.sh') -Raw $bashHook = Get-Content -LiteralPath (Join-Path $root 'shared/hooks/flashbang.sh') -Raw
if ($powerShellHook -notmatch '\$HoldMs\s*=\s*250' -or $powerShellHook -notmatch '\$FadeMs\s*=\s*250' -or ([regex]::Matches($bashHook, 'default=250').Count -lt 2)) { throw 'Flashbang defaults must total 500 milliseconds on Windows and Linux.' } if ($powerShellHook -notmatch '\$HoldMs\s*=\s*250' -or $powerShellHook -notmatch '\$FadeMs\s*=\s*250' -or ([regex]::Matches($bashHook, 'default=250').Count -lt 2)) { throw 'Flashbang defaults must total 500 milliseconds on PowerShell and Bash.' }
$statusInput = '{"model":{"display_name":"Test Model"},"effort":{"level":"high"},"workspace":{"current_dir":"' + $root.Replace('\','\\') + '","repo":{"name":"TestRepo"}},"context_window":{"context_window_size":200000,"used_percentage":8.5,"total_input_tokens":15500,"total_output_tokens":1200}}' $statusInput = '{"model":{"display_name":"Test Model"},"effort":{"level":"high"},"workspace":{"current_dir":"' + $root.Replace('\','\\') + '","repo":{"name":"TestRepo"}},"context_window":{"context_window_size":200000,"used_percentage":8.5,"total_input_tokens":15500,"total_output_tokens":1200}}'
$branch = & git -C $root branch --show-current $branch = & git -C $root branch --show-current
$buildSummaryOutput = & (Join-Path $root 'scripts/build.ps1') -Summary $buildSummaryOutput = & (Join-Path $root 'scripts/build.ps1') -Summary
@@ -17,33 +17,33 @@ if ($plainStatusOutput -ne $expectedStatusOutput) { throw 'PowerShell status lin
$sourceAgentCount = @(Get-ChildItem (Join-Path $root 'shared/agents') -Directory).Count $sourceAgentCount = @(Get-ChildItem (Join-Path $root 'shared/agents') -Directory).Count
$sourceSkillCount = @(Get-ChildItem (Join-Path $root 'shared/skills') -Filter 'SKILL.md' -Recurse).Count $sourceSkillCount = @(Get-ChildItem (Join-Path $root 'shared/skills') -Filter 'SKILL.md' -Recurse).Count
$ruleSkillCount = @(Import-Csv -LiteralPath (Join-Path $root 'adapters/claude/rule-skills.tsv') -Delimiter ([char]9)).Count $ruleSkillCount = @(Import-Csv -LiteralPath (Join-Path $root 'adapters/claude/rule-skills.tsv') -Delimiter ([char]9)).Count
foreach ($platform in @('windows','linux')) { foreach ($shell in @('powershell','bash')) {
foreach ($client in @('codex','claude')) { foreach ($client in @('codex','claude')) {
$package = Join-Path $root "generated/$client-$platform" $package = Join-Path $root "generated/$client-$shell"
$file = if ($client -eq 'codex') { 'config.toml' } else { 'settings.json' } $file = if ($client -eq 'codex') { 'config.toml' } else { 'settings.json' }
$content = Get-Content -LiteralPath (Join-Path $package $file) -Raw $content = Get-Content -LiteralPath (Join-Path $package $file) -Raw
if ($client -eq 'claude') { $settings = $content | ConvertFrom-Json } if ($client -eq 'claude') { $settings = $content | ConvertFrom-Json }
if (@(Get-ChildItem "$package/agents" -File).Count -ne $sourceAgentCount) { throw "Missing agents in $package" } if (@(Get-ChildItem "$package/agents" -File).Count -ne $sourceAgentCount) { throw "Missing agents in $package" }
$expectedSkillCount = if ($client -eq 'claude') { $sourceSkillCount + $ruleSkillCount } else { $sourceSkillCount } $expectedSkillCount = if ($client -eq 'claude') { $sourceSkillCount + $ruleSkillCount } else { $sourceSkillCount }
if (@(Get-ChildItem "$package/skills" -Filter SKILL.md -Recurse).Count -ne $expectedSkillCount) { throw "Missing skills in $package" } if (@(Get-ChildItem "$package/skills" -Filter SKILL.md -Recurse).Count -ne $expectedSkillCount) { throw "Missing skills in $package" }
$expected = if ($platform -eq 'windows') { 'powershell .*flashbang.ps1' } else { 'bash .*flashbang.sh' } $expected = if ($shell -eq 'powershell') { '__POWERSHELL_COMMAND__ .*flashbang.ps1' } else { 'bash .*flashbang.sh' }
if ($content -notmatch $expected -or $content -match '__HOOK_|__WINDOWS_HOOK_') { throw "Incorrect platform command in $package" } if ($content -notmatch $expected -or $content -match '__HOOK_|__POWERSHELL_HOOK_') { throw "Incorrect shell command in $package" }
if ($platform -eq 'windows' -and $content -match 'WindowStyle\s+Hidden') { throw "Windows hook hides the terminal in $package" } if ($shell -eq 'powershell' -and $content -match 'WindowStyle\s+Hidden') { throw "PowerShell hook hides the terminal in $package" }
if ($client -eq 'codex') { if ($client -eq 'codex') {
$events = @([regex]::Matches($content, '(?m)^\[\[hooks\.([^.\]]+)\]\]\s*$') | ForEach-Object { $_.Groups[1].Value }) $events = @([regex]::Matches($content, '(?m)^\[\[hooks\.([^.\]]+)\]\]\s*$') | ForEach-Object { $_.Groups[1].Value })
if ($events.Count -ne 1 -or $events[0] -ne 'Stop' -or $content -match 'flashbang-if-input' -or $content -match '(?m)^async\s*=\s*true\s*$') { throw "Codex finish hook is incorrect in $package" } if ($events.Count -ne 1 -or $events[0] -ne 'Stop' -or $content -match 'flashbang-if-input' -or $content -match '(?m)^async\s*=\s*true\s*$') { throw "Codex finish hook is incorrect in $package" }
if ($content -notmatch 'approvals_reviewer\s*=\s*"auto_review"') { throw "Codex auto review is missing in $package" } if ($content -notmatch 'approvals_reviewer\s*=\s*"auto_review"') { throw "Codex auto review is missing in $package" }
if ($content -notmatch 'status_line\s*=\s*\["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"\]') { throw "Codex status line is incorrect in $package" } if ($content -notmatch 'status_line\s*=\s*\["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"\]') { throw "Codex status line is incorrect in $package" }
} elseif (@($settings.hooks.PSObject.Properties.Name).Count -ne 1 -or $settings.hooks.PSObject.Properties.Name -ne 'Stop' -or $content -match 'flashbang-if-input' -or $settings.hooks.Stop[0].hooks[0].async) { } elseif ((($settings.hooks.PSObject.Properties.Name | Sort-Object) -join ',') -ne 'PreCompact,SessionStart,Stop' -or $content -match 'flashbang-if-input' -or $settings.hooks.Stop[0].hooks[0].async) {
throw "Claude finish hook is incorrect in $package" throw "Claude finish hook is incorrect in $package"
} elseif ($content -notmatch '"defaultMode"\s*:\s*"auto"') { } elseif ($content -notmatch '"defaultMode"\s*:\s*"auto"') {
throw "Claude auto permission mode is missing in $package" throw "Claude auto permission mode is missing in $package"
} elseif (-not (Test-Path -LiteralPath (Join-Path $package "statusline/statusline.$(if ($platform -eq 'windows') { 'ps1' } else { 'sh' })")) -or $settings.statusLine.command -notmatch "statusline\.$(if ($platform -eq 'windows') { 'ps1' } else { 'sh' })") { } elseif (-not (Test-Path -LiteralPath (Join-Path $package "statusline/statusline.$(if ($shell -eq 'powershell') { 'ps1' } else { 'sh' })")) -or $settings.statusLine.command -notmatch "statusline\.$(if ($shell -eq 'powershell') { 'ps1' } else { 'sh' })") {
throw "Claude status line is incorrect in $package" throw "Claude status line is incorrect in $package"
} }
$docFile = if ($client -eq 'codex') { 'AGENTS.md' } else { 'CLAUDE.md' } $docFile = if ($client -eq 'codex') { 'AGENTS.md' } else { 'CLAUDE.md' }
$docContent = Get-Content -LiteralPath (Join-Path $package $docFile) -Raw $docContent = Get-Content -LiteralPath (Join-Path $package $docFile) -Raw
if (([regex]::Matches($docContent, [regex]::Escape('Apply instructions in this order'))).Count -ne 1) { throw "$client-$platform must embed the priority rule exactly once" } if (([regex]::Matches($docContent, [regex]::Escape('Apply instructions in this order'))).Count -ne 1) { throw "$client-$shell must embed the priority rule exactly once" }
if ($client -eq 'codex' -and $docContent -match 'Always load and apply `rules/general\.md`') { throw "$package must not still instruct loading general.md by path" } if ($client -eq 'codex' -and $docContent -match 'Always load and apply `rules/general\.md`') { throw "$package must not still instruct loading general.md by path" }
if ($client -eq 'claude' -and (Test-Path (Join-Path $package 'rules/general.md'))) { throw 'Claude must not automatically load a second copy of general rules.' } if ($client -eq 'claude' -and (Test-Path (Join-Path $package 'rules/general.md'))) { throw 'Claude must not automatically load a second copy of general rules.' }
foreach ($splitRule in @('angular','wpf','ui-ux')) { foreach ($splitRule in @('angular','wpf','ui-ux')) {
@@ -52,27 +52,27 @@ foreach ($platform in @('windows','linux')) {
} }
} }
} }
foreach ($platformSelection in @('1','2')) { foreach ($shellSelection in @('1','2')) {
foreach ($clientSelection in @('1','2','3')) { foreach ($clientSelection in @('1','2','3')) {
$script:answers = [System.Collections.Generic.Queue[string]]::new() $script:answers = [System.Collections.Generic.Queue[string]]::new()
$script:answers.Enqueue($platformSelection) $script:answers.Enqueue($shellSelection)
$script:answers.Enqueue($clientSelection) $script:answers.Enqueue($clientSelection)
function Read-Host { param($Prompt) $answers.Dequeue() } function Read-Host { param($Prompt) $answers.Dequeue() }
$output = & "$PSScriptRoot/install.ps1" -DryRun 6>$null $output = & "$PSScriptRoot/install.ps1" -DryRun 6>$null
if ($output -notcontains 'PASS install dry-run') { throw 'Dry-run did not finish.' } if ($output -notcontains 'PASS install dry-run') { throw 'Dry-run did not finish.' }
$platform = if ($platformSelection -eq '1') { 'windows' } else { 'linux' } $shell = if ($shellSelection -eq '1') { 'powershell' } else { 'bash' }
if (($output -join "`n") -notmatch "-$platform") { throw 'Incorrect selected package.' } if (($output -join "`n") -notmatch "-$shell") { throw 'Incorrect selected package.' }
} }
} }
$doctorSummaryOutput = & (Join-Path $root 'scripts/doctor.ps1') -Summary $doctorSummaryOutput = & (Join-Path $root 'scripts/doctor.ps1') -Summary
if (@($doctorSummaryOutput | Where-Object { $_ -match '^PASS ' }).Count -ne 0) { throw 'Doctor summary mode must not print individual PASS lines.' } if (@($doctorSummaryOutput | Where-Object { $_ -match '^PASS ' }).Count -ne 0) { throw 'Doctor summary mode must not print individual PASS lines.' }
if (@($doctorSummaryOutput | Where-Object { $_ -match '^Doctor: PASS \| \d+ checks$' }).Count -ne 1) { throw "Doctor summary mode must print one 'Doctor: PASS | N checks' line: $($doctorSummaryOutput -join '; ')" } if (@($doctorSummaryOutput | Where-Object { $_ -match '^Doctor: PASS \| \d+ checks$' }).Count -ne 1) { throw "Doctor summary mode must print one 'Doctor: PASS | N checks' line: $($doctorSummaryOutput -join '; ')" }
foreach ($entryPoint in @('install','update')) { foreach ($entryPoint in @('install','update')) {
$preview = @(& "$PSScriptRoot/$entryPoint.ps1" -Summary -DryRun -Client Both -Platform Windows) $preview = @(& "$PSScriptRoot/$entryPoint.ps1" -Summary -DryRun -Client Both -Shell PowerShell)
if ($preview -match '^(SOURCE|CREATE|UPDATE|UNCHANGED|BACKUP|DRYRUN|PASS) ') { throw "$entryPoint summary leaked per-item output." } if ($preview -match '^(SOURCE|CREATE|UPDATE|UNCHANGED|BACKUP|DRYRUN|PASS) ') { throw "$entryPoint summary leaked per-item output." }
if (@($preview | Where-Object { $_ -match ('^' + $entryPoint + ': PASS') }).Count -ne 1) { throw "$entryPoint summary is missing." } if (@($preview | Where-Object { $_ -match ('^' + $entryPoint + ': PASS') }).Count -ne 1) { throw "$entryPoint summary is missing." }
} }
$doctorSummaryOutput | Where-Object { $_ -match '^WARN ' } $doctorSummaryOutput | Where-Object { $_ -match '^WARN ' }
if ($Summary) { Write-Output 'Tests: PASS | platform packages' } else { Write-Output 'PASS four platform packages and six PowerShell selections' } if ($Summary) { Write-Output 'Tests: PASS | shell packages' } else { Write-Output 'PASS four shell packages and six PowerShell selections' }
exit 0 exit 0
+15 -15
View File
@@ -25,9 +25,9 @@ expected_status_output=$'Test Model \xc2\xb7 Effort high \xc2\xb7 TestRepo @ '"$
source_agent_count=$(find "$root/shared/agents" -mindepth 1 -maxdepth 1 -type d | wc -l) source_agent_count=$(find "$root/shared/agents" -mindepth 1 -maxdepth 1 -type d | wc -l)
source_skill_count=$(find "$root/shared/skills" -name SKILL.md | wc -l) source_skill_count=$(find "$root/shared/skills" -name SKILL.md | wc -l)
rule_skill_count=$(($(wc -l < "$root/adapters/claude/rule-skills.tsv") - 1)) rule_skill_count=$(($(wc -l < "$root/adapters/claude/rule-skills.tsv") - 1))
for platform in windows linux; do for shell in powershell bash; do
for client in codex claude; do for client in codex claude; do
package="$root/generated/$client-$platform" package="$root/generated/$client-$shell"
file=config.toml file=config.toml
[ "$client" != claude ] || file=settings.json [ "$client" != claude ] || file=settings.json
test -f "$package/$file" test -f "$package/$file"
@@ -35,8 +35,8 @@ for platform in windows linux; do
expected_skill_count=$source_skill_count expected_skill_count=$source_skill_count
[ "$client" != claude ] || expected_skill_count=$((source_skill_count + rule_skill_count)) [ "$client" != claude ] || expected_skill_count=$((source_skill_count + rule_skill_count))
test "$(find "$package/skills" -name SKILL.md | wc -l)" -eq "$expected_skill_count" test "$(find "$package/skills" -name SKILL.md | wc -l)" -eq "$expected_skill_count"
if [ "$platform" = windows ]; then if [ "$shell" = powershell ]; then
grep -q 'powershell .*flashbang.ps1' "$package/$file" grep -q '__POWERSHELL_COMMAND__ .*flashbang.ps1' "$package/$file"
! grep -Eq 'WindowStyle[[:space:]]+Hidden' "$package/$file" ! grep -Eq 'WindowStyle[[:space:]]+Hidden' "$package/$file"
else else
grep -q 'bash .*flashbang.sh' "$package/$file" grep -q 'bash .*flashbang.sh' "$package/$file"
@@ -48,21 +48,21 @@ for platform in windows linux; do
grep -q 'approvals_reviewer[[:space:]]*=[[:space:]]*"auto_review"' "$package/$file" grep -q 'approvals_reviewer[[:space:]]*=[[:space:]]*"auto_review"' "$package/$file"
grep -Fq 'status_line = ["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"]' "$package/$file" grep -Fq 'status_line = ["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"]' "$package/$file"
else else
[ "$(awk '/^ "hooks": \{/{inside=1; next} inside && /^ \}/{inside=0} inside && /^ "[^"]+": \[$/{gsub(/^ "|": \[$/, ""); print}' "$package/$file")" = Stop ] python3 -c 'import json,sys; settings=json.load(open(sys.argv[1], encoding="utf-8-sig")); assert set(settings["hooks"]) == {"PreCompact", "SessionStart", "Stop"}' "$package/$file"
! grep -q 'flashbang-if-input' "$package/$file" ! grep -q 'flashbang-if-input' "$package/$file"
! grep -q '"async"[[:space:]]*:[[:space:]]*true' "$package/$file" ! grep -q '"async"[[:space:]]*:[[:space:]]*true' "$package/$file"
grep -q '"defaultMode"[[:space:]]*:[[:space:]]*"auto"' "$package/$file" grep -q '"defaultMode"[[:space:]]*:[[:space:]]*"auto"' "$package/$file"
statusline_extension=sh statusline_extension=sh
[ "$platform" != windows ] || statusline_extension=ps1 [ "$shell" != powershell ] || statusline_extension=ps1
test -f "$package/statusline/statusline.$statusline_extension" test -f "$package/statusline/statusline.$statusline_extension"
grep -q "statusline.$statusline_extension" "$package/$file" grep -q "statusline.$statusline_extension" "$package/$file"
fi fi
if grep -Eq '__HOOK_|__WINDOWS_HOOK_' "$package/$file"; then exit 1; fi if grep -Eq '__HOOK_|__POWERSHELL_HOOK_' "$package/$file"; then exit 1; fi
doc_file=AGENTS.md doc_file=AGENTS.md
[ "$client" != claude ] || doc_file=CLAUDE.md [ "$client" != claude ] || doc_file=CLAUDE.md
doc_content=$(cat "$package/$doc_file") doc_content=$(cat "$package/$doc_file")
occurrences=$(printf '%s' "$doc_content" | grep -o 'Apply instructions in this order' | wc -l) occurrences=$(printf '%s' "$doc_content" | grep -o 'Apply instructions in this order' | wc -l)
[ "$occurrences" -eq 1 ] || { printf '%s-%s must embed the priority rule exactly once\n' "$client" "$platform" >&2; exit 1; } [ "$occurrences" -eq 1 ] || { printf '%s-%s must embed the priority rule exactly once\n' "$client" "$shell" >&2; exit 1; }
if [ "$client" = codex ] && printf '%s' "$doc_content" | grep -q 'Always load and apply `rules/general\.md`'; then if [ "$client" = codex ] && printf '%s' "$doc_content" | grep -q 'Always load and apply `rules/general\.md`'; then
printf '%s must not still instruct loading general.md by path\n' "$package" >&2 printf '%s must not still instruct loading general.md by path\n' "$package" >&2
exit 1 exit 1
@@ -84,14 +84,14 @@ for platform in windows linux; do
done done
done done
done done
for platform_selection in 1 2; do for shell_selection in 1 2; do
for client_selection in 1 2 3; do for client_selection in 1 2 3; do
output=$(printf '%s\n%s\n' "$platform_selection" "$client_selection" | bash "$root/scripts/install.sh" --dry-run 2>&1) || { printf '%s\n' "$output" >&2; exit 1; } output=$(printf '%s\n%s\n' "$shell_selection" "$client_selection" | bash "$root/scripts/install.sh" --dry-run 2>&1) || { printf '%s\n' "$output" >&2; exit 1; }
printf '%s\n' "$output" | grep -E 'WARN|FAIL|WARNING|ERROR' || true printf '%s\n' "$output" | grep -E 'WARN|FAIL|WARNING|ERROR' || true
[[ "$output" == *'PASS install dry-run'* ]] [[ "$output" == *'PASS install dry-run'* ]]
platform=windows shell=powershell
[ "$platform_selection" != 2 ] || platform=linux [ "$shell_selection" != 2 ] || shell=bash
[[ "$output" == *"-$platform"* ]] [[ "$output" == *"-$shell"* ]]
done done
done done
doctor_summary_output=$(bash "$root/scripts/doctor.sh" --summary) doctor_summary_output=$(bash "$root/scripts/doctor.sh" --summary)
@@ -104,7 +104,7 @@ if ! printf '%s\n' "$doctor_summary_output" | grep -Eq '^Doctor: PASS \| [0-9]+
exit 1 exit 1
fi fi
for entry_point in install update; do for entry_point in install update; do
preview=$(bash "$root/scripts/$entry_point.sh" --summary --dry-run --client both --platform linux) preview=$(bash "$root/scripts/$entry_point.sh" --summary --dry-run --client both --shell bash)
if printf '%s\n' "$preview" | grep -Eq '^(SOURCE|CREATE|UPDATE|UNCHANGED|BACKUP|DRYRUN|PASS) '; then if printf '%s\n' "$preview" | grep -Eq '^(SOURCE|CREATE|UPDATE|UNCHANGED|BACKUP|DRYRUN|PASS) '; then
printf '%s summary leaked per-item output.\n' "$entry_point" >&2 printf '%s summary leaked per-item output.\n' "$entry_point" >&2
exit 1 exit 1
@@ -112,4 +112,4 @@ for entry_point in install update; do
printf '%s\n' "$preview" | grep -Eiq "^$entry_point: PASS" printf '%s\n' "$preview" | grep -Eiq "^$entry_point: PASS"
done done
printf '%s\n' "$doctor_summary_output" | grep '^WARN ' || true printf '%s\n' "$doctor_summary_output" | grep '^WARN ' || true
if [ "$summary" = true ]; then printf 'Tests: PASS | platform packages\n'; else printf 'PASS four platform packages and six Bash selections\n'; fi if [ "$summary" = true ]; then printf 'Tests: PASS | shell packages\n'; else printf 'PASS four shell packages and six Bash selections\n'; fi
+17 -14
View File
@@ -3,7 +3,7 @@ param(
[switch]$DryRun, [switch]$DryRun,
[switch]$Quick, [switch]$Quick,
[ValidateSet('Codex','Claude','Both')][string]$Client, [ValidateSet('Codex','Claude','Both')][string]$Client,
[ValidateSet('Windows','Linux')][string]$Platform, [ValidateSet('PowerShell','Bash')][string]$Shell,
[switch]$Summary [switch]$Summary
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -12,22 +12,25 @@ $root = Split-Path $PSScriptRoot -Parent
. (Join-Path $PSScriptRoot 'lib/manifest.ps1') . (Join-Path $PSScriptRoot 'lib/manifest.ps1')
. (Join-Path $PSScriptRoot 'lib/install-targets.ps1') . (Join-Path $PSScriptRoot 'lib/install-targets.ps1')
. (Join-Path $PSScriptRoot 'lib/selection-state.ps1') . (Join-Path $PSScriptRoot 'lib/selection-state.ps1')
. (Join-Path $PSScriptRoot 'lib/install-options.ps1')
$homePath = [Environment]::GetFolderPath('UserProfile') $homePath = [Environment]::GetFolderPath('UserProfile')
if ($Quick) { if ($Quick) {
$saved = Read-UpdateSelection -HomePath $homePath -RepositoryRoot $root $saved = Read-UpdateSelection -HomePath $homePath -RepositoryRoot $root
if (-not $Platform) { $Platform = $saved.platform } if (-not $Shell) { $Shell = $saved.shell }
if (-not $Client) { $Client = $saved.client } if (-not $Client) { $Client = $saved.client }
} }
$Platform = Read-InstallPlatform -Platform $Platform $Shell = Read-InstallShell -Shell $Shell
$platform = $Platform.ToLowerInvariant() $shell = $Shell.ToLowerInvariant()
$Client = Read-InstallClient -Client $Client $Client = Read-InstallClient -Client $Client
if (-not $DryRun -and -not (Test-AllManifestsPresent (Get-InstallDestinations -HomePath $homePath -Client $Client))) { if (-not $DryRun -and -not (Test-AllManifestsPresent (Get-InstallDestinations -HomePath $homePath -Client $Client))) {
throw 'Not installed, use the install script.' throw 'Not installed, use the install script.'
} }
$options = if ($Quick) { $saved } else { Read-InstallOptions -HomePath $homePath -RepositoryRoot $root -Client $Client -DryRun:$DryRun }
$buildScript = Join-Path $PSScriptRoot 'build.ps1' $buildScript = Join-Path $PSScriptRoot 'build.ps1'
& $buildScript -Summary:$Summary & $buildScript -Summary:$Summary
if (-not $?) { throw 'Build failed. Update was not started.' } if (-not $?) { throw 'Build failed. Update was not started.' }
@@ -35,20 +38,21 @@ $generated = Join-Path $root 'generated'
if (-not (Test-Path $generated)) { throw 'Generated output is missing after a successful build.' } if (-not (Test-Path $generated)) { throw 'Generated output is missing after a successful build.' }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$shellCommand = 'pwsh -NoProfile -ExecutionPolicy Bypass -File' $powerShellCommand = if (Get-Command powershell -ErrorAction SilentlyContinue) { 'powershell -NoProfile -ExecutionPolicy Bypass -File' } else { 'pwsh -NoProfile -ExecutionPolicy Bypass -File' }
$windowsShellCommand = 'powershell -NoProfile -ExecutionPolicy Bypass -File' $shellCommand = if ($Shell -eq 'PowerShell') { $powerShellCommand } else { 'bash' }
if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { $shellCommand = $windowsShellCommand }
$plugins = if ($Quick) { $plugins = if ($Quick) {
$entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $root) $entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $root)
@{ Selected = @($entries | Where-Object { $saved.selected -contains $_.name }); Deselected = @($entries | Where-Object { $saved.deselected -contains $_.name }) } @{ Selected = @($entries | Where-Object { $saved.selected -contains $_.name }); Deselected = @($entries | Where-Object { $saved.deselected -contains $_.name }) }
} else { } else {
Select-ConfiguredPlugins -RepositoryRoot $root -Client $Client -Mode 'Update' -DryRun:$DryRun Select-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -Mode 'Update' -DryRun:$DryRun
} }
foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Platform $platform -Client $Client)) { if ($options.update_agents) { Update-SelectedAgentClis -Client $Client -DryRun:$DryRun -Summary:$Summary }
foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Shell $shell -Client $Client)) {
Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp $stamp ` Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp $stamp `
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -WindowsShellCommand $windowsShellCommand -DryRun:$DryRun -Summary:$Summary -AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -DryRun:$DryRun -Summary:$Summary
} }
$previousPluginNonInteractive = $script:PluginNonInteractive $previousPluginNonInteractive = $script:PluginNonInteractive
@@ -56,12 +60,11 @@ $previousCI = $env:CI
try { try {
$script:PluginNonInteractive = [bool]$Quick $script:PluginNonInteractive = [bool]$Quick
if ($Quick) { $env:CI = 'true' } if ($Quick) { $env:CI = 'true' }
Install-ConfiguredPlugins -RepositoryRoot $root -Client $Client -DryRun:$DryRun -Update -Summary:$Summary -Entries $plugins.Selected Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Update -Summary:$Summary -Entries $plugins.Selected
Uninstall-DeselectedPlugins -Entries $plugins.Deselected -Client $Client -DryRun:$DryRun -Summary:$Summary
} finally { } finally {
$script:PluginNonInteractive = $previousPluginNonInteractive $script:PluginNonInteractive = $previousPluginNonInteractive
$env:CI = $previousCI $env:CI = $previousCI
} }
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Platform $Platform -Client $Client -Plugins $plugins } if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang }
if ($Summary) { Write-Output "Update: PASS | $Client, $Platform$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS update dry-run' } else { 'PASS update' })) } if ($Summary) { Write-Output "Update: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS update dry-run' } else { 'PASS update' })) }
exit 0 exit 0
+16 -11
View File
@@ -4,7 +4,7 @@ set -euo pipefail
dry_run=false dry_run=false
quick=false quick=false
client="" client=""
platform="" shell=""
summary=false summary=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
@@ -12,7 +12,7 @@ while [ $# -gt 0 ]; do
--quick) quick=true; shift ;; --quick) quick=true; shift ;;
--summary) summary=true; shift ;; --summary) summary=true; shift ;;
--client) client=${2:?--client requires a value}; shift 2 ;; --client) client=${2:?--client requires a value}; shift 2 ;;
--platform) platform=${2:?--platform requires a value}; shift 2 ;; --shell) shell=${2:?--shell requires a value}; shift 2 ;;
*) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;; *) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;;
esac esac
done done
@@ -22,13 +22,14 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
. "$root/scripts/lib/manifest.sh" . "$root/scripts/lib/manifest.sh"
. "$root/scripts/lib/install-targets.sh" . "$root/scripts/lib/install-targets.sh"
. "$root/scripts/lib/selection-state.sh" . "$root/scripts/lib/selection-state.sh"
. "$root/scripts/lib/install-options.sh"
home_path=${HOME:?HOME is required} home_path=${HOME:?HOME is required}
selected_plugins=() selected_plugins=()
deselected_plugins=() deselected_plugins=()
if [ "$quick" = true ]; then read_update_selection; fi if [ "$quick" = true ]; then read_update_selection; fi
platform=$(read_install_platform "$platform") shell=$(read_install_shell "$shell")
client=$(read_install_client "$client") client=$(read_install_client "$client")
if [ "$dry_run" = false ]; then if [ "$dry_run" = false ]; then
@@ -39,6 +40,8 @@ if [ "$dry_run" = false ]; then
fi fi
fi fi
if [ "$quick" = false ]; then read_install_options; fi
build_args=() build_args=()
[ "$summary" = false ] || build_args+=(--summary) [ "$summary" = false ] || build_args+=(--summary)
bash "$root/scripts/build.sh" "${build_args[@]}" bash "$root/scripts/build.sh" "${build_args[@]}"
@@ -46,30 +49,32 @@ generated="$root/generated"
[ -d "$generated" ] || { printf 'Generated output is missing after a successful build.\n' >&2; exit 1; } [ -d "$generated" ] || { printf 'Generated output is missing after a successful build.\n' >&2; exit 1; }
stamp=$(date +%Y%m%d-%H%M%S) stamp=$(date +%Y%m%d-%H%M%S)
powershell_command='pwsh -NoProfile -ExecutionPolicy Bypass -File'
if command -v powershell >/dev/null; then powershell_command='powershell -NoProfile -ExecutionPolicy Bypass -File'; fi
shell_command=bash shell_command=bash
windows_shell_command='powershell -NoProfile -ExecutionPolicy Bypass -File' [ "$shell" != powershell ] || shell_command=$powershell_command
if [ "$quick" = false ]; then if [ "$quick" = false ]; then
select_configured_plugins "$root" "$client" Update "$dry_run" selected_plugins deselected_plugins select_configured_plugins "$root" "$client" Update "$dry_run" selected_plugins deselected_plugins
fi fi
[ "$update_agents" = false ] || update_selected_agent_clis
while IFS='|' read -r source destination; do while IFS='|' read -r source destination; do
ai_config_root=$destination ai_config_root=$destination
command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination") command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination")
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$windows_shell_command" "$dry_run" "$summary" sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang"
done < <(get_install_targets "$generated" "$home_path" "$platform" "$client") done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
if [ "$quick" = true ]; then if [ "$quick" = true ]; then
CI=true install_configured_plugins "$root" "$client" "$dry_run" true selected_plugins "$summary" </dev/null CI=true sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" true selected_plugins "$summary" </dev/null
CI=true uninstall_deselected_plugins "$root" "$client" "$dry_run" deselected_plugins "$summary" </dev/null
else else
install_configured_plugins "$root" "$client" "$dry_run" true selected_plugins "$summary" sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" true selected_plugins "$summary"
uninstall_deselected_plugins "$root" "$client" "$dry_run" deselected_plugins "$summary"
fi fi
if [ "$dry_run" = false ]; then save_update_selection; fi if [ "$dry_run" = false ]; then save_update_selection; fi
if [ "$summary" = true ]; then if [ "$summary" = true ]; then
suffix= suffix=
[ "$dry_run" = false ] || suffix=', dry-run' [ "$dry_run" = false ] || suffix=', dry-run'
printf 'Update: PASS | %s, %s%s\n' "$client" "$platform" "$suffix" printf 'Update: PASS | %s, %s%s\n' "$client" "$shell" "$suffix"
elif "$dry_run"; then printf 'PASS update dry-run\n'; else printf 'PASS update\n'; fi elif "$dry_run"; then printf 'PASS update dry-run\n'; else printf 'PASS update\n'; fi
+1 -1
View File
@@ -1,3 +1,3 @@
# Architect # Architect
Inspect the repository and requirements, identify technical boundaries and constraints, compare viable technical designs, and recommend the smallest maintainable architecture. Decide independently when evidence is sufficient, the choice is reversible, and no meaningful ambiguity remains. Ask the user about ambiguous business behavior, user-visible behavior, UI or UX, APIs, database models, configuration formats, compatibility behavior, or other high-impact unresolved choices. Do not invent business requirements. Start from the parent's requirements and references. Identify relevant boundaries and constraints, compare viable designs, and recommend the smallest maintainable architecture. Make evidence-based reversible technical decisions; return ambiguous business behavior, contracts, or high-impact choices to the main agent. Do not invent requirements or broadly explore unrelated code. Use the shared orchestration handoff protocol.
+3 -1
View File
@@ -1,3 +1,5 @@
# Implementer # Implementer
Understand requirements and existing code, implement the smallest complete solution, follow applicable rules, run proportionate verification, and review your own changes. Preserve unrelated user work. Do not add comments or documentation without explicit user approval. Understand requirements and existing code, implement the smallest complete solution, follow applicable rules, and choose proportionate verification. Inspect your changes to the degree needed for their risk; a separate or full review is not automatic. Preserve unrelated user work. Do not add comments or documentation without explicit user approval.
Stay within assigned ownership, start at supplied references, and inspect additional context only when needed. Return changes, evidence, and unresolved risks using the shared orchestration handoff protocol; the parent owns shared task state.
+1 -1
View File
@@ -1,3 +1,3 @@
# Researcher # Researcher
Inspect existing code, established patterns, relevant implementations, documentation, and constraints. Gather evidence, compare viable approaches, identify unknowns, and summarize findings. Avoid modifying implementation code by default. Start with the parent's question and references. Inspect only code, patterns, documentation, and constraints needed to answer it; expand when evidence requires it. Return conclusions, concrete references, and unknowns using the shared orchestration handoff protocol. Avoid modifying implementation code by default.
+3 -1
View File
@@ -1,3 +1,5 @@
# Reviewer # Reviewer
When delegated, review the change independently. Check correctness, regressions, security, maintainability, architecture violations, insufficient tests, and unnecessary complexity. Report evidence, severity, and actionable findings. Do not modify implementation code unless explicitly assigned that work. Provide analytical correctness review using the acceptance criteria and actual git diff. Inspect changed symbols first, then direct callers, dependencies, tests, and contracts needed to judge correctness. Expand for shared state, configuration, persistence, security, generated code, or public API effects; avoid broad repository exploration by default.
Check logic, regressions, architecture, maintainability, security, and contract consistency. Do not automatically rerun the full test suite or duplicate a verifier's evidence. Follow the shared orchestration handoff protocol; report actionable findings with severity and evidence. Do not modify implementation code unless assigned.
+3 -1
View File
@@ -1,3 +1,5 @@
# Verifier # Verifier
Verify completed work against its requested behavior and acceptance criteria. Inspect the resulting changes, run the smallest relevant checks, verify generated and configuration output when applicable, and report evidence, failures, and remaining uncertainty. Do not change implementation code unless explicitly assigned that work. Provide execution evidence against acceptance criteria. Start with changed paths and relevant test/build commands; choose the smallest sufficient tests, builds, checks, generated-output validation, or behavior reproduction. Reuse valid earlier results instead of repeating checks.
Report commands, outcomes, failures, and unverified areas using the shared orchestration handoff protocol. Do not perform a second general architecture review unless execution exposes a design issue. Do not change implementation code unless assigned.
+1 -1
View File
@@ -4,4 +4,4 @@ This instruction set is generated into the client-specific `AGENTS.md` and `CLAU
__RULE_LOADING__ __RULE_LOADING__
Delegate only when the expected improvement in correctness, independence, specialization, or parallelism outweighs the duplicated context and tool cost. Do not delegate simple repository exploration the main agent can do directly. Do not create a researcher for information the main agent can obtain cheaply itself. Use one implementer for small and medium contained tasks. Run reviewer and verifier together only when their responsibilities are materially different for the current task. Avoid multiple agents independently reading the same large set of files. Use parallel agents only for genuinely independent work. Do not let subagents create further subagents. Main agent owns state and decisions. Delegate only when benefit exceeds context/tool cost; do cheap exploration directly. Use one implementer for contained work and independent scopes for parallel work. Avoid duplicated reads. Subagents report to their parent and never spawn agents. Load orchestration guidance for delegation, subagent work, or long-running state; pass minimal context and retrieve more as needed.
+1 -1
View File
@@ -2,6 +2,6 @@
This directory contains registered hooks and optional utilities. They do not bypass client permission prompts or grant access. This directory contains registered hooks and optional utilities. They do not bypass client permission prompts or grant access.
Both clients register `Stop` (`flashbang`) for finish notifications. Claude also registers `PreCompact` (`Record-Compact`/`record-compact.sh`) and `SessionStart` (`Show-SessionStatePointer`/`show-session-state-pointer.sh`). These use `.ai-session/` under the installed configuration root: the first records a compaction timestamp, and the second points to an existing `state.json`. They do not create a task summary. Both clients register `Stop` (`flashbang`) for finish notifications. Claude also registers `PreCompact` (`Record-Compact`/`record-compact.sh`) and `SessionStart` (`Show-SessionStatePointer`/`show-session-state-pointer.sh`). PreCompact records its timestamp under the installed configuration root. Both session hooks point to an existing workspace `.ai-session/state.json`, using event `cwd` or the process working directory. The main agent owns that state; hooks never write it or print its contents. Missing state is a successful no-op.
`Validate-CommandSafety`/`validate-command-safety.sh` and `Invoke-PostChangeVerification`/`invoke-post-change-verification.sh` are not registered or invoked automatically. The safety utility checks selected command-text patterns; the verification utility runs the repository build only when explicitly invoked. `Test-SessionConfig`/`test-session-config.sh` checks repository inputs during a build. `Validate-CommandSafety`/`validate-command-safety.sh` and `Invoke-PostChangeVerification`/`invoke-post-change-verification.sh` are not registered or invoked automatically. The safety utility checks selected command-text patterns; the verification utility runs the repository build only when explicitly invoked. `Test-SessionConfig`/`test-session-config.sh` checks repository inputs during a build.
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Linux equivalent of flashbang.ps1. The visual overlay is implemented with # Bash equivalent of flashbang.ps1. The visual overlay is implemented with
# Python's standard tkinter module because Bash itself cannot create windows. # Python's standard tkinter module because Bash itself cannot create windows.
# No third-party Python package is required. # No third-party Python package is required.
+2 -1
View File
@@ -4,4 +4,5 @@ $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$stateDir = Join-Path $root '.ai-session' $stateDir = Join-Path $root '.ai-session'
New-Item $stateDir -ItemType Directory -Force | Out-Null New-Item $stateDir -ItemType Directory -Force | Out-Null
('{"event":"compact","timestamp":"' + (Get-Date).ToUniversalTime().ToString('o') + '"}') | Add-Content (Join-Path $stateDir 'telemetry.jsonl') ('{"event":"compact","timestamp":"' + (Get-Date).ToUniversalTime().ToString('o') + '"}') | Add-Content (Join-Path $stateDir 'telemetry.jsonl')
exit 0 & python (Join-Path $PSScriptRoot 'session-state.py') --hook
exit $LASTEXITCODE
@@ -1,4 +1,4 @@
[CmdletBinding()] [CmdletBinding()]
param() param()
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent & python (Join-Path $PSScriptRoot 'session-state.py') --hook
if (Test-Path (Join-Path $root '.ai-session/state.json')) { Write-Output 'Task state is available in .ai-session/state.json; read it only if needed.' } exit $LASTEXITCODE
+1
View File
@@ -3,3 +3,4 @@ set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
mkdir -p "$root/.ai-session" mkdir -p "$root/.ai-session"
printf '{"event":"compact","timestamp":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$root/.ai-session/telemetry.jsonl" printf '{"event":"compact","timestamp":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$root/.ai-session/telemetry.jsonl"
python3 "$root/hooks/scripts/session-state.py" --hook
+128
View File
@@ -0,0 +1,128 @@
import argparse
import json
import os
from pathlib import Path
import sys
import tempfile
from datetime import datetime, timezone
def state_path(workspace):
if not isinstance(workspace, str) or not workspace or any(ord(c) < 32 for c in workspace):
raise ValueError('Invalid workspace')
root = Path(workspace)
if not root.is_absolute() or not root.is_dir():
raise ValueError('Invalid workspace')
root = root.resolve()
directory = root / '.ai-session'
path = directory / 'state.json'
if directory.is_symlink() or path.is_symlink() or directory.resolve().parent != root:
raise ValueError('Invalid state path')
return path
def show_pointer(path):
if path.is_file():
print('Task state is available at ' + str(path) + '; validate its workspace and goal before reuse.')
def hook_pointer():
try:
raw = '' if sys.stdin.isatty() else sys.stdin.buffer.read(1048577).decode('utf-8-sig')
if len(raw) > 1048576:
return
event = json.loads(raw) if raw.strip() else {}
if not isinstance(event, dict):
return
show_pointer(state_path(event.get('cwd', os.getcwd())))
except (ValueError, OSError, RecursionError):
return
def update_state(args):
path = state_path(args.workspace)
if args.pointer:
show_pointer(path)
return
state = json.loads(path.read_text(encoding='utf-8-sig')) if path.exists() else {}
if not isinstance(state, dict):
raise ValueError('Invalid task state')
workspace = str(path.parent.parent)
if state.get('workspace', workspace) != workspace:
raise ValueError('Task state belongs to another workspace')
for old, new in (('changedFiles', 'changed'), ('pending', 'open'), ('importantFindings', 'decisions')):
if old in state:
state.setdefault(new, state.pop(old))
for field in ('goal', 'decisions', 'changed', 'verified', 'open', 'risks'):
if field not in state:
continue
value = state[field]
if field == 'goal':
valid = isinstance(value, str)
else:
valid = isinstance(value, list) and all(isinstance(item, str) for item in value)
if not valid:
raise ValueError('Invalid task state field')
if args.reset:
state = {}
elif args.goal is not None and state.get('goal') and state['goal'] != args.goal:
raise ValueError('A different goal requires reset')
state['workspace'] = workspace
for field in ('goal', 'decisions', 'changed', 'verified', 'open', 'risks'):
value = getattr(args, field)
if value is not None:
state[field] = value
else:
state.setdefault(field, '' if field == 'goal' else [])
state['updatedAt'] = datetime.now(timezone.utc).isoformat()
path.parent.mkdir(exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent,
prefix='.state-', suffix='.tmp', delete=False) as output:
temporary = output.name
json.dump(state, output, ensure_ascii=False, indent=2)
output.write('\n')
os.replace(temporary, path)
temporary = None
finally:
if temporary is not None:
os.unlink(temporary)
def main():
sys.stdout.reconfigure(encoding='utf-8')
parser = argparse.ArgumentParser()
parser.add_argument('--hook', action='store_true')
parser.add_argument('--update-json', action='store_true')
parser.add_argument('--workspace', default=os.getcwd())
parser.add_argument('--pointer', action='store_true')
parser.add_argument('--reset', action='store_true')
parser.add_argument('--goal')
for field in ('decisions', 'changed', 'verified', 'open', 'risks'):
parser.add_argument('--' + field, nargs='*')
args = parser.parse_args()
if args.hook:
hook_pointer()
else:
try:
if args.update_json:
values = json.loads(sys.stdin.buffer.read().decode('utf-8-sig'))
if not isinstance(values, dict):
raise ValueError('Invalid task state update')
for field, value in values.items():
if field not in ('workspace', 'goal', 'decisions', 'changed', 'verified', 'open', 'risks'):
raise ValueError('Invalid task state field')
if field in ('workspace', 'goal'):
if not isinstance(value, str):
raise ValueError('Invalid task state value')
elif not isinstance(value, list) or any(not isinstance(item, str) for item in value):
raise ValueError('Invalid task state list')
setattr(args, field, value)
update_state(args)
except (ValueError, OSError):
parser.exit(1, 'Cannot read or update task state for this workspace.\n')
if __name__ == '__main__':
main()
@@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
[ -f "$root/.ai-session/state.json" ] && printf 'Task state is available in .ai-session/state.json; read it only if needed.\n' python3 "$script_dir/session-state.py" --hook
+5 -8
View File
@@ -21,14 +21,11 @@ Project instructions may specialize global defaults, but must not weaken securit
## Verification ## Verification
* Decide independently whether and how much verification the change needs. Use the smallest set of checks that provides sufficient confidence, based on changed behavior, affected callers, failure impact, reversibility, and remaining uncertainty. * Choose checks independently by behavior, callers, impact, reversibility, and uncertainty. Focused inspection can suffice; builds, tests, new tests, and separate reviews are not automatic.
* A focused inspection can be sufficient for a small, low-risk change. Do not automatically run builds or tests, add tests, or request a separate review for every change. * Execute the cheapest check that detects the likely failure. Broaden for cross-component effects, security/data-loss risks, failures, or unresolved uncertainty. Small changes can still be risky.
* When execution adds useful evidence, start with a targeted syntax/type check, affected project build, focused test, or manual check. Choose the check that can detect the likely failure; these are alternatives, not a mandatory sequence. * Add tests for meaningful behavior or plausible regressions, not implementation details or wording. Stop when risks are covered; repeat only when changed evidence warrants it.
* Expand to integration tests, a full application build, a wider test suite, or an independent review when cross-component effects, security or data-loss risks, failures, or unresolved uncertainty justify the cost. Change size alone does not determine risk. * Explicit user/project/CI requirements remain binding. Generic skill mandates for review, builds, tests, or test-first work do not override proportional verification; skipping needless steps needs no permission.
* Add or update tests when they protect meaningful changed behavior or a plausible regression. Avoid tests that only mirror implementation details or assert wording and formatting. * Report checks and material gaps concisely. Distinguish inspection from execution; never claim unrun checks passed or hide failures.
* Stop once the relevant risks are covered. Repeat or broaden successful checks only after changes that affect their results, new failures, or new evidence of risk.
* Explicit user requests and applicable project or CI requirements remain binding. Generic skill workflows requiring review, builds, tests, or test-first development for every change do not override this proportional approach; no permission is needed to skip unnecessary workflow steps.
* Report the checks actually performed and material limitations concisely. Inspection is not a passing build or test run; do not claim evidence you did not obtain or hide unresolved failures.
## Documentation ## Documentation
+44
View File
@@ -0,0 +1,44 @@
# Agent coordination
## Ownership and context
The main agent owns the goal, decisions, shared knowledge, and progress. Subagents report to their parent; the parent routes only relevant conclusions, file/symbol references, unresolved risks, and constraints. Direct peer communication needs a concrete dependency and parent coordination; never create a communication mesh or let subagents spawn agents.
Delegate only when correctness, specialization, independence, or parallelism outweighs duplicated context and tool cost. Do cheap exploration directly. Use one implementer for contained work. Parallel roles need independent scopes and materially different value; reviewer plus verifier is not an automatic pair.
Each assignment contains only: concrete task, allowed scope/ownership, success criteria, a few starting references, and the handoff protocol reference. Include relevant constraints and tell workers about concurrent edits. Disable conversation inheritance when supported (for example, choose a fresh context); otherwise keep the explicit prompt minimal and do not claim isolation the client cannot provide. Never preload full conversations, repository summaries, architecture, unrelated files, or earlier agent outputs.
Start at supplied references. Retrieve additional symbols, callers, tests, configuration, or applicable rules only to resolve a concrete question. Reuse reliable findings; reread when code changed or evidence is insufficient. Return unresolved business decisions to the main agent.
## Handoff
Use these headings; omit empty optional sections:
```text
RESULT
One concise conclusion.
EVIDENCE
Concrete file:symbol references, commands and outcomes, tests, or sources.
CHANGES
Changed files, if any.
RISKS
Only unresolved risks, uncertainty, or assumptions.
NEXT
Only work still required.
```
Do not repeat the assignment or repository background. Reference code instead of copying it; quote exact code only when needed to explain a defect. Separate conclusions from evidence and confirmed facts from uncertainty. Identify execution versus inspection; never imply an unrun check passed. Include severity and actionable location for review findings.
## Review and verification
Reviewer: analytical correctness, regressions, architecture, maintainability, security, and contracts. Start with the actual diff and changed symbols, then necessary direct callers/dependencies and tests. Expand only when correctness depends on interfaces, shared state, configuration, persistence, security boundaries, generated code, or external/public contracts. Never default to full-repository exploration or rerunning the entire test suite.
Verifier: execution evidence against acceptance criteria. Choose relevant tests, builds, checks, generated-output validation, or reproduction steps; do not repeat an architecture review unless execution exposes a design issue. Reuse adequate earlier evidence. Both roles follow proportional verification; neither is mandatory for every change.
## Canonical task state
For long-running work, the main agent maintains one compact state with `goal`, `decisions`, `changed`, `verified`, `open`, and `risks`. Store conclusions and file/symbol references, never transcripts, copied source, secrets, or narration. Update after material decisions, changes, and verification, before compaction when possible. Mark stale evidence after relevant edits.
Use an existing task-state facility when available; otherwise use the workspace's `.ai-session/state.json`. Keep one active owner per workspace; use separate checkouts for concurrent unrelated tasks. Runtime state is local, not committed. Explicitly share its location when needed; subagents may read it but return updates to the parent instead of maintaining copies. Do not paste the full state into every assignment.
On recovery, verify that the state matches the current workspace and goal, then inspect only unresolved work and changed evidence. Session hooks point to an existing workspace file; they never author decisions or infer completed verification. A pointer is not proof that state is current. Codex follows the same recovery rule manually where lifecycle hooks are unavailable.
+1
View File
@@ -84,6 +84,7 @@
## Tests ## Tests
* Decide whether tests are needed using `general.md`'s Verification rules; this section governs tests that are selected, not a mandatory test run.
* Existing relevant tests must continue to pass. * Existing relevant tests must continue to pass.
* Update tests when internal structure changes make it necessary. * Update tests when internal structure changes make it necessary.
* Do not delete valid tests merely because they fail after a refactoring. * Do not delete valid tests merely because they fail after a refactoring.