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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to HackMyAgent are documented in this file.

## [Unreleased]

## [0.19.1] - 2026-04-22

### Changed
- **`check --json` not-found paths now emit the canonical `NotFoundOutput` shape from `@opena2a/check-core`.** The npm-miss (git-style translated), PyPI 404, and GitHub 404 paths all go through `buildNotFoundOutput({ name, ecosystem, error, errorHint?, suggestions? })`. Closes the data-layer half of the F2/F3/F4 parity fixtures in opena2a-parity.
- **Bare names on npm 404 no longer fall through to the skill resolver.** `hackmyagent check <bare-name>` where the package does not exist on npm used to emit `Invalid skill identifier` on stderr with no JSON. It now emits the same `NotFoundOutput` shape as scoped/git-style misses and exits 1. Scoped names (`@scope/name`) still fall through to skill-identifier fallback on npm 404 — that path is unchanged.

### Engineering
- Adds `__tests__/checker/check-not-found-json.test.ts` as a regression test for the bare-name → npm `NotFoundOutput` emission. CI-skipped (needs network + built `dist/cli.js`); local dev exercises the real shape.

## [0.19.0] - 2026-04-22

### Changed
- **`check` happy-path consumes `@opena2a/check-core@0.1.0` primitives.** `buildCheckOutput` + `translateDownloadError` + `mapScanStatusForMeter` move to the shared package; local `src/check-render.ts` deleted (CA-034 M3). HMA, opena2a-cli (via spawn delegation), and ai-trust now share one implementation for the registered-package `--json` shape — the F1 parity fixture in opena2a-parity is byte-identical across all three.

## [0.18.3] - 2026-04-23

### Added
Expand Down
43 changes: 43 additions & 0 deletions __tests__/checker/check-not-found-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Regression test for CA-034 round 2: `hackmyagent check <bare-name>` emits
// the canonical NotFoundOutput shape on npm misses instead of the legacy
// "Invalid skill identifier" stderr error. Covers the bare-name reclassifier
// at src/cli.ts and the buildNotFoundOutput adoption on the npm miss path.
//
// Spawned test — needs a built dist/cli.js and network access to npm.
// Skipped on CI runners since the intent is local regression coverage for
// the shape, not a live-npm dependency in CI.

import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';

const BARE_NAME = 'totally-nonexistent-pkg-xyz789';
const REPO_ROOT = join(__dirname, '..', '..');
const CLI = join(REPO_ROOT, 'dist', 'cli.js');

function canRun(): boolean {
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return false;
return existsSync(CLI);
}

describe('check <bare-name> --json emits NotFoundOutput shape', () => {
it.runIf(canRun())('returns canonical not-found JSON for a bare npm miss', () => {
const res = spawnSync('node', [CLI, 'check', BARE_NAME, '--json', '--ci'], {
encoding: 'utf8',
timeout: 30_000,
});

expect(res.status).toBe(1);

const stdout = (res.stdout || '').trim();
expect(stdout.length).toBeGreaterThan(0);

const parsed = JSON.parse(stdout);
expect(parsed.name).toBe(BARE_NAME);
expect(parsed.found).toBe(false);
expect(parsed.ecosystem).toBe('npm');
expect(typeof parsed.error).toBe('string');
expect(parsed.error.length).toBeGreaterThan(0);
});
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hackmyagent",
"version": "0.19.0",
"version": "0.19.1",
"description": "Find it. Break it. Fix it. The hacker's toolkit for AI agents.",
"bin": {
"hackmyagent": "dist/cli.js"
Expand Down
45 changes: 38 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
} from './index';
import { resolveAndLogMcpShorthand } from './resolve-mcp';
import { WildScanner, type WildScanReport } from './wild';
import { buildCheckOutput, mapScanStatusForMeter, translateDownloadError } from '@opena2a/check-core';
import { buildCheckOutput, buildNotFoundOutput, mapScanStatusForMeter, translateDownloadError } from '@opena2a/check-core';
import {
isRenderableAnalystFinding,
formatAnalystDescription,
Expand Down Expand Up @@ -363,14 +363,29 @@ Examples:
}

// npm package name: download, run full HMA scan, clean up
// On npm 404, fall through to skill check (skill identifiers look like @scope/name)
// On npm 404 for scoped names, fall through to skill check (skill
// identifiers look like @scope/name). Bare names are not valid skill
// identifiers — emit canonical npm not-found via buildNotFoundOutput
// and exit so the `--json` path agrees with the scoped/git-style shape.
if (looksLikeNpmPackage(skill)) {
try {
await checkNpmPackage(skill, options);
return;
} catch (npmErr: unknown) {
if (npmErr instanceof Error && npmErr.name === 'NpmNotFoundError') {
// Not on npm — fall through to skill check
const isScoped = skill.startsWith('@');
if (!isScoped) {
if (options.json) {
writeJsonStdout(buildNotFoundOutput({
name: skill,
ecosystem: 'npm',
error: `Package "${skill}" not found on npm.`,
}));
} else {
printNotFoundBlock({ pkg: skill, ecosystem: 'npm' });
}
process.exit(1);
}
if (!options.json && !globalCiMode) {
console.error(`Package "${skill}" not found on npm. Trying as skill identifier...`);
}
Expand Down Expand Up @@ -8522,13 +8537,19 @@ async function checkGitHubRepo(
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('128') || message.includes('not found') || message.includes('Repository not found')) {
const errorHint = `Verify the URL: https://github.com/${displayName}`;
if (options.json) {
writeJsonStdout({ name: displayName, ecosystem: 'github', found: false, error: `Repository "${displayName}" not found on GitHub.` });
writeJsonStdout(buildNotFoundOutput({
name: displayName,
ecosystem: 'github',
error: `Repository "${displayName}" not found on GitHub.`,
errorHint,
}));
} else {
printNotFoundBlock({
pkg: displayName,
ecosystem: 'github',
errorHint: `Verify the URL: https://github.com/${displayName}`,
errorHint,
});
}
} else if (message.includes('timeout') || message.includes('Timeout')) {
Expand Down Expand Up @@ -8606,7 +8627,11 @@ async function checkPyPiPackage(
if (!metaRes.ok) {
if (metaRes.status === 404) {
if (options.json) {
writeJsonStdout({ name, ecosystem: 'pypi', found: false, error: `Package "${name}" not found on PyPI.` });
writeJsonStdout(buildNotFoundOutput({
name,
ecosystem: 'pypi',
error: `Package "${name}" not found on PyPI.`,
}));
} else {
printNotFoundBlock({ pkg: name, ecosystem: 'pypi' });
}
Expand Down Expand Up @@ -9124,7 +9149,13 @@ async function checkNpmPackage(
const translated = translateDownloadError(name, message);
if (translated && (translated.errorHint || translated.suggestions)) {
if (options.json) {
writeJsonStdout({ name, ecosystem: 'npm', found: false, error: translated.errorHint, suggestions: translated.suggestions });
writeJsonStdout(buildNotFoundOutput({
name,
ecosystem: 'npm',
error: translated.errorHint,
errorHint: translated.errorHint,
suggestions: translated.suggestions,
}));
} else {
printNotFoundBlock({
pkg: name,
Expand Down
Loading