Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ tmux attach -t my-agent
| `--cli=claude` | CLI type: `claude`, `copilot`, `gemini`, `goose`, `codex`, `aider`, `omp` |
| `--command=<cmd>` | Override the CLI command/executable to launch (instead of the default resolved from `--cli`); combines with `--cli-args` |
| `--rationguard` | Start rationguard watcher alongside pluk (requires `rationguard` on `PATH` — `npm install -g @hivecommons/rationguard` — or `--rationguard-bin`; pluk never fetches it from the registry implicitly) |
| `--rationguard-bin=<cmd>` | Explicit rationguard command to run instead of the one on `PATH`, e.g. `--rationguard-bin='npx --yes @hivecommons/rationguard@0.11.0'` (also `PLUK_RATIONGUARD_BIN`) |
| `--rebuttal=send` | Auto-send rebuttals when rationguard detects excuses |
| `--rationguard-bin=<cmd>` | Explicit rationguard command to run instead of the one on `PATH`, e.g. `--rationguard-bin='npx --yes @hivecommons/rationguard@0.11.0'` (also `PLUK_RATIONGUARD_BIN`); requires `--rationguard` |
| `--rebuttal=send` | Auto-send rebuttals when rationguard detects excuses; requires `--rationguard` |
| `--dangerous` | Skip CLI permission prompts (`--dangerously-skip-permissions` for claude, `--full-auto` for codex, `--non-interactive` for goose, `--approval-mode yolo` for omp) |
| `--dir=/path` | Working directory for the agent |
| `--cli-args="..."` | Extra arguments to pass to the CLI |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixed-attach-rebuttal-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `--rebuttal` now validates its value is either `log` or `send`, exiting with code 2 if not, before any tmux side effect; `--rebuttal` or `--rationguard-bin` set without `--rationguard` now print a stderr warning that the flag is ignored (#280).
16 changes: 16 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,22 @@ function cmdAttach(args: string[]): void {
process.exit(1);
}

// Validate --rebuttal value (must be 'log' or 'send')
if (flags['rebuttal'] !== undefined && flags['rebuttal'] !== 'log' && flags['rebuttal'] !== 'send') {
console.error(`${paintStderr(ANSI_RED, 'Error:')} --rebuttal must be one of: log, send`);
process.exit(2);
}

// Warn if --rebuttal or --rationguard-bin are set without --rationguard
if (flags['rationguard'] !== 'true') {
if (flags['rebuttal'] !== undefined) {
console.error(`${paintStderr(ANSI_RED, 'Warning:')} --rebuttal is ignored without --rationguard`);
}
if (flags['rationguard-bin'] !== undefined) {
console.error(`${paintStderr(ANSI_RED, 'Warning:')} --rationguard-bin is ignored without --rationguard`);
}
}

attach({
session,
cli: flags['cli'] ?? 'claude',
Expand Down
61 changes: 57 additions & 4 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'
let scratch; // per-run temp root
let stubBin; // dir holding fake tmux/which/pgrep
let tmuxLog; // file where the fake tmux records its argv
let rgLog; // file where the fake rationguard records its argv

before(() => {
scratch = mkdtempSync(join(tmpdir(), 'pluk-cli-'));
stubBin = join(scratch, 'bin');
tmuxLog = join(scratch, 'tmux-args.log');
rgLog = join(scratch, 'rationguard-args.log');
mkdirSync(stubBin, { recursive: true });

// Fake tmux: records argv lines; `has-session` fails so attach takes the
Expand All @@ -40,20 +42,22 @@ printf '%s\\n' "$*" >> '${tmuxLog}'
exit 0
`,
);
// Fake which: resolves only tmux and pluk to our stubs, fails otherwise
// (so resolveRationguardBin falls back without probing npx).
// Fake which: resolves only tmux, pluk and rationguard to our stubs, fails
// otherwise (so resolveRationguardBin never probes npx).
writeFileSync(
join(stubBin, 'which'),
`#!/bin/sh
case "$1" in
tmux|pluk) echo '${stubBin}'/"$1"; exit 0 ;;
tmux|pluk|rationguard) echo '${stubBin}'/"$1"; exit 0 ;;
*) exit 1 ;;
esac
`,
);
writeFileSync(join(stubBin, 'pluk'), '#!/bin/sh\nexit 0\n');
writeFileSync(join(stubBin, 'pgrep'), '#!/bin/sh\nexit 1\n');
for (const f of ['tmux', 'which', 'pluk', 'pgrep']) {
// Fake rationguard: records argv and exits so attach's foreground watcher returns.
writeFileSync(join(stubBin, 'rationguard'), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${rgLog}'\nexit 0\n`);
for (const f of ['tmux', 'which', 'pluk', 'pgrep', 'rationguard']) {
chmodSync(join(stubBin, f), 0o755);
}
});
Expand Down Expand Up @@ -266,6 +270,55 @@ test('attach --dangerous word-splits a multi-token danger flag (omp)', () => {
assert.match(calls, /^send-keys -t =agent-omp: -- 'omp' '--approval-mode' 'yolo' Enter$/m);
});

test('attach rejects invalid --rebuttal value with exit code 2 before touching tmux', () => {
rmSync(tmuxLog, { force: true });
const runDir = makeRunDir();
const { code, stderr } = runCli([
'attach', 'agent-r-invalid', `--run-dir=${runDir}`, '--rationguard', '--rebuttal=auto',
]);
assert.equal(code, 2);
assert.match(stderr, /--rebuttal must be one of: log, send/);
assert.throws(() => readFileSync(tmuxLog), /ENOENT/, 'tmux must not be invoked');
});

test('attach --rebuttal without --rationguard prints a warning on stderr', () => {
rmSync(tmuxLog, { force: true });
const runDir = makeRunDir();
const { code, stderr } = runCli([
'attach', 'agent-r-warn', `--run-dir=${runDir}`, '--no-open', '--rebuttal=send',
]);
assert.equal(code, 0);
assert.match(stderr, /Warning: --rebuttal is ignored without --rationguard/);
const calls = readFileSync(tmuxLog, 'utf-8');
assert.ok(calls.includes('new-session'), 'session must still be created');
});

test('attach --rationguard-bin without --rationguard prints a warning on stderr', () => {
rmSync(tmuxLog, { force: true });
const runDir = makeRunDir();
const { code, stderr } = runCli([
'attach', 'agent-rb-warn', `--run-dir=${runDir}`, '--no-open', '--rationguard-bin=/bin/false',
]);
assert.equal(code, 0);
assert.match(stderr, /Warning: --rationguard-bin is ignored without --rationguard/);
const calls = readFileSync(tmuxLog, 'utf-8');
assert.ok(calls.includes('new-session'), 'session must still be created');
});

test('attach --rationguard --rebuttal=log forwards the flag without warning', () => {
rmSync(tmuxLog, { force: true });
rmSync(rgLog, { force: true });
const runDir = makeRunDir();
const { code, stderr } = runCli([
'attach', 'agent-r-ok', `--run-dir=${runDir}`, '--no-open', '--rationguard', '--rebuttal=log',
]);
assert.equal(code, 0, stderr);
assert.doesNotMatch(stderr, /ignored/);
assert.match(stderr, /Creating tmux session/);
const rgCalls = readFileSync(rgLog, 'utf-8');
assert.match(rgCalls, /^watch agent-r-ok .*--rebuttal=log/m);
});

// --- watch over stdin -------------------------------------------------------------

test('watch classifies stdin lines and emits JSONL events', () => {
Expand Down
Loading