Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ jobs:
- name: Opencode hooks
run: pnpm test:opencode-hooks

# Hosted Ubuntu ships PowerShell 7, so each PowerShell hook runs beside
# its bash twin and must give the same answer.
- name: PowerShell hooks
run: pnpm test:powershell-hooks

- name: Hogwild CLI
run: pnpm test:hogwild

Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pnpm release patch|minor|major # Bump version, tag, push (syncs plugin.json, ma

**opencode parity** (`wolfstar-agent-kit/plugins/opencode/wolfstar-hooks.ts`): the same provider gap reaches the tool hooks below. This plugin runs the same bash scripts over the same stdin and stdout contract. It denies a tool call by throwing, which opencode turns into a tool error the model reads. It rewrites a command by mutating `output.args` in place, because opencode hands that same object to the tool. A hook that fails, times out, or is missing logs to stderr and allows the call. It reads its hook list, its order, and its timeouts from the installed `plugin.json`, so it needs no list of its own. `pnpm sync:context` installs the scripts to `~/.local/share/wolfstar-agent-kit/hooks/`, the manifest to `~/.local/share/wolfstar-agent-kit/.claude-plugin/plugin.json`, and the plugin to `~/.config/opencode/plugins/wolfstar-hooks.ts`, locally and on Hogwild.

**PowerShell hooks** (`wolfstar-agent-kit/hooks/*.ps1`, `.claude-plugin/plugin.windows.json`): every registered bash hook has a PowerShell 7 twin, plus `check-config.ps1` and `command-text.ps1` for the sourced helpers. `plugin.windows.json` registers them as `pwsh -File "${CLAUDE_PLUGIN_ROOT}/hooks/<name>.ps1"`, in the order and with the timeouts of `plugin.json`, and `powershell-hooks.test.ts` fails when the two drift. `pnpm sync:context` on Windows copies the hooks, the Windows manifest as `plugin.json`, and the opencode plugin to the same `~/.local/share/wolfstar-agent-kit` layout Linux uses. Claude Code on Windows runs them from that directory as the `wolfstar-agent-kit-hooks` plugin. A hook change is two edits: the `.sh` and the `.ps1`. The parity test runs both over one command list. `codex.json` has no Windows twin yet.

**Hook lifecycle** (`wolfstar-agent-kit/hooks/`, wired in `.claude-plugin/plugin.json`):

- `SessionStart`: detect project type (Nuxt module/app, UnJS, Vue, Node), show git info, warn if not pnpm
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,24 @@ Two more scripts run outside the hook events:
checks, and [`check-config.sh`](./wolfstar-agent-kit/hooks/check-config.sh) reads
the per-project config the others source.

### Windows

Every hook has a PowerShell 7 twin beside it, a `.ps1` with the same name.
[`plugin.windows.json`](./wolfstar-agent-kit/.claude-plugin/plugin.windows.json)
registers them in the same order. A test runs each pair over one command list and
fails when the answers differ.

In PowerShell 7, run `pnpm sync:context`. It copies the hooks and that manifest to
`~/.local/share/wolfstar-agent-kit`, and the opencode plugin to
`~/.config/opencode/plugins`. opencode then runs the `.ps1` hooks in `pwsh`.
For Claude Code, install the directory as a plugin:

```powershell
/plugin install $HOME/.local/share/wolfstar-agent-kit
```

Codex has no Windows hooks yet. A hook change is two edits, the `.sh` and the `.ps1`.

Turn a hook off for one project with `.claude/hooks.json`:

```json
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"lint:fix": "oxlint . --fix && oxfmt .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"test": "pnpm test:release && pnpm test:agent-cli && pnpm test:agent-retro && pnpm test:context && pnpm test:commit-hook && pnpm test:himalaya && pnpm test:browser && pnpm test:opencode-hooks && pnpm test:hogwild && pnpm test:nuxt-4-minors-migration && pnpm test:skills && pnpm test:skill-doc-paths && pnpm test:service && pnpm test:lockfile && pnpm test:github-public && pnpm --filter advocaat test:run && pnpm --filter wolfstar-github-edge test:run && pnpm --filter wolfstar-github-agent test:run && pnpm --filter wolfstar-octorun test:run && pnpm test:octorun-service && pnpm test:notes-viewer",
"test": "pnpm test:release && pnpm test:agent-cli && pnpm test:agent-retro && pnpm test:context && pnpm test:commit-hook && pnpm test:himalaya && pnpm test:browser && pnpm test:opencode-hooks && pnpm test:powershell-hooks && pnpm test:hogwild && pnpm test:nuxt-4-minors-migration && pnpm test:skills && pnpm test:skill-doc-paths && pnpm test:service && pnpm test:lockfile && pnpm test:github-public && pnpm --filter advocaat test:run && pnpm --filter wolfstar-github-edge test:run && pnpm --filter wolfstar-github-agent test:run && pnpm --filter wolfstar-octorun test:run && pnpm test:octorun-service && pnpm test:notes-viewer",
"test:github-public": "vitest run scripts/github-public.test.ts scripts/github-public-ps.test.ts scripts/install.test.ts scripts/agent-context-platform.test.ts scripts/agent-check.test.ts scripts/agent-tools.test.ts",
"sync:tools": "flock -w 180 $HOME/.local/share/wolfstar-agent-kit/tools.lock node --experimental-strip-types scripts/agent-tools.ts",
"test:lockfile": "vitest run scripts/lockfile.test.ts",
Expand Down Expand Up @@ -42,6 +42,7 @@
"test:sentry-checkin": "python3 -m unittest discover -s wolfstar-agent-kit/skills/sentry-checkin/scripts -p 'test_*.py'",
"test:release": "vitest run scripts/release.test.ts",
"release": "node --experimental-strip-types scripts/release.ts",
"test:powershell-hooks": "vitest run wolfstar-agent-kit/hooks/powershell-hooks.test.ts",
"test:opencode-hooks": "vitest run wolfstar-agent-kit/plugins/opencode scripts/opencode-v2-migrate.test.ts",
"test:hogwild": "vitest run wolfstar-agent-kit/skills/hogwild/scripts",
"test:nuxt-4-minors-migration": "vitest run wolfstar-agent-kit/skills/nuxt-4-minors-migration/scripts",
Expand Down
10 changes: 10 additions & 0 deletions scripts/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ function fixture() {
mkdirSync(join(cwd, dir), { recursive: true })
for (const dir of ['.claude-plugin', '.codex-plugin'])
writeFileSync(join(cwd, 'wolfstar-agent-kit', dir, 'plugin.json'), JSON.stringify({ version: '1.0.0' }))
writeFileSync(
join(cwd, 'wolfstar-agent-kit/.claude-plugin/plugin.windows.json'),
JSON.stringify({ version: '1.0.0' }),
)
writeFileSync(join(cwd, '.claude-plugin/marketplace.json'), JSON.stringify({ plugins: [{ version: '1.0.0' }] }))
git('add', '.')
git('commit', '--quiet', '-m', 'chore: initial release')
Expand Down Expand Up @@ -124,5 +128,11 @@ it('prints the preview before releasing and pushes the new version to a local re
{ encoding: 'utf8' },
)
expect(JSON.parse(manifest).version).toBe('1.1.0')
const windowsManifest = execFileSync(
'git',
['--git-dir', remote, 'show', 'v1.1.0:wolfstar-agent-kit/.claude-plugin/plugin.windows.json'],
{ encoding: 'utf8' },
)
expect(JSON.parse(windowsManifest).version).toBe('1.1.0')
expect(git('status', '--porcelain')).toBe('')
})
7 changes: 7 additions & 0 deletions scripts/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ function bumpVersion(version: string, type: 'patch' | 'minor' | 'major'): string
// Get current version from plugin.json
const pluginPath = 'wolfstar-agent-kit/.claude-plugin/plugin.json'
const codexPluginPath = 'wolfstar-agent-kit/.codex-plugin/plugin.json'
const windowsPluginPath = 'wolfstar-agent-kit/.claude-plugin/plugin.windows.json'
const marketplacePath = '.claude-plugin/marketplace.json'
const plugin = JSON.parse(readFileSync(pluginPath, 'utf-8'))
const codexPlugin = JSON.parse(readFileSync(codexPluginPath, 'utf-8'))
const windowsPlugin = JSON.parse(readFileSync(windowsPluginPath, 'utf-8'))
const marketplace = JSON.parse(readFileSync(marketplacePath, 'utf-8'))
const oldVersion = plugin.version
const newVersion = bumpVersion(oldVersion, bumpType)
Expand Down Expand Up @@ -78,6 +80,11 @@ codexPlugin.version = newVersion
writeFileSync(codexPluginPath, `${JSON.stringify(codexPlugin, null, 2)}\n`)
console.log(` βœ“ ${codexPluginPath}`)

// Update the Windows hook manifest
windowsPlugin.version = newVersion
writeFileSync(windowsPluginPath, `${JSON.stringify(windowsPlugin, null, 2)}\n`)
console.log(` βœ“ ${windowsPluginPath}`)

// Update marketplace.json (sync version in plugins array)
marketplace.plugins[0].version = newVersion
writeFileSync(marketplacePath, `${JSON.stringify(marketplace, null, 2)}\n`)
Expand Down
126 changes: 108 additions & 18 deletions scripts/sync-agent-context.ps1
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#Requires -Version 7.0
# The Windows half of `pnpm sync:context` and `pnpm check:context`.
#
# Windows has no bash, so it installs the one command that has a native
# PowerShell build: agent-gh. The instructions, git hooks, and opencode plugin
# are bash, so sync those from Linux or WSL with scripts/sync-agent-context.sh.
# Windows has no bash, so it installs what has a native PowerShell build:
# agent-gh, the PowerShell twins of the plugin hooks with their Windows
# manifest, and the opencode plugin that runs them. The instructions and git
# hooks are bash, so sync those from Linux or WSL with
# scripts/sync-agent-context.sh.
#
# Installing twice changes nothing, and neither mode reads, writes, or moves
# the token file. Neither needs administrator rights.
Expand Down Expand Up @@ -34,6 +36,18 @@ if (-not [System.IO.File]::Exists($source)) {
exit 1
}

# The plugin hooks. The Windows manifest is the only place the PowerShell hooks
# register, as plugin.json is for the bash hooks. Both land where the Linux sync
# puts them, so opencode finds one layout on every platform.
$pluginRoot = [System.IO.Path]::Combine((Split-Path -Parent $PSScriptRoot), 'wolfstar-agent-kit')
$hooksSource = [System.IO.Path]::Combine($pluginRoot, 'hooks')
$manifestSource = [System.IO.Path]::Combine($pluginRoot, '.claude-plugin', 'plugin.windows.json')
$opencodeSource = [System.IO.Path]::Combine($pluginRoot, 'plugins', 'opencode', 'wolfstar-hooks.ts')
$shareDir = [System.IO.Path]::Combine($targetHome, '.local', 'share', 'wolfstar-agent-kit')
$hooksTarget = [System.IO.Path]::Combine($shareDir, 'hooks')
$manifestTarget = [System.IO.Path]::Combine($shareDir, '.claude-plugin', 'plugin.json')
$opencodeTarget = [System.IO.Path]::Combine($targetHome, '.config', 'opencode', 'plugins', 'wolfstar-hooks.ts')

function Test-SameContent([string]$Left, [string]$Right) {
if (-not [System.IO.File]::Exists($Right)) { return $false }
$a = [System.IO.File]::ReadAllBytes($Left)
Expand All @@ -51,34 +65,109 @@ function Test-PathEntry([string]$PathValue, [string]$Directory) {
return $false
}

# Every hook file the Windows manifest registers, once each, in manifest order.
function Get-ManifestHookFiles([string]$Manifest) {
$parsed = [System.IO.File]::ReadAllText($Manifest) | ConvertFrom-Json -Depth 20
$files = [System.Collections.Generic.List[string]]::new()
foreach ($event in $parsed.hooks.PSObject.Properties) {
foreach ($group in $event.Value) {
foreach ($hook in $group.hooks) {
$found = [regex]::Match($hook.command, '([A-Za-z0-9._-]+\.ps1)"?\s*$')
if ($found.Success -and -not $files.Contains($found.Groups[1].Value)) { $files.Add($found.Groups[1].Value) }
}
}
}
return , $files.ToArray()
}

# Every sibling script the hooks dot-source. The manifest never lists them,
# because they are not hooks.
function Get-SupportFiles([string]$HooksDirectory, [string[]]$HookFiles) {
$files = [System.Collections.Generic.List[string]]::new()
foreach ($hook in $HookFiles) {
$path = [System.IO.Path]::Combine($HooksDirectory, $hook)
if (-not [System.IO.File]::Exists($path)) { continue }
foreach ($line in [System.IO.File]::ReadAllLines($path)) {
$found = [regex]::Match($line, '^\s*\.\s+"\$PSScriptRoot/([A-Za-z0-9._-]+)"')
if ($found.Success -and -not $files.Contains($found.Groups[1].Value)) { $files.Add($found.Groups[1].Value) }
}
}
return , $files.ToArray()
}

# The files the hook install places, as source and target pairs. Support files
# come first, so a partial install never leaves a hook without its loader.
function Get-HookInstallPlan {
if (-not [System.IO.File]::Exists($manifestSource)) {
[Console]::Error.WriteLine("The Windows hook manifest is missing: $manifestSource")
exit 1
}
# Assigning the result unrolls the array the helpers return with a leading comma.
[string[]]$hookFiles = Get-ManifestHookFiles $manifestSource
[string[]]$supportFiles = Get-SupportFiles $hooksSource $hookFiles
$plan = [System.Collections.Generic.List[hashtable]]::new()
foreach ($file in ($supportFiles + $hookFiles)) {
$plan.Add(@{ Source = [System.IO.Path]::Combine($hooksSource, $file); Target = [System.IO.Path]::Combine($hooksTarget, $file); Label = $file })
}
$plan.Add(@{ Source = $manifestSource; Target = $manifestTarget; Label = 'plugin.json' })
$plan.Add(@{ Source = $opencodeSource; Target = $opencodeTarget; Label = 'wolfstar-hooks.ts' })
return , $plan.ToArray()
}

# Write a fresh file and move it into place, so a running hook never reads half
# a script. Copying the bytes leaves behind any download mark on the checkout,
# which RemoteSigned would otherwise refuse.
function Copy-IntoPlace([string]$From, [string]$To) {
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($To)) | Out-Null
$staged = "$To.next.$([guid]::NewGuid().ToString('N'))"
try {
[System.IO.File]::WriteAllBytes($staged, [System.IO.File]::ReadAllBytes($From))
[System.IO.File]::Move($staged, $To, $true)
}
finally {
if ([System.IO.File]::Exists($staged)) { [System.IO.File]::Delete($staged) }
}
}

if ($Mode -eq 'check') {
$failed = $false
if (-not (Test-SameContent $source $installed)) {
[Console]::Out.WriteLine('FAIL The public GitHub CLI differs. Run pnpm sync:context.')
exit 1
$failed = $true
}
else { [Console]::Out.WriteLine('ok agent-gh matches its tracked source') }
foreach ($item in (Get-HookInstallPlan)) {
if (-not [System.IO.File]::Exists($item.Target)) {
[Console]::Out.WriteLine("FAIL $($item.Label) is not installed. Run pnpm sync:context.")
$failed = $true
}
elseif (-not (Test-SameContent $item.Source $item.Target)) {
[Console]::Out.WriteLine("FAIL $($item.Label) differs. Run pnpm sync:context.")
$failed = $true
}
}
[Console]::Out.WriteLine('ok agent-gh matches its tracked source')
exit 0
if (-not $failed) { [Console]::Out.WriteLine('ok the PowerShell hooks match their tracked sources') }
exit ([int]$failed)
}

[System.IO.Directory]::CreateDirectory($binDir) | Out-Null
if (Test-SameContent $source $installed) {
[Console]::Out.WriteLine("agent-gh is current: $installed")
}
else {
# Write a fresh file and move it into place, so a running agent-gh never
# reads half a script. Copying the bytes leaves behind any download mark on
# the checkout, which RemoteSigned would otherwise refuse.
$staged = "$installed.next.$([guid]::NewGuid().ToString('N'))"
try {
[System.IO.File]::WriteAllBytes($staged, [System.IO.File]::ReadAllBytes($source))
[System.IO.File]::Move($staged, $installed, $true)
}
finally {
if ([System.IO.File]::Exists($staged)) { [System.IO.File]::Delete($staged) }
}
Copy-IntoPlace $source $installed
[Console]::Out.WriteLine("Installed agent-gh: $installed")
}

$changed = 0
foreach ($item in (Get-HookInstallPlan)) {
if (Test-SameContent $item.Source $item.Target) { continue }
Copy-IntoPlace $item.Source $item.Target
$changed++
}
if ($changed -gt 0) { [Console]::Out.WriteLine("Installed $changed PowerShell hook files: $shareDir") }
else { [Console]::Out.WriteLine("The PowerShell hooks are current: $shareDir") }

if (-not (Test-PathEntry $env:PATH $binDir)) {
if ($AddToPath) {
if (-not $IsWindows) {
Expand Down Expand Up @@ -109,6 +198,7 @@ if (-not [System.IO.File]::Exists($token)) {
[Console]::Out.WriteLine('No public GitHub token yet. Run agent-gh --setup-token to save it.')
}
if ($IsWindows) {
[Console]::Out.WriteLine('Windows installs agent-gh only. Sync the Agent instructions and hooks from Linux or WSL.')
[Console]::Out.WriteLine('Windows installs agent-gh, the PowerShell hooks, and the opencode plugin. Sync the Agent instructions from Linux or WSL.')
[Console]::Out.WriteLine("Claude Code runs the hooks from a plugin: /plugin install $shareDir")
}
exit 0
79 changes: 79 additions & 0 deletions wolfstar-agent-kit/.claude-plugin/plugin.windows.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"name": "wolfstar-agent-kit-hooks",
"version": "6.0.0",
"author": {
"name": "Wolfstar Project"
},
"description": "Wolfstar Project agent kit hooks for Windows PowerShell 7. Install it where Claude Code has no bash.",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.ps1\""
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/pnpm-only.ps1\""
},
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/wt-only.ps1\""
},
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/himalaya-read-only.ps1\""
},
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/pr-skill-only.ps1\""
},
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/github-read-routing.ps1\""
},
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/merged-branch-guard.ps1\"",
"timeout": 10000
},
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-commit-push.ps1\"",
"timeout": 120000
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/oxlint.ps1\"",
"timeout": 30000
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"${CLAUDE_PLUGIN_ROOT}/hooks/command-not-found.ps1\"",
"timeout": 5000
}
]
}
]
}
}
Loading