From 5fa5145c81b372beaa30f772fad51442886809eb Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Fri, 31 Jul 2026 16:37:51 -0700 Subject: [PATCH] Fix bare attach and detach to target all clients --- src/core/cli/core_commands.js | 12 ++++-- src/core/commands/clients.js | 8 ++-- test/core/command-dispatch.test.js | 62 ++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index dcd4602d..044ca8c4 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -272,11 +272,13 @@ function buildCoreCommands(registry) { }, { name: 'attach', - summary: 'Attach an AI client to the local gateway', - usage: 'hyp attach [client] [--client ] [--dry-run] [--json]', + summary: 'Attach AI clients to the local gateway', + usage: 'hyp attach [client|all] [--client ] [--dry-run] [--json]', help: [ 'Points an AI client at the local gateway so its traffic is captured, by', 'writing HypAware-managed settings into that client\'s own config file.', + 'With no client name, attaches every active client adapter. Pass a client', + 'name to limit the command to that client.', 'Idempotent: re-running is a no-op. Reversible with hyp detach, which', 'removes only the managed settings.', '', @@ -297,11 +299,13 @@ function buildCoreCommands(registry) { }, { name: 'detach', - summary: 'Detach an AI client from the local gateway', - usage: 'hyp detach [client] [--client ] [--dry-run] [--json]', + summary: 'Detach AI clients from the local gateway', + usage: 'hyp detach [client|all] [--client ] [--dry-run] [--json]', help: [ 'Removes the HypAware-managed settings hyp attach wrote, leaving the', 'client\'s own configuration otherwise intact. hyp unattach is an alias.', + 'With no client name, detaches every known client integration. Pass a', + 'client name to limit the command to that client.', '', 'Detaching stops future capture for that client; it does not delete', 'anything already recorded (see hyp purge for that).', diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index b705ea95..6cc64859 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -45,7 +45,7 @@ import { pluginStateDir } from './plugin.js' */ /** - * `hyp attach [client] [--client ] [--yes]` + * `hyp attach [client|all] [--client ] [--yes]` * * Resolves the `hypaware.ai-gateway` capability, looks up the named * client adapter, and dispatches to the adapter's `attach()`. Each @@ -61,7 +61,7 @@ export async function runAttach(argv, ctx) { } /** - * `hyp detach [client] [--client ]` + * `hyp detach [client|all] [--client ]` * * Reverses a client's attach. Unlike `attach`, detach does **not** * dispatch to a per-adapter hook: it routes through the single core, @@ -613,12 +613,12 @@ function writeCoreDetachOutput({ ctx, name, json, result }) { /** * Parse an optional positional client name plus `--client `, - * `--dry-run`, and `--json` from argv. + * `--dry-run`, and `--json` from argv. With no name, target all clients. * @param {string[]} argv */ function parseClientArgs(argv) { /** @type {{ client: string, dryRun: boolean, json: boolean, error?: string }} */ - const r = { client: 'claude', dryRun: false, json: false } + const r = { client: 'all', dryRun: false, json: false } /** @type {string | undefined} */ let requestedClient /** diff --git a/test/core/command-dispatch.test.js b/test/core/command-dispatch.test.js index b85e8a53..feaf45db 100644 --- a/test/core/command-dispatch.test.js +++ b/test/core/command-dispatch.test.js @@ -699,6 +699,68 @@ test('attach accepts a positional client name', async () => { ]) }) +test('bare attach expands to every registered client', async () => { + const { registry, kernel, calls } = fakeClientKernel() + const stdout = makeBuf() + const stderr = makeBuf() + + const code = await dispatch(['attach', '--dry-run'], { + stdout, + stderr, + registry, + kernel, + env: { ...process.env, HYP_HOME: await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-attach-all-')) }, + }) + + assert.equal(code, 0) + assert.equal(stderr.text(), '') + assert.deepEqual(calls, [ + { action: 'attach', client: 'claude', dryRun: true, json: false }, + { action: 'attach', client: 'codex', dryRun: true, json: false }, + ]) +}) + +test('bare detach expands to every known client descriptor', async () => { + const { registry, kernel, calls } = fakeClientKernel() + const stdout = makeBuf() + const stderr = makeBuf() + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-detach-all-')) + + const code = await dispatch(['detach', '--json'], { + stdout, + stderr, + registry, + kernel, + env: { ...process.env, HOME: home, CODEX_HOME: home, HYP_HOME: home }, + }) + + assert.equal(code, 0) + assert.equal(stderr.text(), '') + assert.deepEqual(calls, []) + const clients = stdout.text() + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line).client) + assert.ok(clients.includes('claude')) + assert.ok(clients.includes('codex')) +}) + +test('attach and detach help document the all-client default', async () => { + const registry = createCommandRegistry() + registerCoreCommands(registry) + + for (const command of ['attach', 'detach']) { + const stdout = makeBuf() + const stderr = makeBuf() + const code = await dispatch([command, '--help'], { stdout, stderr, registry }) + + assert.equal(code, 0) + assert.equal(stderr.text(), '') + assert.match(stdout.text(), new RegExp(`hyp ${command} \\[client\\|all\\]`)) + assert.match(stdout.text(), /With no client name/) + } +}) + test('unattach alias routes a positional client through the core disk undo', async () => { const { registry, kernel, calls } = fakeClientKernel() const stdout = makeBuf()