Skip to content

Commit f351ed8

Browse files
committed
feat: add guided first-run experience
1 parent 6b600f2 commit f351ed8

21 files changed

Lines changed: 623 additions & 91 deletions

README.md

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,33 +28,32 @@ Research state stays in a private `.omv/` workspace. The project is designed for
2828

2929
**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and Node.js 20 or later.
3030

31-
Install the CLI, then add the skills and agents to Claude Code:
31+
Install the CLI and add the skills and agents to Claude Code:
3232

3333
```sh
3434
npm install --global oh-my-vul
3535
omv setup
3636
```
3737

38-
Check the installation:
38+
From the root of the project you want to research, start a guided workspace:
3939

4040
```sh
41-
omv doctor
41+
omv start
4242
```
4343

44-
From the root of the project you want to research, initialize the private workspace and add it to `.gitignore`:
44+
`omv start` adds `.omv/` to `.gitignore`, detects local project metadata, and asks which vulnerability classes to investigate. To provide the scope non-interactively:
4545

4646
```sh
47-
omv workspace init --gitignore
47+
omv start --vuln xss,auth --no-interactive
4848
```
4949

5050
Open that project in Claude Code, then run:
5151

5252
```text
53-
/using-omv
5453
/omv
5554
```
5655

57-
`/using-omv` applies the evidence and review gates for the session. `/omv` is the main workspace entry point: it shows the active queue and recommends the next action.
56+
`/omv` applies the evidence and review gates, shows the active queue, and recommends the next action. From the shell, bare `omv` opens the same contextual workspace view.
5857

5958
<details>
6059
<summary><strong>Installation options</strong></summary>
@@ -87,11 +86,11 @@ omv setup --scope project --dry-run
8786
## The Workflow
8887

8988
```text
90-
/using-omv
91-
-> apply evidence-before-claims rules for the session
89+
omv start
90+
-> initialize private state and create the first research campaign
9291
9392
/omv
94-
-> start or resume a research campaign
93+
-> resume the campaign with evidence-before-claims rules
9594
9695
/omv-find
9796
-> discover and rank open-source audit targets
@@ -115,8 +114,8 @@ The review gate can send a finding back for more audit, reproduction, deduplicat
115114

116115
| Goal | Command |
117116
|---|---|
118-
| Apply the research evidence gates | `/using-omv` |
119-
| Start, resume, or inspect work | `/omv`, `/omv next` |
117+
| Start a guided local research workspace | `omv start` |
118+
| Resume or inspect work with evidence gates | `/omv`, `omv` |
120119
| Find packages worth auditing | `/omv-find` |
121120
| Trace data flow and evaluate guards | `/omv-audit <id>` |
122121
| Guide a local reproduction | `/omv-repro <id>` |

README.zh-CN.md

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,33 +28,32 @@
2828

2929
**环境要求:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、Node.js 20 或更高版本。
3030

31-
全局安装 CLI,再把 skills 和 agents 装入 Claude Code:
31+
全局安装 CLI,并把 skills 和 agents 装入 Claude Code:
3232

3333
```sh
3434
npm install --global oh-my-vul
3535
omv setup
3636
```
3737

38-
检查安装状态
38+
进入需要研究的项目根目录,启动引导式工作区
3939

4040
```sh
41-
omv doctor
41+
omv start
4242
```
4343

44-
进入需要研究的项目根目录,初始化私有工作区并写入 `.gitignore`
44+
`omv start` 会把 `.omv/` 写入 `.gitignore`、识别本地项目元数据,并询问准备研究的漏洞类型。也可以用非交互方式明确指定
4545

4646
```sh
47-
omv workspace init --gitignore
47+
omv start --vuln xss,auth --no-interactive
4848
```
4949

5050
在该项目中打开 Claude Code,然后输入:
5151

5252
```text
53-
/using-omv
5453
/omv
5554
```
5655

57-
`/using-omv` 会启用证据与审查门槛;`/omv` 是工作区统一入口,用来查看正在处理的 finding 和下一步建议。
56+
`/omv` 会自动启用证据与审查门槛,并显示正在处理的 finding 和下一步建议。在 shell 中直接运行 `omv`,也会打开相同的上下文工作区视图
5857

5958
<details>
6059
<summary><strong>其他安装方式</strong></summary>
@@ -87,11 +86,11 @@ omv setup --scope project --dry-run
8786
## 使用流程
8887

8988
```text
90-
/using-omv
91-
-> 为本次研究启用“证据先于结论”的规则
89+
omv start
90+
-> 初始化私有状态并创建第一个研究 Campaign
9291
9392
/omv
94-
-> 新建或继续一个研究 Campaign
93+
-> 在“证据先于结论”的规则下继续 Campaign
9594
9695
/omv-find
9796
-> 发现并排序值得审计的开源项目
@@ -115,8 +114,8 @@ omv setup --scope project --dry-run
115114

116115
| 目标 | 命令 |
117116
|---|---|
118-
| 启用漏洞研究的证据门槛 | `/using-omv` |
119-
| 开始、继续或查看研究进度 | `/omv``/omv next` |
117+
| 启动引导式本地研究工作区 | `omv start` |
118+
| 在证据门槛下继续或查看进度 | `/omv``omv` |
120119
| 发现值得审计的开源包 | `/omv-find` |
121120
| 跟踪数据流并检查防护 | `/omv-audit <id>` |
122121
| 引导本地复现 | `/omv-repro <id>` |

skills/omv/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ oh-my-vul local-first vulnerability research project manager for Claude Code.
1414
```text
1515
/omv list — list all installed omv-* skills with one-line descriptions
1616
/omv dashboard — show workspace, active workflow queue, and recent activity
17+
/omv start [flags] — initialize the private workspace and first campaign
1718
/omv eval — run deterministic stable skill eval checks
1819
/omv first [flags] — initialize a Campaign.v1 first-mile research plan
1920
/omv campaign — list local research campaigns
@@ -78,6 +79,7 @@ When the user invokes campaign, workspace, lifecycle, repro scaffold, artifact c
7879
Use `omv help`, `omv help review`, `omv help findings`, `omv help repro`, or `omv help report` as the source of truth for exact CLI signatures. For direct aliases:
7980

8081
- `/omv dashboard` -> `omv dashboard`
82+
- `/omv start ...` -> `omv start ...`
8183
- `/omv eval ...` -> `omv eval ...`
8284
- `/omv first ...` -> `omv first ...`
8385
- `/omv campaign ...` -> `omv campaign ...`
@@ -97,7 +99,7 @@ Use `omv help`, `omv help review`, `omv help findings`, `omv help repro`, or `om
9799
- `/omv restore <id>` -> `omv findings restore <id>`
98100
- `/omv findings ...` -> `omv findings ...`
99101

100-
**If `omv` is not found on PATH**, output: "`omv` is not installed. Run: `npx oh-my-vul setup`"
102+
**If `omv` is not found on PATH**, output: "`omv` is not installed. Run: `npm install --global oh-my-vul && omv setup`"
101103

102104
### Subcommand reference
103105

@@ -134,6 +136,7 @@ Use `omv help`, `omv help review`, `omv help findings`, `omv help repro`, or `om
134136
## Workflow Overview
135137

136138
```
139+
omv start → private workspace + detected target + first campaign
137140
omv campaign init → target, scope, priorities, generic lanes
138141
omv campaign surfaces propose → attack-surface cards (pack × class)
139142
omv campaign surfaces select → choose which hypotheses to pursue

skills/using-omv/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,4 @@ omv findings validate <id>
134134
omv review <id> --strict
135135
```
136136
137-
If `omv` is missing: `npx oh-my-vul setup` then re-check with `omv doctor`.
137+
If `omv` is missing: `npm install --global oh-my-vul && omv setup`, then re-check with `omv doctor`.

src/cli/__tests__/args.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ test("CLI argument validation accepts UX flags and command help", () => {
9494
assert.equal(validateArgs(["doctor", "--strict"]).ok, true);
9595
assert.equal(validateArgs(["dashboard"]).ok, true);
9696
assert.equal(validateArgs(["dashboard", "--json"]).ok, true);
97+
assert.equal(validateArgs(["start", "--id", "demo", "--vuln", "xss", "--no-interactive", "--json"]).ok, true);
9798
assert.equal(validateArgs(["eval"]).ok, true);
9899
assert.equal(validateArgs(["eval", "--json"]).ok, true);
99100
assert.equal(validateArgs(["eval", "--junit"]).ok, true);
@@ -113,6 +114,7 @@ test("CLI argument validation accepts UX flags and command help", () => {
113114
assert.equal(validateArgs(["setup", "--help"]).ok, true);
114115
assert.equal(validateArgs(["findings", "validate", "--help"]).ok, true);
115116
assert.equal(validateArgs(["help", "findings", "validate"]).ok, true);
117+
assert.equal(validateArgs(["help", "--all"]).ok, true);
116118
});
117119

118120
test("CLI argument validation enforces eval target and output format rules", () => {

src/cli/__tests__/commands.test.ts

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,42 @@ test("compiled CLI renders help through the command router", () => {
3232
assert.equal(result.status, 0, result.stderr);
3333
assert.match(result.stdout, /oh-my-vul/);
3434
assert.match(result.stdout, /Usage:/);
35-
assert.match(result.stdout, /omv findings workflow/);
35+
assert.match(result.stdout, /omv start/);
36+
assert.doesNotMatch(result.stdout, /omv submissions record/);
3637
});
3738

38-
test("compiled CLI rejects an unknown command with actionable text and a non-zero exit", () => {
39-
const result = runCli(["not-a-command"]);
39+
test("compiled CLI keeps the exhaustive command reference behind help --all", () => {
40+
const result = runCli(["help", "--all"]);
41+
assert.equal(result.status, 0, result.stderr);
42+
assert.match(result.stdout, /omv submissions record/);
43+
assert.match(result.stdout, /workspace init \[--gitignore\]/);
44+
});
45+
46+
test("bare CLI is read-only before initialization and opens the dashboard afterward", async () => {
47+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-commands-entry-"));
48+
try {
49+
const welcome = runCli([], projectRoot);
50+
assert.equal(welcome.status, 0, welcome.stderr);
51+
assert.match(welcome.stdout, /omv start/);
52+
assert.equal(existsSync(join(projectRoot, ".omv")), false);
53+
54+
await mkdir(join(projectRoot, ".omv"), { recursive: true });
55+
const dashboard = runCli([], projectRoot);
56+
assert.equal(dashboard.status, 0, dashboard.stderr);
57+
assert.match(dashboard.stdout, /oh-my-vul dashboard/);
58+
} finally {
59+
await rm(projectRoot, { recursive: true, force: true });
60+
}
61+
});
62+
63+
test("compiled CLI rejects an unknown command with focused suggestions and a non-zero exit", () => {
64+
const result = runCli(["find"]);
4065

4166
assert.equal(result.status, 1);
42-
assert.match(result.stderr, /Unknown command: not-a-command/);
43-
assert.match(result.stderr, /Valid commands:/);
44-
assert.match(result.stdout, /Usage:/);
67+
assert.match(result.stderr, /Unknown command: find/);
68+
assert.match(result.stderr, /Did you mean:/);
69+
assert.match(result.stderr, /\/omv-find/);
70+
assert.doesNotMatch(result.stdout + result.stderr, /omv submissions record/);
4571
});
4672

4773
test("dashboard human output uses the canonical workflow columns", async () => {
@@ -53,6 +79,7 @@ test("dashboard human output uses the canonical workflow columns", async () => {
5379
assert.equal(result.status, 0, result.stderr);
5480
assert.match(result.stdout, /\bverdict\b/);
5581
assert.match(result.stdout, /\bblocker\b/);
82+
assert.match(result.stdout, /\b(?:CLI|CLAUDE)\b/);
5683
} finally {
5784
await rm(projectRoot, { recursive: true, force: true });
5885
}
@@ -67,12 +94,16 @@ test("dashboard JSON output is one parseable document with stable core fields",
6794
assert.equal(result.status, 0, result.stderr);
6895
const output = JSON.parse(result.stdout) as {
6996
status?: { root?: string; activeCount?: number };
70-
workflow?: unknown[];
97+
workflow?: Array<{ nextAction: string; action: { surface: string; command: string; reason: string } }>;
98+
campaigns?: unknown[];
7199
activity?: unknown[];
72100
};
73101
assert.equal(output.status?.root, await realpath(join(projectRoot, ".omv")));
74102
assert.equal(output.status?.activeCount, 1);
75103
assert.equal(Array.isArray(output.workflow), true);
104+
assert.equal(Array.isArray(output.campaigns), true);
105+
assert.equal(output.workflow?.[0].nextAction, output.workflow?.[0].action.command);
106+
assert.match(output.workflow?.[0].action.surface ?? "", /^(?:cli|claude)$/);
76107
assert.equal(Array.isArray(output.activity), true);
77108
} finally {
78109
await rm(projectRoot, { recursive: true, force: true });
@@ -92,6 +123,12 @@ test("compiled CLI runs the canonical Campaign workflow with stable JSON", async
92123
assert.equal(existsSync(initJson.yamlPath), true);
93124
assert.equal(existsSync(initJson.runbookPath), true);
94125

126+
const dashboard = runCli(["dashboard", "--json"], projectRoot);
127+
assert.equal(dashboard.status, 0, dashboard.stderr);
128+
const dashboardJson = JSON.parse(dashboard.stdout) as { campaigns: Array<{ id: string; nextAction: string }> };
129+
assert.equal(dashboardJson.campaigns[0].id, "demo");
130+
assert.match(dashboardJson.campaigns[0].nextAction, /^omv campaign/);
131+
95132
const listed = runCli(["campaign", "--json"], projectRoot);
96133
assert.equal(listed.status, 0, listed.stderr);
97134
assert.deepEqual((JSON.parse(listed.stdout) as Array<{ id: string }>).map((item) => item.id), ["demo"]);
@@ -132,6 +169,72 @@ test("compiled first alias initializes and JSON never prompts for missing values
132169
}
133170
});
134171

172+
test("compiled start detects npm context, initializes privacy state, and preserves campaign collisions", async () => {
173+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-commands-start-"));
174+
try {
175+
await writeFile(join(projectRoot, "package.json"), JSON.stringify({
176+
name: "detected-package",
177+
version: "1.2.3",
178+
repository: { url: "https://example.test/detected-package.git" },
179+
}), "utf-8");
180+
const started = runCli(["start", "--id", "guided", "--vuln", "xss", "--no-interactive", "--json"], projectRoot);
181+
assert.equal(started.status, 0, started.stderr);
182+
const result = JSON.parse(started.stdout) as {
183+
campaign: { campaign: { id: string; target: { name: string; version: string; ecosystem: string; source: string } } };
184+
};
185+
assert.deepEqual(result.campaign.campaign.target, {
186+
name: "detected-package",
187+
version: "1.2.3",
188+
ecosystem: "npm",
189+
source: "https://example.test/detected-package.git",
190+
});
191+
assert.equal(await readFile(join(projectRoot, ".gitignore"), "utf-8"), ".omv/\n");
192+
193+
const collision = runCli(["start", "--id", "guided", "--vuln", "ssrf", "--no-interactive"], projectRoot);
194+
assert.equal(collision.status, 1);
195+
assert.match(collision.stderr, /already exists/);
196+
assert.match(await readFile(join(projectRoot, ".omv", "campaigns", "guided.yaml"), "utf-8"), /xss/);
197+
} finally {
198+
await rm(projectRoot, { recursive: true, force: true });
199+
}
200+
});
201+
202+
test("compiled start detects non-npm manifests and honors explicit overrides", async () => {
203+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-commands-start-go-"));
204+
try {
205+
await writeFile(join(projectRoot, "go.mod"), "module example.test/demo\n", "utf-8");
206+
const detected = runCli(["start", "--id", "go-demo", "--vuln", "ssrf", "--no-interactive", "--json"], projectRoot);
207+
assert.equal(detected.status, 0, detected.stderr);
208+
assert.equal((JSON.parse(detected.stdout) as { campaign: { campaign: { target: { ecosystem: string } } } }).campaign.campaign.target.ecosystem, "go");
209+
210+
const overridden = runCli([
211+
"start", "--id", "override", "--target", "explicit", "--version", "9.0.0",
212+
"--source", "local-source", "--ecosystem", "rust", "--vuln", "path-traversal",
213+
"--no-interactive", "--json",
214+
], projectRoot);
215+
assert.equal(overridden.status, 0, overridden.stderr);
216+
assert.deepEqual(
217+
(JSON.parse(overridden.stdout) as { campaign: { campaign: { target: unknown } } }).campaign.campaign.target,
218+
{ name: "explicit", version: "9.0.0", source: "local-source", ecosystem: "rust" },
219+
);
220+
} finally {
221+
await rm(projectRoot, { recursive: true, force: true });
222+
}
223+
});
224+
225+
test("compiled start never guesses vulnerability classes in non-interactive mode", async () => {
226+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-commands-start-missing-"));
227+
try {
228+
const result = runCli(["start", "--no-interactive", "--json"], projectRoot);
229+
assert.equal(result.status, 1);
230+
assert.match(result.stderr, /missing required fields: vulnerability classes/i);
231+
assert.equal(existsSync(join(projectRoot, ".omv")), true);
232+
assert.equal(existsSync(join(projectRoot, ".omv", "campaigns", `${projectRoot.split("/").pop()}.yaml`)), false);
233+
} finally {
234+
await rm(projectRoot, { recursive: true, force: true });
235+
}
236+
});
237+
135238
test("compiled CLI runs the SourceRef and report provenance workflow with stable JSON", async () => {
136239
const projectRoot = await projectWithFinding();
137240
try {

src/cli/__tests__/findings.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,13 +293,22 @@ test("finding workflow recommends audit, repro, report, and archive next actions
293293
["ready-report", "needs-repro", "needs-audit", "blocked"],
294294
);
295295
assert.equal(workflow.find((finding) => finding.id === "needs-audit")?.nextAction, "/omv-audit needs-audit");
296+
assert.deepEqual(workflow.find((finding) => finding.id === "needs-audit")?.action, {
297+
surface: "claude",
298+
command: "/omv-audit needs-audit",
299+
reason: "audit evidence still missing",
300+
});
296301
assert.equal(workflow.find((finding) => finding.id === "needs-repro")?.nextAction, "/omv-repro needs-repro");
297302
assert.equal(workflow.find((finding) => finding.id === "ready-report")?.nextAction, "/omv-report ready-report");
298303
assert.equal(workflow.find((finding) => finding.id === "ready-report")?.priorityReason, "confirmed finding ready for report");
299304
assert.equal(
300305
workflow.find((finding) => finding.id === "blocked")?.nextAction,
301306
"omv findings archive blocked --reason blocked",
302307
);
308+
assert.equal(workflow.find((finding) => finding.id === "blocked")?.action.surface, "cli");
309+
for (const finding of workflow) {
310+
assert.equal(finding.nextAction, finding.action.command);
311+
}
303312
} finally {
304313
await rm(projectRoot, { recursive: true, force: true });
305314
}

0 commit comments

Comments
 (0)