@marcomigozzi/trailhead 0.12.1 → 0.14.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 (66) hide show
  1. package/README.it.md +38 -13
  2. package/README.md +48 -13
  3. package/bin/lib/codex-projection.js +18 -11
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +79 -7
  38. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +1 -1
  39. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  40. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  42. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  44. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  45. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  46. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -4
  47. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  48. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  49. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  50. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  51. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  52. package/plugins/trailhead/skills/_shared/techniques/prototype.md +15 -16
  53. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  54. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  55. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  56. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  57. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  58. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  59. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  60. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  61. package/plugins/trailhead/tests/mod.test.ts +14 -0
  62. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  63. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  64. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  65. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  66. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -49,6 +49,21 @@
49
49
  // stray keyword token in a genuinely non-write command from being
50
50
  // misclassified as a write.
51
51
  //
52
+ // Third follow-up fix (#219): the walk compared raw, still-quoted tokens, but bash
53
+ // removes quotes before `gh` runs, so `"gh" issue comment`, `gh 'issue' comment`,
54
+ // `gh issue com""ment`, `\gh ...` or `$'gh' ...` hid the write from both guards.
55
+ // Every token is now compared (and `sub`/`verb` returned) in its quote-removed
56
+ // form. This slightly over-detects (a quote inside a single-quoted word is
57
+ // dropped too), which is the safe direction for a guard.
58
+ //
59
+ // Fourth follow-up fix (#225): the verb was read as the token right after the
60
+ // subcommand, so a flag between them (`gh issue -R o/r comment 5`,
61
+ // `gh pr --repo=o/r edit 5`) made the verb the flag itself and hid the write
62
+ // from both guards. Flags after the subcommand are now skipped with the same
63
+ // arity model as the global walk (a glued `--flag=value` or a boolean global
64
+ // flag takes no value, any other flag consumes one following non-flag token)
65
+ // before the verb is read, in the primary walk and in the fail-safe fallback.
66
+ //
52
67
  // Self-contained. No requires.
53
68
 
54
69
  // gh's boolean global flags (take NO value). gh's only value-taking global
@@ -62,11 +77,83 @@ const BOOLEAN_GLOBAL_FLAGS = new Set(['-h', '--help', '--version']);
62
77
  // below to recover from an unknown boolean flag swallowing the subcommand.
63
78
  const WRITE_SUBCOMMANDS = new Set(['issue', 'pr', 'api']);
64
79
 
80
+ // Single-letter ANSI-C escapes (`$'\n'`).
81
+ const ANSI_C_SIMPLE = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', e: '\x1b' };
82
+
83
+ // Decode the escape at word[i] (the char after a backslash) inside a `$'...'` span:
84
+ // { out, next }. Unknown pairs keep the escaped char, so nothing is lost.
85
+ function ansiEscape(word, i) {
86
+ const n = word[i];
87
+ if (Object.prototype.hasOwnProperty.call(ANSI_C_SIMPLE, n)) return { out: ANSI_C_SIMPLE[n], next: i + 1 };
88
+ const rest = word.slice(i);
89
+ let m;
90
+ if ((m = /^x([0-9a-fA-F]{1,2})/.exec(rest)) || (m = /^u([0-9a-fA-F]{1,4})/.exec(rest)) || (m = /^U([0-9a-fA-F]{1,8})/.exec(rest))) {
91
+ const cp = parseInt(m[1], 16);
92
+ return { out: cp <= 0x10ffff ? String.fromCodePoint(cp) : m[0], next: i + m[0].length };
93
+ }
94
+ if ((m = /^[0-7]{1,3}/.exec(rest))) return { out: String.fromCharCode(parseInt(m[0], 8) & 0xff), next: i + m[0].length };
95
+ return { out: n, next: i + 1 };
96
+ }
97
+
98
+ // A word as bash passes it once quoting is removed: a `$'...'` span is decoded
99
+ // (\xHH, \NNN, \uHHHH, \n ...), `$` before a `"` is dropped, a backslash escape keeps
100
+ // the escaped char, quote characters are dropped (#219). Also returns, per output
101
+ // char, the id of the quoted span it came from (-1 for unquoted text), so a caller can
102
+ // tell `"-F x"` (one span, free text) from `-"F"/"p a"` (the flag, then a value).
103
+ function dequoteSpans(word) {
104
+ const w = String(word);
105
+ let text = '';
106
+ const spans = [];
107
+ const put = (str, id) => { text += str; for (let k = 0; k < str.length; k++) spans.push(id); };
108
+ let q = null; // the open quote: ' or "
109
+ let id = 0;
110
+ for (let i = 0; i < w.length; i++) {
111
+ const c = w[i];
112
+ if (q === "'") { if (c === "'") q = null; else put(c, id); continue; }
113
+ if (q === '"') {
114
+ if (c === '"') q = null;
115
+ else if (c === '\\' && i + 1 < w.length && '$`"\\\n'.includes(w[i + 1])) {
116
+ // bash: inside "..." a backslash only escapes $ ` " \ and newline (which vanishes)
117
+ i++;
118
+ if (w[i] !== '\n') put(w[i], id);
119
+ } else put(c, id);
120
+ continue;
121
+ }
122
+ if (c === '$' && w[i + 1] === "'") {
123
+ id++;
124
+ i += 2;
125
+ while (i < w.length && w[i] !== "'") {
126
+ if (w[i] === '\\' && i + 1 < w.length) { const e = ansiEscape(w, i + 1); put(e.out, id); i = e.next; } else put(w[i++], id);
127
+ }
128
+ } else if (c === '$' && w[i + 1] === '"') {
129
+ continue; // locale quoting: the `$` vanishes, the double-quoted span follows
130
+ } else if (c === '\\' && i + 1 < w.length) put(w[++i], -1);
131
+ else if (c === "'" || c === '"') { q = c; id++; }
132
+ else put(c, -1);
133
+ }
134
+ return { text, spans };
135
+ }
136
+
137
+ const dequote = (word) => dequoteSpans(word).text;
138
+
139
+ // Skip the flags starting at toks[i] and return the index of the first
140
+ // non-flag token. A glued `--flag=value` or a BOOLEAN_GLOBAL_FLAGS member takes
141
+ // no value; any other flag consumes one following non-flag token (#153, #225).
142
+ function skipFlags(toks, i) {
143
+ while (i < toks.length && toks[i].startsWith('-')) {
144
+ const flag = toks[i];
145
+ i++; // the flag token itself
146
+ if (flag.includes('=') || BOOLEAN_GLOBAL_FLAGS.has(flag)) continue; // no separate value
147
+ if (i < toks.length && !toks[i].startsWith('-')) i++; // consume its value
148
+ }
149
+ return i;
150
+ }
151
+
65
152
  // Parse a raw shell command string and locate the gh subcommand/verb.
66
153
  // Returns { sub, verb } (verb may be undefined) or null when this is not a
67
154
  // gh invocation at all.
68
155
  function parseGhSubcommand(cmd) {
69
- const toks = String(cmd).trim().split(/\s+/);
156
+ const toks = String(cmd).trim().split(/\s+/).map(dequote);
70
157
  let i = 0;
71
158
  while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
72
159
  if (i >= toks.length || !/(^|\/)gh$/.test(toks[i])) return null; // the gh binary
@@ -75,14 +162,9 @@ function parseGhSubcommand(cmd) {
75
162
  // Primary positional walk. -R/--repo (and any non-boolean, non-glued flag)
76
163
  // is treated as value-taking: consume one following non-flag token so an
77
164
  // unknown two-token global flag's value cannot shift the subcommand (#153).
78
- while (i < toks.length && toks[i].startsWith('-')) {
79
- const flag = toks[i];
80
- i++; // the flag token itself
81
- if (flag.includes('=') || BOOLEAN_GLOBAL_FLAGS.has(flag)) continue; // no separate value
82
- if (i < toks.length && !toks[i].startsWith('-')) i++; // consume its value
83
- }
165
+ i = skipFlags(toks, i);
84
166
  let sub = toks[i];
85
- let verb = toks[i + 1];
167
+ let verb = toks[skipFlags(toks, i + 1)];
86
168
  // Fail-safe fallback: if the walk did NOT land on a recognized write
87
169
  // subcommand, an unknown *boolean* flag before the subcommand may have
88
170
  // consumed the subcommand itself as if it were the flag's value (the mirror
@@ -93,10 +175,10 @@ function parseGhSubcommand(cmd) {
93
175
  // non-write command does not become a spurious write.
94
176
  if (!WRITE_SUBCOMMANDS.has(sub)) {
95
177
  for (let j = start; j < toks.length; j++) {
96
- if (WRITE_SUBCOMMANDS.has(toks[j])) { sub = toks[j]; verb = toks[j + 1]; break; }
178
+ if (WRITE_SUBCOMMANDS.has(toks[j])) { sub = toks[j]; verb = toks[skipFlags(toks, j + 1)]; break; }
97
179
  }
98
180
  }
99
181
  return { sub, verb };
100
182
  }
101
183
 
102
- module.exports = { parseGhSubcommand, BOOLEAN_GLOBAL_FLAGS, WRITE_SUBCOMMANDS };
184
+ module.exports = { parseGhSubcommand, dequote, dequoteSpans, BOOLEAN_GLOBAL_FLAGS, WRITE_SUBCOMMANDS };
@@ -96,4 +96,59 @@ ok('gh --foo --bar issue edit 5 resolves issue/edit (two unknown boolean flags)'
96
96
  ok('gh repo view owner/repo resolves sub "repo" (not forced into a write keyword)',
97
97
  (() => { const r = parseGhSubcommand('gh repo view owner/repo'); return r.sub === 'repo'; })());
98
98
 
99
+ // --- #219: a quoted or split word is the same word for bash ---
100
+ ok('"gh" issue comment resolves issue/comment',
101
+ (() => { const r = parseGhSubcommand('"gh" issue comment 5'); return r && r.sub === 'issue' && r.verb === 'comment'; })());
102
+ ok("gh 'issue' comment resolves issue/comment",
103
+ (() => { const r = parseGhSubcommand("gh 'issue' comment 5"); return r.sub === 'issue' && r.verb === 'comment'; })());
104
+ ok('gh issue com""ment resolves the verb comment',
105
+ (() => { const r = parseGhSubcommand('gh issue com""ment 5'); return r.sub === 'issue' && r.verb === 'comment'; })());
106
+ ok('"/usr/bin/gh" api resolves api',
107
+ (() => { const r = parseGhSubcommand('"/usr/bin/gh" api x'); return r && r.sub === 'api'; })());
108
+ ok('\\gh and g"h" and $\'gh\' are the gh binary',
109
+ ['\\gh issue edit 5', 'g"h" issue edit 5', "$'gh' issue edit 5"].every((c) => { const r = parseGhSubcommand(c); return r && r.sub === 'issue' && r.verb === 'edit'; }));
110
+ ok('gh is\\sue comment resolves issue',
111
+ (() => { const r = parseGhSubcommand('gh is\\sue comment 5'); return r.sub === 'issue' && r.verb === 'comment'; })());
112
+ ok('"ghx" issue is not the gh binary', parseGhSubcommand('"ghx" issue comment 5') === null);
113
+ ok("gh 'repo' view still resolves repo",
114
+ (() => { const r = parseGhSubcommand("gh 'repo' view"); return r.sub === 'repo' && r.verb === 'view'; })());
115
+
116
+ // --- #219: dequote decodes ANSI-C spans and drops quotes and escapes ---
117
+ {
118
+ const { dequote } = require('./gh-subcommand.js');
119
+ ok('dequote drops quote characters', dequote(`a'b'"c"`) === 'abc');
120
+ ok('dequote keeps an escaped char', dequote('\\gh') === 'gh');
121
+ ok('dequote decodes a hex escape', dequote(`$'\\x67h'`) === 'gh');
122
+ ok('dequote decodes an octal escape', dequote(`$'\\151ssue'`) === 'issue');
123
+ ok('dequote decodes a unicode escape', dequote(`$'\\u0067h'`) === 'gh');
124
+ ok('dequote decodes single-letter escapes', dequote(`$'a\\tb\\\\c\\'d'`) === "a\tb\\c'd");
125
+ ok('dequote keeps a backslash that bash keeps inside double quotes', dequote('"\\nn"') === '\\nn');
126
+ ok('dequote drops the backslash of \\$ inside double quotes', dequote('"\\$x"') === '$x');
127
+ ok('dequote drops a backslash-newline inside double quotes', dequote('"a\\\nb"') === 'ab');
128
+ ok('dequote keeps a backslash literal inside single quotes', dequote("'a\\b'") === 'a\\b');
129
+ ok('dequote drops the $ of a locale-quoted span', dequote('$"gh"') === 'gh');
130
+ ok('dequote leaves a bare variable alone', dequote('$HOME') === '$HOME');
131
+ const { dequoteSpans } = require('./gh-subcommand.js');
132
+ ok('dequoteSpans tags each char with its quoted span', JSON.stringify(dequoteSpans(`-"F"/"p a"`)) === JSON.stringify({ text: '-F/p a', spans: [-1, 1, -1, 2, 2, 2] }));
133
+ ok('dequoteSpans keeps one span for "-F handling"', dequoteSpans('"-F x"').spans.every((x) => x === 1));
134
+ ok('body-sources re-exports the same dequote', require('./body-sources.js').dequote === dequote);
135
+ const { flagValues } = require('./body-sources.js');
136
+ const J = JSON.stringify;
137
+ ok("flagValues sees a quoted long flag", J(flagValues(["'--body-file'", 'f'], '--body-file', '-F')) === J(['f']));
138
+ ok('flagValues sees a split long flag', J(flagValues(['--body-fi""le', 'f'], '--body-file', '-F')) === J(['f']));
139
+ ok('flagValues sees a quoted short flag', J(flagValues([`"-F"`, 'f'], '--body-file', '-F')) === J(['f']));
140
+ ok('flagValues sees a fully quoted --flag=value', J(flagValues(['"--body-file=/p"'], '--body-file', '-F')) === J(['/p']));
141
+ ok('flagValues keeps a raw value untouched', J(flagValues(['--body', `'a b'`], '--body', '-b')) === J([`'a b'`]));
142
+ }
143
+
144
+ // --- #225: a flag between the subcommand and the verb is skipped ---
145
+ {
146
+ const r = (c) => { const x = parseGhSubcommand(c); return x.sub + '/' + x.verb; };
147
+ ok('gh issue -R o/r comment 5 resolves issue/comment', r('gh issue -R o/r comment 5') === 'issue/comment');
148
+ ok('gh pr --repo o/r edit 5 resolves pr/edit', r('gh pr --repo o/r edit 5') === 'pr/edit');
149
+ ok('gh issue --repo=o/r comment 5 resolves issue/comment', r('gh issue --repo=o/r comment 5') === 'issue/comment');
150
+ ok('gh -R a/b issue -R o/r edit 5 resolves issue/edit', r('gh -R a/b issue -R o/r edit 5') === 'issue/edit');
151
+ ok('gh issue --help comment: boolean flag takes no value', r('gh issue --help comment') === 'issue/comment');
152
+ }
153
+
99
154
  console.log(`✓ gh-subcommand: ${passed} assertions passed`);
@@ -25,12 +25,28 @@ function questionText(q) {
25
25
  return [q.question, q.header, ...labels].filter((s) => typeof s === 'string').join('\n');
26
26
  }
27
27
 
28
+ const questionsOf = (input) => ((input && Array.isArray(input.questions)) ? input.questions : []);
29
+
30
+ // Is this single question itself a mockup-approval ask?
31
+ function isApprovalQuestion(q) {
32
+ const t = q ? questionText(q) : '';
33
+ return MOCKUP_WORD.test(t) && APPROVAL_WORD.test(t);
34
+ }
35
+
28
36
  // AskUserQuestion input: does any single question ask to approve a mockup?
29
37
  function isMockupApprovalAsk(input) {
30
- const qs = (input && Array.isArray(input.questions)) ? input.questions : [];
31
- return qs.some((q) => {
32
- const t = q ? questionText(q) : '';
33
- return MOCKUP_WORD.test(t) && APPROVAL_WORD.test(t);
38
+ return questionsOf(input).some(isApprovalQuestion);
39
+ }
40
+
41
+ // Does a mockup-approval question carry the link itself (question, header,
42
+ // option label or description)? A link in an unrelated question of the same
43
+ // ask does not count.
44
+ function askHasLink(input) {
45
+ return questionsOf(input).some((q) => {
46
+ if (!isApprovalQuestion(q)) return false;
47
+ const opts = Array.isArray(q.options) ? q.options : [];
48
+ const parts = [q.question, q.header, ...opts.flatMap((o) => [o && o.label, o && o.description])];
49
+ return parts.some(hasLink);
34
50
  });
35
51
  }
36
52
 
@@ -47,4 +63,4 @@ const BLOCK_REASON =
47
63
  'screen/variant) in the plain prose immediately before the question, not the project ' +
48
64
  'name, then ask again. See "Surface the mockup" in _shared/techniques/prototype.md.';
49
65
 
50
- module.exports = { hasLink, isMockupApprovalAsk, isMockupApprovalMessage, BLOCK_REASON };
66
+ module.exports = { hasLink, isMockupApprovalAsk, askHasLink, isMockupApprovalMessage, BLOCK_REASON };