Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions skills/vizzly/references/cli-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,23 @@ vizzly context review-queue --source <local-or-cloud> --json
Use IDs and page values from the API response. Pass cursors back unchanged,
keep the same query, and request only what you need. Fetch comments when they
help explain a review.


### Comments and agent disclosure

After the user authorizes a review action, comment on the build or the exact
screenshot area using IDs from discovery. Regions use the original bitmap's
pixels, with the origin at its top-left; use the image dimensions, not the viewport.

```bash
vizzly comment <build-id> "Navigation overlaps the heading" --screenshot <screenshot-id> --region 20,40,300,80 --json
vizzly comment <build-id> "Follow-up" --screenshot <screenshot-id> --reply-to <comment-id> --json
```

Review decisions and comments automatically disclose Codex, Claude Code, Pi,
Gemini CLI or OpenCode when the harness exports its identification variable.
The logged-in user remains the author; disclosure never grants permissions.
Use `--via-agent <slug>` for another agent or to resolve conflicting markers.
`VIZZLY_VIA_AGENT` provides an environment override. Explicit flags win.
Only the reported agent name/slug is sent; session IDs and prompts stay local.
Do not treat reported disclosure as verified identity or permission to act.
48 changes: 44 additions & 4 deletions src/adapters/cli/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,13 @@ function findNestedCommandRequest(
return null;
}

let valueOptions = new Set(['-c', '--config', '--token', '--log-level']);
let valueOptions = new Set([
'-c',
'--config',
'--token',
'--log-level',
'--via-agent',
]);

for (let i = startIndex; i < args.length; i++) {
let arg = args[i];
Expand Down Expand Up @@ -419,7 +425,13 @@ function findRequestedCommand(
commandNames,
nestedCommandNames = new Map()
) {
let valueOptions = new Set(['-c', '--config', '--token', '--log-level']);
let valueOptions = new Set([
'-c',
'--config',
'--token',
'--log-level',
'--via-agent',
]);
let args = argv.slice(2);
let jsonValueCandidate = null;

Expand Down Expand Up @@ -544,6 +556,10 @@ program
.version(getPackageVersion())
.option('-c, --config <path>', 'Config file path')
.option('--token <token>', 'Vizzly API token')
.option(
'--via-agent <slug>',
'Disclose the agent assisting a review or comment'
)
.option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)')
.option(
'--log-level <level>',
Expand Down Expand Up @@ -1383,6 +1399,7 @@ api

program
.command('approve')
.option('--auth <source>', 'Credential source: auto, user, token', 'auto')
.description('Approve a comparison')
.argument('<comparison-id>', 'Comparison ID to approve')
.option('-m, --comment <message>', 'Optional comment explaining the approval')
Expand All @@ -1394,6 +1411,9 @@ Examples:
$ vizzly approve abc123 -m "LGTM" # Approve with comment
$ vizzly approve abc123 --json # Output as JSON for scripting

Known agents are disclosed automatically. Set the agent with --via-agent <slug>
or VIZZLY_VIA_AGENT.

Workflow:
1. List comparisons: vizzly comparisons -b <build-id>
2. Review the changes in the web UI or via URLs in the output
Expand All @@ -1413,6 +1433,7 @@ Workflow:

program
.command('reject')
.option('--auth <source>', 'Credential source: auto, user, token', 'auto')
.description('Reject a comparison')
.argument('<comparison-id>', 'Comparison ID to reject')
.option('-r, --reason <message>', 'Required reason for rejection')
Expand All @@ -1424,6 +1445,9 @@ Examples:
$ vizzly reject abc123 --reason "Needs design review"
$ vizzly reject abc123 -r "Regression" --json

Known agents are disclosed automatically. Set the agent with --via-agent <slug>
or VIZZLY_VIA_AGENT.

Workflow:
1. List comparisons: vizzly comparisons -b <build-id>
2. Review the changes in the web UI or via URLs in the output
Expand All @@ -1443,12 +1467,22 @@ Workflow:

program
.command('comment')
.description('Add a comment to a build')
.option('--auth <source>', 'Credential source: auto, user, token', 'auto')
.description('Add a comment to a build or screenshot')
.option('--screenshot <id>', 'Screenshot ID in this build')
.option(
'--region <x,y,width,height>',
'Screenshot region in bitmap pixels from the top-left'
)
.option(
'--reply-to <id>',
'Reply to a comment on the same build or screenshot'
)
.argument('<build-id>', 'Build ID to comment on')
.argument('<message>', 'Comment message')
.option(
'-t, --type <type>',
'Comment type: general, approval, rejection',
'Comment type: general, approval, rejection, question',
'general'
)
.addHelpText(
Expand All @@ -1459,6 +1493,12 @@ Examples:
$ vizzly comment abc123 "Approved" -t approval
$ vizzly comment abc123 "Please fix the header" -t rejection
$ vizzly comment abc123 "CI feedback" --json
$ vizzly comment <build-id> "Header overlaps" --screenshot <screenshot-id> --region 20,40,300,80
$ vizzly comment <build-id> "Reviewed" --via-agent codex

Known agents are disclosed automatically. Set the agent with --via-agent <slug>
or VIZZLY_VIA_AGENT.
Only agent details are sent; session and thread IDs stay local.

Workflow:
1. Get build ID: vizzly builds --branch main
Expand Down
1 change: 1 addition & 0 deletions src/domains/api/api.commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export async function apiCommand(
token,
credential,
command: 'api',
attribution: globalOptions,
allowNoToken: Boolean(options.schemaDiscovery),
});

Expand Down
101 changes: 101 additions & 0 deletions src/domains/auth/agent-attribution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { VizzlyError } from '../../shared/vizzly-error.js';

let agents = [
{
slug: 'codex',
name: 'Codex',
matches: env => Boolean(env.CODEX_THREAD_ID || env.CODEX_SESSION_ID),
},
{
slug: 'claude-code',
name: 'Claude Code',
matches: env => env.CLAUDECODE === '1',
},
{ slug: 'pi', name: 'Pi', matches: env => Boolean(env.PI_SESSION_ID) },
{
slug: 'gemini-cli',
name: 'Gemini CLI',
matches: env => env.GEMINI_CLI === '1',
},
{ slug: 'opencode', name: 'OpenCode', matches: env => env.OPENCODE === '1' },
];

function agentVia(slug) {
if (slug === 'none') {
throw new VizzlyError(
'Use an agent slug. Agent attribution cannot be turned off.',
'INVALID_AGENT_ATTRIBUTION'
);
}
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 48) {
throw new VizzlyError(
'Agent slug must be lowercase words separated by hyphens (maximum 48 characters).',
'INVALID_AGENT_ATTRIBUTION'
);
}
return {
type: 'agent',
slug,
name: agents.find(agent => agent.slug === slug)?.name || slug,
version: null,
};
}

export function resolveAgentAttribution(options = {}, env = process.env) {
let override = options.viaAgent || env.VIZZLY_VIA_AGENT;
if (override) return agentVia(override);
let matches = agents.filter(agent => agent.matches(env));
if (matches.length > 1) {
throw new VizzlyError(
`Multiple agents detected: ${matches.map(agent => agent.name).join(', ')}. Use --via-agent <slug> to choose the agent.`,
'AMBIGUOUS_AGENT_ATTRIBUTION'
);
}
return matches.length ? agentVia(matches[0].slug) : null;
}

export function attributeReviewRequest(
endpoint,
fetchOptions,
options = {},
env = process.env
) {
let path = endpoint.split('?')[0].replace(/\/+$/, '');
let reviewWrite =
/^\/api\/visual-review\/builds\/[^/]+(?:\/comparisons\/[^/]+|\/ocr-dynamic-content\/[^/]+)?\/decision$/.test(
path
);
let commentWrite =
/^\/api\/sdk\/builds\/[^/]+(?:\/screenshots\/[^/]+)?\/comments$/.test(path);
if (
fetchOptions.method?.toUpperCase() !== 'POST' ||
(!reviewWrite && !commentWrite)
)
return fetchOptions;
let via = resolveAgentAttribution(options, env);
let body = JSON.parse(fetchOptions.body || '{}');
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new VizzlyError(
'Review and comment requests require a JSON object.',
'INVALID_REQUEST_BODY'
);
}
let overridden = options.viaAgent || env.VIZZLY_VIA_AGENT;
if (overridden || !Object.hasOwn(body, 'via')) {
let { via: _previous, ...content } = body;
body = via ? { ...content, via } : content;
} else if (
via &&
(!body.via ||
body.via.type !== via.type ||
body.via.slug !== via.slug ||
body.via.name !== via.name ||
(body.via.version ?? null) !== via.version)
) {
throw new VizzlyError(
'Request via conflicts with the detected agent. Use --via-agent <slug> to choose the agent.',
'AMBIGUOUS_AGENT_ATTRIBUTION'
);
}
return { ...fetchOptions, body: JSON.stringify(body) };
}
7 changes: 7 additions & 0 deletions src/domains/auth/cloud-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../../shared/cloud-protocol.js';
import { getPackageVersion } from '../../shared/package-info.js';
import { AuthError, VizzlyError } from '../../shared/vizzly-error.js';
import { attributeReviewRequest } from './agent-attribution.js';
import { createInsufficientScopeError } from './auth-errors.js';

/**
Expand Down Expand Up @@ -71,6 +72,12 @@ export function createApiClient(options = {}) {
* @returns {Promise<Object>} Parsed JSON response
*/
async function request(endpoint, fetchOptions = {}, isRetry = false) {
fetchOptions = attributeReviewRequest(
endpoint,
fetchOptions,
options.attribution,
options.env
);
let {
responseType = 'json',
retryAuthentication = ['GET', 'HEAD'].includes(
Expand Down
Loading
Loading