@vincentt-xr/cli 0.6.0 → 0.7.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.
package/dist/cli/args.js CHANGED
@@ -29,6 +29,7 @@ export const BOOLEAN_FLAGS = new Set([
29
29
  "--restart",
30
30
  "--no-qr",
31
31
  "--verbose",
32
+ "--send",
32
33
  "--no-install",
33
34
  "--allow-custom-api",
34
35
  "--yes",
package/dist/cli/index.js CHANGED
@@ -36,7 +36,11 @@ import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
36
36
  import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
37
37
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
38
38
  import { record as recordInvocation } from "../dx/journal.js";
39
- import { runDx } from "../dx/command.js";
39
+ import { dxSendCommand, parseDxOptions, runDx } from "../dx/command.js";
40
+ import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
41
+ import { postReport } from "../dx/send.js";
42
+ import { nudgeFor } from "../dx/nudge.js";
43
+ import { verbOf } from "../dx/skeleton.js";
40
44
  import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
41
45
  // The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
42
46
  // CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
@@ -576,6 +580,42 @@ async function version() {
576
580
  for (const line of renderVersion(report))
577
581
  console.log(line);
578
582
  }
583
+ // ---- dx ---------------------------------------------------------------------
584
+ async function dx() {
585
+ const options = parseDxOptions(argv);
586
+ if (!options.send) {
587
+ process.exitCode = runDx(argv, (l) => console.log(l));
588
+ return;
589
+ }
590
+ // `--send` is the one path out of this machine, so it resolves auth the way
591
+ // every other authed verb does and refuses rather than guessing.
592
+ process.exitCode = await dxSendCommand({
593
+ entries: readJournal,
594
+ print: (l) => console.log(l),
595
+ now: () => Date.now(),
596
+ enabled: () => journalEnabled(),
597
+ path: journalPath,
598
+ cliVersion,
599
+ auth: async () => {
600
+ // resolveConfig THROWS when there is no PAT, and its message is written
601
+ // for a verb that cannot proceed at all. Sending a DX report is
602
+ // optional, so a signed-out creator gets one plain line from
603
+ // dxSendCommand instead of a config-file stack trace.
604
+ try {
605
+ const cfg = await resolveConfig(projectDir(), {
606
+ allowCustomApi: has("--allow-custom-api"),
607
+ });
608
+ return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
609
+ }
610
+ catch {
611
+ return undefined;
612
+ }
613
+ },
614
+ post: (apiUrl, pat, report) => postReport(apiUrl, pat, report),
615
+ confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
616
+ interactive: () => Boolean(process.stdin.isTTY),
617
+ }, options);
618
+ }
579
619
  // ---- servers ----------------------------------------------------------------
580
620
  async function relay() {
581
621
  const { startRelay } = await import("../relay/server.js");
@@ -631,7 +671,9 @@ function usage() {
631
671
  `Meta\n` +
632
672
  ` version CLI version, and the API this machine resolves to\n` +
633
673
  ` dx Where this dev loop fought you [--days <n>] [--json]\n` +
634
- ` Reads a local journal of your own runs. Never sent.\n\n` +
674
+ ` Reads a local journal of your own runs. Local by default.\n` +
675
+ ` --send reports the findings to the Vincentt team; it\n` +
676
+ ` shows the exact payload and asks first. [--note <text>]\n\n` +
635
677
  `Common options: --dir <path> (project dir, default cwd)\n` +
636
678
  ` --allow-custom-api honor a binding's apiUrl override\n`);
637
679
  }
@@ -680,8 +722,7 @@ async function main() {
680
722
  case "relay":
681
723
  return relay();
682
724
  case "dx":
683
- process.exitCode = runDx(argv, (l) => console.log(l));
684
- return;
725
+ return dx();
685
726
  default:
686
727
  // Flow L30: rolling back and taking an address offline are console
687
728
  // actions, and a bare "unknown command" here would be the product failing
@@ -708,6 +749,19 @@ function journal(exitCode, error) {
708
749
  exitCode,
709
750
  error,
710
751
  });
752
+ // The hint goes out AFTER the run it is about, so it reads as a footnote to
753
+ // what just happened rather than a warning about what is about to. Never on
754
+ // `dx` itself — telling someone reading the friction report that they could
755
+ // read the friction report is noise.
756
+ if (verbOf(argv) === "dx")
757
+ return;
758
+ try {
759
+ for (const l of nudgeFor(readJournal(), Date.now()))
760
+ console.log(l);
761
+ }
762
+ catch {
763
+ // A hint that can break a publish is not worth having.
764
+ }
711
765
  }
712
766
  main()
713
767
  .then(() => {
@@ -1,4 +1,5 @@
1
1
  import type { JournalEntry } from "./journal.js";
2
+ import type { WireReport } from "./send.js";
2
3
  export interface DxDeps {
3
4
  entries: () => JournalEntry[];
4
5
  print: (line: string) => void;
@@ -10,8 +11,40 @@ export interface DxOptions {
10
11
  /** Only consider runs from the last N days. */
11
12
  days: number;
12
13
  json: boolean;
14
+ /** Send the findings to the Vincentt team. Never implied by anything else. */
15
+ send: boolean;
16
+ /** Skip the confirmation prompt. Only meaningful with --send. */
17
+ yes: boolean;
18
+ /** The creator's own words about the friction, carried with the report. */
19
+ note?: string;
13
20
  }
14
21
  export declare function parseDxOptions(args: string[]): DxOptions;
15
22
  export declare function dxCommand(deps: DxDeps, options: DxOptions): number;
16
23
  /** Wire the command to the real journal. */
17
24
  export declare function runDx(args: string[], print: (line: string) => void): number;
25
+ export interface DxSendDeps extends DxDeps {
26
+ /** Resolved API host + PAT. Absent when the creator is not signed in. */
27
+ auth: () => Promise<{
28
+ apiUrl: string;
29
+ pat: string;
30
+ } | undefined>;
31
+ post: (apiUrl: string, pat: string, report: WireReport) => Promise<{
32
+ ref: string;
33
+ findings: number;
34
+ expiresAt: string;
35
+ }>;
36
+ /** Ask before sending. Returns false to abort. */
37
+ confirm: (question: string) => Promise<boolean>;
38
+ cliVersion: () => string;
39
+ /** True when stdin is a terminal — i.e. a human is here to answer. */
40
+ interactive: () => boolean;
41
+ }
42
+ /**
43
+ * `vincentt dx --send`.
44
+ *
45
+ * The order is the consent model and is not an implementation detail: find the
46
+ * friction, BUILD the payload, SHOW it in full, ask, and only then send. A
47
+ * creator who cannot see what leaves their machine before it leaves has not
48
+ * agreed to anything.
49
+ */
50
+ export declare function dxSendCommand(deps: DxSendDeps, options: DxOptions): Promise<number>;
@@ -1,12 +1,18 @@
1
1
  import { findClusters, rankClusters } from "./clusters.js";
2
2
  import { journalEnabled, journalPath, readJournal } from "./journal.js";
3
+ import { buildReport, renderPayload } from "./send.js";
3
4
  const DEFAULT_DAYS = 7;
4
5
  export function parseDxOptions(args) {
5
6
  const i = args.indexOf("--days");
6
7
  const raw = i !== -1 ? Number(args[i + 1]) : NaN;
8
+ const n = args.indexOf("--note");
9
+ const note = n !== -1 ? args[n + 1] : undefined;
7
10
  return {
8
11
  days: Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DAYS,
9
12
  json: args.includes("--json"),
13
+ send: args.includes("--send"),
14
+ yes: args.includes("--yes"),
15
+ note: note !== undefined && !note.startsWith("-") ? note : undefined,
10
16
  };
11
17
  }
12
18
  function withinDays(entries, days, now) {
@@ -93,3 +99,69 @@ export function runDx(args, print) {
93
99
  path: journalPath,
94
100
  }, parseDxOptions(args));
95
101
  }
102
+ /**
103
+ * `vincentt dx --send`.
104
+ *
105
+ * The order is the consent model and is not an implementation detail: find the
106
+ * friction, BUILD the payload, SHOW it in full, ask, and only then send. A
107
+ * creator who cannot see what leaves their machine before it leaves has not
108
+ * agreed to anything.
109
+ */
110
+ export async function dxSendCommand(deps, options) {
111
+ if (!deps.enabled()) {
112
+ deps.print("The DX journal is off (VINCENTT_DX_JOURNAL=0). There is nothing to send.");
113
+ return 0;
114
+ }
115
+ const entries = withinDays(deps.entries(), options.days, deps.now());
116
+ const clusters = rankClusters(findClusters(entries));
117
+ if (clusters.length === 0) {
118
+ // Nothing to report is a result, not a failure. Sending an empty report
119
+ // would cost the creator a round trip to tell us nothing.
120
+ deps.print(`No friction detected in the last ${options.days} days — nothing to send.`);
121
+ return 0;
122
+ }
123
+ // --yes REQUIRES a terminal.
124
+ //
125
+ // The nudge prints into an agent's transcript, and an agent that reads it has
126
+ // every incentive to act: it just failed four times. Its reflex for an
127
+ // interactive prompt is to add --yes. That path ends with the AGENT deciding
128
+ // to transmit, supplying the note, and the human seeing the payload scroll
129
+ // past in a transcript they may not be reading — which is not the opt-in this
130
+ // feature is built around.
131
+ //
132
+ // Without --yes there is no such path: the prompt reads EOF under a pipe and
133
+ // fails closed. So the gate is narrow on purpose — it closes the one way an
134
+ // autonomous caller could consent on a creator's behalf, and leaves --yes
135
+ // working for the human who has read the payload.
136
+ if (options.yes && !deps.interactive()) {
137
+ deps.print("Sending a DX report is the creator's call, so --yes needs a terminal.");
138
+ deps.print("Run `vincentt dx --send` yourself, or drop --yes to be asked.");
139
+ return 1;
140
+ }
141
+ const auth = await deps.auth();
142
+ if (auth === undefined) {
143
+ deps.print("Not signed in. Run `vincentt login` to send a report.");
144
+ return 1;
145
+ }
146
+ const report = buildReport(clusters, deps.cliVersion(), options.note);
147
+ for (const l of renderPayload(report))
148
+ deps.print(l);
149
+ deps.print("");
150
+ if (!options.yes && !(await deps.confirm("Send this report?"))) {
151
+ deps.print("Not sent.");
152
+ return 0;
153
+ }
154
+ try {
155
+ const res = await deps.post(auth.apiUrl, auth.pat, report);
156
+ deps.print("");
157
+ deps.print(`✓ Reported — ${res.findings} ${res.findings === 1 ? "finding" : "findings"}, ref ${res.ref}`);
158
+ deps.print(` Kept until ${res.expiresAt}, then deleted.`);
159
+ deps.print(" Your journal stays on this machine.");
160
+ return 0;
161
+ }
162
+ catch (err) {
163
+ deps.print("");
164
+ deps.print(`✗ ${err instanceof Error ? err.message : String(err)}`);
165
+ return 1;
166
+ }
167
+ }
@@ -0,0 +1,7 @@
1
+ import type { JournalEntry } from "./journal.js";
2
+ /**
3
+ * The nudge fires only on a cluster the CURRENT run just completed, so it
4
+ * appears at the moment of friction rather than as a standing complaint about
5
+ * something that happened this morning.
6
+ */
7
+ export declare function nudgeFor(entries: JournalEntry[], now: number): string[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The nudge: a one-line hint, printed after a command that just completed a
3
+ * friction cluster, telling the creator the report exists.
4
+ *
5
+ * It is here because a command nobody remembers produces no data. A creator
6
+ * building an AR app is not going to think "I should audit my tooling" — they
7
+ * are going to swear at the fourth preview and keep going. The moment the
8
+ * friction happens is the only moment the report is cheap to ask for.
9
+ *
10
+ * It is a HINT and never an action. Nothing is sent, nothing is queued, and the
11
+ * creator still has to type the command — which is what keeps `--send` an
12
+ * explicit act rather than a dialog they clicked through while annoyed.
13
+ */
14
+ import { findClusters } from "./clusters.js";
15
+ /** How far back a nudge looks. A cluster older than this is not "right now". */
16
+ const WINDOW_MS = 15 * 60 * 1000;
17
+ /**
18
+ * The nudge fires only on a cluster the CURRENT run just completed, so it
19
+ * appears at the moment of friction rather than as a standing complaint about
20
+ * something that happened this morning.
21
+ */
22
+ export function nudgeFor(entries, now) {
23
+ if (entries.length === 0)
24
+ return [];
25
+ const recent = entries.filter((e) => {
26
+ const t = Date.parse(e.ts);
27
+ return Number.isFinite(t) && now - t <= WINDOW_MS;
28
+ });
29
+ if (recent.length === 0)
30
+ return [];
31
+ const last = recent[recent.length - 1];
32
+ const clusters = findClusters(recent);
33
+ // Only a cluster the LAST entry belongs to. A cluster the caller already
34
+ // worked past is not friction they are feeling now, and nagging about it is
35
+ // how a hint becomes noise a reader learns to skip.
36
+ const live = clusters.find((c) => c.skeleton === last.skel && c.entries[c.entries.length - 1].ts === last.ts);
37
+ if (live === undefined)
38
+ return [];
39
+ const what = live.kind === "repetition"
40
+ ? `${live.entries.length} runs of \`${live.skeleton}\` in a few minutes.`
41
+ : `\`${live.skeleton}\` failed ${live.errors}× just now.`;
42
+ // STATES A FACT, NEVER AN INSTRUCTION.
43
+ //
44
+ // This line lands in an agent's transcript, because in the agent-driven loop
45
+ // the CLI's stdout IS the agent's input. "Run `vincentt dx --send` to report
46
+ // this" is a directive an autonomous caller will simply obey — and a report
47
+ // an agent decided to send is automation wearing consent's clothes, not the
48
+ // opt-in this feature is built around. Naming that a command exists is
49
+ // information; telling a caller to transmit is not.
50
+ //
51
+ // Note it names `vincentt dx` (which only READS, locally) and never the
52
+ // sending form.
53
+ return ["", ` ! ${what}`, " `vincentt dx` shows friction from your recent runs."];
54
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `vincentt dx --send` — the one place anything from the journal leaves the
3
+ * machine.
4
+ *
5
+ * The journal itself is local and unconditional. Sending is neither: it happens
6
+ * only when a creator types this flag, and only after they have been shown the
7
+ * exact payload. That ordering is the whole consent model — a report is an act,
8
+ * not a background transmission — so nothing in this file may be called from a
9
+ * path the creator did not ask for.
10
+ *
11
+ * What goes: the SHAPE of the friction (skeleton, counts, timing, error class
12
+ * names) plus the CLI version and platform. What never goes: the raw journal,
13
+ * flag values, paths, error messages, or anything about which project this was.
14
+ * The server re-validates all of it — a client that leaked a value cannot
15
+ * persist it by asking — but the boundary is drawn here first, because a payload
16
+ * that needs server-side scrubbing was built wrong.
17
+ */
18
+ import type { Cluster } from "./clusters.js";
19
+ /** A finding as it goes on the wire. Mirrors the API's dxFindingRequest. */
20
+ export interface WireFinding {
21
+ kind: "error" | "repetition";
22
+ skeleton: string;
23
+ calls: number;
24
+ errors: number;
25
+ spanMs: number;
26
+ errorClasses?: string[];
27
+ note?: string;
28
+ }
29
+ export interface WireReport {
30
+ cliVersion: string;
31
+ platform: string;
32
+ findings: WireFinding[];
33
+ }
34
+ /**
35
+ * The server bounds a report at 50 findings and refuses the whole thing past
36
+ * that, so the worst clusters are sent rather than the request rejected. They
37
+ * arrive ranked, so this is a head, not a sample.
38
+ */
39
+ export declare const MAX_SENT_FINDINGS = 50;
40
+ export declare function platformTag(): string;
41
+ /**
42
+ * Build the wire payload from ranked clusters.
43
+ *
44
+ * Every field here is derived from the journal's own skeletonized entries — this
45
+ * function reads counts and timings, never values. If a future journal field
46
+ * carries content, it must not be added to this mapping without the same
47
+ * scrutiny the skeleton got.
48
+ */
49
+ export declare function buildReport(clusters: Cluster[], cliVersion: string, note?: string): WireReport;
50
+ /**
51
+ * Render the payload for the creator to read BEFORE it is sent.
52
+ *
53
+ * This is not a summary of the payload — it is the payload, formatted. A preview
54
+ * that paraphrased what would be sent would be exactly the wrong thing to show
55
+ * someone deciding whether to send it.
56
+ */
57
+ export declare function renderPayload(report: WireReport): string[];
58
+ export interface SendResult {
59
+ ref: string;
60
+ findings: number;
61
+ expiresAt: string;
62
+ }
63
+ export declare class DXSendError extends Error {
64
+ readonly status?: number | undefined;
65
+ constructor(message: string, status?: number | undefined);
66
+ }
67
+ /**
68
+ * POST the report. The caller has already shown the payload and had it accepted.
69
+ */
70
+ export declare function postReport(apiUrl: string, pat: string, report: WireReport, fetchImpl?: typeof fetch): Promise<SendResult>;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The server bounds a report at 50 findings and refuses the whole thing past
3
+ * that, so the worst clusters are sent rather than the request rejected. They
4
+ * arrive ranked, so this is a head, not a sample.
5
+ */
6
+ export const MAX_SENT_FINDINGS = 50;
7
+ /** Error classes per finding, matching the server's bound. */
8
+ const MAX_ERROR_CLASSES = 10;
9
+ export function platformTag() {
10
+ return `${process.platform}-${process.arch}`;
11
+ }
12
+ /**
13
+ * Build the wire payload from ranked clusters.
14
+ *
15
+ * Every field here is derived from the journal's own skeletonized entries — this
16
+ * function reads counts and timings, never values. If a future journal field
17
+ * carries content, it must not be added to this mapping without the same
18
+ * scrutiny the skeleton got.
19
+ */
20
+ export function buildReport(clusters, cliVersion, note) {
21
+ const findings = clusters.slice(0, MAX_SENT_FINDINGS).map((c) => {
22
+ const classes = [
23
+ ...new Set(c.entries.map((e) => e.err).filter((e) => e !== null)),
24
+ ].slice(0, MAX_ERROR_CLASSES);
25
+ const f = {
26
+ kind: c.kind,
27
+ skeleton: c.skeleton,
28
+ calls: c.entries.length,
29
+ errors: c.errors,
30
+ spanMs: Math.max(0, Math.round(c.spanMs)),
31
+ };
32
+ if (classes.length > 0)
33
+ f.errorClasses = classes;
34
+ return f;
35
+ });
36
+ // The note rides on the first finding: it is the creator's own words about the
37
+ // friction they are reporting, and the report is about one sitting.
38
+ if (note !== undefined && note.trim() !== "" && findings.length > 0) {
39
+ findings[0].note = note.trim();
40
+ }
41
+ return { cliVersion, platform: platformTag(), findings };
42
+ }
43
+ /**
44
+ * Render the payload for the creator to read BEFORE it is sent.
45
+ *
46
+ * This is not a summary of the payload — it is the payload, formatted. A preview
47
+ * that paraphrased what would be sent would be exactly the wrong thing to show
48
+ * someone deciding whether to send it.
49
+ */
50
+ export function renderPayload(report) {
51
+ const hasNote = report.findings.some((f) => f.note !== undefined && f.note !== "");
52
+ const lines = [
53
+ "This is what would be sent:",
54
+ "",
55
+ ...JSON.stringify(report, null, 2)
56
+ .split("\n")
57
+ .map((l) => ` ${l}`),
58
+ "",
59
+ ];
60
+ // The generated fields carry shape only. The NOTE does not, and saying
61
+ // otherwise above a note that may hold a path or a token would be asking the
62
+ // creator to consent on a false claim — the one thing this screen exists to
63
+ // prevent. So the sentence changes when a note is present.
64
+ if (hasNote) {
65
+ lines.push(" The generated fields carry no paths, flag values, or error messages.", " YOUR NOTE IS SENT EXACTLY AS WRITTEN — check it for paths, URLs,", " tokens, or customer names before sending.");
66
+ }
67
+ else {
68
+ lines.push(" No paths, no flag values, no error messages, and nothing about which", " project this was.");
69
+ }
70
+ lines.push(" Your journal stays on this machine.");
71
+ return lines;
72
+ }
73
+ export class DXSendError extends Error {
74
+ status;
75
+ constructor(message, status) {
76
+ super(message);
77
+ this.status = status;
78
+ this.name = "DXSendError";
79
+ }
80
+ }
81
+ /**
82
+ * POST the report. The caller has already shown the payload and had it accepted.
83
+ */
84
+ export async function postReport(apiUrl, pat, report, fetchImpl = fetch) {
85
+ const res = await fetchImpl(`${apiUrl}/dx-reports`, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json", authorization: `Bearer ${pat}` },
88
+ body: JSON.stringify(report),
89
+ });
90
+ if (!res.ok) {
91
+ let detail = "";
92
+ try {
93
+ const body = (await res.json());
94
+ detail = body.message ?? "";
95
+ }
96
+ catch {
97
+ // A non-JSON body is not more informative than the status.
98
+ }
99
+ if (res.status === 401) {
100
+ throw new DXSendError("Your access token is no longer valid — run `vincentt login`.", 401);
101
+ }
102
+ // A 400 here means the payload this CLI built failed the server's own
103
+ // grammar. Surfaced verbatim rather than softened: it is a bug in this CLI,
104
+ // and the creator is the one who can report it.
105
+ throw new DXSendError(detail !== "" ? detail : `The report was not accepted (${res.status}).`, res.status);
106
+ }
107
+ return (await res.json());
108
+ }
@@ -10,6 +10,83 @@
10
10
  */
11
11
  import { BOOLEAN_FLAGS } from "../cli/args.js";
12
12
  const ELIDED = "<v>";
13
+ /**
14
+ * The verbs this CLI dispatches. A first bare token that is not one of these is
15
+ * not a verb — it is whatever the caller typed, and it must not be kept.
16
+ *
17
+ * Keeping it verbatim was a leak: an agent that mistypes
18
+ * `vincentt publish-acme-demo-to-prod`, fails, and retries builds a top-ranked
19
+ * friction cluster, so a customer's name ended up at the head of both the
20
+ * on-disk journal and any report built from it. Lowercase kebab-case is exactly
21
+ * what slugs and project names are made of, so no character-class check catches
22
+ * this — only a list of the verbs that actually exist.
23
+ */
24
+ const KNOWN_VERBS = new Set([
25
+ "create",
26
+ "dx",
27
+ "feedback",
28
+ "init",
29
+ "link",
30
+ "login",
31
+ "logout",
32
+ "logs",
33
+ "network",
34
+ "preview",
35
+ "publish",
36
+ "relay",
37
+ "trace",
38
+ "unlink",
39
+ "version",
40
+ "whoami",
41
+ "help",
42
+ ]);
43
+ /** Flags the CLI defines. An unknown `--flag` name is caller text, not signal. */
44
+ const KNOWN_FLAGS = new Set([
45
+ "--allow-custom-api",
46
+ "--api",
47
+ "--console",
48
+ "--days",
49
+ "--dir",
50
+ "--dist",
51
+ "--editor",
52
+ "--errors",
53
+ "--force",
54
+ "--help",
55
+ "--json",
56
+ "--limit",
57
+ "--name",
58
+ "--no-install",
59
+ "--no-qr",
60
+ "--note",
61
+ "--org",
62
+ "--port",
63
+ "--preview",
64
+ "--restart",
65
+ "--send",
66
+ "--session",
67
+ "--since",
68
+ "--slug",
69
+ "--stop",
70
+ "--template",
71
+ "--tester",
72
+ "--timeout",
73
+ "--verbose",
74
+ "--version",
75
+ "--viewer",
76
+ "--wait",
77
+ "--watch",
78
+ "--yes",
79
+ ]);
80
+ /**
81
+ * Placeholders for tokens the CLI does not recognize. Angle brackets cannot
82
+ * appear in a token the caller typed, so a caller cannot forge one.
83
+ *
84
+ * A mistyped verb still produces a finding — it clusters under `<verb>`, which
85
+ * is itself worth seeing (repeated unknown verbs mean an agent is guessing at a
86
+ * command that should exist).
87
+ */
88
+ const UNKNOWN_VERB = "<verb>";
89
+ const UNKNOWN_FLAG = "--<flag>";
13
90
  /**
14
91
  * Normalize an argv into `verb --flag=<v> <v>` form — the key a retry cluster
15
92
  * groups on. Two calls that differ only in their values share a skeleton, which
@@ -20,12 +97,18 @@ export function skeleton(args) {
20
97
  for (let i = 0; i < args.length; i++) {
21
98
  const a = args[i];
22
99
  if (!a.startsWith("-")) {
23
- // The verb is the first bare token and is kept verbatim; every later
24
- // positional is a value (a slug, an id, a path) and is elided.
25
- out.push(out.length === 0 ? a : ELIDED);
100
+ // The first bare token is the verb — kept only when it is one we actually
101
+ // dispatch. Every later positional is a value (a slug, an id, a path).
102
+ if (out.length === 0) {
103
+ out.push(KNOWN_VERBS.has(a) ? a : UNKNOWN_VERB);
104
+ }
105
+ else {
106
+ out.push(ELIDED);
107
+ }
26
108
  continue;
27
109
  }
28
- const name = a.split("=")[0];
110
+ const rawName = a.split("=")[0];
111
+ const name = KNOWN_FLAGS.has(rawName) ? rawName : UNKNOWN_FLAG;
29
112
  if (BOOLEAN_FLAGS.has(a)) {
30
113
  out.push(name);
31
114
  continue;
@@ -47,7 +130,7 @@ export function verbOf(args) {
47
130
  for (let i = 0; i < args.length; i++) {
48
131
  const a = args[i];
49
132
  if (!a.startsWith("-"))
50
- return a;
133
+ return KNOWN_VERBS.has(a) ? a : UNKNOWN_VERB;
51
134
  if (!BOOLEAN_FLAGS.has(a) && !a.includes("="))
52
135
  i++;
53
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",