From 6340399ebe8189a90e7a0cd3a1244972d934b18f Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Fri, 10 Jul 2026 16:34:17 +0200 Subject: [PATCH 01/10] CLI-815 Install Claude Code CAG PostToolUse hook --- CLAUDE.md | 2 + .../features/context-augmentation-feature.ts | 4 +- .../commands/integrate/claude/declaration.ts | 36 ++++ .../integrate/claude/hook-templates.ts | 8 + .../integrate/context-augmentation.test.ts | 175 +++++++++++++++++- .../integrate/claude/hook-templates.test.ts | 39 +++- 6 files changed, 261 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 67b418608..70a8e82b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +79,8 @@ SQAA is **project-scoped and opt-in**: integrate orchestrators call `resolveSqaa `sonar context [action] [args...]` is a passthrough to the locally-installed `sonar-context-augmentation` binary (CAG). It forwards args verbatim, propagates the child exit code, and injects context through `SONAR_CONTEXT_ORGANIZATION`, `SONAR_CONTEXT_PROJECT`, `SONAR_CONTEXT_TOKEN`, and `SONAR_CONTEXT_URL` env vars. Every CAG spawn (including the `--help` path and the integrate/post-update flows) also carries `SONAR_CONTEXT_INVOCATION_ID` — the per-CLI-process correlation id from `src/lib/invocation-id.ts`, shared with telemetry's `invocation_id` and forwarded by CAG to its daemon as the `x-sonar-invocation-id` header. The passthrough resolves project context from the recorded declarative CAG feature state for the current project rather than running full project auto-discovery. Implementation in `src/cli/commands/context/`. The binary is downloaded by `sonar integrate claude` / `sonar integrate copilot` / `sonar integrate codex` / `sonar integrate antigravity` / `sonar integrate cursor` (skip with `--skip-context`); `sonar context` itself never auto-installs and emits a clear "not installed" error pointing the user back to integrate. `--global` integrations also skip CAG setup; install it by re-running `sonar integrate ` from a project directory. The `--global` skip notice (`Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.`) is only emitted when the org is actually entitled to CAG — unentitled orgs skip silently. The CLI owns the agent skill file declaratively as a `wholeFile` resource inside a single CAG feature and renders it by calling `sonar-context-augmentation tool print-skill --invocation-prefix "sonar context" --sca-enabled=`; the rendered file is written to `.claude/skills/sonar-context-augmentation/SKILL.md`, `.github/skills/sonar-context-augmentation/SKILL.md`, or `.agents/skills/sonar-context-augmentation/SKILL.md` depending on the agent. **Cursor** also writes to `.agents/skills/sonar-context-augmentation/SKILL.md` — the shared cross-tool skills directory it reads alongside Codex and Antigravity — rather than a Cursor-private `.cursor/skills` copy, so the three tools share one skill instead of duplicating it. Cursor loads skills on demand (distinct from Cursor's SQAA delivery, which is an always-applied `.cursor/rules/*.mdc` rule because that protocol must run every turn). That same feature also declares a `tool integrate --invocation-prefix "sonar context"` operation, but the operation is install-only: the framework passes `executionMode=install|update` into feature contexts, and CAG uses `shouldApply` so `tool integrate` runs during `sonar integrate ` but not during post-update refreshes. Post-update still reinstalls the shared dependency, best-effort runs `sonar-context-augmentation tool stop --all` against the previously-installed binary before replacing it, and refreshes the skill file. Replay failures are debug-logged so they do not abort CLI startup. +For Claude Code project installs, that same CAG feature also writes a generated `PostToolUse` launcher under `.claude/hooks/sonar-context-augmentation/build-scripts/` and patches `.claude/settings.json` with matcher `Bash|PowerShell|Monitor|Read`. The launcher delegates to `sonar context __hook ClaudePostToolUse`, uses `sonar-context-augmentation` as the managed marker for idempotency/removal, coexists with the SQAA `PostToolUse` entry, and is refreshed on post-update like the skill file. + `--help`, `-h`, and bare `sonar context` (no action) are forwarded to CAG. Before installing, `sonar integrate claude|copilot|codex|antigravity` pre-flights the CAG entitlement check: `SonarQubeClient.hasCagEntitlement(orgKey)` resolves the org UUID via `/organizations/organizations` then calls `GET /cag/cag-entitlement/{uuid}` (SonarQube Cloud only). If `allowed` is false, CAG setup is skipped with a warning (cloud) or a plain info line (SonarQube Server). Any error in the check is treated as "not entitled". The `sonar context` passthrough is not gated — CAG itself enforces entitlement per-request. diff --git a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts index e65d8d922..136e5aa4d 100644 --- a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts +++ b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts @@ -28,7 +28,7 @@ import { CONTEXT_AUGMENTATION_FEATURE_PREVIEW, } from '../feature-constants'; import { contextAugmentationBinaryDependency } from '../registry/dependencies'; -import { wholeFile } from '../registry/resources'; +import { type ResourceDeclaration, wholeFile } from '../registry/resources'; import { askUser, skip } from '../registry/selection'; import type { FeatureDeclaration, IntegrationContext } from '../registry/types'; @@ -39,6 +39,7 @@ export const CONTEXT_AUGMENTATION_TOOL_INTEGRATION_OPERATION_ID = export interface ContextAugmentationSkillFeatureOptions { targetPath: (context: IntegrationContext) => string; + resources?: ResourceDeclaration[]; } export function createContextAugmentationFeature< @@ -65,6 +66,7 @@ export function createContextAugmentationFeature< scaEnabled: context.attrs?.scaEnabled === true, }), }), + ...(options.resources ?? []), ], operations: [ { diff --git a/src/cli/commands/integrate/claude/declaration.ts b/src/cli/commands/integrate/claude/declaration.ts index b7cd7cad1..4f5843bb8 100644 --- a/src/cli/commands/integrate/claude/declaration.ts +++ b/src/cli/commands/integrate/claude/declaration.ts @@ -52,6 +52,8 @@ import { askUser, jsonPatch, skip, textSnippet, wholeFile } from '../_common/reg import { SQAA_HOOK_FEATURE_ID } from '../_common/sqaa-entitlement'; import type { IntegrateAgentOptions } from '../_common/types'; import { + getContextAugmentationPostToolTemplateUnix, + getContextAugmentationPostToolTemplateWindows, getSecretPreToolTemplateUnix, getSecretPreToolTemplateWindows, getSecretPromptTemplateUnix, @@ -65,6 +67,9 @@ const SETTINGS_FILE = 'settings.json'; const CLAUDE_MD_FILE = 'CLAUDE.md'; const PRETOOL_SCRIPT_REL = 'sonar-secrets/build-scripts/pretool-secrets'; const PROMPT_SCRIPT_REL = 'sonar-secrets/build-scripts/prompt-secrets'; +const CONTEXT_POSTTOOL_SCRIPT_REL = + 'sonar-context-augmentation/build-scripts/posttool-context-augmentation'; +const CONTEXT_POSTTOOL_MATCHER = 'Bash|PowerShell|Monitor|Read'; export const CLAUDE_INTEGRATION_ID = 'claude-code'; @@ -221,6 +226,37 @@ export const claudeIntegration: IntegrationDeclaration }, createContextAugmentationFeature({ targetPath: resolveClaudeSkillPath, + resources: [ + wholeFile({ + id: 'posttool-context-augmentation-script', + displayName: 'Claude Context Augmentation PostToolUse hook script', + targetPath: (context) => + resolveAgentHookScriptPath(context, CLAUDE_CONFIG_DIR, CONTEXT_POSTTOOL_SCRIPT_REL), + content: { + unix: getContextAugmentationPostToolTemplateUnix(), + windows: getContextAugmentationPostToolTemplateWindows(), + }, + executable: true, + }), + jsonPatch({ + id: 'claude-settings-context-augmentation-hook', + displayName: 'Claude Context Augmentation hook configuration', + targetPath: resolveClaudeSettingsPath, + defaultValue: { hooks: {} }, + patch: (document, context) => + upsertAgentHooks(document, [ + createAgentHookEntry( + context, + CLAUDE_CONFIG_DIR, + 'PostToolUse', + CONTEXT_POSTTOOL_MATCHER, + 'sonar-context-augmentation', + CONTEXT_POSTTOOL_SCRIPT_REL, + ), + ]), + removePatch: (document) => removeAgentHooks(document, ['sonar-context-augmentation']), + }), + ], }), ], }; diff --git a/src/cli/commands/integrate/claude/hook-templates.ts b/src/cli/commands/integrate/claude/hook-templates.ts index 5efdd59bd..dcb8d6d98 100644 --- a/src/cli/commands/integrate/claude/hook-templates.ts +++ b/src/cli/commands/integrate/claude/hook-templates.ts @@ -43,6 +43,14 @@ export function getSecretPromptTemplateWindows(): string { return windowsTemplate('sonar hook claude-prompt-submit'); } +export function getContextAugmentationPostToolTemplateUnix(): string { + return unixTemplate('sonar context __hook ClaudePostToolUse'); +} + +export function getContextAugmentationPostToolTemplateWindows(): string { + return windowsTemplate('sonar context __hook ClaudePostToolUse'); +} + export function getSqaaPostToolTemplateUnix(projectKey: string): string { return unixTemplate(formatSqaaPostToolHookCommandUnix('claude-post-tool-use', projectKey)); } diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index 61c0b6ebe..20f8cd05f 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -31,7 +31,7 @@ import { COPILOT_INTEGRATION_ID } from '../../../../src/cli/commands/integrate/c import { detectPlatform } from '../../../../src/lib/platform-detector.js'; import { SONAR_CONTEXT_AUGMENTATION_VERSION } from '../../../../src/lib/signatures.js'; import type { CliState, InstalledIntegrationFeature } from '../../../../src/lib/state.js'; -import { TestHarness } from '../../harness'; +import { hookScriptName, TestHarness } from '../../harness'; import { type CagInvocation, readCagInvocations as readInvocations, @@ -125,12 +125,78 @@ function expectSkillFile(harness: TestHarness, relativePath: string, scaEnabled: ); } +interface ClaudeHookCommand { + type?: string; + command?: string; + timeout?: number; +} + +interface ClaudeHookEntry { + matcher?: string; + hooks?: ClaudeHookCommand[]; +} + +interface ClaudeSettings { + hooks?: { + PostToolUse?: ClaudeHookEntry[]; + }; +} + +type HarnessRoot = 'cwd' | 'userHome'; + +function readClaudeSettings(harness: TestHarness, root: HarnessRoot): ClaudeSettings | undefined { + const file = harness[root].file('.claude', 'settings.json'); + if (!file.exists()) { + return undefined; + } + return file.asJson() as ClaudeSettings; +} + +function claudeCagPostToolEntries(settings: ClaudeSettings | undefined): ClaudeHookEntry[] { + return (settings?.hooks?.PostToolUse ?? []).filter((entry) => + entry.hooks?.some((hook) => hook.command?.includes(CLAUDE_CAG_HOOK_MARKER)), + ); +} + +function claudeSqaaPostToolEntries(settings: ClaudeSettings | undefined): ClaudeHookEntry[] { + return (settings?.hooks?.PostToolUse ?? []).filter((entry) => + entry.hooks?.some((hook) => hook.command?.includes('sonar-sqaa')), + ); +} + +function expectClaudeCagHookInstalled(harness: TestHarness): void { + const script = harness.cwd.file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH); + expect(script.exists()).toBe(true); + expect(script.asText()).toContain('sonar context __hook ClaudePostToolUse'); + + const entries = claudeCagPostToolEntries(readClaudeSettings(harness, 'cwd')); + expect(entries).toHaveLength(1); + expect(entries[0]?.matcher).toBe(CLAUDE_CAG_POSTTOOL_MATCHER); + expect(entries[0]?.hooks?.[0]?.type).toBe('command'); + expect(entries[0]?.hooks?.[0]?.command).toContain(CLAUDE_CAG_HOOK_MARKER); + expect(entries[0]?.hooks?.[0]?.timeout).toBe(60); +} + +function expectClaudeCagHookAbsent(harness: TestHarness): void { + for (const root of ['cwd', 'userHome'] as const) { + expect(harness[root].file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH).exists()).toBe(false); + expect(claudeCagPostToolEntries(readClaudeSettings(harness, root))).toHaveLength(0); + } +} + +function markHarnessCwdAsGitRoot(harness: TestHarness): void { + harness.cwd.writeFile('.git/HEAD', 'ref: refs/heads/main\n'); +} + const PROJECT_KEY = 'my-project'; const ORG_KEY = 'my-org'; const TOKEN = 'cloud-token'; const CLAUDE_SKILL_PATH = '.claude/skills/sonar-context-augmentation/SKILL.md'; const COPILOT_SKILL_PATH = '.github/skills/sonar-context-augmentation/SKILL.md'; const CODEX_SKILL_PATH = '.agents/skills/sonar-context-augmentation/SKILL.md'; +const CLAUDE_CAG_HOOK_MARKER = 'sonar-context-augmentation'; +const CLAUDE_CAG_POSTTOOL_MATCHER = 'Bash|PowerShell|Monitor|Read'; +const CLAUDE_CAG_POSTTOOL_SCRIPT_PATH = `.claude/hooks/sonar-context-augmentation/build-scripts/${hookScriptName('posttool-context-augmentation')}`; const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; describe('integrate claude — Context Augmentation', () => { @@ -138,6 +204,7 @@ describe('integrate claude — Context Augmentation', () => { beforeEach(async () => { harness = await TestHarness.create(); + markHarnessCwdAsGitRoot(harness); await harness.newFakeBinariesServer().start(); }); @@ -201,6 +268,7 @@ describe('integrate claude — Context Augmentation', () => { `✓ sonar-context-augmentation ${SONAR_CONTEXT_AUGMENTATION_VERSION}`, ); expectSkillFile(harness, CLAUDE_SKILL_PATH, true); + expectClaudeCagHookInstalled(harness); // State records the declarative feature. const state = loadState(harness); @@ -214,6 +282,96 @@ describe('integrate claude — Context Augmentation', () => { { timeout: 30000 }, ); + it( + 'does not duplicate the Claude Context Augmentation hook and preserves unrelated PostToolUse entries', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(TOKEN) + .withProject(PROJECT_KEY) + .withCagEntitlement(ORG_KEY) + .withScaEnabled(true) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, TOKEN, ORG_KEY); + harness.state().withContextAugmentationBinaryInstalled(); + harness.cwd.writeFile( + 'sonar-project.properties', + [ + `sonar.host.url=${serverUrl}`, + `sonar.projectKey=${PROJECT_KEY}`, + `sonar.organization=${ORG_KEY}`, + ].join('\n'), + ); + harness.cwd.writeFile( + '.claude/settings.json', + JSON.stringify({ + hooks: { + PostToolUse: [ + { + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo user-hook', timeout: 15 }], + }, + ], + }, + }), + ); + + const env = { + SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, + SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, + }; + expect( + (await harness.run('integrate claude --non-interactive', { extraEnv: env })).exitCode, + ).toBe(0); + expect( + (await harness.run('integrate claude --non-interactive', { extraEnv: env })).exitCode, + ).toBe(0); + + const settings = readClaudeSettings(harness, 'cwd'); + expectClaudeCagHookInstalled(harness); + expect(claudeCagPostToolEntries(settings)).toHaveLength(1); + expect(settings?.hooks?.PostToolUse?.some((entry) => entry.matcher === 'Bash')).toBe(true); + }, + { timeout: 30000 }, + ); + + it( + 'coexists with the Claude SQAA PostToolUse hook', + async () => { + const entitlementUuid = 'test-uuid-1234'; + const server = await harness + .newFakeServer() + .withAuthToken(TOKEN) + .withProject(PROJECT_KEY) + .withCagEntitlement(ORG_KEY, { uuid: entitlementUuid }) + .withSqaaEntitlement(ORG_KEY, entitlementUuid) + .withScaEnabled(true) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, TOKEN, ORG_KEY); + harness.state().withContextAugmentationBinaryInstalled(); + + const result = await harness.run( + `integrate claude --project ${PROJECT_KEY} --non-interactive`, + { + extraEnv: { + SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, + SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, + }, + }, + ); + + expect(result.exitCode).toBe(0); + const settings = readClaudeSettings(harness, 'cwd'); + expectClaudeCagHookInstalled(harness); + expect(claudeSqaaPostToolEntries(settings)).toHaveLength(1); + expect(claudeSqaaPostToolEntries(settings)[0]?.matcher).toBe('Edit|Write'); + expect(claudeCagPostToolEntries(settings)).toHaveLength(1); + }, + { timeout: 30000 }, + ); + it( 'passes --sca-enabled=false to print-skill and warns when SCA enablement check fails', async () => { @@ -250,6 +408,7 @@ describe('integrate claude — Context Augmentation', () => { expect(integrate.argv).toEqual(['tool', 'integrate', '--invocation-prefix', 'sonar context']); expect(result.stderr).toContain('Could not verify SCA availability'); expectSkillFile(harness, CLAUDE_SKILL_PATH, false); + expectClaudeCagHookInstalled(harness); const state = loadState(harness); expectRecordedCagFeature(state, { integrationId: CLAUDE_INTEGRATION_ID, @@ -295,6 +454,7 @@ describe('integrate claude — Context Augmentation', () => { 'sonar-context-augmentation tool print-skill produced empty output', ); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); }, { timeout: 30000 }, ); @@ -326,6 +486,7 @@ describe('integrate claude — Context Augmentation', () => { const nonProbe = invocations.filter((i) => i.argv[0] !== '--version'); expect(nonProbe).toEqual([]); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); }, @@ -366,6 +527,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain('not available for your organization'); }, { timeout: 30000 }, @@ -405,6 +567,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain('could not verify entitlement'); }, { timeout: 30000 }, @@ -565,6 +728,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expectSkillFile(harness, CLAUDE_SKILL_PATH, false); + expectClaudeCagHookInstalled(harness); expect(result.stderr).toContain('Vortex context augmentation tool integration failed.'); }, { timeout: 30000 }, @@ -596,6 +760,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain('a project key and organization are required'); }, { timeout: 30000 }, @@ -625,6 +790,7 @@ describe('integrate claude — Context Augmentation', () => { const nonProbe = readInvocations(harness).filter((i) => i.argv[0] !== '--version'); expect(nonProbe).toEqual([]); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); // "not available on SonarQube Server" info line must appear, not the // misleading "organization required" warning expect(result.stdout + result.stderr).toContain('not available on SonarQube Server'); @@ -639,6 +805,7 @@ describe('integrate copilot — Context Augmentation', () => { beforeEach(async () => { harness = await TestHarness.create(); + markHarnessCwdAsGitRoot(harness); harness.state().withSecretsBinaryInstalled(); }); @@ -691,6 +858,7 @@ describe('integrate copilot — Context Augmentation', () => { expectContextEnv(integrate, serverUrl); expect(result.stdout).not.toContain('Running: sonar-context-augmentation'); expectSkillFile(harness, COPILOT_SKILL_PATH, false); + expectClaudeCagHookAbsent(harness); // State records the declarative feature under the Copilot integration. const state = loadState(harness); @@ -710,6 +878,7 @@ describe('integrate codex — Context Augmentation', () => { beforeEach(async () => { harness = await TestHarness.create(); + markHarnessCwdAsGitRoot(harness); harness.state().withSecretsBinaryInstalled(); }); @@ -762,6 +931,7 @@ describe('integrate codex — Context Augmentation', () => { expectContextEnv(integrate, serverUrl); expect(result.stdout).not.toContain('Running: sonar-context-augmentation'); expectSkillFile(harness, CODEX_SKILL_PATH, false); + expectClaudeCagHookAbsent(harness); const state = loadState(harness); expectRecordedCagFeature(state, { @@ -884,6 +1054,7 @@ describe('integrate --global — Context Augmentation', () => { beforeEach(async () => { harness = await TestHarness.create(); + markHarnessCwdAsGitRoot(harness); harness.state().withSecretsBinaryInstalled(); }); @@ -921,6 +1092,7 @@ describe('integrate --global — Context Augmentation', () => { expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); expect(harness.cwd.file(COPILOT_SKILL_PATH).exists()).toBe(false); expect(harness.cwd.file(CODEX_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain( 'Skipping Vortex context augmentation: not supported with --global', ); @@ -952,6 +1124,7 @@ describe('integrate --global — Context Augmentation', () => { expect(nonProbe).toEqual([]); const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); + expectClaudeCagHookAbsent(harness); expect(`${result.stdout}\n${result.stderr}`).not.toContain( 'Skipping Vortex context augmentation: not supported with --global', ); diff --git a/tests/unit/cli/commands/integrate/claude/hook-templates.test.ts b/tests/unit/cli/commands/integrate/claude/hook-templates.test.ts index 6834413de..2622182b7 100644 --- a/tests/unit/cli/commands/integrate/claude/hook-templates.test.ts +++ b/tests/unit/cli/commands/integrate/claude/hook-templates.test.ts @@ -27,6 +27,8 @@ import { WINDOWS_SONAR_COMMAND_GUARD, } from '../../../../../../src/cli/commands/integrate/_common/hooks'; import { + getContextAugmentationPostToolTemplateUnix, + getContextAugmentationPostToolTemplateWindows, getSecretPreToolTemplateUnix, getSecretPreToolTemplateWindows, getSecretPromptTemplateUnix, @@ -82,6 +84,23 @@ describe('Secret Scanning Hook Templates', () => { }); }); +describe('Context Augmentation PostToolUse Hook Templates', () => { + it('PostTool Unix hook: bash shebang, delegates to sonar context hook', () => { + const template = getContextAugmentationPostToolTemplateUnix(); + + expect(template.startsWith('#!/bin/bash')).toBe(true); + expect(template.includes('sonar context __hook ClaudePostToolUse')).toBe(true); + expect(template.includes(UNIX_SONAR_COMMAND_GUARD)).toBe(true); + }); + + it('PostTool Windows hook: delegates to sonar context hook', () => { + const template = getContextAugmentationPostToolTemplateWindows(); + + expect(template.includes('sonar context __hook ClaudePostToolUse')).toBe(true); + expect(template.includes(WINDOWS_SONAR_COMMAND_GUARD)).toBe(true); + }); +}); + describe('SQAA PostToolUse Hook Templates', () => { it('PostTool Unix hook: bash shebang, delegates to sonar hook with project key', () => { const template = getSqaaPostToolTemplateUnix('my-project'); @@ -117,12 +136,14 @@ describe('SQAA PostToolUse Hook Templates', () => { }); describe('Template Integrity', () => { - it('All 6 templates are valid non-empty strings with distinct content', () => { + it('All 8 templates are valid non-empty strings with distinct content', () => { const templates = [ getSecretPreToolTemplateUnix(), getSecretPreToolTemplateWindows(), getSecretPromptTemplateUnix(), getSecretPromptTemplateWindows(), + getContextAugmentationPostToolTemplateUnix(), + getContextAugmentationPostToolTemplateWindows(), getSqaaPostToolTemplateUnix('proj'), getSqaaPostToolTemplateWindows('proj'), ]; @@ -144,6 +165,8 @@ describe('Template Integrity', () => { getSecretPreToolTemplateWindows(), getSecretPromptTemplateUnix(), getSecretPromptTemplateWindows(), + getContextAugmentationPostToolTemplateUnix(), + getContextAugmentationPostToolTemplateWindows(), getSqaaPostToolTemplateUnix('proj'), getSqaaPostToolTemplateWindows('proj'), ]; @@ -159,5 +182,19 @@ describe('Template Integrity', () => { expect(getSecretPreToolTemplateUnix().includes('claude-post-tool-use')).toBe(false); expect(getSecretPromptTemplateUnix().includes('claude-post-tool-use')).toBe(false); + expect(getContextAugmentationPostToolTemplateUnix().includes('claude-post-tool-use')).toBe( + false, + ); + }); + + it('Context Augmentation template routes to the double-underscore CAG hook', () => { + expect( + getContextAugmentationPostToolTemplateUnix().includes( + 'sonar context __hook ClaudePostToolUse', + ), + ).toBe(true); + expect(getContextAugmentationPostToolTemplateUnix().includes('sonar context _hook')).toBe( + false, + ); }); }); From cdd0c0e76c4525ed4fd8fac26a4a2de305ee42e9 Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Fri, 10 Jul 2026 17:48:18 +0200 Subject: [PATCH 02/10] Rollback CAG hook when tool integrate fails Avoid leaving Claude PostToolUse hook resources active when context augmentation setup aborts before the feature is recorded. --- .../features/context-augmentation-feature.ts | 28 +++++++++++++------ .../integrate/context-augmentation.test.ts | 2 +- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts index 136e5aa4d..b394e7b8c 100644 --- a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts +++ b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts @@ -45,6 +45,8 @@ export interface ContextAugmentationSkillFeatureOptions { export function createContextAugmentationFeature< TOptions extends { installContextAugmentation?: boolean }, >(options: ContextAugmentationSkillFeatureOptions): FeatureDeclaration { + const extraResources = options.resources ?? []; + return { id: CONTEXT_AUGMENTATION_FEATURE_ID, displayName: 'context augmentation', @@ -66,21 +68,29 @@ export function createContextAugmentationFeature< scaEnabled: context.attrs?.scaEnabled === true, }), }), - ...(options.resources ?? []), + ...extraResources, ], operations: [ { id: CONTEXT_AUGMENTATION_TOOL_INTEGRATION_OPERATION_ID, displayName: 'context augmentation tool integration', shouldApply: (context) => context.executionMode === 'install', - apply: async (context) => - runToolIntegrateCommand({ - auth: getRequiredAuth(context), - binaryPath: resolveContextAugmentationBinaryPath(context), - projectRoot: context.targetRoot, - projectKey: getOptionalStringAttr(context, 'projectKey'), - scaEnabled: context.attrs?.scaEnabled === true, - }), + apply: async (context) => { + try { + await runToolIntegrateCommand({ + auth: getRequiredAuth(context), + binaryPath: resolveContextAugmentationBinaryPath(context), + projectRoot: context.targetRoot, + projectKey: getOptionalStringAttr(context, 'projectKey'), + scaEnabled: context.attrs?.scaEnabled === true, + }); + } catch (error) { + for (const resource of [...extraResources].reverse()) { + await resource.remove(context); + } + throw error; + } + }, }, ], }; diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index 20f8cd05f..ede9e86ba 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -728,7 +728,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expectSkillFile(harness, CLAUDE_SKILL_PATH, false); - expectClaudeCagHookInstalled(harness); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain('Vortex context augmentation tool integration failed.'); }, { timeout: 30000 }, From 5fbab747e7a556e89afc2e2250a347dec62497e8 Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Fri, 10 Jul 2026 17:59:50 +0200 Subject: [PATCH 03/10] Preserve CAG failure during hook rollback Make rollback of extra context augmentation resources best-effort so cleanup errors do not mask the original tool integrate failure. Also, make log a warning if remove fails. --- .../features/context-augmentation-feature.ts | 34 ++++- .../context-augmentation-feature.test.ts | 124 ++++++++++++++++++ 2 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts diff --git a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts index b394e7b8c..c62284ab9 100644 --- a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts +++ b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts @@ -19,7 +19,9 @@ */ import { CONTEXT_AUGMENTATION_BINARY_NAME } from '../../../../../lib/install-types'; +import logger from '../../../../../lib/logger'; import { SONAR_CONTEXT_AUGMENTATION_VERSION } from '../../../../../lib/signatures'; +import { warn } from '../../../../../ui'; import { CommandFailedError } from '../../../_common/error'; import { getOptionalStringAttr } from '../attrs'; import { printContextAugmentationSkill, runToolIntegrateCommand } from '../context-augmentation'; @@ -85,9 +87,7 @@ export function createContextAugmentationFeature< scaEnabled: context.attrs?.scaEnabled === true, }); } catch (error) { - for (const resource of [...extraResources].reverse()) { - await resource.remove(context); - } + await rollBackExtraResourcesBestEffort(extraResources, context); throw error; } }, @@ -110,3 +110,31 @@ function getRequiredAuth(context: IntegrationContext) { } return context.auth; } + +async function rollBackExtraResourcesBestEffort( + resources: ResourceDeclaration[], + context: IntegrationContext, +): Promise { + const failedResources: string[] = []; + + for (const resource of [...resources].reverse()) { + try { + await resource.remove(context); + } catch (rollbackError) { + failedResources.push(resource.displayName ?? resource.id); + logger.warn( + `Failed to roll back context augmentation resource ${resource.id}: ${ + (rollbackError as Error).message + }`, + ); + } + } + + if (failedResources.length > 0) { + warn( + `Could not fully roll back Vortex context augmentation resources after setup failed. Manual cleanup may be needed for: ${failedResources.join( + ', ', + )}.`, + ); + } +} diff --git a/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts b/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts new file mode 100644 index 000000000..e8d07e576 --- /dev/null +++ b/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts @@ -0,0 +1,124 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; + +import type { + AppliedResource, + IntegrationContext, + ResourceDeclaration, +} from '../../../../../../src/cli/commands/integrate/_common/registry'; +import { CONTEXT_AUGMENTATION_BINARY_NAME } from '../../../../../../src/lib/install-types'; +import { getDefaultState } from '../../../../../../src/lib/state'; +import { findMockUiCall, setMockUi } from '../../../../../../src/ui'; + +let toolIntegrateError = new Error('tool integrate failed'); + +const integrateCagModule = + await import('../../../../../../src/cli/commands/integrate/_common/context-augmentation'); +await mock.module( + '../../../../../../src/cli/commands/integrate/_common/context-augmentation', + () => ({ + ...integrateCagModule, + printContextAugmentationSkill: () => Promise.resolve('skill'), + runToolIntegrateCommand: () => Promise.reject(toolIntegrateError), + }), +); + +const { createContextAugmentationFeature } = + await import('../../../../../../src/cli/commands/integrate/_common/features/context-augmentation-feature'); + +describe('createContextAugmentationFeature', () => { + beforeEach(() => { + toolIntegrateError = new Error('tool integrate failed'); + setMockUi(true); + }); + + afterEach(() => { + setMockUi(false); + }); + + it('attempts all extra resource rollbacks and rethrows the original tool integrate error', async () => { + const removed: string[] = []; + const rollbackError = new Error('rollback failed'); + const feature = createContextAugmentationFeature({ + targetPath: () => 'SKILL.md', + resources: [ + makeResource('first-extra-resource', () => { + removed.push('first-extra-resource'); + }), + makeResource('second-extra-resource', () => { + removed.push('second-extra-resource'); + throw rollbackError; + }), + ], + }); + const operation = feature.operations?.[0]; + + expect(operation).toBeDefined(); + let caught: unknown; + try { + await Promise.resolve().then(() => operation!.apply(makeContext())); + } catch (error) { + caught = error; + } + + expect(caught).toBe(toolIntegrateError); + expect(removed).toEqual(['second-extra-resource', 'first-extra-resource']); + expect( + findMockUiCall('warn', 'Manual cleanup may be needed for: second-extra-resource'), + ).toBeDefined(); + }); +}); + +function makeResource(id: string, remove: () => void): ResourceDeclaration { + return { + id, + resourceType: 'test-resource', + apply: () => ({ id, resourceType: 'test-resource' }) satisfies AppliedResource, + isApplied: () => true, + remove, + }; +} + +function makeContext(): IntegrationContext { + return { + state: getDefaultState('test'), + targetRoot: '/tmp/project', + scope: 'project', + executionMode: 'install', + auth: { + connectionType: 'cloud', + orgKey: 'org', + serverUrl: 'https://sonarcloud.io', + token: 'token', + }, + resolvedDependencies: new Map([ + [ + CONTEXT_AUGMENTATION_BINARY_NAME, + { + id: CONTEXT_AUGMENTATION_BINARY_NAME, + dependencyType: 'sonarsource-binary', + path: '/tmp/sonar-context-augmentation', + }, + ], + ]), + }; +} From 9171f3690ee863cf5d9a6cdfc6e40147141354c0 Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Mon, 13 Jul 2026 15:43:29 +0200 Subject: [PATCH 04/10] Route Claude CAG hooks through generic __hook Install Claude PostToolUse and PostToolUseFailure CAG hooks through sonar context __hook Claude so CAG can dispatch on the hook event carried by the payload. --- CLAUDE.md | 2 +- .../commands/integrate/claude/declaration.ts | 14 +++++-- .../integrate/claude/hook-templates.ts | 4 +- .../integrate/context-augmentation.test.ts | 38 +++++++++++++------ .../integrate/claude/hook-templates.test.ts | 10 ++--- 5 files changed, 46 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 70a8e82b0..d03738b5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ SQAA is **project-scoped and opt-in**: integrate orchestrators call `resolveSqaa `sonar context [action] [args...]` is a passthrough to the locally-installed `sonar-context-augmentation` binary (CAG). It forwards args verbatim, propagates the child exit code, and injects context through `SONAR_CONTEXT_ORGANIZATION`, `SONAR_CONTEXT_PROJECT`, `SONAR_CONTEXT_TOKEN`, and `SONAR_CONTEXT_URL` env vars. Every CAG spawn (including the `--help` path and the integrate/post-update flows) also carries `SONAR_CONTEXT_INVOCATION_ID` — the per-CLI-process correlation id from `src/lib/invocation-id.ts`, shared with telemetry's `invocation_id` and forwarded by CAG to its daemon as the `x-sonar-invocation-id` header. The passthrough resolves project context from the recorded declarative CAG feature state for the current project rather than running full project auto-discovery. Implementation in `src/cli/commands/context/`. The binary is downloaded by `sonar integrate claude` / `sonar integrate copilot` / `sonar integrate codex` / `sonar integrate antigravity` / `sonar integrate cursor` (skip with `--skip-context`); `sonar context` itself never auto-installs and emits a clear "not installed" error pointing the user back to integrate. `--global` integrations also skip CAG setup; install it by re-running `sonar integrate ` from a project directory. The `--global` skip notice (`Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.`) is only emitted when the org is actually entitled to CAG — unentitled orgs skip silently. The CLI owns the agent skill file declaratively as a `wholeFile` resource inside a single CAG feature and renders it by calling `sonar-context-augmentation tool print-skill --invocation-prefix "sonar context" --sca-enabled=`; the rendered file is written to `.claude/skills/sonar-context-augmentation/SKILL.md`, `.github/skills/sonar-context-augmentation/SKILL.md`, or `.agents/skills/sonar-context-augmentation/SKILL.md` depending on the agent. **Cursor** also writes to `.agents/skills/sonar-context-augmentation/SKILL.md` — the shared cross-tool skills directory it reads alongside Codex and Antigravity — rather than a Cursor-private `.cursor/skills` copy, so the three tools share one skill instead of duplicating it. Cursor loads skills on demand (distinct from Cursor's SQAA delivery, which is an always-applied `.cursor/rules/*.mdc` rule because that protocol must run every turn). That same feature also declares a `tool integrate --invocation-prefix "sonar context"` operation, but the operation is install-only: the framework passes `executionMode=install|update` into feature contexts, and CAG uses `shouldApply` so `tool integrate` runs during `sonar integrate ` but not during post-update refreshes. Post-update still reinstalls the shared dependency, best-effort runs `sonar-context-augmentation tool stop --all` against the previously-installed binary before replacing it, and refreshes the skill file. Replay failures are debug-logged so they do not abort CLI startup. -For Claude Code project installs, that same CAG feature also writes a generated `PostToolUse` launcher under `.claude/hooks/sonar-context-augmentation/build-scripts/` and patches `.claude/settings.json` with matcher `Bash|PowerShell|Monitor|Read`. The launcher delegates to `sonar context __hook ClaudePostToolUse`, uses `sonar-context-augmentation` as the managed marker for idempotency/removal, coexists with the SQAA `PostToolUse` entry, and is refreshed on post-update like the skill file. +For Claude Code project installs, that same CAG feature also writes a generated launcher under `.claude/hooks/sonar-context-augmentation/build-scripts/` and patches `.claude/settings.json` with `PostToolUse` and `PostToolUseFailure` entries using matcher `Bash|PowerShell|Monitor|Read`. The launcher delegates to `sonar context __hook Claude`, uses `sonar-context-augmentation` as the managed marker for idempotency/removal, coexists with the SQAA `PostToolUse` entry, and is refreshed on post-update like the skill file. CAG distinguishes the concrete Claude hook event from the stdin payload. `--help`, `-h`, and bare `sonar context` (no action) are forwarded to CAG. diff --git a/src/cli/commands/integrate/claude/declaration.ts b/src/cli/commands/integrate/claude/declaration.ts index 4f5843bb8..851d67169 100644 --- a/src/cli/commands/integrate/claude/declaration.ts +++ b/src/cli/commands/integrate/claude/declaration.ts @@ -69,7 +69,7 @@ const PRETOOL_SCRIPT_REL = 'sonar-secrets/build-scripts/pretool-secrets'; const PROMPT_SCRIPT_REL = 'sonar-secrets/build-scripts/prompt-secrets'; const CONTEXT_POSTTOOL_SCRIPT_REL = 'sonar-context-augmentation/build-scripts/posttool-context-augmentation'; -const CONTEXT_POSTTOOL_MATCHER = 'Bash|PowerShell|Monitor|Read'; +const CONTEXT_TOOL_MATCHER = 'Bash|PowerShell|Monitor|Read'; export const CLAUDE_INTEGRATION_ID = 'claude-code'; @@ -229,7 +229,7 @@ export const claudeIntegration: IntegrationDeclaration resources: [ wholeFile({ id: 'posttool-context-augmentation-script', - displayName: 'Claude Context Augmentation PostToolUse hook script', + displayName: 'Claude Context Augmentation hook script', targetPath: (context) => resolveAgentHookScriptPath(context, CLAUDE_CONFIG_DIR, CONTEXT_POSTTOOL_SCRIPT_REL), content: { @@ -249,7 +249,15 @@ export const claudeIntegration: IntegrationDeclaration context, CLAUDE_CONFIG_DIR, 'PostToolUse', - CONTEXT_POSTTOOL_MATCHER, + CONTEXT_TOOL_MATCHER, + 'sonar-context-augmentation', + CONTEXT_POSTTOOL_SCRIPT_REL, + ), + createAgentHookEntry( + context, + CLAUDE_CONFIG_DIR, + 'PostToolUseFailure', + CONTEXT_TOOL_MATCHER, 'sonar-context-augmentation', CONTEXT_POSTTOOL_SCRIPT_REL, ), diff --git a/src/cli/commands/integrate/claude/hook-templates.ts b/src/cli/commands/integrate/claude/hook-templates.ts index dcb8d6d98..fe1e51f45 100644 --- a/src/cli/commands/integrate/claude/hook-templates.ts +++ b/src/cli/commands/integrate/claude/hook-templates.ts @@ -44,11 +44,11 @@ export function getSecretPromptTemplateWindows(): string { } export function getContextAugmentationPostToolTemplateUnix(): string { - return unixTemplate('sonar context __hook ClaudePostToolUse'); + return unixTemplate('sonar context __hook Claude'); } export function getContextAugmentationPostToolTemplateWindows(): string { - return windowsTemplate('sonar context __hook ClaudePostToolUse'); + return windowsTemplate('sonar context __hook Claude'); } export function getSqaaPostToolTemplateUnix(projectKey: string): string { diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index ede9e86ba..43cc72dce 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -139,6 +139,7 @@ interface ClaudeHookEntry { interface ClaudeSettings { hooks?: { PostToolUse?: ClaudeHookEntry[]; + PostToolUseFailure?: ClaudeHookEntry[]; }; } @@ -152,12 +153,19 @@ function readClaudeSettings(harness: TestHarness, root: HarnessRoot): ClaudeSett return file.asJson() as ClaudeSettings; } -function claudeCagPostToolEntries(settings: ClaudeSettings | undefined): ClaudeHookEntry[] { - return (settings?.hooks?.PostToolUse ?? []).filter((entry) => +function claudeCagHookEntries( + settings: ClaudeSettings | undefined, + eventType: keyof NonNullable, +): ClaudeHookEntry[] { + return (settings?.hooks?.[eventType] ?? []).filter((entry) => entry.hooks?.some((hook) => hook.command?.includes(CLAUDE_CAG_HOOK_MARKER)), ); } +function claudeCagPostToolEntries(settings: ClaudeSettings | undefined): ClaudeHookEntry[] { + return claudeCagHookEntries(settings, 'PostToolUse'); +} + function claudeSqaaPostToolEntries(settings: ClaudeSettings | undefined): ClaudeHookEntry[] { return (settings?.hooks?.PostToolUse ?? []).filter((entry) => entry.hooks?.some((hook) => hook.command?.includes('sonar-sqaa')), @@ -167,20 +175,26 @@ function claudeSqaaPostToolEntries(settings: ClaudeSettings | undefined): Claude function expectClaudeCagHookInstalled(harness: TestHarness): void { const script = harness.cwd.file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH); expect(script.exists()).toBe(true); - expect(script.asText()).toContain('sonar context __hook ClaudePostToolUse'); - - const entries = claudeCagPostToolEntries(readClaudeSettings(harness, 'cwd')); - expect(entries).toHaveLength(1); - expect(entries[0]?.matcher).toBe(CLAUDE_CAG_POSTTOOL_MATCHER); - expect(entries[0]?.hooks?.[0]?.type).toBe('command'); - expect(entries[0]?.hooks?.[0]?.command).toContain(CLAUDE_CAG_HOOK_MARKER); - expect(entries[0]?.hooks?.[0]?.timeout).toBe(60); + expect(script.asText()).toContain('sonar context __hook Claude'); + expect(script.asText()).not.toContain('ClaudePostToolUse'); + + const settings = readClaudeSettings(harness, 'cwd'); + for (const eventType of ['PostToolUse', 'PostToolUseFailure'] as const) { + const entries = claudeCagHookEntries(settings, eventType); + expect(entries).toHaveLength(1); + expect(entries[0]?.matcher).toBe(CLAUDE_CAG_POSTTOOL_MATCHER); + expect(entries[0]?.hooks?.[0]?.type).toBe('command'); + expect(entries[0]?.hooks?.[0]?.command).toContain(CLAUDE_CAG_HOOK_MARKER); + expect(entries[0]?.hooks?.[0]?.timeout).toBe(60); + } } function expectClaudeCagHookAbsent(harness: TestHarness): void { for (const root of ['cwd', 'userHome'] as const) { expect(harness[root].file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH).exists()).toBe(false); - expect(claudeCagPostToolEntries(readClaudeSettings(harness, root))).toHaveLength(0); + const settings = readClaudeSettings(harness, root); + expect(claudeCagHookEntries(settings, 'PostToolUse')).toHaveLength(0); + expect(claudeCagHookEntries(settings, 'PostToolUseFailure')).toHaveLength(0); } } @@ -331,6 +345,7 @@ describe('integrate claude — Context Augmentation', () => { const settings = readClaudeSettings(harness, 'cwd'); expectClaudeCagHookInstalled(harness); expect(claudeCagPostToolEntries(settings)).toHaveLength(1); + expect(claudeCagHookEntries(settings, 'PostToolUseFailure')).toHaveLength(1); expect(settings?.hooks?.PostToolUse?.some((entry) => entry.matcher === 'Bash')).toBe(true); }, { timeout: 30000 }, @@ -368,6 +383,7 @@ describe('integrate claude — Context Augmentation', () => { expect(claudeSqaaPostToolEntries(settings)).toHaveLength(1); expect(claudeSqaaPostToolEntries(settings)[0]?.matcher).toBe('Edit|Write'); expect(claudeCagPostToolEntries(settings)).toHaveLength(1); + expect(claudeCagHookEntries(settings, 'PostToolUseFailure')).toHaveLength(1); }, { timeout: 30000 }, ); diff --git a/tests/unit/cli/commands/integrate/claude/hook-templates.test.ts b/tests/unit/cli/commands/integrate/claude/hook-templates.test.ts index 2622182b7..dace1b4eb 100644 --- a/tests/unit/cli/commands/integrate/claude/hook-templates.test.ts +++ b/tests/unit/cli/commands/integrate/claude/hook-templates.test.ts @@ -89,14 +89,16 @@ describe('Context Augmentation PostToolUse Hook Templates', () => { const template = getContextAugmentationPostToolTemplateUnix(); expect(template.startsWith('#!/bin/bash')).toBe(true); - expect(template.includes('sonar context __hook ClaudePostToolUse')).toBe(true); + expect(template.includes('sonar context __hook Claude')).toBe(true); + expect(template.includes('ClaudePostToolUse')).toBe(false); expect(template.includes(UNIX_SONAR_COMMAND_GUARD)).toBe(true); }); it('PostTool Windows hook: delegates to sonar context hook', () => { const template = getContextAugmentationPostToolTemplateWindows(); - expect(template.includes('sonar context __hook ClaudePostToolUse')).toBe(true); + expect(template.includes('sonar context __hook Claude')).toBe(true); + expect(template.includes('ClaudePostToolUse')).toBe(false); expect(template.includes(WINDOWS_SONAR_COMMAND_GUARD)).toBe(true); }); }); @@ -189,9 +191,7 @@ describe('Template Integrity', () => { it('Context Augmentation template routes to the double-underscore CAG hook', () => { expect( - getContextAugmentationPostToolTemplateUnix().includes( - 'sonar context __hook ClaudePostToolUse', - ), + getContextAugmentationPostToolTemplateUnix().includes('sonar context __hook Claude'), ).toBe(true); expect(getContextAugmentationPostToolTemplateUnix().includes('sonar context _hook')).toBe( false, From 9abb91ccb4fd19697c9365287935d82ad59d65ab Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Mon, 13 Jul 2026 21:23:24 +0200 Subject: [PATCH 05/10] Preserve CAG hooks on retry setup failure When Claude CAG was already recorded, a retry could skip hook resources as already applied but still rerun tool integrate. If that operation failed, the rollback removed the existing hook script/settings while state continued to report the feature as installed. Pass the owning integration id into the CAG feature and only roll back extra resources when the feature was not already recorded for the same integration, scope, and target root. First-install failures still clean up partial hook resources. Add regression coverage for a failing retry preserving the existing Claude CAG hooks. --- .../features/context-augmentation-feature.ts | 25 +++++++- .../integrate/antigravity/declaration.ts | 1 + .../commands/integrate/claude/declaration.ts | 1 + .../commands/integrate/codex/declaration.ts | 1 + .../commands/integrate/copilot/declaration.ts | 1 + .../commands/integrate/cursor/declaration.ts | 1 + .../integrate/context-augmentation.test.ts | 61 +++++++++++++++++++ .../context-augmentation-feature.test.ts | 1 + 8 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts index c62284ab9..89c38a02a 100644 --- a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts +++ b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts @@ -40,6 +40,7 @@ export const CONTEXT_AUGMENTATION_TOOL_INTEGRATION_OPERATION_ID = 'context-augmentation-tool-integrate'; export interface ContextAugmentationSkillFeatureOptions { + integrationId: string; targetPath: (context: IntegrationContext) => string; resources?: ResourceDeclaration[]; } @@ -87,7 +88,13 @@ export function createContextAugmentationFeature< scaEnabled: context.attrs?.scaEnabled === true, }); } catch (error) { - await rollBackExtraResourcesBestEffort(extraResources, context); + // context augmentation faced an error, so we need to rollback (e.g. installed hooks). + // Ideally the declarative installer would tell operations which resources were + // applied during the current attempt, so rollback could remove only those. Until + // that plumbing exists, keep already-recorded resources intact on retry failures. + if (!isContextAugmentationFeatureRecorded(context, options.integrationId)) { + await rollBackExtraResourcesBestEffort(extraResources, context); + } throw error; } }, @@ -111,6 +118,22 @@ function getRequiredAuth(context: IntegrationContext) { return context.auth; } +function isContextAugmentationFeatureRecorded( + context: IntegrationContext, + integrationId: string, +): boolean { + return ( + context.state.integrations.installed + .find((integration) => integration.integrationId === integrationId) + ?.features.some( + (feature) => + feature.featureId === CONTEXT_AUGMENTATION_FEATURE_ID && + feature.scope === context.scope && + feature.targetRoot === context.targetRoot, + ) ?? false + ); +} + async function rollBackExtraResourcesBestEffort( resources: ResourceDeclaration[], context: IntegrationContext, diff --git a/src/cli/commands/integrate/antigravity/declaration.ts b/src/cli/commands/integrate/antigravity/declaration.ts index 6013e254a..ab9bac18c 100644 --- a/src/cli/commands/integrate/antigravity/declaration.ts +++ b/src/cli/commands/integrate/antigravity/declaration.ts @@ -234,6 +234,7 @@ export const antigravityIntegration: IntegrationDeclaration({ + integrationId: ANTIGRAVITY_INTEGRATION_ID, targetPath: resolveAntigravitySkillPath, }), ], diff --git a/src/cli/commands/integrate/claude/declaration.ts b/src/cli/commands/integrate/claude/declaration.ts index 851d67169..a03b11014 100644 --- a/src/cli/commands/integrate/claude/declaration.ts +++ b/src/cli/commands/integrate/claude/declaration.ts @@ -225,6 +225,7 @@ export const claudeIntegration: IntegrationDeclaration ], }, createContextAugmentationFeature({ + integrationId: CLAUDE_INTEGRATION_ID, targetPath: resolveClaudeSkillPath, resources: [ wholeFile({ diff --git a/src/cli/commands/integrate/codex/declaration.ts b/src/cli/commands/integrate/codex/declaration.ts index 0da14ee5c..a54f697f4 100644 --- a/src/cli/commands/integrate/codex/declaration.ts +++ b/src/cli/commands/integrate/codex/declaration.ts @@ -204,6 +204,7 @@ export const codexIntegration: IntegrationDeclaration = ], }, createContextAugmentationFeature({ + integrationId: CODEX_INTEGRATION_ID, targetPath: resolveCodexSkillPath, }), ], diff --git a/src/cli/commands/integrate/copilot/declaration.ts b/src/cli/commands/integrate/copilot/declaration.ts index db11cfdd9..ab85e816c 100644 --- a/src/cli/commands/integrate/copilot/declaration.ts +++ b/src/cli/commands/integrate/copilot/declaration.ts @@ -180,6 +180,7 @@ export const copilotIntegration: IntegrationDeclaration({ + integrationId: COPILOT_INTEGRATION_ID, targetPath: resolveCopilotSkillPath, }), ], diff --git a/src/cli/commands/integrate/cursor/declaration.ts b/src/cli/commands/integrate/cursor/declaration.ts index 2043d0fca..d41c8a7a9 100644 --- a/src/cli/commands/integrate/cursor/declaration.ts +++ b/src/cli/commands/integrate/cursor/declaration.ts @@ -254,6 +254,7 @@ export const cursorIntegration: IntegrationDeclaration ], }, createContextAugmentationFeature({ + integrationId: CURSOR_INTEGRATION_ID, targetPath: resolveCursorCagSkillPath, }), ], diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index 43cc72dce..3938ee847 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -750,6 +750,67 @@ describe('integrate claude — Context Augmentation', () => { { timeout: 30000 }, ); + it( + 'preserves existing Claude CAG hook resources when retry setup fails', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(TOKEN) + .withProject(PROJECT_KEY) + .withCagEntitlement(ORG_KEY) + .withScaEnabled(true) + .start(); + const serverUrl = server.baseUrl(); + const env = { + SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, + SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, + }; + harness.withAuth(serverUrl, TOKEN, ORG_KEY); + harness.state().withContextAugmentationBinaryInstalled(); + harness.cwd.writeFile( + 'sonar-project.properties', + [ + `sonar.host.url=${serverUrl}`, + `sonar.projectKey=${PROJECT_KEY}`, + `sonar.organization=${ORG_KEY}`, + ].join('\n'), + ); + + const firstResult = await harness.run('integrate claude --non-interactive', { + extraEnv: env, + }); + + expect(firstResult.exitCode).toBe(0); + expectClaudeCagHookInstalled(harness); + const stateAfterSuccessfulInstall = harness.stateJsonFile.asText(); + const hookScriptBeforeRetry = harness.cwd.file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH).asText(); + const settingsBeforeRetry = harness.cwd.file('.claude', 'settings.json').asText(); + harness + .state() + .withRawState(stateAfterSuccessfulInstall) + .withContextAugmentationBinaryInstalled({ initExitCode: 1 }); + + const retryResult = await harness.run('integrate claude --non-interactive', { + extraEnv: env, + }); + + expect(retryResult.exitCode).toBe(1); + expect(retryResult.stderr).toContain('Vortex context augmentation tool integration failed.'); + expect(harness.cwd.file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH).asText()).toBe( + hookScriptBeforeRetry, + ); + expect(harness.cwd.file('.claude', 'settings.json').asText()).toBe(settingsBeforeRetry); + expectClaudeCagHookInstalled(harness); + expectRecordedCagFeature(loadState(harness), { + integrationId: CLAUDE_INTEGRATION_ID, + projectRoot: harness.cwd.path, + scaEnabled: true, + serverUrl, + }); + }, + { timeout: 30000 }, + ); + it( 'skips CAG with a warning on SonarQube Cloud when no project key is configured', async () => { diff --git a/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts b/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts index e8d07e576..42a634d90 100644 --- a/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts +++ b/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts @@ -59,6 +59,7 @@ describe('createContextAugmentationFeature', () => { const removed: string[] = []; const rollbackError = new Error('rollback failed'); const feature = createContextAugmentationFeature({ + integrationId: 'test-integration', targetPath: () => 'SKILL.md', resources: [ makeResource('first-extra-resource', () => { From 687f137f72a4924b3be58074c8f4eecb050f50e3 Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Mon, 13 Jul 2026 21:28:51 +0200 Subject: [PATCH 06/10] Revert "Preserve CAG hooks on retry setup failure" This reverts commit 9abb91ccb4fd19697c9365287935d82ad59d65ab. --- .../features/context-augmentation-feature.ts | 25 +------- .../integrate/antigravity/declaration.ts | 1 - .../commands/integrate/claude/declaration.ts | 1 - .../commands/integrate/codex/declaration.ts | 1 - .../commands/integrate/copilot/declaration.ts | 1 - .../commands/integrate/cursor/declaration.ts | 1 - .../integrate/context-augmentation.test.ts | 61 ------------------- .../context-augmentation-feature.test.ts | 1 - 8 files changed, 1 insertion(+), 91 deletions(-) diff --git a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts index 89c38a02a..c62284ab9 100644 --- a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts +++ b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts @@ -40,7 +40,6 @@ export const CONTEXT_AUGMENTATION_TOOL_INTEGRATION_OPERATION_ID = 'context-augmentation-tool-integrate'; export interface ContextAugmentationSkillFeatureOptions { - integrationId: string; targetPath: (context: IntegrationContext) => string; resources?: ResourceDeclaration[]; } @@ -88,13 +87,7 @@ export function createContextAugmentationFeature< scaEnabled: context.attrs?.scaEnabled === true, }); } catch (error) { - // context augmentation faced an error, so we need to rollback (e.g. installed hooks). - // Ideally the declarative installer would tell operations which resources were - // applied during the current attempt, so rollback could remove only those. Until - // that plumbing exists, keep already-recorded resources intact on retry failures. - if (!isContextAugmentationFeatureRecorded(context, options.integrationId)) { - await rollBackExtraResourcesBestEffort(extraResources, context); - } + await rollBackExtraResourcesBestEffort(extraResources, context); throw error; } }, @@ -118,22 +111,6 @@ function getRequiredAuth(context: IntegrationContext) { return context.auth; } -function isContextAugmentationFeatureRecorded( - context: IntegrationContext, - integrationId: string, -): boolean { - return ( - context.state.integrations.installed - .find((integration) => integration.integrationId === integrationId) - ?.features.some( - (feature) => - feature.featureId === CONTEXT_AUGMENTATION_FEATURE_ID && - feature.scope === context.scope && - feature.targetRoot === context.targetRoot, - ) ?? false - ); -} - async function rollBackExtraResourcesBestEffort( resources: ResourceDeclaration[], context: IntegrationContext, diff --git a/src/cli/commands/integrate/antigravity/declaration.ts b/src/cli/commands/integrate/antigravity/declaration.ts index ab9bac18c..6013e254a 100644 --- a/src/cli/commands/integrate/antigravity/declaration.ts +++ b/src/cli/commands/integrate/antigravity/declaration.ts @@ -234,7 +234,6 @@ export const antigravityIntegration: IntegrationDeclaration({ - integrationId: ANTIGRAVITY_INTEGRATION_ID, targetPath: resolveAntigravitySkillPath, }), ], diff --git a/src/cli/commands/integrate/claude/declaration.ts b/src/cli/commands/integrate/claude/declaration.ts index a03b11014..851d67169 100644 --- a/src/cli/commands/integrate/claude/declaration.ts +++ b/src/cli/commands/integrate/claude/declaration.ts @@ -225,7 +225,6 @@ export const claudeIntegration: IntegrationDeclaration ], }, createContextAugmentationFeature({ - integrationId: CLAUDE_INTEGRATION_ID, targetPath: resolveClaudeSkillPath, resources: [ wholeFile({ diff --git a/src/cli/commands/integrate/codex/declaration.ts b/src/cli/commands/integrate/codex/declaration.ts index a54f697f4..0da14ee5c 100644 --- a/src/cli/commands/integrate/codex/declaration.ts +++ b/src/cli/commands/integrate/codex/declaration.ts @@ -204,7 +204,6 @@ export const codexIntegration: IntegrationDeclaration = ], }, createContextAugmentationFeature({ - integrationId: CODEX_INTEGRATION_ID, targetPath: resolveCodexSkillPath, }), ], diff --git a/src/cli/commands/integrate/copilot/declaration.ts b/src/cli/commands/integrate/copilot/declaration.ts index ab85e816c..db11cfdd9 100644 --- a/src/cli/commands/integrate/copilot/declaration.ts +++ b/src/cli/commands/integrate/copilot/declaration.ts @@ -180,7 +180,6 @@ export const copilotIntegration: IntegrationDeclaration({ - integrationId: COPILOT_INTEGRATION_ID, targetPath: resolveCopilotSkillPath, }), ], diff --git a/src/cli/commands/integrate/cursor/declaration.ts b/src/cli/commands/integrate/cursor/declaration.ts index d41c8a7a9..2043d0fca 100644 --- a/src/cli/commands/integrate/cursor/declaration.ts +++ b/src/cli/commands/integrate/cursor/declaration.ts @@ -254,7 +254,6 @@ export const cursorIntegration: IntegrationDeclaration ], }, createContextAugmentationFeature({ - integrationId: CURSOR_INTEGRATION_ID, targetPath: resolveCursorCagSkillPath, }), ], diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index 3938ee847..43cc72dce 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -750,67 +750,6 @@ describe('integrate claude — Context Augmentation', () => { { timeout: 30000 }, ); - it( - 'preserves existing Claude CAG hook resources when retry setup fails', - async () => { - const server = await harness - .newFakeServer() - .withAuthToken(TOKEN) - .withProject(PROJECT_KEY) - .withCagEntitlement(ORG_KEY) - .withScaEnabled(true) - .start(); - const serverUrl = server.baseUrl(); - const env = { - SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, - SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, - }; - harness.withAuth(serverUrl, TOKEN, ORG_KEY); - harness.state().withContextAugmentationBinaryInstalled(); - harness.cwd.writeFile( - 'sonar-project.properties', - [ - `sonar.host.url=${serverUrl}`, - `sonar.projectKey=${PROJECT_KEY}`, - `sonar.organization=${ORG_KEY}`, - ].join('\n'), - ); - - const firstResult = await harness.run('integrate claude --non-interactive', { - extraEnv: env, - }); - - expect(firstResult.exitCode).toBe(0); - expectClaudeCagHookInstalled(harness); - const stateAfterSuccessfulInstall = harness.stateJsonFile.asText(); - const hookScriptBeforeRetry = harness.cwd.file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH).asText(); - const settingsBeforeRetry = harness.cwd.file('.claude', 'settings.json').asText(); - harness - .state() - .withRawState(stateAfterSuccessfulInstall) - .withContextAugmentationBinaryInstalled({ initExitCode: 1 }); - - const retryResult = await harness.run('integrate claude --non-interactive', { - extraEnv: env, - }); - - expect(retryResult.exitCode).toBe(1); - expect(retryResult.stderr).toContain('Vortex context augmentation tool integration failed.'); - expect(harness.cwd.file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH).asText()).toBe( - hookScriptBeforeRetry, - ); - expect(harness.cwd.file('.claude', 'settings.json').asText()).toBe(settingsBeforeRetry); - expectClaudeCagHookInstalled(harness); - expectRecordedCagFeature(loadState(harness), { - integrationId: CLAUDE_INTEGRATION_ID, - projectRoot: harness.cwd.path, - scaEnabled: true, - serverUrl, - }); - }, - { timeout: 30000 }, - ); - it( 'skips CAG with a warning on SonarQube Cloud when no project key is configured', async () => { diff --git a/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts b/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts index 42a634d90..e8d07e576 100644 --- a/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts +++ b/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts @@ -59,7 +59,6 @@ describe('createContextAugmentationFeature', () => { const removed: string[] = []; const rollbackError = new Error('rollback failed'); const feature = createContextAugmentationFeature({ - integrationId: 'test-integration', targetPath: () => 'SKILL.md', resources: [ makeResource('first-extra-resource', () => { From e1cd3ed6f253b458a138c15ae74bede431f69fb4 Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Mon, 13 Jul 2026 21:29:03 +0200 Subject: [PATCH 07/10] Revert "Preserve CAG failure during hook rollback" This reverts commit 5fbab747e7a556e89afc2e2250a347dec62497e8. --- .../features/context-augmentation-feature.ts | 34 +---- .../context-augmentation-feature.test.ts | 124 ------------------ 2 files changed, 3 insertions(+), 155 deletions(-) delete mode 100644 tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts diff --git a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts index c62284ab9..b394e7b8c 100644 --- a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts +++ b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts @@ -19,9 +19,7 @@ */ import { CONTEXT_AUGMENTATION_BINARY_NAME } from '../../../../../lib/install-types'; -import logger from '../../../../../lib/logger'; import { SONAR_CONTEXT_AUGMENTATION_VERSION } from '../../../../../lib/signatures'; -import { warn } from '../../../../../ui'; import { CommandFailedError } from '../../../_common/error'; import { getOptionalStringAttr } from '../attrs'; import { printContextAugmentationSkill, runToolIntegrateCommand } from '../context-augmentation'; @@ -87,7 +85,9 @@ export function createContextAugmentationFeature< scaEnabled: context.attrs?.scaEnabled === true, }); } catch (error) { - await rollBackExtraResourcesBestEffort(extraResources, context); + for (const resource of [...extraResources].reverse()) { + await resource.remove(context); + } throw error; } }, @@ -110,31 +110,3 @@ function getRequiredAuth(context: IntegrationContext) { } return context.auth; } - -async function rollBackExtraResourcesBestEffort( - resources: ResourceDeclaration[], - context: IntegrationContext, -): Promise { - const failedResources: string[] = []; - - for (const resource of [...resources].reverse()) { - try { - await resource.remove(context); - } catch (rollbackError) { - failedResources.push(resource.displayName ?? resource.id); - logger.warn( - `Failed to roll back context augmentation resource ${resource.id}: ${ - (rollbackError as Error).message - }`, - ); - } - } - - if (failedResources.length > 0) { - warn( - `Could not fully roll back Vortex context augmentation resources after setup failed. Manual cleanup may be needed for: ${failedResources.join( - ', ', - )}.`, - ); - } -} diff --git a/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts b/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts deleted file mode 100644 index e8d07e576..000000000 --- a/tests/unit/cli/commands/integrate/_common/context-augmentation-feature.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * SonarQube CLI - * Copyright (C) SonarSource Sàrl - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; - -import type { - AppliedResource, - IntegrationContext, - ResourceDeclaration, -} from '../../../../../../src/cli/commands/integrate/_common/registry'; -import { CONTEXT_AUGMENTATION_BINARY_NAME } from '../../../../../../src/lib/install-types'; -import { getDefaultState } from '../../../../../../src/lib/state'; -import { findMockUiCall, setMockUi } from '../../../../../../src/ui'; - -let toolIntegrateError = new Error('tool integrate failed'); - -const integrateCagModule = - await import('../../../../../../src/cli/commands/integrate/_common/context-augmentation'); -await mock.module( - '../../../../../../src/cli/commands/integrate/_common/context-augmentation', - () => ({ - ...integrateCagModule, - printContextAugmentationSkill: () => Promise.resolve('skill'), - runToolIntegrateCommand: () => Promise.reject(toolIntegrateError), - }), -); - -const { createContextAugmentationFeature } = - await import('../../../../../../src/cli/commands/integrate/_common/features/context-augmentation-feature'); - -describe('createContextAugmentationFeature', () => { - beforeEach(() => { - toolIntegrateError = new Error('tool integrate failed'); - setMockUi(true); - }); - - afterEach(() => { - setMockUi(false); - }); - - it('attempts all extra resource rollbacks and rethrows the original tool integrate error', async () => { - const removed: string[] = []; - const rollbackError = new Error('rollback failed'); - const feature = createContextAugmentationFeature({ - targetPath: () => 'SKILL.md', - resources: [ - makeResource('first-extra-resource', () => { - removed.push('first-extra-resource'); - }), - makeResource('second-extra-resource', () => { - removed.push('second-extra-resource'); - throw rollbackError; - }), - ], - }); - const operation = feature.operations?.[0]; - - expect(operation).toBeDefined(); - let caught: unknown; - try { - await Promise.resolve().then(() => operation!.apply(makeContext())); - } catch (error) { - caught = error; - } - - expect(caught).toBe(toolIntegrateError); - expect(removed).toEqual(['second-extra-resource', 'first-extra-resource']); - expect( - findMockUiCall('warn', 'Manual cleanup may be needed for: second-extra-resource'), - ).toBeDefined(); - }); -}); - -function makeResource(id: string, remove: () => void): ResourceDeclaration { - return { - id, - resourceType: 'test-resource', - apply: () => ({ id, resourceType: 'test-resource' }) satisfies AppliedResource, - isApplied: () => true, - remove, - }; -} - -function makeContext(): IntegrationContext { - return { - state: getDefaultState('test'), - targetRoot: '/tmp/project', - scope: 'project', - executionMode: 'install', - auth: { - connectionType: 'cloud', - orgKey: 'org', - serverUrl: 'https://sonarcloud.io', - token: 'token', - }, - resolvedDependencies: new Map([ - [ - CONTEXT_AUGMENTATION_BINARY_NAME, - { - id: CONTEXT_AUGMENTATION_BINARY_NAME, - dependencyType: 'sonarsource-binary', - path: '/tmp/sonar-context-augmentation', - }, - ], - ]), - }; -} From be84a247e74744d04ea72676de1e3baa03b3d40a Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Mon, 13 Jul 2026 21:32:58 +0200 Subject: [PATCH 08/10] Revert "Rollback CAG hook when tool integrate fails" This reverts commit cdd0c0e76c4525ed4fd8fac26a4a2de305ee42e9. --- .../features/context-augmentation-feature.ts | 28 ++++++------------- .../integrate/context-augmentation.test.ts | 2 +- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts index b394e7b8c..136e5aa4d 100644 --- a/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts +++ b/src/cli/commands/integrate/_common/features/context-augmentation-feature.ts @@ -45,8 +45,6 @@ export interface ContextAugmentationSkillFeatureOptions { export function createContextAugmentationFeature< TOptions extends { installContextAugmentation?: boolean }, >(options: ContextAugmentationSkillFeatureOptions): FeatureDeclaration { - const extraResources = options.resources ?? []; - return { id: CONTEXT_AUGMENTATION_FEATURE_ID, displayName: 'context augmentation', @@ -68,29 +66,21 @@ export function createContextAugmentationFeature< scaEnabled: context.attrs?.scaEnabled === true, }), }), - ...extraResources, + ...(options.resources ?? []), ], operations: [ { id: CONTEXT_AUGMENTATION_TOOL_INTEGRATION_OPERATION_ID, displayName: 'context augmentation tool integration', shouldApply: (context) => context.executionMode === 'install', - apply: async (context) => { - try { - await runToolIntegrateCommand({ - auth: getRequiredAuth(context), - binaryPath: resolveContextAugmentationBinaryPath(context), - projectRoot: context.targetRoot, - projectKey: getOptionalStringAttr(context, 'projectKey'), - scaEnabled: context.attrs?.scaEnabled === true, - }); - } catch (error) { - for (const resource of [...extraResources].reverse()) { - await resource.remove(context); - } - throw error; - } - }, + apply: async (context) => + runToolIntegrateCommand({ + auth: getRequiredAuth(context), + binaryPath: resolveContextAugmentationBinaryPath(context), + projectRoot: context.targetRoot, + projectKey: getOptionalStringAttr(context, 'projectKey'), + scaEnabled: context.attrs?.scaEnabled === true, + }), }, ], }; diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index 43cc72dce..fe047d289 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -744,7 +744,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expectSkillFile(harness, CLAUDE_SKILL_PATH, false); - expectClaudeCagHookAbsent(harness); + expectClaudeCagHookInstalled(harness); expect(result.stderr).toContain('Vortex context augmentation tool integration failed.'); }, { timeout: 30000 }, From 87b04eec0ac73688efddbe88f9c07a28b22c8207 Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Mon, 13 Jul 2026 22:05:30 +0200 Subject: [PATCH 09/10] CLI-815 Add Claude CAG PreToolUse hook --- CLAUDE.md | 2 +- src/cli/commands/integrate/claude/declaration.ts | 8 ++++++++ .../specs/integrate/context-augmentation.test.ts | 11 ++++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d03738b5b..b0d5a42b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ SQAA is **project-scoped and opt-in**: integrate orchestrators call `resolveSqaa `sonar context [action] [args...]` is a passthrough to the locally-installed `sonar-context-augmentation` binary (CAG). It forwards args verbatim, propagates the child exit code, and injects context through `SONAR_CONTEXT_ORGANIZATION`, `SONAR_CONTEXT_PROJECT`, `SONAR_CONTEXT_TOKEN`, and `SONAR_CONTEXT_URL` env vars. Every CAG spawn (including the `--help` path and the integrate/post-update flows) also carries `SONAR_CONTEXT_INVOCATION_ID` — the per-CLI-process correlation id from `src/lib/invocation-id.ts`, shared with telemetry's `invocation_id` and forwarded by CAG to its daemon as the `x-sonar-invocation-id` header. The passthrough resolves project context from the recorded declarative CAG feature state for the current project rather than running full project auto-discovery. Implementation in `src/cli/commands/context/`. The binary is downloaded by `sonar integrate claude` / `sonar integrate copilot` / `sonar integrate codex` / `sonar integrate antigravity` / `sonar integrate cursor` (skip with `--skip-context`); `sonar context` itself never auto-installs and emits a clear "not installed" error pointing the user back to integrate. `--global` integrations also skip CAG setup; install it by re-running `sonar integrate ` from a project directory. The `--global` skip notice (`Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.`) is only emitted when the org is actually entitled to CAG — unentitled orgs skip silently. The CLI owns the agent skill file declaratively as a `wholeFile` resource inside a single CAG feature and renders it by calling `sonar-context-augmentation tool print-skill --invocation-prefix "sonar context" --sca-enabled=`; the rendered file is written to `.claude/skills/sonar-context-augmentation/SKILL.md`, `.github/skills/sonar-context-augmentation/SKILL.md`, or `.agents/skills/sonar-context-augmentation/SKILL.md` depending on the agent. **Cursor** also writes to `.agents/skills/sonar-context-augmentation/SKILL.md` — the shared cross-tool skills directory it reads alongside Codex and Antigravity — rather than a Cursor-private `.cursor/skills` copy, so the three tools share one skill instead of duplicating it. Cursor loads skills on demand (distinct from Cursor's SQAA delivery, which is an always-applied `.cursor/rules/*.mdc` rule because that protocol must run every turn). That same feature also declares a `tool integrate --invocation-prefix "sonar context"` operation, but the operation is install-only: the framework passes `executionMode=install|update` into feature contexts, and CAG uses `shouldApply` so `tool integrate` runs during `sonar integrate ` but not during post-update refreshes. Post-update still reinstalls the shared dependency, best-effort runs `sonar-context-augmentation tool stop --all` against the previously-installed binary before replacing it, and refreshes the skill file. Replay failures are debug-logged so they do not abort CLI startup. -For Claude Code project installs, that same CAG feature also writes a generated launcher under `.claude/hooks/sonar-context-augmentation/build-scripts/` and patches `.claude/settings.json` with `PostToolUse` and `PostToolUseFailure` entries using matcher `Bash|PowerShell|Monitor|Read`. The launcher delegates to `sonar context __hook Claude`, uses `sonar-context-augmentation` as the managed marker for idempotency/removal, coexists with the SQAA `PostToolUse` entry, and is refreshed on post-update like the skill file. CAG distinguishes the concrete Claude hook event from the stdin payload. +For Claude Code project installs, that same CAG feature also writes a generated launcher under `.claude/hooks/sonar-context-augmentation/build-scripts/` and patches `.claude/settings.json` with `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` entries using matcher `Bash|PowerShell|Monitor|Read`. The launcher delegates to `sonar context __hook Claude`, uses `sonar-context-augmentation` as the managed marker for idempotency/removal, coexists with the SQAA `PostToolUse` entry, and is refreshed on post-update like the skill file. CAG distinguishes the concrete Claude hook event from the stdin payload. `--help`, `-h`, and bare `sonar context` (no action) are forwarded to CAG. diff --git a/src/cli/commands/integrate/claude/declaration.ts b/src/cli/commands/integrate/claude/declaration.ts index 851d67169..bbb46a33c 100644 --- a/src/cli/commands/integrate/claude/declaration.ts +++ b/src/cli/commands/integrate/claude/declaration.ts @@ -245,6 +245,14 @@ export const claudeIntegration: IntegrationDeclaration defaultValue: { hooks: {} }, patch: (document, context) => upsertAgentHooks(document, [ + createAgentHookEntry( + context, + CLAUDE_CONFIG_DIR, + 'PreToolUse', + CONTEXT_TOOL_MATCHER, + 'sonar-context-augmentation', + CONTEXT_POSTTOOL_SCRIPT_REL, + ), createAgentHookEntry( context, CLAUDE_CONFIG_DIR, diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index fe047d289..d4805364a 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -138,6 +138,7 @@ interface ClaudeHookEntry { interface ClaudeSettings { hooks?: { + PreToolUse?: ClaudeHookEntry[]; PostToolUse?: ClaudeHookEntry[]; PostToolUseFailure?: ClaudeHookEntry[]; }; @@ -179,7 +180,7 @@ function expectClaudeCagHookInstalled(harness: TestHarness): void { expect(script.asText()).not.toContain('ClaudePostToolUse'); const settings = readClaudeSettings(harness, 'cwd'); - for (const eventType of ['PostToolUse', 'PostToolUseFailure'] as const) { + for (const eventType of CLAUDE_CAG_EVENT_TYPES) { const entries = claudeCagHookEntries(settings, eventType); expect(entries).toHaveLength(1); expect(entries[0]?.matcher).toBe(CLAUDE_CAG_POSTTOOL_MATCHER); @@ -193,8 +194,9 @@ function expectClaudeCagHookAbsent(harness: TestHarness): void { for (const root of ['cwd', 'userHome'] as const) { expect(harness[root].file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH).exists()).toBe(false); const settings = readClaudeSettings(harness, root); - expect(claudeCagHookEntries(settings, 'PostToolUse')).toHaveLength(0); - expect(claudeCagHookEntries(settings, 'PostToolUseFailure')).toHaveLength(0); + for (const eventType of CLAUDE_CAG_EVENT_TYPES) { + expect(claudeCagHookEntries(settings, eventType)).toHaveLength(0); + } } } @@ -211,6 +213,7 @@ const CODEX_SKILL_PATH = '.agents/skills/sonar-context-augmentation/SKILL.md'; const CLAUDE_CAG_HOOK_MARKER = 'sonar-context-augmentation'; const CLAUDE_CAG_POSTTOOL_MATCHER = 'Bash|PowerShell|Monitor|Read'; const CLAUDE_CAG_POSTTOOL_SCRIPT_PATH = `.claude/hooks/sonar-context-augmentation/build-scripts/${hookScriptName('posttool-context-augmentation')}`; +const CLAUDE_CAG_EVENT_TYPES = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'] as const; const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; describe('integrate claude — Context Augmentation', () => { @@ -344,6 +347,7 @@ describe('integrate claude — Context Augmentation', () => { const settings = readClaudeSettings(harness, 'cwd'); expectClaudeCagHookInstalled(harness); + expect(claudeCagHookEntries(settings, 'PreToolUse')).toHaveLength(1); expect(claudeCagPostToolEntries(settings)).toHaveLength(1); expect(claudeCagHookEntries(settings, 'PostToolUseFailure')).toHaveLength(1); expect(settings?.hooks?.PostToolUse?.some((entry) => entry.matcher === 'Bash')).toBe(true); @@ -382,6 +386,7 @@ describe('integrate claude — Context Augmentation', () => { expectClaudeCagHookInstalled(harness); expect(claudeSqaaPostToolEntries(settings)).toHaveLength(1); expect(claudeSqaaPostToolEntries(settings)[0]?.matcher).toBe('Edit|Write'); + expect(claudeCagHookEntries(settings, 'PreToolUse')).toHaveLength(1); expect(claudeCagPostToolEntries(settings)).toHaveLength(1); expect(claudeCagHookEntries(settings, 'PostToolUseFailure')).toHaveLength(1); }, From 3b50ca1133e9a7663f88e210ff1225d2030acd08 Mon Sep 17 00:00:00 2001 From: Michael Jabbour Date: Wed, 22 Jul 2026 15:32:39 +0200 Subject: [PATCH 10/10] Rename the hook The hook is not about posttool only anymore --- src/commands/integrate/claude/declaration.ts | 22 ++++++------ .../integrate/claude/hook-templates.ts | 4 +-- .../integrate/context-augmentation.test.ts | 10 +++--- .../integrate/claude/hook-templates.test.ts | 34 ++++++++----------- 4 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/commands/integrate/claude/declaration.ts b/src/commands/integrate/claude/declaration.ts index 8e6df9596..a3b3e0718 100644 --- a/src/commands/integrate/claude/declaration.ts +++ b/src/commands/integrate/claude/declaration.ts @@ -52,8 +52,8 @@ import { askUser, jsonPatch, skip, textSnippet, wholeFile } from '../_common/reg import { SQAA_HOOK_FEATURE_ID } from '../_common/sqaa-entitlement.ts'; import type { IntegrateAgentOptions } from '../_common/types.ts'; import { - getContextAugmentationPostToolTemplateUnix, - getContextAugmentationPostToolTemplateWindows, + getContextAugmentationHookTemplateUnix, + getContextAugmentationHookTemplateWindows, getSecretPreToolTemplateUnix, getSecretPreToolTemplateWindows, getSecretPromptTemplateUnix, @@ -67,8 +67,8 @@ const SETTINGS_FILE = 'settings.json'; const CLAUDE_MD_FILE = 'CLAUDE.md'; const PRETOOL_SCRIPT_REL = 'sonar-secrets/build-scripts/pretool-secrets'; const PROMPT_SCRIPT_REL = 'sonar-secrets/build-scripts/prompt-secrets'; -const CONTEXT_POSTTOOL_SCRIPT_REL = - 'sonar-context-augmentation/build-scripts/posttool-context-augmentation'; +const CONTEXT_HOOK_SCRIPT_REL = + 'sonar-context-augmentation/build-scripts/context-augmentation-hook'; const CONTEXT_TOOL_MATCHER = 'Bash|PowerShell|Monitor|Read'; export const CLAUDE_INTEGRATION_ID = 'claude-code'; @@ -228,13 +228,13 @@ export const claudeIntegration: IntegrationDeclaration targetPath: resolveClaudeSkillPath, resources: [ wholeFile({ - id: 'posttool-context-augmentation-script', + id: 'context-augmentation-hook-script', displayName: 'Claude Context Augmentation hook script', targetPath: (context) => - resolveAgentHookScriptPath(context, CLAUDE_CONFIG_DIR, CONTEXT_POSTTOOL_SCRIPT_REL), + resolveAgentHookScriptPath(context, CLAUDE_CONFIG_DIR, CONTEXT_HOOK_SCRIPT_REL), content: { - unix: getContextAugmentationPostToolTemplateUnix(), - windows: getContextAugmentationPostToolTemplateWindows(), + unix: getContextAugmentationHookTemplateUnix(), + windows: getContextAugmentationHookTemplateWindows(), }, executable: true, }), @@ -251,7 +251,7 @@ export const claudeIntegration: IntegrationDeclaration 'PreToolUse', CONTEXT_TOOL_MATCHER, 'sonar-context-augmentation', - CONTEXT_POSTTOOL_SCRIPT_REL, + CONTEXT_HOOK_SCRIPT_REL, ), createAgentHookEntry( context, @@ -259,7 +259,7 @@ export const claudeIntegration: IntegrationDeclaration 'PostToolUse', CONTEXT_TOOL_MATCHER, 'sonar-context-augmentation', - CONTEXT_POSTTOOL_SCRIPT_REL, + CONTEXT_HOOK_SCRIPT_REL, ), createAgentHookEntry( context, @@ -267,7 +267,7 @@ export const claudeIntegration: IntegrationDeclaration 'PostToolUseFailure', CONTEXT_TOOL_MATCHER, 'sonar-context-augmentation', - CONTEXT_POSTTOOL_SCRIPT_REL, + CONTEXT_HOOK_SCRIPT_REL, ), ]), removePatch: (document) => removeAgentHooks(document, ['sonar-context-augmentation']), diff --git a/src/commands/integrate/claude/hook-templates.ts b/src/commands/integrate/claude/hook-templates.ts index 181c05309..f60e08911 100644 --- a/src/commands/integrate/claude/hook-templates.ts +++ b/src/commands/integrate/claude/hook-templates.ts @@ -43,11 +43,11 @@ export function getSecretPromptTemplateWindows(): string { return windowsTemplate('sonar hook claude-prompt-submit'); } -export function getContextAugmentationPostToolTemplateUnix(): string { +export function getContextAugmentationHookTemplateUnix(): string { return unixTemplate('sonar context __hook Claude'); } -export function getContextAugmentationPostToolTemplateWindows(): string { +export function getContextAugmentationHookTemplateWindows(): string { return windowsTemplate('sonar context __hook Claude'); } diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index ef1b6a2fb..c244f9ab6 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -184,7 +184,7 @@ function claudeSqaaPostToolEntries(settings: ClaudeSettings | undefined): Claude } function expectClaudeCagHookInstalled(harness: TestHarness): void { - const script = harness.cwd.file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH); + const script = harness.cwd.file(CLAUDE_CAG_HOOK_SCRIPT_PATH); expect(script.exists()).toBe(true); expect(script.asText()).toContain('sonar context __hook Claude'); expect(script.asText()).not.toContain('ClaudePostToolUse'); @@ -193,7 +193,7 @@ function expectClaudeCagHookInstalled(harness: TestHarness): void { for (const eventType of CLAUDE_CAG_EVENT_TYPES) { const entries = claudeCagHookEntries(settings, eventType); expect(entries).toHaveLength(1); - expect(entries[0]?.matcher).toBe(CLAUDE_CAG_POSTTOOL_MATCHER); + expect(entries[0]?.matcher).toBe(CLAUDE_CAG_HOOK_MATCHER); expect(entries[0]?.hooks?.[0]?.type).toBe('command'); expect(entries[0]?.hooks?.[0]?.command).toContain(CLAUDE_CAG_HOOK_MARKER); expect(entries[0]?.hooks?.[0]?.timeout).toBe(60); @@ -202,7 +202,7 @@ function expectClaudeCagHookInstalled(harness: TestHarness): void { function expectClaudeCagHookAbsent(harness: TestHarness): void { for (const root of ['cwd', 'userHome'] as const) { - expect(harness[root].file(CLAUDE_CAG_POSTTOOL_SCRIPT_PATH).exists()).toBe(false); + expect(harness[root].file(CLAUDE_CAG_HOOK_SCRIPT_PATH).exists()).toBe(false); const settings = readClaudeSettings(harness, root); for (const eventType of CLAUDE_CAG_EVENT_TYPES) { expect(claudeCagHookEntries(settings, eventType)).toHaveLength(0); @@ -227,8 +227,8 @@ const CLAUDE_SKILL_PATH = '.claude/skills/sonar-context-augmentation/SKILL.md'; const COPILOT_SKILL_PATH = '.github/skills/sonar-context-augmentation/SKILL.md'; const CODEX_SKILL_PATH = '.agents/skills/sonar-context-augmentation/SKILL.md'; const CLAUDE_CAG_HOOK_MARKER = 'sonar-context-augmentation'; -const CLAUDE_CAG_POSTTOOL_MATCHER = 'Bash|PowerShell|Monitor|Read'; -const CLAUDE_CAG_POSTTOOL_SCRIPT_PATH = `.claude/hooks/sonar-context-augmentation/build-scripts/${hookScriptName('posttool-context-augmentation')}`; +const CLAUDE_CAG_HOOK_MATCHER = 'Bash|PowerShell|Monitor|Read'; +const CLAUDE_CAG_HOOK_SCRIPT_PATH = `.claude/hooks/sonar-context-augmentation/build-scripts/${hookScriptName('context-augmentation-hook')}`; const CLAUDE_CAG_EVENT_TYPES = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'] as const; const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; diff --git a/tests/unit/commands/integrate/claude/hook-templates.test.ts b/tests/unit/commands/integrate/claude/hook-templates.test.ts index dda3b7c4f..109118a5c 100644 --- a/tests/unit/commands/integrate/claude/hook-templates.test.ts +++ b/tests/unit/commands/integrate/claude/hook-templates.test.ts @@ -27,8 +27,8 @@ import { WINDOWS_SONAR_COMMAND_GUARD, } from '../../../../../src/commands/integrate/_common/hooks.ts'; import { - getContextAugmentationPostToolTemplateUnix, - getContextAugmentationPostToolTemplateWindows, + getContextAugmentationHookTemplateUnix, + getContextAugmentationHookTemplateWindows, getSecretPreToolTemplateUnix, getSecretPreToolTemplateWindows, getSecretPromptTemplateUnix, @@ -84,9 +84,9 @@ describe('Secret Scanning Hook Templates', () => { }); }); -describe('Context Augmentation PostToolUse Hook Templates', () => { - it('PostTool Unix hook: bash shebang, delegates to sonar context hook', () => { - const template = getContextAugmentationPostToolTemplateUnix(); +describe('Context Augmentation Hook Templates', () => { + it('Unix hook: bash shebang, delegates to sonar context hook', () => { + const template = getContextAugmentationHookTemplateUnix(); expect(template.startsWith('#!/bin/bash')).toBe(true); expect(template.includes('sonar context __hook Claude')).toBe(true); @@ -94,8 +94,8 @@ describe('Context Augmentation PostToolUse Hook Templates', () => { expect(template.includes(UNIX_SONAR_COMMAND_GUARD)).toBe(true); }); - it('PostTool Windows hook: delegates to sonar context hook', () => { - const template = getContextAugmentationPostToolTemplateWindows(); + it('Windows hook: delegates to sonar context hook', () => { + const template = getContextAugmentationHookTemplateWindows(); expect(template.includes('sonar context __hook Claude')).toBe(true); expect(template.includes('ClaudePostToolUse')).toBe(false); @@ -144,8 +144,8 @@ describe('Template Integrity', () => { getSecretPreToolTemplateWindows(), getSecretPromptTemplateUnix(), getSecretPromptTemplateWindows(), - getContextAugmentationPostToolTemplateUnix(), - getContextAugmentationPostToolTemplateWindows(), + getContextAugmentationHookTemplateUnix(), + getContextAugmentationHookTemplateWindows(), getSqaaPostToolTemplateUnix('proj'), getSqaaPostToolTemplateWindows('proj'), ]; @@ -167,8 +167,8 @@ describe('Template Integrity', () => { getSecretPreToolTemplateWindows(), getSecretPromptTemplateUnix(), getSecretPromptTemplateWindows(), - getContextAugmentationPostToolTemplateUnix(), - getContextAugmentationPostToolTemplateWindows(), + getContextAugmentationHookTemplateUnix(), + getContextAugmentationHookTemplateWindows(), getSqaaPostToolTemplateUnix('proj'), getSqaaPostToolTemplateWindows('proj'), ]; @@ -184,17 +184,13 @@ describe('Template Integrity', () => { expect(getSecretPreToolTemplateUnix().includes('claude-post-tool-use')).toBe(false); expect(getSecretPromptTemplateUnix().includes('claude-post-tool-use')).toBe(false); - expect(getContextAugmentationPostToolTemplateUnix().includes('claude-post-tool-use')).toBe( - false, - ); + expect(getContextAugmentationHookTemplateUnix().includes('claude-post-tool-use')).toBe(false); }); it('Context Augmentation template routes to the double-underscore CAG hook', () => { - expect( - getContextAugmentationPostToolTemplateUnix().includes('sonar context __hook Claude'), - ).toBe(true); - expect(getContextAugmentationPostToolTemplateUnix().includes('sonar context _hook')).toBe( - false, + expect(getContextAugmentationHookTemplateUnix().includes('sonar context __hook Claude')).toBe( + true, ); + expect(getContextAugmentationHookTemplateUnix().includes('sonar context _hook')).toBe(false); }); });