apiKeyHelper is a shell command line, not a path, so the raw value written into settings.json was split at the first space. A Windows profile named "Mohammed Ahmed" produced an attempt to run C:\Users\Mohammed, surfacing as "your apiKeyHelper script is failing" with nothing to go on. Quote the value when it contains anything a shell cares about, and leave it bare otherwise so no existing settings.json churns on the next switch. The POSIX port had the same bug against a /Users/First Last home; shlex.quote has exactly the wanted "leave ordinary paths alone" behaviour. doctor could not see any of this. It quoted the path itself before running it, so it exercised a command line Claude Code never uses and passed while the real one failed. It now reads the string out of settings.json, reports it when it is not what a switch would write, and runs that string through a shell. The helper itself exited 1 in silence on four distinct faults - no state, no preset, no key, undecryptable key - collapsing them into one indistinguishable message. Each now names itself on stderr, which is what /status displays. The DPAPI case says what it actually means: a key stored by a different Windows account than the one Claude Code runs as. Success paths stay silent, so stdout still carries the key and nothing else. Also make install.ps1 survive a Restricted execution policy: piped through iex it is not subject to the policy, but invoking the installed script for the key prompt is, which is where a fresh install died. Set Process scope for the install, offer to set CurrentUser to RemoteSigned, and clear the mark-of-the-web that Expand-Archive can leave on the extracted scripts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
201 lines
9.9 KiB
PowerShell
201 lines
9.9 KiB
PowerShell
<#
|
|
One-time installer for claude-mode.
|
|
|
|
Installs to %USERPROFILE%\.claude-mode, drops a cmd.exe shim on PATH, and
|
|
wires `claude-mode` + a `claude` wrapper into the PowerShell profile.
|
|
|
|
Idempotent: safe to re-run to upgrade. Existing presets are not overwritten
|
|
unless -Force is passed. Your ~/.claude/settings.json is NOT touched here -
|
|
that only happens when you actually run `claude-mode openrouter|anthropic`.
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[switch] $Force, # overwrite existing presets
|
|
[switch] $SkipKeyPrompt # do not prompt for the OpenRouter key
|
|
)
|
|
|
|
Set-StrictMode -Version 1.0
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Piped in as `irm ... | iex` the installer itself is never subject to the
|
|
# execution policy - but invoking the installed claude-mode.ps1 below is, and
|
|
# on a stock Restricted machine that fails. Process scope lasts only for this
|
|
# powershell.exe and does not weaken the machine or user policy.
|
|
try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction Stop } catch { }
|
|
|
|
$repoUrl = 'https://git.nebulm.com/smoido/claude-mode'
|
|
|
|
# Running from a checkout, the source is the files next to this script. Piped
|
|
# in (irm ... | iex) there is no script file and no checkout, so fetch the
|
|
# repository archive and install from that instead.
|
|
$src = $null
|
|
if ($PSScriptRoot -and (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'claude-mode.ps1'))) {
|
|
$src = $PSScriptRoot
|
|
}
|
|
else {
|
|
[Net.ServicePointManager]::SecurityProtocol =
|
|
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
|
$tmp = Join-Path $env:TEMP ('claude-mode-src-' + [IO.Path]::GetRandomFileName().Replace('.', ''))
|
|
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
|
$zip = Join-Path $tmp 'repo.zip'
|
|
Write-Host "no local checkout found - fetching source from $repoUrl" -ForegroundColor Cyan
|
|
Invoke-WebRequest -Uri "$repoUrl/archive/master.zip" -OutFile $zip -UseBasicParsing
|
|
Expand-Archive -LiteralPath $zip -DestinationPath $tmp
|
|
# Forgejo nests everything under a repo-named folder; find it wherever it is.
|
|
$extracted = Get-ChildItem -LiteralPath $tmp -Directory |
|
|
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'claude-mode.ps1') } |
|
|
Select-Object -First 1
|
|
if ($extracted) { $src = $extracted.FullName }
|
|
elseif (Test-Path -LiteralPath (Join-Path $tmp 'claude-mode.ps1')) { $src = $tmp }
|
|
else { throw 'archive did not contain claude-mode.ps1 - repo layout changed?' }
|
|
}
|
|
|
|
$root = Join-Path $env:USERPROFILE '.claude-mode'
|
|
|
|
Write-Host "installing claude-mode -> $root" -ForegroundColor Cyan
|
|
|
|
# --- 1. directories ---------------------------------------------------------
|
|
foreach ($d in @($root, "$root\presets", "$root\vault", "$root\backups", "$root\bin")) {
|
|
if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
|
}
|
|
|
|
# Lock the whole tree to the current user. The vault is DPAPI-encrypted on top
|
|
# of this, but there is no reason for the directory to be broadly readable.
|
|
try {
|
|
$acl = Get-Acl -LiteralPath $root
|
|
if ($acl.AreAccessRulesProtected) {
|
|
# Already locked down by a previous run. Re-applying would need
|
|
# SeSecurityPrivilege on some systems, so leave it alone.
|
|
Write-Host ' ok .claude-mode ACL already locked to current user' -ForegroundColor Green
|
|
throw [System.OperationCanceledException]::new('already-protected')
|
|
}
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
foreach ($r in @($acl.Access)) { [void]$acl.RemoveAccessRule($r) }
|
|
$me = New-Object System.Security.Principal.NTAccount($env:USERDOMAIN, $env:USERNAME)
|
|
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
$me, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
|
|
Set-Acl -LiteralPath $root -AclObject $acl
|
|
Write-Host ' ok locked .claude-mode ACL to current user' -ForegroundColor Green
|
|
} catch [System.OperationCanceledException] {
|
|
# already protected - nothing to do
|
|
} catch {
|
|
Write-Host " warn ACL hardening failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
|
}
|
|
|
|
# --- 2. scripts -------------------------------------------------------------
|
|
Copy-Item -LiteralPath (Join-Path $src 'claude-mode.ps1') -Destination $root -Force
|
|
# claude-mode reads VERSION from its own directory to stamp health.json.
|
|
if (Test-Path -LiteralPath (Join-Path $src 'VERSION')) {
|
|
Copy-Item -LiteralPath (Join-Path $src 'VERSION') -Destination $root -Force
|
|
}
|
|
Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.ps1') -Destination "$root\bin" -Force
|
|
Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.cmd') -Destination "$root\bin" -Force
|
|
Write-Host ' ok copied claude-mode.ps1 + key helper' -ForegroundColor Green
|
|
|
|
# --- 3. presets -------------------------------------------------------------
|
|
foreach ($p in Get-ChildItem -LiteralPath (Join-Path $src 'presets') -Filter '*.json') {
|
|
$dest = Join-Path "$root\presets" $p.Name
|
|
if ((Test-Path -LiteralPath $dest) -and -not $Force) {
|
|
Write-Host " skip preset $($p.BaseName) (exists; -Force to overwrite)" -ForegroundColor DarkGray
|
|
} else {
|
|
Copy-Item -LiteralPath $p.FullName -Destination $dest -Force
|
|
Write-Host " ok preset $($p.BaseName)" -ForegroundColor Green
|
|
}
|
|
}
|
|
|
|
# --- 4. initial state (native mode; settings.json untouched) ----------------
|
|
$statePath = Join-Path $root 'state.json'
|
|
if (-not (Test-Path -LiteralPath $statePath)) {
|
|
@{ mode = 'anthropic'; preset = 'default'; updated = (Get-Date).ToString('o') } |
|
|
ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8
|
|
Write-Host ' ok state.json initialised (mode=anthropic)' -ForegroundColor Green
|
|
}
|
|
|
|
# --- 5. cmd.exe / PATH shim -------------------------------------------------
|
|
$binDir = Join-Path $env:USERPROFILE '.local\bin'
|
|
if (-not (Test-Path -LiteralPath $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
|
|
$shim = Join-Path $binDir 'claude-mode.cmd'
|
|
@"
|
|
@echo off
|
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\.claude-mode\claude-mode.ps1" %*
|
|
"@ | Set-Content -LiteralPath $shim -Encoding ASCII
|
|
Write-Host " ok shim $shim" -ForegroundColor Green
|
|
|
|
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
|
if ($userPath -notlike "*$binDir*") {
|
|
[Environment]::SetEnvironmentVariable('Path', "$userPath;$binDir", 'User')
|
|
Write-Host " ok added $binDir to User PATH (new terminals only)" -ForegroundColor Green
|
|
}
|
|
|
|
# --- 6. PowerShell profile --------------------------------------------------
|
|
$profilePath = $PROFILE.CurrentUserAllHosts
|
|
$profileDir = Split-Path -Parent $profilePath
|
|
if (-not (Test-Path -LiteralPath $profileDir)) { New-Item -ItemType Directory -Path $profileDir -Force | Out-Null }
|
|
|
|
$snippet = Get-Content -LiteralPath (Join-Path $src 'profile-snippet.ps1') -Raw
|
|
$current = ''
|
|
if (Test-Path -LiteralPath $profilePath) { $current = Get-Content -LiteralPath $profilePath -Raw }
|
|
|
|
$startMark = '# >>> claude-mode >>>'
|
|
$endMark = '# <<< claude-mode <<<'
|
|
|
|
if ($current -match [regex]::Escape($startMark)) {
|
|
$pattern = '(?s)' + [regex]::Escape($startMark) + '.*?' + [regex]::Escape($endMark)
|
|
$current = [regex]::Replace($current, $pattern, $snippet.TrimEnd())
|
|
Set-Content -LiteralPath $profilePath -Value $current -Encoding UTF8
|
|
Write-Host " ok updated claude-mode block in $profilePath" -ForegroundColor Green
|
|
} else {
|
|
Add-Content -LiteralPath $profilePath -Value ("`r`n" + $snippet) -Encoding UTF8
|
|
Write-Host " ok appended claude-mode block to $profilePath" -ForegroundColor Green
|
|
}
|
|
|
|
# --- 7. execution policy ----------------------------------------------------
|
|
# Without this the profile block above never loads, so `claude-mode` and the
|
|
# `claude` wrapper simply do not exist in PowerShell. Offer to fix it rather
|
|
# than printing a warning the user has to act on later.
|
|
$pol = Get-ExecutionPolicy -Scope CurrentUser
|
|
if ($pol -in @('Restricted', 'Undefined', 'AllSigned')) {
|
|
$effective = Get-ExecutionPolicy
|
|
if ($effective -in @('Restricted', 'AllSigned')) {
|
|
Write-Host ''
|
|
Write-Host " warn execution policy is '$effective'; the profile block will not load," -ForegroundColor Yellow
|
|
Write-Host " so 'claude-mode' will not be a command in PowerShell." -ForegroundColor Yellow
|
|
$ans = Read-Host ' Set CurrentUser policy to RemoteSigned now? [Y/n]'
|
|
if ($ans -eq '' -or $ans -match '^(y|yes)$') {
|
|
try {
|
|
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop
|
|
Write-Host ' ok CurrentUser execution policy set to RemoteSigned' -ForegroundColor Green
|
|
} catch {
|
|
Write-Host " warn could not set policy: $($_.Exception.Message)" -ForegroundColor Yellow
|
|
Write-Host ' Run manually: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned' -ForegroundColor Yellow
|
|
}
|
|
} else {
|
|
Write-Host ' Skipped. Run manually: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned' -ForegroundColor Yellow
|
|
}
|
|
}
|
|
}
|
|
|
|
# Expand-Archive can carry the mark-of-the-web onto extracted files, which
|
|
# RemoteSigned then refuses. Clear it on what was just installed.
|
|
Get-ChildItem -LiteralPath $root -Recurse -Filter '*.ps1' -ErrorAction SilentlyContinue |
|
|
Unblock-File -ErrorAction SilentlyContinue
|
|
|
|
# --- 8. key ----------------------------------------------------------------
|
|
if (-not $SkipKeyPrompt) {
|
|
$vault = Join-Path $root 'vault\openrouter.cred'
|
|
if ((Test-Path -LiteralPath $vault) -and -not $Force) {
|
|
Write-Host ' ok OpenRouter key already stored' -ForegroundColor Green
|
|
} else {
|
|
Write-Host ''
|
|
& (Join-Path $root 'claude-mode.ps1') set-key openrouter
|
|
}
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host 'done. Open a NEW terminal, then:' -ForegroundColor Cyan
|
|
Write-Host ' claude-mode status'
|
|
Write-Host ' claude-mode openrouter default'
|
|
Write-Host ' claude-mode doctor'
|
|
Write-Host ' claude-mode anthropic'
|