# Install Worklog Hook # # Installs (or removes) the Claude Code SessionEnd hook that logs one JSON # line per coding session for the PCMA Daily Work Tracker. # # What It Does # Install: copies worklog-session-end.ps1 into the hook folder, backs up # the Claude Code settings file, and adds one SessionEnd entry. # Every other key and every other hook is preserved. Running it # again changes nothing. # Uninstall: removes only that entry and the copied script. # -WhatIf: prints what would change and changes nothing. # # Safety # The new settings text is compared with the original before anything is # written. If any key or value outside the hook entry would differ, the # installer stops and leaves the settings file untouched. # # Examples # .\Install-WorkLogHook.ps1 # .\Install-WorkLogHook.ps1 -WhatIf # .\Install-WorkLogHook.ps1 -RepoFilter "PCMA*;pcma-*" # .\Install-WorkLogHook.ps1 -Uninstall # .\Install-WorkLogHook.ps1 -SettingsPath C:\Temp\settings.json -HookDir C:\Temp\hooks # # Runs under Windows PowerShell 5.1 and PowerShell 7. ASCII-only source. [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$SettingsPath = (Join-Path $HOME '.claude\settings.json'), [string]$HookDir = (Join-Path $HOME '.claude\hooks'), # Seconds Claude Code allows the hook at session end. Without a timeout on # the entry, SessionEnd hooks share a 1.5 second budget and a long session # would be cut off before its line is written. 0 leaves the key out. [int]$TimeoutSeconds = 30, # Optional. One or more wildcard patterns separated by semicolons, for # example "PCMA*;pcma-*". Only sessions whose repo name, or a folder name # in the working directory path, matches a pattern are logged. Empty logs # every session. Running the installer again with a different value (or # none) rewrites the one existing entry. [string]$RepoFilter = '', [switch]$Uninstall ) $ErrorActionPreference = 'Stop' $HookFileName = 'worklog-session-end.ps1' $HookPattern = 'worklog-session-end\.ps1' $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) $Invariant = [System.Globalization.CultureInfo]::InvariantCulture # --------------------------------------------------------------------------- # Settings Parsing # --------------------------------------------------------------------------- function ConvertFrom-SettingsJson { param([string]$Text) # PowerShell 7 turns date-like strings into DateTime values unless told # not to, which would rewrite them on save. -DateKind exists from 7.5. # Older 7.x versions are covered by the preservation check below. $options = @{} if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) { $options['DateKind'] = 'String' } return ($Text | ConvertFrom-Json @options) } function Test-IsWorkLogHook { param($Hook) if ($null -eq $Hook) { return $false } if ($Hook -isnot [System.Management.Automation.PSCustomObject]) { return $false } $property = $Hook.PSObject.Properties['command'] if ($null -eq $property) { return $false } return ($property.Value -is [string] -and $property.Value -match $HookPattern) } # --------------------------------------------------------------------------- # Preservation Check # --------------------------------------------------------------------------- # Both documents are flattened into "path=value" leaves with a parser that # does not reinterpret strings, then compared. This is independent of # ConvertFrom-Json and ConvertTo-Json, so it catches anything they change. function Protect-PathPart { param([string]$Name) return $Name.Replace('~', '~0').Replace('/', '~1').Replace('#', '~2') } function Add-ElementLeaves { param($Element, [string]$Path, [System.Collections.Generic.List[string]]$Leaves) # PowerShell 7: System.Text.Json elements. switch ($Element.ValueKind.ToString()) { 'Object' { $any = $false foreach ($member in $Element.EnumerateObject()) { $any = $true Add-ElementLeaves $member.Value ($Path + '/' + (Protect-PathPart $member.Name)) $Leaves } if (-not $any) { $Leaves.Add($Path + '={}') } } 'Array' { $index = 0 foreach ($item in $Element.EnumerateArray()) { Add-ElementLeaves $item ($Path + '/#' + $index) $Leaves $index++ } if ($index -eq 0) { $Leaves.Add($Path + '=[]') } } 'String' { $Leaves.Add($Path + '=s:' + $Element.GetString()) } 'Number' { $Leaves.Add($Path + '=n:' + $Element.GetDouble().ToString('R', $Invariant)) } 'True' { $Leaves.Add($Path + '=b:true') } 'False' { $Leaves.Add($Path + '=b:false') } default { $Leaves.Add($Path + '=null') } } } function Add-ObjectLeaves { param($Node, [string]$Path, [System.Collections.Generic.List[string]]$Leaves) # Windows PowerShell 5.1: JavaScriptSerializer dictionaries and arrays. if ($null -eq $Node) { $Leaves.Add($Path + '=null') } elseif ($Node -is [System.Collections.IDictionary]) { if ($Node.Count -eq 0) { $Leaves.Add($Path + '={}') } foreach ($key in @($Node.Keys)) { Add-ObjectLeaves $Node[$key] ($Path + '/' + (Protect-PathPart ([string]$key))) $Leaves } } elseif ($Node -is [string]) { $Leaves.Add($Path + '=s:' + $Node) } elseif ($Node -is [bool]) { $Leaves.Add($Path + '=b:' + $Node.ToString().ToLowerInvariant()) } elseif ($Node -is [datetime]) { $Leaves.Add($Path + '=d:' + $Node.Ticks) } elseif ($Node -is [System.Collections.IEnumerable]) { $index = 0 foreach ($item in $Node) { Add-ObjectLeaves $item ($Path + '/#' + $index) $Leaves $index++ } if ($index -eq 0) { $Leaves.Add($Path + '=[]') } } else { $Leaves.Add($Path + '=n:' + ([double]$Node).ToString('R', $Invariant)) } } function Get-JsonLeaves { param([string]$Text) $leaves = New-Object 'System.Collections.Generic.List[string]' if ($PSVersionTable.PSVersion.Major -ge 6) { $document = [System.Text.Json.JsonDocument]::Parse($Text) try { Add-ElementLeaves $document.RootElement '' $leaves } finally { $document.Dispose() } } else { Add-Type -AssemblyName System.Web.Extensions $serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer $serializer.MaxJsonLength = [int]::MaxValue $serializer.RecursionLimit = 512 Add-ObjectLeaves ($serializer.DeserializeObject($Text)) '' $leaves } return ,$leaves } function Get-OutsideLeaves { param($Leaves) # Everything that is not part of hooks.SessionEnd. An empty "hooks" object # and an empty document are ignored: install fills them and uninstall can # leave them empty again. $kept = New-Object 'System.Collections.Generic.List[string]' foreach ($leaf in $Leaves) { if ($leaf -match '^/hooks/SessionEnd(/|=)') { continue } if ($leaf -ceq '/hooks={}' -or $leaf -ceq '={}') { continue } $kept.Add($leaf) } $kept.Sort([System.StringComparer]::Ordinal) return ,$kept } function Get-ForeignSessionEndLeaves { param($Leaves) # The SessionEnd leaves that belong to other hooks, with array positions # removed so that adding or removing this hook does not shift them. $groupRegex = [regex]'^/hooks/SessionEnd/#(\d+)' $hookRegex = [regex]'^/hooks/SessionEnd/#(\d+)/hooks/#(\d+)(/|=)' $commandRegex = [regex]'^/hooks/SessionEnd/#\d+/hooks/#\d+/command=s:' $ours = @{} $hooksPerGroup = @{} foreach ($leaf in $Leaves) { $hit = $hookRegex.Match($leaf) if (-not $hit.Success) { continue } $group = $hit.Groups[1].Value $hook = $hit.Groups[2].Value if (-not $hooksPerGroup.ContainsKey($group)) { $hooksPerGroup[$group] = @{} } $hooksPerGroup[$group][$hook] = $true if ($commandRegex.IsMatch($leaf) -and $leaf -match $HookPattern) { $ours[$group + '/' + $hook] = $true } } $ourGroups = @{} foreach ($group in $hooksPerGroup.Keys) { $all = $true foreach ($hook in $hooksPerGroup[$group].Keys) { if (-not $ours.ContainsKey($group + '/' + $hook)) { $all = $false } } if ($all) { $ourGroups[$group] = $true } } $kept = New-Object 'System.Collections.Generic.List[string]' foreach ($leaf in $Leaves) { $groupHit = $groupRegex.Match($leaf) if (-not $groupHit.Success) { continue } if ($ourGroups.ContainsKey($groupHit.Groups[1].Value)) { continue } $hit = $hookRegex.Match($leaf) if ($hit.Success -and $ours.ContainsKey($hit.Groups[1].Value + '/' + $hit.Groups[2].Value)) { continue } $kept.Add(($leaf -replace '/#\d+', '/#')) } $kept.Sort([System.StringComparer]::Ordinal) return ,$kept } function Get-LeafPath { param([string]$Leaf) $cut = $Leaf.IndexOf('=') if ($cut -lt 0) { return $Leaf } return $Leaf.Substring(0, $cut) } function Compare-LeafLists { param($Before, $After, [string]$Label) # Returns a description of the first difference, or $null. Only the path # is reported, never the value. $count = [Math]::Max($Before.Count, $After.Count) for ($i = 0; $i -lt $count; $i++) { $left = $null $right = $null if ($i -lt $Before.Count) { $left = $Before[$i] } if ($i -lt $After.Count) { $right = $After[$i] } if ($left -cne $right) { $path = '' if ($null -ne $left) { $path = Get-LeafPath $left } else { $path = Get-LeafPath $right } return ('{0}: difference at {1}' -f $Label, $path) } } return $null } function Test-SettingsPreserved { param([string]$BeforeText, [string]$AfterText) $before = Get-JsonLeaves $BeforeText $after = Get-JsonLeaves $AfterText $problem = Compare-LeafLists (Get-OutsideLeaves $before) (Get-OutsideLeaves $after) 'settings outside hooks.SessionEnd' if ($null -ne $problem) { return $problem } return (Compare-LeafLists (Get-ForeignSessionEndLeaves $before) (Get-ForeignSessionEndLeaves $after) 'other SessionEnd hooks') } # --------------------------------------------------------------------------- # File Helpers # --------------------------------------------------------------------------- function Write-SettingsFile { param([string]$Path, [string]$Text) # Write beside the target, then swap, so a failure cannot leave half a file. $directory = Split-Path -Parent $Path if (-not (Test-Path -LiteralPath $directory -PathType Container)) { [void][System.IO.Directory]::CreateDirectory($directory) } $temp = $Path + '.tmp-' + $PID [System.IO.File]::WriteAllText($temp, $Text, $Utf8NoBom) try { if (Test-Path -LiteralPath $Path -PathType Leaf) { try { [System.IO.File]::Replace($temp, $Path, $null) } catch { Copy-Item -LiteralPath $temp -Destination $Path -Force } } else { [System.IO.File]::Move($temp, $Path) } } finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force } } } function Get-BackupPath { param([string]$Path) $base = $Path + '.bak-' + (Get-Date).ToString('yyyyMMdd-HHmmss', $Invariant) $candidate = $base $suffix = 2 while (Test-Path -LiteralPath $candidate) { $candidate = $base + '-' + $suffix $suffix++ } return $candidate } function Get-TargetFolder { param([string]$HookPath) # Ask the hook itself, through the same launcher Claude Code will use. try { $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $HookPath -ShowTargetFolder 2>$null $text = [string](@($output) | Select-Object -First 1) if ([string]::IsNullOrWhiteSpace($text)) { return $null } return $text.Trim() } catch { return $null } } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- try { $SettingsPath = [System.IO.Path]::GetFullPath($SettingsPath) $HookDir = [System.IO.Path]::GetFullPath($HookDir) $source = Join-Path $PSScriptRoot $HookFileName $destination = Join-Path $HookDir $HookFileName $command = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "{0}"' -f $destination $RepoFilter = ([string]$RepoFilter).Trim() if ($RepoFilter.Length -gt 0) { # The value goes inside double quotes on a command line that Claude # Code may run through bash or cmd, so characters that either shell # would act on inside quotes are refused rather than escaped. if ($RepoFilter -match '["`$\\%!]') { throw 'The -RepoFilter value may not contain a double quote, backtick, dollar sign, backslash, percent sign, or exclamation mark.' } $command += ' -RepoFilter "{0}"' -f $RepoFilter } $sameFile = ([System.IO.Path]::GetFullPath($source) -ieq $destination) if (-not $Uninstall -and -not (Test-Path -LiteralPath $source -PathType Leaf)) { throw ('Hook script not found beside the installer: ' + $source) } # Read The Settings $settingsExists = Test-Path -LiteralPath $SettingsPath -PathType Leaf $originalText = '{}' if ($settingsExists) { $originalText = [System.IO.File]::ReadAllText($SettingsPath) if ([string]::IsNullOrWhiteSpace($originalText)) { $originalText = '{}' } } $settings = ConvertFrom-SettingsJson $originalText if ($settings -isnot [System.Management.Automation.PSCustomObject]) { throw ('The settings file does not hold a JSON object: ' + $SettingsPath) } $hooksObject = $null $hooksProperty = $settings.PSObject.Properties['hooks'] if ($null -ne $hooksProperty) { $hooksObject = $hooksProperty.Value } if ($null -ne $hooksObject -and $hooksObject -isnot [System.Management.Automation.PSCustomObject]) { throw 'The "hooks" value in the settings file is not a JSON object.' } $groups = @() if ($null -ne $hooksObject) { $sessionEndProperty = $hooksObject.PSObject.Properties['SessionEnd'] if ($null -ne $sessionEndProperty -and $null -ne $sessionEndProperty.Value) { $groups = @($sessionEndProperty.Value) } } # Plan The Change $kept = New-Object System.Collections.ArrayList $found = New-Object System.Collections.ArrayList foreach ($group in $groups) { $groupHooks = $null if ($group -is [System.Management.Automation.PSCustomObject]) { $groupHooks = $group.PSObject.Properties['hooks'] } if ($null -eq $groupHooks) { [void]$kept.Add($group); continue } $all = @($groupHooks.Value) $mine = @($all | Where-Object { Test-IsWorkLogHook $_ }) if ($mine.Count -eq 0) { [void]$kept.Add($group); continue } foreach ($hook in $mine) { [void]$found.Add($hook) } $rest = @($all | Where-Object { -not (Test-IsWorkLogHook $_) }) if ($rest.Count -gt 0) { $groupHooks.Value = [object[]]$rest [void]$kept.Add($group) } } $entry = [ordered]@{ type = 'command'; command = $command } if ($TimeoutSeconds -gt 0) { $entry['timeout'] = $TimeoutSeconds } $newGroup = [pscustomobject]@{ hooks = @([pscustomobject]$entry) } $entryJson = $newGroup | ConvertTo-Json -Depth 50 -Compress $settingsChange = $true if ($Uninstall) { if ($found.Count -eq 0) { $settingsChange = $false } } elseif ($found.Count -eq 1) { $current = $found[0] $currentTimeout = $current.PSObject.Properties['timeout'] $timeoutMatches = $false if ($TimeoutSeconds -gt 0) { $timeoutMatches = ($null -ne $currentTimeout -and $currentTimeout.Value -eq $TimeoutSeconds) } else { $timeoutMatches = ($null -eq $currentTimeout) } if ($current.command -ceq $command -and $current.type -ceq 'command' -and $timeoutMatches) { $settingsChange = $false } } $newText = $null if ($settingsChange) { $newGroups = New-Object System.Collections.ArrayList foreach ($group in $kept) { [void]$newGroups.Add($group) } if (-not $Uninstall) { [void]$newGroups.Add($newGroup) } if ($newGroups.Count -gt 0) { if ($null -eq $hooksObject) { $hooksObject = [pscustomobject]@{} $settings | Add-Member -NotePropertyName 'hooks' -NotePropertyValue $hooksObject -Force } $hooksObject | Add-Member -NotePropertyName 'SessionEnd' -NotePropertyValue ([object[]]$newGroups.ToArray()) -Force } elseif ($null -ne $hooksObject) { $hooksObject.PSObject.Properties.Remove('SessionEnd') if (@($hooksObject.PSObject.Properties).Count -eq 0) { $settings.PSObject.Properties.Remove('hooks') } } $newText = ($settings | ConvertTo-Json -Depth 50) + [Environment]::NewLine $problem = Test-SettingsPreserved $originalText $newText if ($null -ne $problem) { throw ('Stopped before writing: the rewritten settings would not match the original (' + $problem + '). Nothing was changed. Try running the installer with powershell.exe.') } } # Apply $mode = 'Install' if ($Uninstall) { $mode = 'Uninstall' } Write-Host ('{0} work log hook' -f $mode) Write-Host (' Settings file: ' + $SettingsPath) Write-Host (' Hook script: ' + $destination) if (-not $Uninstall) { $filterText = 'all repositories' if ($RepoFilter.Length -gt 0) { $filterText = $RepoFilter } Write-Host (' Repo filter: ' + $filterText) } if (-not $Uninstall) { if ($sameFile) { Write-Host 'The hook script is already in the hook folder.' } elseif ($PSCmdlet.ShouldProcess($destination, 'Copy hook script')) { if (-not (Test-Path -LiteralPath $HookDir -PathType Container)) { [void][System.IO.Directory]::CreateDirectory($HookDir) } Copy-Item -LiteralPath $source -Destination $destination -Force Write-Host 'Copied the hook script.' } } if ($settingsChange) { if ($settingsExists) { $backup = Get-BackupPath $SettingsPath if ($PSCmdlet.ShouldProcess($backup, 'Back up settings file')) { Copy-Item -LiteralPath $SettingsPath -Destination $backup Write-Host ('Backed up the settings file to ' + $backup) } } $action = 'Add SessionEnd hook entry' if ($Uninstall) { $action = 'Remove SessionEnd hook entry' } elseif (-not $settingsExists) { $action = 'Create settings file with SessionEnd hook entry' } elseif ($found.Count -gt 0) { $action = 'Replace SessionEnd hook entry' } if ($PSCmdlet.ShouldProcess($SettingsPath, $action)) { Write-SettingsFile $SettingsPath $newText if ($Uninstall) { Write-Host 'Removed the SessionEnd hook entry.' } elseif ($found.Count -gt 0) { Write-Host 'Replaced the existing SessionEnd hook entry.' } else { Write-Host 'Added the SessionEnd hook entry.' } } if (-not $Uninstall) { Write-Host (' Entry: ' + $entryJson) } } elseif ($Uninstall) { Write-Host 'The settings file has no work log hook entry. Settings unchanged.' } else { Write-Host 'The settings file already has the work log hook entry. Settings unchanged.' } if ($Uninstall) { if ($sameFile) { Write-Host 'The hook script is the installer source copy. It was left in place.' } elseif (Test-Path -LiteralPath $destination -PathType Leaf) { if ($PSCmdlet.ShouldProcess($destination, 'Delete hook script')) { Remove-Item -LiteralPath $destination -Force Write-Host 'Deleted the hook script.' } } else { Write-Host 'No copied hook script to delete.' } } else { # Report Where Lines Will Go $probe = $source if (Test-Path -LiteralPath $destination -PathType Leaf) { $probe = $destination } $target = Get-TargetFolder $probe if ($null -ne $target) { Write-Host ('Session lines will be written to: ' + $target) } else { Write-Warning 'The PCMA OneDrive could not be resolved on this machine, so the hook will write nothing. Sign in to OneDrive with the PCMA account, or set WORKLOG_ONEDRIVE_ROOT to the PCMA OneDrive folder.' } } exit 0 } catch { [Console]::Error.WriteLine('Install-WorkLogHook failed: ' + $_.Exception.Message) exit 1 }