securevibe 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,8 +28,9 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
28
28
  | `securevibe init [path]` | **Wire SecureVibe in: pre-commit guard + GitHub Action + gitignore** (doc 12) |
29
29
  | `securevibe fix [path]` | **Autonomously fix findings, then re-scan to verify** (doc 05) |
30
30
  | `securevibe explain [path]` | AI-powered plain-language explanations for critical/high findings |
31
+ | `securevibe pr-comment [path]` | Post/update a PR findings summary (used by the GitHub Actions workflow) |
31
32
  | `securevibe deps [path]` | Audit dependencies for known CVEs against the local OSV database (SCA) |
32
- | `securevibe db <update\|status>` | Manage the local OSV database — `update` is the only networked command |
33
+ | `securevibe db <update\|status>` | Manage the local OSV database — `update` is networked |
33
34
  | `securevibe ready [path]` | Launch readiness scorecard: pass/fail gates + go/no-go verdict |
34
35
  | `securevibe ai-audit [path]` | Focus on the AI-agent attack surface (doc 04) |
35
36
  | `securevibe protect [path]` | Remediation-first view: the fix for every finding (doc 05) |
@@ -40,11 +41,19 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
40
41
 
41
42
  Flags: `--json`, `--sarif` (GitHub code-scanning / CI interop), `--no-color`,
42
43
  `--ci` (exit `2` on BLOCK, `1` on WARN, `0` on SHIP — for build gates),
43
- `--staged` (scan only git-staged files — what the pre-commit guard uses).
44
+ `--staged` (scan only git-staged files — what the pre-commit guard uses),
45
+ `--diff <base>` (scan only files changed since `<base>` — what the PR-comment step
46
+ uses; also exempt from the daily usage limit, same as `--staged`).
44
47
  `fix` flags: `--apply` (write to disk; asks before each change), `--yes` (apply every
45
48
  verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-only), `--json`.
46
49
  `init` flags: `--force` (replace an existing hook/workflow/config).
47
50
 
51
+ The REPL renders a bordered input box with a live status line (target + active AI-fixer
52
+ provider). Type `/` to open a command-discovery menu, or type commands directly as
53
+ before (`scan`, `fix --apply`, `help`, etc. — the `/` prefix is optional, not required).
54
+ Up/down arrow cycles through your command history. `scan`/`fix`/`explain` show a single
55
+ live progress spinner instead of scrolling status lines.
56
+
48
57
  ## Always-on (doc 12)
49
58
 
50
59
  `securevibe init` makes the tool continuous so insecure code can't slip through:
@@ -53,11 +62,16 @@ verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-on
53
62
  a blocking issue** — including a committed secret (verified: it aborts the commit, then lets the
54
63
  clean version through);
55
64
  - a **GitHub Actions workflow** (`.github/workflows/securevibe.yml`) that scans every PR and uploads
56
- SARIF to GitHub code scanning, gating the build on deployment-readiness;
65
+ SARIF to GitHub code scanning, gates the build on deployment-readiness, and posts a PR comment
66
+ summarizing findings in the *changed* files only (updated in place on new commits, not reposted —
67
+ and the only other command besides `db update` that talks to the network, and only to post a
68
+ findings summary already computed locally, never your source code);
57
69
  - gitignores `.env` / backups and drops a minimal `securevibe.config.json`.
58
70
 
59
- It is idempotent (existing files are left alone unless `--force`) and runs entirely locally —
60
- **no account, no upload, and your secrets / API keys are never stored or transmitted.**
71
+ It is idempotent (existing files are left alone unless `--force`). Everything it installs runs
72
+ locally — **no account, and your secrets / API keys are never stored or transmitted.** The one
73
+ exception is the PR-comment step above, which uploads a findings summary (never your source code)
74
+ already computed by the local scan.
61
75
 
62
76
  ## The fix loop (doc 05)
63
77
 
@@ -121,8 +135,9 @@ against a local copy of the [OSV](https://osv.dev) advisory database, and report
121
135
  CVE still blocks deployment, and `securevibe fix` bumps the package to the lowest non vulnerable
122
136
  version while preserving your range prefix (`^`, `~`, `==`, ...).
123
137
  - **Offline first.** A bundled seed (`data/osv-seed.json`) ships with the CLI, so `deps` works with
124
- no network. `db update` is the **only networked command**: it streams the public OSV npm and PyPI
125
- dumps into `~/.securevibe/db`. Nothing about your code or package list leaves the machine.
138
+ no network. `db update` is the only command that **downloads** anything (the public OSV npm and
139
+ PyPI dumps, into `~/.securevibe/db`). `pr-comment` is the only command that **uploads** anything,
140
+ and only a findings summary already computed locally — never your code or package list.
126
141
  - **Honest framing.** A clean result reads "no known advisories as of `<db date>`", not "secure" —
127
142
  it means nothing matched the local database on that date, which is not a proof of safety.
128
143
 
@@ -34,11 +34,15 @@ export function gitDir(root) {
34
34
  /**
35
35
  * Staged files (added/copied/modified) as absolute paths, filtered to those
36
36
  * that exist on disk. Returns null if not a git repo / git unavailable.
37
+ * `--relative` matters when `root` is a subdirectory of the repo: git diff
38
+ * paths are toplevel-relative by default, which would silently mis-resolve
39
+ * against `root` otherwise (and --relative also scopes the diff to `root`,
40
+ * matching "scan this path" semantics for a subdirectory target).
37
41
  */
38
42
  export async function listStagedFiles(root) {
39
43
  if (!isGitRepo(root))
40
44
  return null;
41
- const out = git(root, ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"]);
45
+ const out = git(root, ["diff", "--cached", "--relative", "--name-only", "--diff-filter=ACM", "-z"]);
42
46
  if (out == null)
43
47
  return null;
44
48
  const rels = out.split("\0").filter((s) => s.length > 0);
@@ -56,3 +60,32 @@ export async function listStagedFiles(root) {
56
60
  }
57
61
  return abs;
58
62
  }
63
+ /**
64
+ * Files changed (added/copied/modified) between `baseRef` and the working
65
+ * tree, as absolute paths, filtered to those that still exist on disk. Used
66
+ * by `--diff` (PR-scoped scans) — same shape as listStagedFiles, but diffing
67
+ * against an arbitrary ref instead of the index. Returns null if not a git
68
+ * repo / git unavailable, or if the diff itself fails (e.g. `baseRef` doesn't
69
+ * exist in this checkout).
70
+ */
71
+ export async function listChangedFiles(root, baseRef) {
72
+ if (!isGitRepo(root))
73
+ return null;
74
+ const out = git(root, ["diff", "--relative", "--name-only", "--diff-filter=ACM", "-z", baseRef]);
75
+ if (out == null)
76
+ return null;
77
+ const rels = out.split("\0").filter((s) => s.length > 0);
78
+ const abs = [];
79
+ for (const rel of rels) {
80
+ const p = path.join(root, rel);
81
+ try {
82
+ const st = await fs.stat(p);
83
+ if (st.isFile())
84
+ abs.push(p);
85
+ }
86
+ catch {
87
+ /* changed-but-removed or unreadable — skip */
88
+ }
89
+ }
90
+ return abs;
91
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Minimal GitHub REST client for the pr-comment command (doc 12). Posts or
3
+ * updates a single PR comment, found via a hidden marker so re-runs replace
4
+ * it in place instead of piling up new comments. Every failure (auth,
5
+ * network, rate limit) is caught and returned as a result — this must never
6
+ * throw, since posting the comment is best-effort visibility, not a gate
7
+ * (the existing `scan --ci` step is what's allowed to fail the build).
8
+ */
9
+ export const PR_COMMENT_MARKER = "<!-- securevibe-pr-comment -->";
10
+ const API_BASE = "https://api.github.com";
11
+ function authHeaders(token) {
12
+ return {
13
+ Authorization: `Bearer ${token}`,
14
+ Accept: "application/vnd.github+json",
15
+ "Content-Type": "application/json",
16
+ "X-GitHub-Api-Version": "2022-11-28",
17
+ };
18
+ }
19
+ /** Finds an existing SecureVibe comment on the PR by its hidden marker, or null if none/on any failure. */
20
+ async function findExistingComment(target) {
21
+ try {
22
+ const res = await fetch(`${API_BASE}/repos/${target.owner}/${target.repo}/issues/${target.prNumber}/comments?per_page=100`, { headers: authHeaders(target.token) });
23
+ if (!res.ok)
24
+ return null;
25
+ const comments = await res.json();
26
+ if (!Array.isArray(comments))
27
+ return null;
28
+ const existing = comments.find((c) => typeof c?.body === "string" && c.body.includes(PR_COMMENT_MARKER));
29
+ return existing?.id ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * Posts a new PR comment, or updates the existing SecureVibe comment (found
37
+ * via the marker) in place. Never throws — any failure comes back as
38
+ * `{ posted: false, error }` so the caller can warn and continue.
39
+ */
40
+ export async function postOrUpdateComment(target, body) {
41
+ try {
42
+ const existingId = await findExistingComment(target);
43
+ const url = existingId
44
+ ? `${API_BASE}/repos/${target.owner}/${target.repo}/issues/comments/${existingId}`
45
+ : `${API_BASE}/repos/${target.owner}/${target.repo}/issues/${target.prNumber}/comments`;
46
+ const res = await fetch(url, {
47
+ method: existingId ? "PATCH" : "POST",
48
+ headers: authHeaders(target.token),
49
+ body: JSON.stringify({ body }),
50
+ });
51
+ if (!res.ok) {
52
+ return { posted: false, updated: false, error: `GitHub API error: ${res.status}` };
53
+ }
54
+ return { posted: true, updated: existingId !== null };
55
+ }
56
+ catch (err) {
57
+ return { posted: false, updated: false, error: err?.message ?? String(err) };
58
+ }
59
+ }
@@ -2,7 +2,7 @@
2
2
  * `securevibe init` (doc 12) — make SecureVibe always-on in a project with one
3
3
  * command. Installs a git pre-commit guard (blocks commits that introduce a
4
4
  * BLOCK-level issue, including committed secrets), a GitHub Actions workflow
5
- * (PR scan + SARIF upload to code scanning), gitignores `.env` and backups, and
5
+ * (PR scan + SARIF upload + PR summary comment), gitignores `.env` and backups, and
6
6
  * drops a minimal config. Idempotent: existing files are left alone unless
7
7
  * `--force`. Everything runs locally — no account, no upload, no API key.
8
8
  */
@@ -72,8 +72,8 @@ if [ "$?" -eq 2 ]; then
72
72
  fi
73
73
  exit 0
74
74
  `;
75
- const WORKFLOW = `# SecureVibe — scan every PR and upload findings to GitHub code scanning.
76
- # Managed by \`securevibe init\`.
75
+ const WORKFLOW = `# SecureVibe — scan every PR, upload findings to GitHub code scanning, and
76
+ # post a PR summary comment. Managed by \`securevibe init\`.
77
77
  name: SecureVibe
78
78
  on:
79
79
  pull_request:
@@ -82,11 +82,14 @@ on:
82
82
  permissions:
83
83
  contents: read
84
84
  security-events: write
85
+ issues: write
85
86
  jobs:
86
87
  securevibe:
87
88
  runs-on: ubuntu-latest
88
89
  steps:
89
90
  - uses: actions/checkout@v4
91
+ with:
92
+ fetch-depth: 0
90
93
  - uses: actions/setup-node@v4
91
94
  with:
92
95
  node-version: 20
@@ -101,6 +104,12 @@ jobs:
101
104
  sarif_file: securevibe.sarif
102
105
  - name: Gate the build on deployment readiness
103
106
  run: securevibe scan . --ci
107
+ - name: PR comment
108
+ if: always() && github.event_name == 'pull_request'
109
+ env:
110
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
111
+ PR_NUMBER: \${{ github.event.pull_request.number }}
112
+ run: securevibe pr-comment . --diff \${{ github.event.pull_request.base.sha }}
104
113
  `;
105
114
  export async function runInit(root, opts = {}) {
106
115
  const absRoot = path.resolve(root);
@@ -122,7 +131,7 @@ export async function runInit(root, opts = {}) {
122
131
  });
123
132
  }
124
133
  // 4) GitHub Actions workflow.
125
- actions.push(await writeIfAbsent(absRoot, ".github/workflows/securevibe.yml", WORKFLOW, opts.force, "PR scan + SARIF upload"));
134
+ actions.push(await writeIfAbsent(absRoot, ".github/workflows/securevibe.yml", WORKFLOW, opts.force, "PR scan + SARIF upload + PR summary comment"));
126
135
  return { root: absRoot, isGitRepo: repo, actions };
127
136
  }
128
137
  async function ensureGitignore(root) {
@@ -1,6 +1,13 @@
1
1
  import { walk } from "./ast.js";
2
- /** Untrusted-input sources. Matching text is considered tainted at its origin. */
3
- const SOURCE_RE = /\b(req|request)\.(query|body|params|headers|cookies)\b|\bsearchParams\.get\b|\bnextUrl\.searchParams\b|\bparams\.[a-zA-Z_$]|\breq(uest)?\.json\(\)|\bawait\s+\w+\.json\(\)|\bprocess\.argv\b|\bflask\.request\.|\brequest\.args\b|\brequest\.form\b|\brequest\.json\b/;
2
+ /**
3
+ * Untrusted-input sources. Matching text is considered tainted at its origin.
4
+ * `req(uest)?.json()` (with or without a preceding `await`) is intentionally
5
+ * scoped to those two names, not `\w+.json()` generically -- an unrestricted
6
+ * `\w+` also matched `await res.json()` on a response *we* fetched (e.g. an
7
+ * outbound GitHub API call), wrongly treating our own already-fetched data
8
+ * as untrusted input.
9
+ */
10
+ const SOURCE_RE = /\b(req|request)\.(query|body|params|headers|cookies)\b|\bsearchParams\.get\b|\bnextUrl\.searchParams\b|\bparams\.[a-zA-Z_$]|\breq(uest)?\.json\(\)|\bprocess\.argv\b|\bflask\.request\.|\brequest\.args\b|\brequest\.form\b|\brequest\.json\b/;
4
11
  function collectAssignments(root) {
5
12
  const out = [];
6
13
  walk(root, (n) => {
@@ -32,7 +39,12 @@ function referencesTainted(text, tainted) {
32
39
  if (SOURCE_RE.test(text))
33
40
  return true;
34
41
  for (const v of tainted) {
35
- // word-boundary match so `id` doesn't match `idx`
42
+ // word-boundary match so `id` doesn't match `idx` -- but this also can't
43
+ // tell a real reference to `v` from `v`'s name coincidentally appearing
44
+ // inside a string/template literal (e.g. a URL path segment: a tainted
45
+ // var named `comments` false-matched a fetch URL literally containing
46
+ // "/comments"). Known false-positive class, not fixed here -- narrowing
47
+ // SOURCE_RE only closes the one source that produced this instance.
36
48
  const re = new RegExp(`\\b${escapeRe(v)}\\b`);
37
49
  if (re.test(text))
38
50
  return true;
@@ -2,6 +2,19 @@
2
2
  * Shared helpers used by both the one-shot CLI commands (index.ts) and the
3
3
  * interactive REPL (repl.ts), so the two entry points never duplicate logic.
4
4
  */
5
+ /**
6
+ * Pure (no I/O) check: is there no AI-fixer provider key configured right
7
+ * now? Returns the static nudge text to print when true, undefined when a
8
+ * key is already set. Shared by the interactive `askLine`-based offer below
9
+ * and by the Ink REPL path (ui/repl-tui/App.ts), which can't safely open a
10
+ * second readline on process.stdin while Ink holds it in raw mode — so it
11
+ * prints this instead of calling maybeOfferAiFixerSetup().
12
+ */
13
+ export function noKeyNudgeMessage() {
14
+ if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
15
+ return undefined;
16
+ return " no AI-fixer key set — those findings show as manual review. Run `securevibe config set-key groq` (outside the REPL) to enable AI-powered fixes.\n\n";
17
+ }
5
18
  /**
6
19
  * If `fix` is about to run with no AI-fixer provider key available (and we're
7
20
  * in an interactive terminal), offer to set one up on the spot — a free Groq
@@ -9,7 +22,7 @@
9
22
  * never touches process.env if the user declines or a key is already set.
10
23
  */
11
24
  export async function maybeOfferAiFixerSetup() {
12
- if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
25
+ if (!noKeyNudgeMessage())
13
26
  return;
14
27
  const { isInteractive, askLine } = await import("./ui/prompt.js");
15
28
  if (!isInteractive())
@@ -52,7 +65,8 @@ export async function maybeOfferAiFixerSetupDuringInit(root, deps) {
52
65
  const isInteractive = deps?.isInteractive ?? (await import("./ui/prompt.js")).isInteractive;
53
66
  if (!isInteractive())
54
67
  return;
55
- process.stderr.write("\n This project uses an LLM SDK — SecureVibe can auto-fix AI-specific findings\n (tool-hijack, prompt-injection sinks) with a free key.\n");
68
+ const writePreamble = deps?.writePreamble ?? ((text) => process.stderr.write(text));
69
+ writePreamble("\n This project uses an LLM SDK — SecureVibe can auto-fix AI-specific findings\n (tool-hijack, prompt-injection sinks) with a free key.\n");
56
70
  const offer = deps?.offer ?? maybeOfferAiFixerSetup;
57
71
  await offer();
58
72
  if (!(process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)) {
package/dist/index.js CHANGED
@@ -22,16 +22,18 @@ function addCommon(cmd) {
22
22
  .option("--sarif", "output SARIF 2.1.0 (for CI / GitHub code scanning)")
23
23
  .option("--no-color", "disable coloured output")
24
24
  .option("--staged", "scan only git-staged files (for pre-commit guards)")
25
+ .option("--diff <base>", "scan only files changed since <base> (for PR checks)")
25
26
  .option("--ci", "CI mode: exit non-zero based on deployment readiness");
26
27
  }
27
28
  /**
28
29
  * Free beta metering: one use per analysis command against the daily quota.
29
- * The --staged commit guard is exempt a quota must never block a commit.
30
- * Returns the usage state so callers can show it, or undefined when this run
31
- * wasn't metered (--staged).
30
+ * The --staged commit guard and --diff (PR-scoped CI scans) are both exempt
31
+ * an always-on automated guard must never be the thing that breaks. Returns
32
+ * the usage state so callers can show it, or undefined when this run wasn't
33
+ * metered.
32
34
  */
33
35
  async function meter(opts = {}) {
34
- if (opts.staged)
36
+ if (opts.staged || opts.diff)
35
37
  return undefined;
36
38
  const { recordUse, limitMessage } = await import("./usage.js");
37
39
  const r = await recordUse();
@@ -62,6 +64,20 @@ async function runScan(pathArg, opts, scanOpts = {}) {
62
64
  scanOpts = { ...scanOpts, files: staged };
63
65
  }
64
66
  }
67
+ // --diff <base>: analyze only files changed since <base> (PR-scoped CI checks).
68
+ if (opts.diff) {
69
+ const path = await import("node:path");
70
+ const { listChangedFiles } = await import("./engine/git.js");
71
+ const absRoot = path.resolve(target);
72
+ const changed = await listChangedFiles(absRoot, opts.diff);
73
+ if (changed === null) {
74
+ if (!quiet)
75
+ process.stderr.write(` · could not diff against ${opts.diff} — scanning the whole tree\n`);
76
+ }
77
+ else {
78
+ scanOpts = { ...scanOpts, files: changed };
79
+ }
80
+ }
65
81
  const result = await scan(target, {
66
82
  ...scanOpts,
67
83
  onProgress: quiet ? undefined : (m) => process.stderr.write(` · ${m}\n`),
@@ -201,6 +217,36 @@ program
201
217
  }
202
218
  await maybeNotifyUpdate({ json: opts.json });
203
219
  });
220
+ program
221
+ .command("pr-comment")
222
+ .description("Post/update a PR summary of changed-file findings (used by the GitHub Actions workflow)")
223
+ .argument("[path]", "path to the repository", ".")
224
+ .option("--diff <base>", "base ref to diff against (the workflow passes the PR's base SHA)")
225
+ .action(async (pathArg, opts) => {
226
+ if (!opts.diff) {
227
+ process.stderr.write(" pr-comment: --diff <base> is required (the workflow passes the PR's base SHA).\n");
228
+ process.exit(64);
229
+ }
230
+ const { result } = await runScan(pathArg, { diff: opts.diff });
231
+ const { renderPrComment } = await import("./ui/prComment.js");
232
+ const body = renderPrComment(result);
233
+ const token = process.env.GITHUB_TOKEN;
234
+ const repoSlug = process.env.GITHUB_REPOSITORY;
235
+ const prNumber = process.env.PR_NUMBER ? Number(process.env.PR_NUMBER) : undefined;
236
+ if (!token || !repoSlug || !prNumber) {
237
+ process.stdout.write(body + "\n");
238
+ return;
239
+ }
240
+ const [owner, repo] = repoSlug.split("/");
241
+ const { postOrUpdateComment } = await import("./engine/github.js");
242
+ const posted = await postOrUpdateComment({ owner, repo, prNumber, token }, body);
243
+ if (!posted.posted) {
244
+ process.stderr.write(` pr-comment: could not post to GitHub (${posted.error ?? "unknown error"}) — printing instead.\n`);
245
+ process.stdout.write(body + "\n");
246
+ return;
247
+ }
248
+ process.stderr.write(` pr-comment: ${posted.updated ? "updated" : "posted"} the summary on PR #${prNumber}.\n`);
249
+ });
204
250
  addCommon(program
205
251
  .command("ai-audit")
206
252
  .description("Focus on the AI-application / agent attack surface (doc 04)")
@@ -275,7 +321,7 @@ addCommon(program
275
321
  });
276
322
  program
277
323
  .command("db")
278
- .description("Manage the local OSV vulnerability database (the only networked command)")
324
+ .description("Manage the local OSV vulnerability database (update is networked)")
279
325
  .argument("<action>", "update | status")
280
326
  .option("--no-color", "disable coloured output")
281
327
  .action(async (action, opts) => {
package/dist/repl.js CHANGED
@@ -40,28 +40,62 @@ async function meterOrPrint(io) {
40
40
  }
41
41
  return r;
42
42
  }
43
- const HELP = [
44
- " Commands:",
45
- " scan [--json] full scan",
46
- " fix [--apply] [--yes] [--no-llm] [--json] fix findings",
47
- " explain [--json] AI explanations for critical/high findings",
48
- " ai-audit | protect | attack-map | score | deps | ready [--json]",
49
- " init [--force] wire up the pre-commit guard + CI",
50
- " config show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
51
- " db update | status manage the local OSV database",
52
- " cd <path> change the scan target",
53
- " pwd show the current scan target",
54
- " help this message",
55
- " exit | quit leave the session",
56
- ].join("\n");
57
- /** Public entry point: real stdio, refuses to start without a TTY. */
43
+ /** Public entry point: real stdio, refuses to start without a TTY. Mounts
44
+ * the Ink interactive app (ui/repl-tui/App.ts) -- runReplSession above stays
45
+ * the tested, TTY-free core; this is the real-terminal presentation layer. */
58
46
  export async function runRepl(initialPath) {
59
47
  if (!isInteractive()) {
60
48
  process.stderr.write(" repl needs an interactive terminal.\n");
61
49
  process.exitCode = 64;
62
50
  return;
63
51
  }
64
- await runReplSession(initialPath, { input: process.stdin, output: process.stdout });
52
+ const { render } = await import("ink");
53
+ const React = (await import("react")).default;
54
+ const { App } = await import("./ui/repl-tui/App.js");
55
+ const { waitUntilExit } = render(React.createElement(App, { initialPath }));
56
+ await waitUntilExit();
57
+ }
58
+ /**
59
+ * Handle one line of input against the given state: built-in commands
60
+ * (exit/quit/q, help, pwd, cd) plus everything dispatch() handles. Returns
61
+ * true if the session should end after this line. Shared by the legacy
62
+ * readline loop (runReplSession, below) and the Ink app (ui/repl-tui/App.ts)
63
+ * — extracted so both drive the exact same behavior from one place.
64
+ */
65
+ export async function processLine(line, state, io) {
66
+ const trimmed = line.trim();
67
+ if (!trimmed)
68
+ return false;
69
+ const tokens = tokenize(trimmed);
70
+ const cmd = tokens[0];
71
+ const rest = tokens.slice(1);
72
+ try {
73
+ if (cmd === "exit" || cmd === "quit" || cmd === "q")
74
+ return true;
75
+ if (cmd === "help" || cmd === "?") {
76
+ const { renderHelp } = await import("./ui/repl-tui/commandRegistry.js");
77
+ write(io, renderHelp() + "\n\n");
78
+ return false;
79
+ }
80
+ if (cmd === "pwd") {
81
+ write(io, ` ${state.root}\n\n`);
82
+ return false;
83
+ }
84
+ if (cmd === "cd") {
85
+ if (!rest[0]) {
86
+ write(io, " usage: cd <path>\n\n");
87
+ return false;
88
+ }
89
+ state.root = path.resolve(state.root, rest[0]);
90
+ write(io, ` target: ${state.root}\n\n`);
91
+ return false;
92
+ }
93
+ await dispatch(cmd, rest, state, io);
94
+ }
95
+ catch (err) {
96
+ write(io, ` error: ${err?.message ?? err}\n\n`);
97
+ }
98
+ return false;
65
99
  }
66
100
  /** The actual session loop, over injectable streams — testable without a TTY. */
67
101
  export async function runReplSession(initialPath, io) {
@@ -73,39 +107,17 @@ export async function runReplSession(initialPath, io) {
73
107
  write(io, ` target: ${state.root}\n\n`);
74
108
  while (true) {
75
109
  const raw = await askLine(`securevibe (${path.basename(state.root)})> `, io.input, io.output);
76
- const line = raw.trim();
77
- if (!line)
78
- continue;
79
- const tokens = tokenize(line);
80
- const cmd = tokens[0];
81
- const rest = tokens.slice(1);
82
- try {
83
- if (cmd === "exit" || cmd === "quit" || cmd === "q")
84
- break;
85
- if (cmd === "help" || cmd === "?") {
86
- write(io, HELP + "\n\n");
87
- continue;
88
- }
89
- if (cmd === "pwd") {
90
- write(io, ` ${state.root}\n\n`);
91
- continue;
92
- }
93
- if (cmd === "cd") {
94
- if (!rest[0]) {
95
- write(io, " usage: cd <path>\n\n");
96
- continue;
97
- }
98
- state.root = path.resolve(state.root, rest[0]);
99
- write(io, ` target: ${state.root}\n\n`);
100
- continue;
101
- }
102
- await dispatch(cmd, rest, state, io);
103
- }
104
- catch (err) {
105
- write(io, ` error: ${err?.message ?? err}\n\n`);
106
- }
110
+ if (await processLine(raw, state, io))
111
+ break;
107
112
  }
108
113
  }
114
+ /** Progress callback for scan()/runFix()/explainFindings(): prefers io.onProgress
115
+ * (Ink path) over the default scrolling-line behavior (legacy readline path). */
116
+ function progressCallback(io, asJson) {
117
+ if (asJson)
118
+ return undefined;
119
+ return io.onProgress ?? ((m) => write(io, ` · ${m}\n`));
120
+ }
109
121
  async function dispatch(cmd, tokens, state, io) {
110
122
  const asJson = hasFlag(tokens, "--json");
111
123
  if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd)) {
@@ -113,7 +125,7 @@ async function dispatch(cmd, tokens, state, io) {
113
125
  const usage = await meterOrPrint(io);
114
126
  const result = await scan(state.root, {
115
127
  aiOnly: cmd === "ai-audit",
116
- onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
128
+ onProgress: progressCallback(io, asJson),
117
129
  });
118
130
  state.lastResult = result;
119
131
  if (asJson) {
@@ -130,15 +142,28 @@ async function dispatch(cmd, tokens, state, io) {
130
142
  const opts = { apply: hasFlag(tokens, "--apply"), yes: hasFlag(tokens, "--yes"), json: asJson };
131
143
  const decision = decideApply(opts, true);
132
144
  if (decision.apply && !hasFlag(tokens, "--no-llm")) {
133
- const { maybeOfferAiFixerSetup } = await import("./index-support.js");
134
- await maybeOfferAiFixerSetup();
145
+ if (io.inkMode) {
146
+ const { noKeyNudgeMessage } = await import("./index-support.js");
147
+ const msg = noKeyNudgeMessage();
148
+ if (msg)
149
+ write(io, msg);
150
+ }
151
+ else {
152
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
153
+ await maybeOfferAiFixerSetup();
154
+ }
135
155
  }
136
156
  const usage = await meterOrPrint(io);
157
+ const confirmFn = decision.prompt
158
+ ? io.makeConfirm
159
+ ? io.makeConfirm(renderApprovalRequest)
160
+ : makeFixConfirm(renderApprovalRequest, io.input, io.output)
161
+ : undefined;
137
162
  const result = await runFix(state.root, {
138
163
  apply: decision.apply,
139
164
  noLlm: hasFlag(tokens, "--no-llm"),
140
- confirm: decision.prompt ? makeFixConfirm(renderApprovalRequest, io.input, io.output) : undefined,
141
- onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
165
+ confirm: confirmFn,
166
+ onProgress: progressCallback(io, asJson),
142
167
  });
143
168
  if (asJson) {
144
169
  write(io, fixToJson(result) + "\n\n");
@@ -152,16 +177,24 @@ async function dispatch(cmd, tokens, state, io) {
152
177
  }
153
178
  if (cmd === "explain") {
154
179
  if (!asJson) {
155
- const { maybeOfferAiFixerSetup } = await import("./index-support.js");
156
- await maybeOfferAiFixerSetup();
180
+ if (io.inkMode) {
181
+ const { noKeyNudgeMessage } = await import("./index-support.js");
182
+ const msg = noKeyNudgeMessage();
183
+ if (msg)
184
+ write(io, msg);
185
+ }
186
+ else {
187
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
188
+ await maybeOfferAiFixerSetup();
189
+ }
157
190
  }
158
191
  const { explainFindings } = await import("./engine/explain.js");
159
192
  const usage = await meterOrPrint(io);
160
193
  const { scan } = await import("./engine/scan.js");
161
- const scanResult = await scan(state.root, { onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`) });
194
+ const scanResult = await scan(state.root, { onProgress: progressCallback(io, asJson) });
162
195
  state.lastResult = scanResult;
163
196
  const report = await explainFindings(scanResult, {
164
- onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
197
+ onProgress: progressCallback(io, asJson),
165
198
  });
166
199
  if (asJson) {
167
200
  const { explainToJson } = await import("./ui/explain.js");
@@ -177,8 +210,17 @@ async function dispatch(cmd, tokens, state, io) {
177
210
  const { renderInit } = await import("./ui/init.js");
178
211
  const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
179
212
  write(io, renderInit(result) + "\n");
180
- const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
181
- await maybeOfferAiFixerSetupDuringInit(state.root);
213
+ const { maybeOfferAiFixerSetupDuringInit, noKeyNudgeMessage } = await import("./index-support.js");
214
+ await maybeOfferAiFixerSetupDuringInit(state.root, io.inkMode
215
+ ? {
216
+ writePreamble: (text) => write(io, text),
217
+ offer: async () => {
218
+ const msg = noKeyNudgeMessage();
219
+ if (msg)
220
+ write(io, msg);
221
+ },
222
+ }
223
+ : undefined);
182
224
  return;
183
225
  }
184
226
  if (cmd === "config") {
@@ -232,6 +274,10 @@ async function handleConfig(tokens, io) {
232
274
  return;
233
275
  }
234
276
  if (action === "set-key") {
277
+ if (io.inkMode) {
278
+ write(io, ` config set-key needs a real terminal prompt — run \`securevibe config set-key ${provider}\` as a one-shot command outside the REPL (or set the ${provider === "groq" ? "GROQ_API_KEY" : "ANTHROPIC_API_KEY"} env var directly).\n\n`);
279
+ return;
280
+ }
235
281
  const hint = provider === "groq" ? " (free — console.groq.com/keys)" : " (console.anthropic.com)";
236
282
  const key = (await askLine(` Paste your ${provider} API key${hint}: `, io.input, io.output)).trim();
237
283
  if (!key) {
@@ -0,0 +1,55 @@
1
+ import { SEVERITY_ORDER } from "../engine/types.js";
2
+ import { PR_COMMENT_MARKER } from "../engine/github.js";
3
+ const MAX_FINDINGS = 15;
4
+ const SEV_LABEL = {
5
+ critical: "CRITICAL",
6
+ high: "HIGH",
7
+ medium: "MEDIUM",
8
+ low: "LOW",
9
+ info: "INFO",
10
+ };
11
+ const READINESS_LABEL = {
12
+ ship: "✓ Ship",
13
+ warn: "⚠ Ship with warnings",
14
+ block: "✗ Block deploy",
15
+ };
16
+ function severityCounts(findings) {
17
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
18
+ for (const f of findings)
19
+ counts[f.severity]++;
20
+ return ["critical", "high", "medium", "low"]
21
+ .filter((s) => counts[s] > 0)
22
+ .map((s) => `**${counts[s]}** ${SEV_LABEL[s]}`)
23
+ .join(" · ");
24
+ }
25
+ export function renderPrComment(result) {
26
+ const L = [];
27
+ L.push(PR_COMMENT_MARKER);
28
+ L.push("## SecureVibe — changed-files scan");
29
+ L.push("");
30
+ L.push(`**Grade ${result.score.grade}** (${result.score.composite}/100) — ${READINESS_LABEL[result.score.readiness]}`);
31
+ L.push("");
32
+ if (result.findings.length === 0) {
33
+ L.push("No findings in the files changed by this PR. ✓");
34
+ return L.join("\n");
35
+ }
36
+ L.push(severityCounts(result.findings));
37
+ L.push("");
38
+ const sorted = [...result.findings].sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || b.confidence - a.confidence);
39
+ const shown = sorted.slice(0, MAX_FINDINGS);
40
+ const remaining = sorted.length - shown.length;
41
+ for (const f of shown) {
42
+ L.push(`### ${SEV_LABEL[f.severity]} — ${f.title}`);
43
+ L.push(`\`${f.file}:${f.line}\``);
44
+ L.push("");
45
+ L.push(`**why:** ${f.why}`);
46
+ L.push("");
47
+ L.push(`**fix:** ${f.fix}`);
48
+ L.push("");
49
+ }
50
+ if (remaining > 0) {
51
+ L.push(`_+${remaining} more finding${remaining === 1 ? "" : "s"} — run \`securevibe scan\` locally to see all._`);
52
+ L.push("");
53
+ }
54
+ return L.join("\n");
55
+ }
@@ -0,0 +1,130 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Box, Static, Text, useApp, useInput } from "ink";
3
+ import path from "node:path";
4
+ import { mapAnswer } from "../prompt.js";
5
+ import { processLine } from "../../repl.js";
6
+ import { makeCallbackWritable } from "./outputStream.js";
7
+ import { filterCommands } from "./commandRegistry.js";
8
+ import { StatusLine } from "./StatusLine.js";
9
+ import { InputBox } from "./InputBox.js";
10
+ import { SlashMenu } from "./SlashMenu.js";
11
+ import { Spinner } from "./Spinner.js";
12
+ const h = React.createElement;
13
+ // Static<T> is generic; createElement can't infer T from an untyped call the
14
+ // way JSX's <Static<string>> would. Pin T=string via a TS instantiation
15
+ // expression instead of casting the props object.
16
+ const StringStatic = (Static);
17
+ /**
18
+ * Top-level Ink app: <Static> scrollback (everything dispatch() writes,
19
+ * unchanged from the plain-text output the legacy readline loop already
20
+ * produces) above a live bottom region (status line, spinner, slash menu,
21
+ * input box). Every submitted line goes through the exact same
22
+ * processLine() the legacy readline loop and repl.test.ts already exercise
23
+ * (Task 1) -- this component only changes how a line is captured and how
24
+ * output is framed, never what a command does.
25
+ */
26
+ export function App({ initialPath }) {
27
+ const { exit } = useApp();
28
+ const stateRef = useRef({ root: path.resolve(initialPath) });
29
+ const [lines, setLines] = useState([]);
30
+ const [value, setValue] = useState("");
31
+ const [history, setHistory] = useState([]);
32
+ const [busy, setBusy] = useState(false);
33
+ const [progressMessage, setProgressMessage] = useState(null);
34
+ const [confirmKind, setConfirmKind] = useState(null);
35
+ const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
36
+ const confirmResolverRef = useRef(null);
37
+ const pushLine = (text) => setLines((prev) => [...prev, text]);
38
+ useEffect(() => {
39
+ (async () => {
40
+ const { applyStoredKeysToEnv } = await import("../../config.js");
41
+ await applyStoredKeysToEnv();
42
+ const { renderBanner } = await import("../banner.js");
43
+ pushLine(renderBanner({ subtitle: "interactive session — type `help` or `/`" }) + "\n");
44
+ pushLine(` target: ${stateRef.current.root}\n`);
45
+ })();
46
+ }, []);
47
+ const menuOpen = value.startsWith("/");
48
+ const filteredCommands = menuOpen ? filterCommands(value.slice(1)) : [];
49
+ const clampedIndex = Math.min(selectedMenuIndex, Math.max(0, filteredCommands.length - 1));
50
+ useInput((_input, key) => {
51
+ if (!menuOpen)
52
+ return;
53
+ if (key.upArrow)
54
+ setSelectedMenuIndex((i) => Math.max(0, i - 1));
55
+ if (key.downArrow)
56
+ setSelectedMenuIndex((i) => Math.min(filteredCommands.length - 1, i + 1));
57
+ if (key.escape)
58
+ setValue("");
59
+ });
60
+ const handleChange = (v) => {
61
+ setSelectedMenuIndex(0);
62
+ setValue(v);
63
+ };
64
+ const handleSubmit = async (line) => {
65
+ if (confirmResolverRef.current) {
66
+ const answer = mapAnswer(line, confirmKind ?? "patch");
67
+ confirmResolverRef.current(answer);
68
+ setValue("");
69
+ return;
70
+ }
71
+ // A command is already running (busy stays true for its whole duration,
72
+ // including while a confirm is pending -- but that case already returned
73
+ // above). Ignore a second real submission rather than racing two
74
+ // processLine() calls over the same mutable ReplState. The legacy
75
+ // readline loop can't hit this: only one askLine() is ever pending, so
76
+ // there's no way to submit a second line before the first resolves.
77
+ if (busy)
78
+ return;
79
+ if (menuOpen) {
80
+ const chosen = filteredCommands[clampedIndex];
81
+ setValue(chosen ? chosen.name + " " : "");
82
+ return;
83
+ }
84
+ if (!line.trim()) {
85
+ setValue("");
86
+ return;
87
+ }
88
+ setHistory((prev) => [...prev, line]);
89
+ setValue("");
90
+ setBusy(true);
91
+ setProgressMessage(null);
92
+ const io = {
93
+ input: process.stdin,
94
+ output: makeCallbackWritable(pushLine),
95
+ inkMode: true,
96
+ onProgress: (m) => setProgressMessage(m),
97
+ makeConfirm: (render) => {
98
+ return (req) => new Promise((resolve) => {
99
+ pushLine(render(req) + "\n");
100
+ setConfirmKind(req.kind);
101
+ confirmResolverRef.current = (answer) => {
102
+ setConfirmKind(null);
103
+ confirmResolverRef.current = null;
104
+ resolve(answer);
105
+ };
106
+ });
107
+ },
108
+ };
109
+ const shouldExit = await processLine(line, stateRef.current, io);
110
+ setBusy(false);
111
+ setProgressMessage(null);
112
+ if (shouldExit)
113
+ exit();
114
+ };
115
+ return h(React.Fragment, null, h(StringStatic, {
116
+ items: lines,
117
+ children: (line, i) => h(Text, { key: i }, line),
118
+ }), h(Box, { flexDirection: "column" }, h(StatusLine, { targetRoot: stateRef.current.root }), busy && progressMessage ? h(Spinner, { message: progressMessage }) : null, confirmKind
119
+ ? h(Text, { dimColor: true }, confirmKind === "patch"
120
+ ? " Apply this change? [y]es / [n]o / [a]ll / [q]uit"
121
+ : " Apply these supporting changes? [y]es / [n]o")
122
+ : null, menuOpen ? h(SlashMenu, { items: filteredCommands, selectedIndex: clampedIndex }) : null, h(InputBox, {
123
+ value,
124
+ onChange: handleChange,
125
+ onSubmit: handleSubmit,
126
+ history,
127
+ onHistoryNavigate: setValue,
128
+ menuOpen,
129
+ })));
130
+ }
@@ -0,0 +1,48 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ const h = React.createElement;
5
+ /**
6
+ * Bordered single-line input (`>` prompt, rounded border). Up/down arrow
7
+ * cycles `history`, but only when the slash menu isn't open -- App.ts's own
8
+ * useInput takes over up/down for menu-item selection in that state.
9
+ * TextInput itself only handles left/right cursor movement and
10
+ * backspace/delete, never up/down, so there's no conflict between this
11
+ * hook and TextInput's own internal input handling.
12
+ */
13
+ export function InputBox({ value, onChange, onSubmit, history, onHistoryNavigate, menuOpen }) {
14
+ const [historyIndex, setHistoryIndex] = useState(null);
15
+ useInput((_input, key) => {
16
+ if (menuOpen)
17
+ return;
18
+ if (key.upArrow) {
19
+ if (history.length === 0)
20
+ return;
21
+ const next = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
22
+ setHistoryIndex(next);
23
+ onHistoryNavigate(history[next]);
24
+ return;
25
+ }
26
+ if (key.downArrow) {
27
+ if (historyIndex === null)
28
+ return;
29
+ const next = historyIndex + 1;
30
+ if (next >= history.length) {
31
+ setHistoryIndex(null);
32
+ onHistoryNavigate("");
33
+ }
34
+ else {
35
+ setHistoryIndex(next);
36
+ onHistoryNavigate(history[next]);
37
+ }
38
+ }
39
+ });
40
+ return h(Box, { borderStyle: "round", paddingX: 1 }, h(Text, null, "> "), h(TextInput, {
41
+ value,
42
+ onChange: (v) => {
43
+ setHistoryIndex(null);
44
+ onChange(v);
45
+ },
46
+ onSubmit,
47
+ }));
48
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ const h = React.createElement;
4
+ /** Purely presentational -- App.ts owns navigation (up/down) and selection (Enter). */
5
+ export function SlashMenu({ items, selectedIndex }) {
6
+ if (items.length === 0) {
7
+ return h(Box, { paddingX: 1 }, h(Text, { dimColor: true }, "no matching commands"));
8
+ }
9
+ return h(Box, { flexDirection: "column", paddingX: 1 }, ...items.map((item, i) => h(Text, { key: item.name, color: i === selectedIndex ? "cyan" : undefined }, `${i === selectedIndex ? "› " : " "}/${item.name} ${item.args} — ${item.description}`)));
10
+ }
@@ -0,0 +1,18 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Box, Text } from "ink";
3
+ const h = React.createElement;
4
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
+ /**
6
+ * One live-updating line (braille spinner + current step's message),
7
+ * replacing the old scrolling "· message" lines during scan/fix/explain.
8
+ * Hand-rolled rather than a dependency -- ten frames and a setInterval is
9
+ * simple enough not to justify another package.
10
+ */
11
+ export function Spinner({ message }) {
12
+ const [frame, setFrame] = useState(0);
13
+ useEffect(() => {
14
+ const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
15
+ return () => clearInterval(id);
16
+ }, []);
17
+ return h(Box, null, h(Text, { color: "cyan" }, FRAMES[frame]), h(Text, null, ` ${message}`));
18
+ }
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import path from "node:path";
4
+ import { llmAvailability } from "../../engine/fix/llm.js";
5
+ const h = React.createElement;
6
+ /**
7
+ * target: <basename> · fixer: <groq|anthropic|none>, right-aligned, dim.
8
+ * fixer reflects the same provider-selection precedence fix's LLM path
9
+ * already uses (llmAvailability), not a separately reimplemented rule.
10
+ */
11
+ export function StatusLine({ targetRoot }) {
12
+ const avail = llmAvailability(false);
13
+ const fixer = avail.provider ?? "none";
14
+ return h(Box, { justifyContent: "flex-end" }, h(Text, { dimColor: true }, `target: ${path.basename(targetRoot)} · fixer: ${fixer}`));
15
+ }
@@ -0,0 +1,36 @@
1
+ export const COMMANDS = [
2
+ { name: "scan", args: "[--json]", description: "full scan" },
3
+ { name: "fix", args: "[--apply] [--yes] [--no-llm] [--json]", description: "fix findings" },
4
+ { name: "explain", args: "[--json]", description: "AI explanations for critical/high findings" },
5
+ { name: "ai-audit", args: "[--json]", description: "focus on the AI-agent attack surface" },
6
+ { name: "protect", args: "[--json]", description: "AI execution firewall guidance" },
7
+ { name: "attack-map", args: "[--json]", description: "map findings to attacker paths" },
8
+ { name: "score", args: "[--json]", description: "score-only view" },
9
+ { name: "deps", args: "[--json]", description: "dependency / CVE audit" },
10
+ { name: "ready", args: "[--json]", description: "launch readiness scorecard" },
11
+ { name: "init", args: "[--force]", description: "wire up the pre-commit guard + CI" },
12
+ {
13
+ name: "config",
14
+ args: "show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
15
+ description: "manage AI-fixer keys",
16
+ },
17
+ { name: "db", args: "update | status", description: "manage the local OSV database" },
18
+ { name: "cd", args: "<path>", description: "change the scan target" },
19
+ { name: "pwd", args: "", description: "show the current scan target" },
20
+ { name: "help", args: "", description: "this message" },
21
+ { name: "exit", args: "", description: "leave the session (aliases: quit, q)" },
22
+ ];
23
+ /** Prefix match on command name, case-insensitive. Empty query returns all. */
24
+ export function filterCommands(query) {
25
+ const q = query.toLowerCase();
26
+ return COMMANDS.filter((c) => c.name.toLowerCase().startsWith(q));
27
+ }
28
+ /** Plain-text `help` output, one line per command. */
29
+ export function renderHelp() {
30
+ const lines = [" Commands:"];
31
+ for (const c of COMMANDS) {
32
+ const left = ` ${c.name}${c.args ? " " + c.args : ""}`;
33
+ lines.push(left.length < 44 ? left.padEnd(44) + c.description : left + " " + c.description);
34
+ }
35
+ return lines.join("\n");
36
+ }
@@ -0,0 +1,16 @@
1
+ import { Writable } from "node:stream";
2
+ /**
3
+ * A real Writable whose _write feeds every chunk to a callback instead of a
4
+ * terminal. Lets dispatch()'s existing `io.output.write(text)` calls feed
5
+ * Ink's <Static> scrollback region instead of writing directly to stdout,
6
+ * which would corrupt Ink's own live-region redraws (the input box, status
7
+ * line, spinner, and slash menu all redraw in place on every keystroke).
8
+ */
9
+ export function makeCallbackWritable(onWrite) {
10
+ return new Writable({
11
+ write(chunk, _encoding, callback) {
12
+ onWrite(chunk.toString());
13
+ callback();
14
+ },
15
+ });
16
+ }
package/dist/version.js CHANGED
@@ -2,4 +2,4 @@
2
2
  // Single source of truth for the CLI's own version, so it can't drift from
3
3
  // what commander reports and what the update-check compares against.
4
4
  // Kept in sync with the "version" field in package.json by hand at release time.
5
- export const VERSION = "0.1.10";
5
+ export const VERSION = "0.1.12";
package/package.json CHANGED
@@ -1,66 +1,71 @@
1
- {
2
- "name": "securevibe",
3
- "version": "0.1.10",
4
- "description": "Autonomous AI security engineer for AI generated apps: scan, fix, verify, and gate every commit before you launch",
5
- "type": "module",
6
- "license": "SEE LICENSE IN LICENSE",
7
- "author": "Benedict Patrick <benedictroger2006@gmail.com>",
8
- "homepage": "https://github.com/Benedictpatrick/secure-vibe#readme",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/Benedictpatrick/secure-vibe.git",
12
- "directory": "packages/cli"
13
- },
14
- "bugs": {
15
- "url": "https://github.com/Benedictpatrick/secure-vibe/issues"
16
- },
17
- "keywords": [
18
- "security",
19
- "scanner",
20
- "sast",
21
- "ai security",
22
- "prompt injection",
23
- "vulnerability",
24
- "secrets",
25
- "sca",
26
- "cve",
27
- "launch readiness",
28
- "vibe coding",
29
- "cli"
30
- ],
31
- "engines": {
32
- "node": ">=20"
33
- },
34
- "bin": {
35
- "securevibe": "./dist/index.js"
36
- },
37
- "files": [
38
- "dist",
39
- "data",
40
- "LICENSE"
41
- ],
42
- "scripts": {
43
- "build": "tsc -p tsconfig.json",
44
- "dev": "tsx src/index.ts",
45
- "start": "node dist/index.js",
46
- "test": "node run-tests.mjs"
47
- },
48
- "dependencies": {
49
- "commander": "^12.1.0",
50
- "diff": "^5.2.0",
51
- "picocolors": "^1.1.1",
52
- "tree-sitter-wasms": "^0.1.12",
53
- "web-tree-sitter": "^0.25.10",
54
- "yauzl": "^3.2.0"
55
- },
56
- "optionalDependencies": {
57
- "@anthropic-ai/sdk": "^0.39.0"
58
- },
59
- "devDependencies": {
60
- "@types/diff": "^5.2.0",
61
- "@types/node": "^20.14.0",
62
- "@types/yauzl": "^2.10.3",
63
- "tsx": "^4.19.0",
64
- "typescript": "^5.6.0"
65
- }
66
- }
1
+ {
2
+ "name": "securevibe",
3
+ "version": "0.1.12",
4
+ "description": "Autonomous AI security engineer for AI generated apps: scan, fix, verify, and gate every commit before you launch",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "author": "Benedict Patrick <benedictroger2006@gmail.com>",
8
+ "homepage": "https://github.com/Benedictpatrick/secure-vibe#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Benedictpatrick/secure-vibe.git",
12
+ "directory": "packages/cli"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/Benedictpatrick/secure-vibe/issues"
16
+ },
17
+ "keywords": [
18
+ "security",
19
+ "scanner",
20
+ "sast",
21
+ "ai security",
22
+ "prompt injection",
23
+ "vulnerability",
24
+ "secrets",
25
+ "sca",
26
+ "cve",
27
+ "launch readiness",
28
+ "vibe coding",
29
+ "cli"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "bin": {
35
+ "securevibe": "./dist/index.js"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "data",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.json",
44
+ "dev": "tsx src/index.ts",
45
+ "start": "node dist/index.js",
46
+ "test": "node run-tests.mjs"
47
+ },
48
+ "dependencies": {
49
+ "commander": "^12.1.0",
50
+ "diff": "^5.2.0",
51
+ "ink": "^6.8.0",
52
+ "ink-text-input": "^6.0.0",
53
+ "picocolors": "^1.1.1",
54
+ "react": "^19.2.7",
55
+ "tree-sitter-wasms": "^0.1.12",
56
+ "web-tree-sitter": "^0.25.10",
57
+ "yauzl": "^3.2.0"
58
+ },
59
+ "optionalDependencies": {
60
+ "@anthropic-ai/sdk": "^0.39.0"
61
+ },
62
+ "devDependencies": {
63
+ "@types/diff": "^5.2.0",
64
+ "@types/node": "^20.14.0",
65
+ "@types/react": "^19.2.17",
66
+ "@types/yauzl": "^2.10.3",
67
+ "ink-testing-library": "^4.0.0",
68
+ "tsx": "^4.19.0",
69
+ "typescript": "^5.6.0"
70
+ }
71
+ }