qa-engineer 0.10.0 → 0.11.0

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.
Files changed (63) hide show
  1. package/README.md +167 -59
  2. package/package.json +1 -1
  3. package/packages/engine/bin/qa-engine.mjs +232 -8
  4. package/packages/engine/lib/analysis/har.mjs +34 -0
  5. package/packages/engine/lib/analysis/network.mjs +237 -0
  6. package/packages/engine/lib/analysis/report-html.mjs +47 -734
  7. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  8. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  9. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  10. package/packages/engine/lib/report/components/charts.mjs +424 -0
  11. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  12. package/packages/engine/lib/report/components/findings.mjs +258 -0
  13. package/packages/engine/lib/report/components/nav.mjs +99 -0
  14. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  15. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  16. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  17. package/packages/engine/lib/report/core/model.mjs +270 -0
  18. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  19. package/packages/engine/lib/report/core/sections.mjs +978 -0
  20. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  21. package/packages/engine/lib/report/export/html.mjs +183 -0
  22. package/packages/engine/lib/report/export/machine.mjs +290 -0
  23. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  24. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  25. package/packages/engine/lib/report/theme/css.mjs +529 -0
  26. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  27. package/packages/engine/lib/report/version.mjs +78 -0
  28. package/packages/engine/package.json +2 -2
  29. package/packages/installer/lib/agents/targets.mjs +90 -0
  30. package/packages/installer/lib/agents/user-level.mjs +80 -0
  31. package/packages/installer/lib/cli/flags.mjs +20 -2
  32. package/packages/installer/lib/commands/doctor.mjs +6 -4
  33. package/packages/installer/lib/commands/install.mjs +134 -93
  34. package/packages/installer/lib/commands/repair.mjs +10 -6
  35. package/packages/installer/lib/commands/self-test.mjs +4 -3
  36. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  37. package/packages/installer/lib/commands/update.mjs +9 -4
  38. package/packages/installer/lib/commands/verify.mjs +13 -12
  39. package/packages/installer/lib/constants.mjs +13 -0
  40. package/packages/installer/lib/core/conflict.mjs +5 -4
  41. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  42. package/packages/installer/lib/core/integrity.mjs +59 -0
  43. package/packages/installer/lib/core/lockfile.mjs +19 -3
  44. package/packages/installer/lib/core/plan.mjs +213 -0
  45. package/packages/installer/lib/core/qa-home.mjs +145 -0
  46. package/packages/installer/lib/core/scope.mjs +274 -0
  47. package/packages/installer/lib/core/validate-install.mjs +37 -12
  48. package/packages/installer/package.json +1 -1
  49. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  50. package/shared/tooling/qa-tool.mjs +41 -5
  51. package/skills/qa-api/scripts/qa-tool.mjs +41 -5
  52. package/skills/qa-audit/scripts/qa-tool.mjs +41 -5
  53. package/skills/qa-debug/scripts/qa-tool.mjs +41 -5
  54. package/skills/qa-explore/SKILL.md +26 -10
  55. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  56. package/skills/qa-explore/references/api-replay.md +40 -1
  57. package/skills/qa-explore/references/report-pipeline.md +265 -95
  58. package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
  59. package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
  60. package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
  61. package/skills/qa-init/scripts/qa-tool.mjs +41 -5
  62. package/skills/qa-report/scripts/qa-tool.mjs +41 -5
  63. package/skills/qa-run/scripts/qa-tool.mjs +41 -5
@@ -0,0 +1,137 @@
1
+ // Design tokens for the report.
2
+ //
3
+ // One source for colour, type, and spacing, consumed twice: the stylesheet emits
4
+ // them as custom properties, and the SVG charts read the JavaScript values directly
5
+ // because an `<svg>` attribute cannot resolve `var(--sev-critical)` in every renderer
6
+ // that matters (Safari's print path and most PDF engines drop it).
7
+ //
8
+ // So a severity has exactly one red, and a chart cannot drift from the badge beside
9
+ // it. Adding a severity means adding it here, and the badge, the donut segment, and
10
+ // the legend all learn about it at once.
11
+
12
+ /**
13
+ * Severity is the report's primary signal, so it gets a full ramp rather than a
14
+ * single hex: a badge needs a readable foreground on a tinted chip, a chart needs a
15
+ * saturated fill, and dark mode needs both again at a different luminance.
16
+ */
17
+ export const SEVERITY = Object.freeze({
18
+ critical: {
19
+ label: 'Critical',
20
+ rank: 0,
21
+ light: { fg: '#8a1c13', bg: '#fef3f2', border: '#fda29b', solid: '#d92d20' },
22
+ dark: { fg: '#ffbdb5', bg: 'rgba(217,45,32,.16)', border: 'rgba(253,162,155,.32)', solid: '#f97066' },
23
+ meaning: 'Blocks release. Data loss, a security hole, or a core flow that cannot be completed.',
24
+ },
25
+ high: {
26
+ label: 'High',
27
+ rank: 1,
28
+ light: { fg: '#b93815', bg: '#fff4ed', border: '#f9b98c', solid: '#ef6820' },
29
+ dark: { fg: '#ffc9a8', bg: 'rgba(239,104,32,.16)', border: 'rgba(249,185,140,.32)', solid: '#f38744' },
30
+ meaning: 'Fix before release. A user hits this on a normal path and the product does the wrong thing.',
31
+ },
32
+ medium: {
33
+ label: 'Medium',
34
+ rank: 2,
35
+ light: { fg: '#a15c07', bg: '#fffaeb', border: '#fedf89', solid: '#f79009' },
36
+ dark: { fg: '#fedf89', bg: 'rgba(247,144,9,.14)', border: 'rgba(254,223,137,.3)', solid: '#fdb022' },
37
+ meaning: 'Fix soon. Real but survivable — a workaround exists, or the path is less common.',
38
+ },
39
+ low: {
40
+ label: 'Low',
41
+ rank: 3,
42
+ light: { fg: '#3538cd', bg: '#eef4ff', border: '#b2ccff', solid: '#6172f3' },
43
+ dark: { fg: '#b2ccff', bg: 'rgba(97,114,243,.16)', border: 'rgba(178,204,255,.3)', solid: '#8098f9' },
44
+ meaning: 'Worth fixing. Polish, hygiene, or a measurement that needs confirming before it is acted on.',
45
+ },
46
+ });
47
+
48
+ /** Non-severity status colours: test outcomes, check results, page health. */
49
+ export const STATUS = Object.freeze({
50
+ passed: { label: 'Passed', light: { fg: '#067647', bg: '#ecfdf3', solid: '#17b26a' }, dark: { fg: '#75e0a7', bg: 'rgba(23,178,106,.14)', solid: '#47cd89' } },
51
+ failed: { label: 'Failed', light: { fg: '#b42318', bg: '#fef3f2', solid: '#f04438' }, dark: { fg: '#fda29b', bg: 'rgba(240,68,56,.14)', solid: '#f97066' } },
52
+ blocked: { label: 'Blocked', light: { fg: '#b54708', bg: '#fffaeb', solid: '#f79009' }, dark: { fg: '#fedf89', bg: 'rgba(247,144,9,.14)', solid: '#fdb022' } },
53
+ skipped: { label: 'Skipped', light: { fg: '#475467', bg: '#f2f4f7', solid: '#98a2b3' }, dark: { fg: '#cdd5df', bg: 'rgba(152,162,179,.14)', solid: '#98a2b3' } },
54
+ neutral: { label: '—', light: { fg: '#475467', bg: '#f2f4f7', solid: '#98a2b3' }, dark: { fg: '#cdd5df', bg: 'rgba(152,162,179,.14)', solid: '#98a2b3' } },
55
+ });
56
+
57
+ /** The release decision, which owns the loudest surface on the page. */
58
+ export const VERDICT = Object.freeze({
59
+ ship: { label: 'Ship it', tone: 'good', blurb: 'No release-blocking defects were found.' },
60
+ 'ship-with-risks': { label: 'Ship with risks', tone: 'warn', blurb: 'Releasable, but known defects go out with it.' },
61
+ 'do-not-ship': { label: 'Do not ship', tone: 'bad', blurb: 'At least one defect blocks release.' },
62
+ 'insufficient-data': { label: 'Insufficient data', tone: 'muted', blurb: 'The run could not establish enough to judge.' },
63
+ // The four `classification` values, mapped onto the same surface so a result
64
+ // without an `executive` block still renders a verdict rather than a blank strip.
65
+ pass: { label: 'No defects found', tone: 'good', blurb: 'Nothing reportable was found in the areas covered.' },
66
+ 'issues-found': { label: 'Issues found', tone: 'warn', blurb: 'Defects were found and are listed below.' },
67
+ blocked: { label: 'Blocked', tone: 'bad', blurb: 'The run could not complete.' },
68
+ ready: { label: 'Ready to ship', tone: 'good', blurb: 'Every gate the run checked is green.' },
69
+ 'ready-with-risks': { label: 'Ready with risks', tone: 'warn', blurb: 'Releasable with known risks accepted.' },
70
+ 'not-ready': { label: 'Not ready', tone: 'bad', blurb: 'A gate the run checked is red.' },
71
+ });
72
+
73
+ /** A score's band. Lighthouse taught every reader what 0–49 / 50–89 / 90+ means. */
74
+ export function scoreBand(score) {
75
+ const value = Number(score);
76
+ if (!Number.isFinite(value)) return 'muted';
77
+ if (value >= 90) return 'good';
78
+ if (value >= 50) return 'warn';
79
+ return 'bad';
80
+ }
81
+
82
+ /** Tone colours, used by scores, verdict surfaces, and gauges. */
83
+ export const TONE = Object.freeze({
84
+ good: { light: '#17b26a', dark: '#47cd89' },
85
+ warn: { light: '#f79009', dark: '#fdb022' },
86
+ bad: { light: '#f04438', dark: '#f97066' },
87
+ muted: { light: '#98a2b3', dark: '#98a2b3' },
88
+ accent: { light: '#5344e8', dark: '#9b8afb' },
89
+ });
90
+
91
+ /**
92
+ * The categorical ramp for charts that plot something other than severity — API
93
+ * timings, payload sizes, page health. Ordered for maximum separation between
94
+ * neighbours, and checked against both backgrounds.
95
+ */
96
+ export const CATEGORICAL = Object.freeze([
97
+ '#5344e8', '#0ba5ec', '#17b26a', '#f79009', '#ee46bc', '#7a5af8', '#2ed3b7', '#f38744',
98
+ ]);
99
+
100
+ /** Which colour a chart should use for `key`, in `mode` ('light' | 'dark'). */
101
+ export function colourFor(key, mode = 'light') {
102
+ if (SEVERITY[key]) return SEVERITY[key][mode].solid;
103
+ if (STATUS[key]) return STATUS[key][mode].solid;
104
+ if (TONE[key]) return TONE[key][mode];
105
+ return TONE.muted[mode];
106
+ }
107
+
108
+ /** Severity keys, worst first — the order findings and legends are rendered in. */
109
+ export const SEVERITY_ORDER = Object.freeze(
110
+ Object.keys(SEVERITY).sort((a, b) => SEVERITY[a].rank - SEVERITY[b].rank),
111
+ );
112
+
113
+ /**
114
+ * Core Web Vitals thresholds, as Google publishes them. Used to colour a metric tile
115
+ * and to decide whether a number is worth a reader's attention — a report that shows
116
+ * `LCP 4.1s` in the same grey as `TTFB 40ms` has buried the finding.
117
+ */
118
+ export const VITALS = Object.freeze({
119
+ lcpMs: { label: 'LCP', full: 'Largest Contentful Paint', good: 2500, poor: 4000, unit: 'ms', plain: 'How long until the main content is visible' },
120
+ fcpMs: { label: 'FCP', full: 'First Contentful Paint', good: 1800, poor: 3000, unit: 'ms', plain: 'How long until anything is painted' },
121
+ ttfbMs: { label: 'TTFB', full: 'Time to First Byte', good: 800, poor: 1800, unit: 'ms', plain: 'How long the server took to start responding' },
122
+ inpMs: { label: 'INP', full: 'Interaction to Next Paint', good: 200, poor: 500, unit: 'ms', plain: 'How quickly the page responds to a click or tap' },
123
+ cls: { label: 'CLS', full: 'Cumulative Layout Shift', good: 0.1, poor: 0.25, unit: '', plain: 'How much the layout jumps while loading' },
124
+ longTaskMs: { label: 'Long tasks', full: 'Blocking main-thread time', good: 200, poor: 600, unit: 'ms', plain: 'Time the page spent unable to respond' },
125
+ domNodes: { label: 'DOM nodes', full: 'DOM size', good: 1500, poor: 3000, unit: '', plain: 'How large the rendered page is' },
126
+ jsHeapBytes: { label: 'JS heap', full: 'JavaScript heap used', good: 30e6, poor: 100e6, unit: 'bytes', plain: 'Memory the page is holding' },
127
+ });
128
+
129
+ /** 'good' | 'warn' | 'bad' for a vitals measurement, or 'muted' when unknown. */
130
+ export function vitalBand(key, value) {
131
+ const spec = VITALS[key];
132
+ const number = Number(value);
133
+ if (!spec || !Number.isFinite(number)) return 'muted';
134
+ if (number <= spec.good) return 'good';
135
+ if (number <= spec.poor) return 'warn';
136
+ return 'bad';
137
+ }
@@ -0,0 +1,78 @@
1
+ // The three versions a report is stamped with, and why there are three.
2
+ //
3
+ // A report outlives the code that made it. Someone opens a two-year-old QA report and
4
+ // needs to know what they are looking at; someone else needs to re-render an archived
5
+ // artifact and needs to know whether today's renderer still understands it. One
6
+ // version number cannot answer both, because the three things move independently:
7
+ //
8
+ // SCHEMA the shape of the JSON an agent produces.
9
+ // Bumping it can break an old renderer. Major bump = breaking.
10
+ // THEME the visual identity — colour, type, spacing, components.
11
+ // Bumping it changes how a report *looks*, never what it *says*.
12
+ // RENDERER the code that turns schema into documents.
13
+ // Bumping it can change output for the same input; a fixed bug counts.
14
+ //
15
+ // A theme change must never require a schema bump, and a renderer fix must never
16
+ // invalidate an archived artifact. Keeping them apart is what makes that true.
17
+ //
18
+ // ## The identity claim
19
+ //
20
+ // THEME_NAME is the part a reader recognises without being told — the thing that makes
21
+ // a report from Claude Code and a report from Cursor look like the same document,
22
+ // because they *are* the same document rendered by the same code. Changing it is a
23
+ // deliberate act of rebranding, not a routine edit.
24
+
25
+ /** The canonical report schema this renderer reads and this pack's skills emit. */
26
+ export const SCHEMA_VERSION = '2.0';
27
+
28
+ /** The visual identity. Bump the minor for refinements, the major for a redesign. */
29
+ export const THEME_NAME = 'Enterprise';
30
+ export const THEME_VERSION = '1.0';
31
+
32
+ /** The rendering code itself. Bump on any change to the produced output. */
33
+ export const RENDERER_VERSION = '1.0';
34
+
35
+ /**
36
+ * Schema versions this renderer accepts.
37
+ *
38
+ * Listed rather than range-checked so that adding support for a version is a
39
+ * deliberate edit with a test behind it, and so an unknown future version fails with a
40
+ * message naming what *is* supported instead of rendering something half-understood.
41
+ */
42
+ export const SUPPORTED_SCHEMA_VERSIONS = Object.freeze(['1.0', '1.1', '2.0']);
43
+
44
+ /** The block stamped into every rendered document and every JSON export. */
45
+ export function versionStamp() {
46
+ return Object.freeze({
47
+ schemaVersion: SCHEMA_VERSION,
48
+ themeName: THEME_NAME,
49
+ themeVersion: THEME_VERSION,
50
+ rendererVersion: RENDERER_VERSION,
51
+ });
52
+ }
53
+
54
+ /** "Enterprise v1.0 · renderer 1.0 · schema 2.0" — the one-line form for a footer. */
55
+ export function versionLine() {
56
+ return `${THEME_NAME} v${THEME_VERSION} · renderer ${RENDERER_VERSION} · schema ${SCHEMA_VERSION}`;
57
+ }
58
+
59
+ /**
60
+ * The schema version a result declares, whichever contract it uses.
61
+ *
62
+ * A canonical report says so directly. A per-skill contract carries its version in
63
+ * `contract.version`, of which only major.minor is meaningful for compatibility — a
64
+ * patch bump to a contract has never changed its shape.
65
+ */
66
+ export function schemaVersionOf(result) {
67
+ if (result?.schemaVersion) return String(result.schemaVersion);
68
+ const declared = result?.contract?.version;
69
+ if (!declared) return null;
70
+ const [major, minor] = String(declared).split('.');
71
+ return minor === undefined ? major : `${major}.${minor}`;
72
+ }
73
+
74
+ /** True when this renderer understands the result's schema version. */
75
+ export function isSupportedSchema(result) {
76
+ const version = schemaVersionOf(result);
77
+ return version === null || SUPPORTED_SCHEMA_VERSIONS.includes(version);
78
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qa-engineer/engine",
3
- "version": "0.9.2",
4
- "description": "Deterministic QA analysis engine zero dependencies, Node only",
3
+ "version": "0.11.0",
4
+ "description": "Deterministic QA analysis engine \u2014 zero dependencies, Node only",
5
5
  "license": "MIT",
6
6
  "private": true,
7
7
  "type": "module",
@@ -0,0 +1,90 @@
1
+ // Which agent directories an install writes to, for a given scope.
2
+ //
3
+ // Project installs keep the behaviour they always had: detect the hosts present in the
4
+ // repository and write to each unique discovery directory. Global installs cannot work
5
+ // that way — detection reads project markers, and there is no project — so they resolve
6
+ // against the table of user-level directories instead, and say plainly which hosts they
7
+ // could not serve.
8
+ //
9
+ // Keeping this out of `registry.mjs` matters: that file is the catalogue of *what hosts
10
+ // exist*, checked against their documentation. This file is the policy for *where to
11
+ // write*, which differs per scope. Mixing them is how the project-only assumption got
12
+ // baked into the catalogue in the first place.
13
+
14
+ import path from 'node:path';
15
+
16
+ import { resolveInstallTargets, getAgent, AGENTS, UNKNOWN_AGENT_ID } from './registry.mjs';
17
+ import { userSkillsDir, globalCapableAgents, projectOnlyAgents } from './user-level.mjs';
18
+
19
+ /**
20
+ * Agent targets for a scope.
21
+ *
22
+ * A target is `{ id, name, tier, skillsDir, wrapperDir, wrapperFormat, detected, linkable }`
23
+ * where `skillsDir` is relative to the scope root. For a global scope that root is the
24
+ * user's home, so `.claude/skills` means `~/.claude/skills`.
25
+ */
26
+ export function resolveScopeTargets(scope, { explicitIds = [], allAgents = false } = {}) {
27
+ if (scope.kind !== 'global') {
28
+ // Project and workspace both install into a directory tree the user controls, and
29
+ // detection works there. Workspace simply detects at the monorepo root.
30
+ return resolveInstallTargets(scope.root, explicitIds).map((agent) => ({
31
+ ...agent,
32
+ // A workspace shares its engine, so its skills can be linked; a project bundles.
33
+ linkable: scope.shareEngine,
34
+ }));
35
+ }
36
+
37
+ const wanted = allAgents
38
+ ? globalCapableAgents()
39
+ : explicitIds.length > 0
40
+ ? explicitIds
41
+ : globalCapableAgents();
42
+
43
+ const targets = [];
44
+ const seen = new Set();
45
+ for (const id of wanted) {
46
+ const agent = getAgent(id);
47
+ if (!agent) throw new Error(`unknown agent id: ${id}`);
48
+ const dir = userSkillsDir(id);
49
+ // Requested explicitly but unserviceable: skipped here and reported by the caller,
50
+ // rather than written to a path the host does not read.
51
+ if (!dir || seen.has(dir)) continue;
52
+ seen.add(dir);
53
+ targets.push({
54
+ ...agent,
55
+ skillsDir: dir,
56
+ // Wrappers are project-scoped by construction — a slash-command file lives beside
57
+ // the repository it serves — so a global install writes none.
58
+ wrapperFormat: null,
59
+ wrapperDir: null,
60
+ detected: false,
61
+ requested: explicitIds.includes(id),
62
+ linkable: true,
63
+ });
64
+ }
65
+
66
+ return targets;
67
+ }
68
+
69
+ /**
70
+ * Hosts a global install cannot serve, and why.
71
+ *
72
+ * Printed after a global install so the user learns it from the tool rather than from a
73
+ * slash command that never appears. Silence here is the failure mode this exists to
74
+ * prevent.
75
+ */
76
+ export function describeUnservedAgents(scope, targets) {
77
+ if (scope.kind !== 'global') return [];
78
+ const served = new Set(targets.map((t) => t.id));
79
+ return projectOnlyAgents().filter((entry) => !served.has(entry.id));
80
+ }
81
+
82
+ /** Every agent id the installer knows, for `--help` and validation. */
83
+ export function knownAgentIds() {
84
+ return AGENTS.map((a) => a.id).filter((id) => id !== UNKNOWN_AGENT_ID);
85
+ }
86
+
87
+ /** Absolute path of an agent's skills directory within a scope. */
88
+ export function targetPath(scope, target) {
89
+ return path.join(scope.root, target.skillsDir);
90
+ }
@@ -0,0 +1,80 @@
1
+ // Where each agent looks for skills that belong to the *user* rather than to a project.
2
+ //
3
+ // This table is what makes a global install real. A machine-wide install is only useful
4
+ // if the agent actually reads the place we put things, and there is no shared convention
5
+ // — every host invented its own, and several have none at all.
6
+ //
7
+ // ## The rule this table follows
8
+ //
9
+ // A path appears here only when the host's own documentation says it reads it. Writing
10
+ // skills to a guessed path is the worst failure this installer can produce: the files
11
+ // are there, the install reports success, the agent never sees them, and the user
12
+ // concludes the tool is broken. That is strictly worse than saying "this host has no
13
+ // user-level path — install into the project", which is at least actionable.
14
+ //
15
+ // So `null` here is a real answer, carrying the reason. Adding a host later is one entry
16
+ // in this table and nothing else.
17
+
18
+ import path from 'node:path';
19
+
20
+ // Paths are relative to the user's home directory, POSIX-style, and joined with the
21
+ // platform separator at use. `os.homedir()` already resolves to %USERPROFILE% on Windows.
22
+ export const USER_LEVEL = Object.freeze({
23
+ 'claude-code': {
24
+ dir: '.claude/skills',
25
+ docs: 'https://code.claude.com/docs/en/skills',
26
+ note: 'Personal skills, read in every project.',
27
+ },
28
+ antigravity: {
29
+ dir: '.gemini/config/skills',
30
+ docs: 'https://antigravity.google/docs/skills',
31
+ note: 'Global skills directory, read alongside the workspace one.',
32
+ },
33
+
34
+ // No verified user-level skills directory. Each of these reads project-relative paths
35
+ // only, as far as their documentation states. A project install serves them today; if
36
+ // a host adds a user-level path, it becomes one entry above.
37
+ cursor: { dir: null, reason: 'reads .agents/skills and .cursor/skills per project; no documented user-level path' },
38
+ codex: { dir: null, reason: 'documents .agents/skills at cwd, parent, and repo root — all project-relative' },
39
+ opencode: { dir: null, reason: 'documents per-project skills; ~/.config/opencode holds commands, not skills' },
40
+ 'gemini-cli': { dir: null, reason: 'no documented user-level skills path' },
41
+ 'github-copilot': { dir: null, reason: 'prompt files are repository-scoped' },
42
+ kimi: { dir: null, reason: 'no documented user-level skills path' },
43
+ 'agent-skills': {
44
+ dir: null,
45
+ reason: 'the shared Agent Skills path is project-relative by specification; there is no user-level equivalent',
46
+ },
47
+ });
48
+
49
+ /** The user-level skills directory for an agent, relative to home, or null. */
50
+ export function userSkillsDir(agentId) {
51
+ return USER_LEVEL[agentId]?.dir ?? null;
52
+ }
53
+
54
+ /** Why an agent has no user-level directory, for an honest install report. */
55
+ export function userLevelReason(agentId) {
56
+ return USER_LEVEL[agentId]?.reason ?? 'not a known agent';
57
+ }
58
+
59
+ /** Agent ids that can be served by a global install. */
60
+ export function globalCapableAgents() {
61
+ return Object.keys(USER_LEVEL).filter((id) => USER_LEVEL[id].dir);
62
+ }
63
+
64
+ /** Agent ids that a global install cannot serve, with the reason for each. */
65
+ export function projectOnlyAgents() {
66
+ return Object.keys(USER_LEVEL)
67
+ .filter((id) => !USER_LEVEL[id].dir)
68
+ .map((id) => ({ id, reason: USER_LEVEL[id].reason }));
69
+ }
70
+
71
+ /**
72
+ * The home-relative path of one skill for one agent.
73
+ *
74
+ * Returned POSIX-style because it becomes a lockfile entry, and a lockfile written on
75
+ * Windows has to be readable by the same installer on Linux.
76
+ */
77
+ export function userSkillPath(agentId, skill) {
78
+ const dir = userSkillsDir(agentId);
79
+ return dir ? path.posix.join(dir, skill) : null;
80
+ }
@@ -19,6 +19,9 @@ export function parseCommonFlags(argv, { strict = true } = {}) {
19
19
  dryRun: false,
20
20
  help: false,
21
21
  project: null,
22
+ global: false,
23
+ workspace: false,
24
+ allAgents: false,
22
25
  agents: [],
23
26
  rest: /** @type {string[]} */ ([]),
24
27
  };
@@ -38,9 +41,24 @@ export function parseCommonFlags(argv, { strict = true } = {}) {
38
41
  const id = argv[++i];
39
42
  if (!id) throw usageError('--agent requires an id');
40
43
  flags.agents.push(id);
44
+ } else if (a === '--global' || a === '-g') {
45
+ flags.global = true;
46
+ } else if (a === '--workspace' || a === '-w') {
47
+ flags.workspace = true;
48
+ } else if (a === '--all-agents') {
49
+ flags.allAgents = true;
41
50
  } else if (a === '--project' || a === '-C') {
42
- flags.project = argv[++i];
43
- if (!flags.project) throw usageError('--project requires a path');
51
+ // `--project` doubles as a scope selector and as a path. Bare, it means "this
52
+ // directory"; with a value it names one. Requiring a path made `--project` alone
53
+ // an error while `--global` alone worked, which reads as an inconsistency rather
54
+ // than as a rule.
55
+ const next = argv[i + 1];
56
+ if (next && !next.startsWith('-')) {
57
+ flags.project = next;
58
+ i += 1;
59
+ } else {
60
+ flags.project = '.';
61
+ }
44
62
  } else if (strict) {
45
63
  throw usageError(`unknown option: ${a}`);
46
64
  } else {
@@ -3,7 +3,8 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { EXIT, SHARED_SKILLS_DIR } from '../constants.mjs';
6
- import { resolveSourceRoot, resolveProjectRoot } from '../core/paths.mjs';
6
+ import { resolveSourceRoot } from '../core/paths.mjs';
7
+ import { resolveOperatingScope } from '../core/scope.mjs';
7
8
  import { VERSION, SPEC_REVISION } from '../version.mjs';
8
9
  import { AGENTS, resolveInstallTargets, listAgentIds } from '../agents/registry.mjs';
9
10
  import { packHasBundles, verifyEngine } from '../core/bundle.mjs';
@@ -35,7 +36,8 @@ Each failure includes an exact repair command.`);
35
36
  return EXIT.OK;
36
37
  }
37
38
 
38
- const root = resolveProjectRoot(opts.project ?? process.cwd());
39
+ const scope = resolveOperatingScope(opts);
40
+ const root = scope.root;
39
41
  let source = null;
40
42
  try {
41
43
  source = resolveSourceRoot();
@@ -45,10 +47,10 @@ Each failure includes an exact repair command.`);
45
47
 
46
48
  const detected = AGENTS.filter((a) => a.detect(root)).map((a) => a.id);
47
49
  const targets = resolveInstallTargets(root, opts.agents).map((a) => a.id);
48
- const lock = readLock(root);
50
+ const lock = readLock(root, scope.lockfile);
49
51
  const gitOk = hasGit(root);
50
52
  const scan = scanProject(root, opts.agents);
51
- const validation = lock ? validateInstall(root) : null;
53
+ const validation = lock ? validateInstall(root, { scope }) : null;
52
54
 
53
55
  /** @type {Array<{ section: string, id: string, ok: boolean, message: string, hint?: string }>} */
54
56
  const checklist = [];