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
10 changes: 10 additions & 0 deletions .changeset/watch-p0-p1-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@bradygaster/squad-cli': patch
---

fix(watch): wire missing CLI flags to config, validate --state-backend, fix auth stderr parsing (#834)

- Wire --notify-level, --overnight-start, --overnight-end, --sentinel-file to WatchConfig and loadWatchConfig merge
- Add --state-backend flag with upfront validation against allowed values (worktree, git-notes, orphan, external)
- Fix probeCurrentGhUser() to use `gh api user -q .login` (stdout) instead of parsing `gh auth status` stderr
- Also wire authUser through loadWatchConfig merge logic (was accepted but silently dropped)
45 changes: 45 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,45 @@ async function main(): Promise<void> {
? args[authUserIdx + 1]
: undefined;

// --notify-level runtime validation
const notifyLevelIdx = args.indexOf('--notify-level');
const rawNotifyLevel = (notifyLevelIdx !== -1 && args[notifyLevelIdx + 1])
? args[notifyLevelIdx + 1]
: undefined;
const validNotifyLevels = ['all', 'important', 'none'] as const;
const notifyLevel = rawNotifyLevel && (validNotifyLevels as readonly string[]).includes(rawNotifyLevel)
? rawNotifyLevel as typeof validNotifyLevels[number]
: rawNotifyLevel
? (console.error(`\u26a0\ufe0f Invalid --notify-level "${rawNotifyLevel}". Valid: all, important, none.`), undefined)
: undefined;

const overnightStartIdx = args.indexOf('--overnight-start');
const overnightStart = (overnightStartIdx !== -1 && args[overnightStartIdx + 1])
? args[overnightStartIdx + 1]
: undefined;

const overnightEndIdx = args.indexOf('--overnight-end');
const overnightEnd = (overnightEndIdx !== -1 && args[overnightEndIdx + 1])
? args[overnightEndIdx + 1]
: undefined;

const sentinelFileIdx = args.indexOf('--sentinel-file');
const sentinelFile = (sentinelFileIdx !== -1 && args[sentinelFileIdx + 1])
? args[sentinelFileIdx + 1]
: undefined;

// --state-backend runtime validation: reject invalid values upfront
const stateBackendIdx = args.indexOf('--state-backend');
const rawStateBackend = (stateBackendIdx !== -1 && args[stateBackendIdx + 1])
? args[stateBackendIdx + 1]
: undefined;
const validBackends = ['worktree', 'git-notes', 'orphan', 'external'] as const;
if (rawStateBackend && !(validBackends as readonly string[]).includes(rawStateBackend)) {
console.error(`\u26a0\ufe0f Invalid --state-backend "${rawStateBackend}". Valid: ${validBackends.join(', ')}.`);
process.exit(1);
}
const stateBackend = rawStateBackend as typeof validBackends[number] | undefined;

// Build capability overrides from CLI flags and --no-{cap} flags
const capabilities: Record<string, boolean | Record<string, unknown>> = {};
const registry = createDefaultRegistry();
Expand Down Expand Up @@ -456,13 +495,19 @@ async function main(): Promise<void> {
dispatchMode,
logFile,
authUser,
notifyLevel,
overnightStart,
overnightEnd,
sentinelFile,
stateBackend,
capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined,
});

// After parsing all flags, check for positional args that look like prompts.
// Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional).
const knownValueFlags = new Set([
'--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--auth-user',
'--dispatch-mode', '--log-file', '--notify-level', '--overnight-start', '--overnight-end', '--sentinel-file', '--state-backend',
]);
const watchArgStart = args.indexOf(cmd) + 1;
const watchArgs = args.slice(watchArgStart);
Expand Down
35 changes: 34 additions & 1 deletion packages/squad-cli/src/cli/commands/watch/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export interface WatchConfig {
verbose?: boolean;
/** Preferred auth user for platform operations (e.g. gh auth switch --user). */
authUser?: string;
/** Notification level for watch events. */
notifyLevel?: 'all' | 'important' | 'none';
/** Hour to begin overnight mode (e.g. "22:00"). */
overnightStart?: string;
/** Hour to end overnight mode (e.g. "08:00"). */
overnightEnd?: string;
/** Path to a sentinel file — watch shuts down gracefully when removed. */
sentinelFile?: string;
/** State persistence backend. */
stateBackend?: 'worktree' | 'git-notes' | 'orphan' | 'external';
}

const DEFAULTS: WatchConfig = {
Expand Down Expand Up @@ -83,6 +93,12 @@ export function loadWatchConfig(
...(cliOverrides.capabilities ?? {}),
},
verbose: cliOverrides.verbose ?? fileConfig.verbose ?? false,
authUser: cliOverrides.authUser ?? fileConfig.authUser,
notifyLevel: cliOverrides.notifyLevel ?? fileConfig.notifyLevel,
overnightStart: cliOverrides.overnightStart ?? fileConfig.overnightStart,
overnightEnd: cliOverrides.overnightEnd ?? fileConfig.overnightEnd,
sentinelFile: cliOverrides.sentinelFile ?? fileConfig.sentinelFile,
stateBackend: cliOverrides.stateBackend ?? fileConfig.stateBackend,
};

return merged;
Expand All @@ -106,10 +122,27 @@ function normalizeFileConfig(raw: Record<string, unknown>): Partial<WatchConfig>
}
}
if (typeof raw['logFile'] === 'string') result.logFile = raw['logFile'];
if (typeof raw['authUser'] === 'string') result.authUser = raw['authUser'];
if (typeof raw['notifyLevel'] === 'string') {
const level = raw['notifyLevel'];
if (level === 'all' || level === 'important' || level === 'none') {
result.notifyLevel = level;
}
}
if (typeof raw['overnightStart'] === 'string') result.overnightStart = raw['overnightStart'];
if (typeof raw['overnightEnd'] === 'string') result.overnightEnd = raw['overnightEnd'];
if (typeof raw['sentinelFile'] === 'string') result.sentinelFile = raw['sentinelFile'];
if (typeof raw['stateBackend'] === 'string') {
const backend = raw['stateBackend'];
const validBackends = ['worktree', 'git-notes', 'orphan', 'external'] as const;
if ((validBackends as readonly string[]).includes(backend)) {
result.stateBackend = backend as typeof validBackends[number];
}
}

// Everything else is a capability key
const caps: Record<string, boolean | Record<string, unknown>> = {};
const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', 'dispatchMode', 'logFile']);
const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', 'dispatchMode', 'logFile', 'authUser', 'notifyLevel', 'overnightStart', 'overnightEnd', 'sentinelFile', 'stateBackend']);
for (const [key, value] of Object.entries(raw)) {
if (reserved.has(key)) continue;
if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) {
Expand Down
18 changes: 9 additions & 9 deletions packages/squad-cli/src/cli/commands/watch/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { spawnSync } from 'node:child_process';

/** Shape of the PID file written by runWatch at startup. */
export interface WatchPidInfo {
Expand Down Expand Up @@ -77,19 +77,19 @@ function formatUptime(ms: number): string {
* Duplicated from index.ts to avoid circular imports — the canonical
* version lives in `getActiveGhUser()` in the watch index.
*/
/** Probes gh auth status — captures both stdout and stderr since gh may write to either. */
/** Probes current gh user — uses `gh api user` which writes to stdout reliably. */
function probeCurrentGhUser(): string | undefined {
try {
const result = execFileSync('gh', ['auth', 'status', '--active'], {
const result = spawnSync('gh', ['api', 'user', '-q', '.login'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
});
const match = result.match(/account\s+(\S+)/);
return match?.[1];
} catch (e) {
const stderr = (e as { stderr?: string }).stderr ?? '';
const match = stderr.match(/account\s+(\S+)/);
return match?.[1];
const login = (result.stdout ?? '').trim();
if (login && result.status === 0) return login;
return undefined;
} catch {
return undefined;
}
}

Expand Down
Loading