Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md

This commit is contained in:
2026-09-24 13:07:21 -04:00
commit ea70a9cb5f
38 changed files with 9604 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
<#
.SYNOPSIS
Register (or remove) the two Windows scheduled tasks that make AVWatch
run by itself.
.DESCRIPTION
Creates:
"AVWatch Scan" mid-week checks. Cheap: it only asks Claude about
versions it has never seen before, and reuses cached
web lookups. Writes reports\latest.html. No email.
"AVWatch Digest" the Monday briefing. Full pass, every outstanding
update gets a verdict, then the email goes out.
Tasks run as the current user so they inherit your environment variables
(API key, SMTP password). They are set to run whether or not you are
logged in, and to start late if the machine was off at the scheduled time.
Run this from a normal PowerShell window -- no administrator rights are
needed for a per-user task.
.EXAMPLE
.\install_tasks.ps1
.\install_tasks.ps1 -ScanDays Tuesday,Thursday,Saturday -ScanTime 06:00 -DigestTime 07:15
.\install_tasks.ps1 -Remove
#>
[CmdletBinding()]
param(
[string[]]$ScanDays = @('Tuesday', 'Thursday', 'Saturday'),
[string]$ScanTime = '06:00',
[string]$DigestDay = 'Monday',
[string]$DigestTime = '07:00',
[string]$ProjectRoot,
[switch]$Remove
)
$ErrorActionPreference = 'Stop'
if (-not $ProjectRoot) {
$ProjectRoot = Split-Path -Parent $PSScriptRoot
}
$runner = Join-Path $ProjectRoot 'scripts\run_avwatch.ps1'
$scanTaskName = 'AVWatch Scan'
$digestTaskName = 'AVWatch Digest'
if ($Remove) {
foreach ($name in $scanTaskName, $digestTaskName) {
if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $name -Confirm:$false
Write-Output "removed scheduled task: $name"
} else {
Write-Output "not present: $name"
}
}
return
}
if (-not (Test-Path $runner)) {
throw "runner script not found: $runner"
}
# --- sanity check before we schedule anything ---------------------------
Write-Output "Checking the setup first..."
Push-Location $ProjectRoot
try {
$venvPython = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
if (Test-Path $venvPython) { $python = $venvPython }
else { $python = (Get-Command python).Source }
& $python -m avwatch doctor
if ($LASTEXITCODE -ne 0) {
Write-Warning ("'avwatch doctor' reported problems (exit $LASTEXITCODE). " +
"Scheduling anyway -- fix them and the tasks will start working.")
}
} finally {
Pop-Location
}
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-LogonType S4U -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-DontStopOnIdleEnd `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Hours 3)
function New-AvwatchAction([string]$Command) {
# -NoProfile keeps a slow or broken PowerShell profile out of the picture.
$argument = ('-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" ' +
'-Command {1} -ProjectRoot "{2}"') -f $runner, $Command, $ProjectRoot
New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argument `
-WorkingDirectory $ProjectRoot
}
# --- mid-week scans ------------------------------------------------------
$scanTrigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek $ScanDays -At $ScanTime
Register-ScheduledTask -TaskName $scanTaskName `
-Action (New-AvwatchAction 'scan') `
-Trigger $scanTrigger `
-Principal $principal `
-Settings $settings `
-Description ("AVWatch: check the AV fleet for new firmware and software " +
"releases and refresh reports\latest.html. No email.") `
-Force | Out-Null
Write-Output "registered: $scanTaskName ($($ScanDays -join ', ') at $ScanTime)"
# --- Monday digest -------------------------------------------------------
$digestTrigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek $DigestDay -At $DigestTime
Register-ScheduledTask -TaskName $digestTaskName `
-Action (New-AvwatchAction 'digest') `
-Trigger $digestTrigger `
-Principal $principal `
-Settings $settings `
-Description ("AVWatch: full update-and-risk pass, then email the weekly " +
"briefing to the recipients in config.toml.") `
-Force | Out-Null
Write-Output "registered: $digestTaskName ($DigestDay at $DigestTime)"
Write-Output ""
Write-Output "Done. Useful commands:"
Write-Output " Start-ScheduledTask -TaskName '$digestTaskName' # run the digest now"
Write-Output " Get-ScheduledTaskInfo -TaskName '$digestTaskName' # last run time and result"
Write-Output " Get-ChildItem '$ProjectRoot\logs' | Sort LastWriteTime -Desc | Select -First 5"
Write-Output ""
Write-Output "Secrets must be set as USER environment variables so the tasks"
Write-Output "inherit them. For example:"
Write-Output ' setx ANTHROPIC_API_KEY "sk-ant-..."'
Write-Output ' setx AVWATCH_SMTP_PASSWORD "your-app-password"'
Write-Output "Open a new shell after setx before testing."
+79
View File
@@ -0,0 +1,79 @@
<#
.SYNOPSIS
Wrapper that Windows Task Scheduler calls. Runs one avwatch command and
writes a timestamped log.
.DESCRIPTION
Kept deliberately thin: it resolves the project folder, prefers a local
virtual environment if one exists, runs the command, and keeps the last
30 logs. Secrets come from the user's environment variables, which the
scheduled task inherits because it runs as that user.
.EXAMPLE
.\run_avwatch.ps1 -Command scan
.\run_avwatch.ps1 -Command digest
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('scan', 'digest')]
[string]$Command,
[string]$ProjectRoot,
[string[]]$ExtraArgs = @()
)
$ErrorActionPreference = 'Stop'
if (-not $ProjectRoot) {
$ProjectRoot = Split-Path -Parent $PSScriptRoot
}
Set-Location $ProjectRoot
$logDir = Join-Path $ProjectRoot 'logs'
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$stamp = Get-Date -Format 'yyyy-MM-dd_HHmm'
$logFile = Join-Path $logDir "$Command`_$stamp.log"
# Prefer a project virtualenv if present, else whatever python is on PATH.
$venvPython = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
if (Test-Path $venvPython) {
$python = $venvPython
} else {
$cmd = Get-Command python -ErrorAction SilentlyContinue
if (-not $cmd) {
"FATAL: no python found on PATH and no .venv present" | Out-File $logFile -Encoding utf8
exit 1
}
$python = $cmd.Source
}
"=== avwatch $Command @ $(Get-Date -Format 'u') ===" | Out-File $logFile -Encoding utf8
"python: $python" | Out-File $logFile -Append -Encoding utf8
"root: $ProjectRoot" | Out-File $logFile -Append -Encoding utf8
# Report which credentials are visible, without ever logging their values.
foreach ($name in 'ANTHROPIC_API_KEY', 'AVWATCH_SMTP_PASSWORD',
'AVWATCH_GITHUB_TOKEN', 'AVWATCH_REDDIT_CLIENT_ID') {
$present = if ([Environment]::GetEnvironmentVariable($name)) { 'set' } else { 'NOT SET' }
"$name`: $present" | Out-File $logFile -Append -Encoding utf8
}
"" | Out-File $logFile -Append -Encoding utf8
$argList = @('-m', 'avwatch', $Command) + $ExtraArgs
& $python $argList *>&1 | Out-File $logFile -Append -Encoding utf8
$code = $LASTEXITCODE
"" | Out-File $logFile -Append -Encoding utf8
"=== exit code $code @ $(Get-Date -Format 'u') ===" | Out-File $logFile -Append -Encoding utf8
# Keep the last 30 logs so this never fills a booth machine's disk.
Get-ChildItem $logDir -Filter '*.log' |
Sort-Object LastWriteTime -Descending |
Select-Object -Skip 30 |
Remove-Item -Force -ErrorAction SilentlyContinue
exit $code
+108
View File
@@ -0,0 +1,108 @@
<#
.SYNOPSIS
Ask for the Anthropic API key and store it as a user environment
variable.
.DESCRIPTION
Exists so nobody has to paste a command containing a placeholder. Telling
someone to run setx ANTHROPIC_API_KEY "<your-key>" invites the
placeholder being pasted verbatim, and the angle brackets and quotes can
leave PowerShell sitting at a continuation prompt with nothing working.
Run it with no arguments:
powershell -ExecutionPolicy Bypass -File scripts\set_api_key.ps1
The key is read here and handed straight to setx. It is not written to
any file in this project, not logged, and not echoed to the screen.
#>
[CmdletBinding()]
param(
[switch]$Check
)
$ErrorActionPreference = 'Stop'
function Test-KeyShape([string]$k) {
if ([string]::IsNullOrWhiteSpace($k)) { return 'empty' }
if ($k -match '\.\.\.') { return 'placeholder' }
if ($k -match '[<>]') { return 'placeholder' }
if ($k.Trim().Length -lt 25) { return 'tooshort' }
if (-not $k.StartsWith('sk-ant-')) { return 'prefix' }
return 'ok'
}
# --- --Check mode: report what is currently set, without revealing it ----
if ($Check) {
$existing = [Environment]::GetEnvironmentVariable('ANTHROPIC_API_KEY', 'User')
if (-not $existing) {
$existing = [Environment]::GetEnvironmentVariable('ANTHROPIC_API_KEY', 'Process')
}
$shape = Test-KeyShape $existing
switch ($shape) {
'empty' { Write-Output "No ANTHROPIC_API_KEY is set." }
'placeholder' { Write-Output "ANTHROPIC_API_KEY is set to a PLACEHOLDER, not a real key. Run this script without -Check to fix it." }
'tooshort' { Write-Output "ANTHROPIC_API_KEY is set but is too short to be a real key." }
'prefix' { Write-Output "ANTHROPIC_API_KEY is set but does not start with 'sk-ant-'." }
'ok' { Write-Output ("ANTHROPIC_API_KEY looks valid (starts {0}, {1} characters)." -f $existing.Substring(0,11), $existing.Trim().Length) }
}
return
}
Write-Output ""
Write-Output "Anthropic API key setup"
Write-Output "======================="
Write-Output ""
Write-Output "Get a key from: https://console.anthropic.com/settings/keys"
Write-Output " -> Create Key -> copy it"
Write-Output ""
Write-Output "A real key starts with 'sk-ant-' and is about 100 characters long."
Write-Output "Paste it below and press Enter. Nothing is shown as you type."
Write-Output ""
$secure = Read-Host -Prompt 'Paste your API key' -AsSecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try {
$key = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr).Trim()
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
switch (Test-KeyShape $key) {
'empty' {
Write-Output ""
Write-Output "Nothing was entered. No change made."
exit 1
}
'placeholder' {
Write-Output ""
Write-Output "That looks like example text rather than a key (it contains"
Write-Output "'...' or angle brackets). No change made -- copy the real key"
Write-Output "from the Anthropic console and run this again."
exit 1
}
'tooshort' {
Write-Output ""
Write-Output ("That is only {0} characters. A real key is around 100." -f $key.Length)
Write-Output "No change made."
exit 1
}
'prefix' {
Write-Output ""
Write-Output "That does not start with 'sk-ant-', so it is probably not an"
Write-Output "Anthropic key. No change made."
exit 1
}
}
setx ANTHROPIC_API_KEY $key | Out-Null
# Also set it for THIS session, so the next command works without reopening.
$env:ANTHROPIC_API_KEY = $key
Write-Output ""
Write-Output ("Saved. Key starts {0} and is {1} characters." -f $key.Substring(0,11), $key.Length)
Write-Output ""
Write-Output "It is active in this window already, and in any new window from now on."
Write-Output ""
Write-Output "Check it: python -m avwatch doctor"
Write-Output "Then run: python -m avwatch digest --dry-run --no-email"
Write-Output ""