ronin-skillforce 0.1.0 → 0.2.0-beta.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 (155) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +202 -21
  3. package/NOTICE +19 -0
  4. package/README.md +38 -5
  5. package/bin/cli.js +15 -3
  6. package/bin/commands/archive.js +192 -0
  7. package/bin/commands/capstone-status.js +82 -0
  8. package/bin/commands/create.js +69 -12
  9. package/bin/commands/import.js +5 -5
  10. package/bin/commands/status.js +1 -1
  11. package/bin/commands/studio-autostart.js +414 -0
  12. package/bin/commands/studio-state.js +159 -0
  13. package/bin/commands/studio.js +351 -17
  14. package/bin/commands/trail.js +153 -3
  15. package/bin/commands/web.js +8 -10
  16. package/bin/course-reader.js +6 -15
  17. package/bin/platforms/_shared.js +154 -28
  18. package/bin/platforms/claude.js +23 -42
  19. package/bin/platforms/cursor.js +23 -20
  20. package/bin/platforms/gemini.js +18 -19
  21. package/bin/platforms/opencode.js +47 -28
  22. package/package.json +5 -4
  23. package/plugin.json +1 -1
  24. package/shared/SKILL.md +102 -72
  25. package/shared/agents/coach.md +19 -16
  26. package/shared/agents/navigator.md +27 -34
  27. package/shared/agents/professor.md +16 -34
  28. package/shared/agents/researcher.md +1 -1
  29. package/shared/agents/spotter.md +3 -4
  30. package/shared/commands/professor/archive.md +65 -72
  31. package/shared/commands/professor/capstone-review.md +5 -5
  32. package/shared/commands/professor/capstone.md +3 -3
  33. package/shared/commands/professor/done.md +59 -31
  34. package/shared/commands/professor/hint.md +14 -2
  35. package/shared/commands/professor/navigator.md +27 -1
  36. package/shared/commands/professor/new-topic.md +3 -3
  37. package/shared/commands/professor/next.md +9 -5
  38. package/shared/commands/professor/note.md +6 -6
  39. package/shared/commands/professor/progress.md +1 -1
  40. package/shared/commands/professor/publish.md +5 -5
  41. package/shared/commands/professor/quiz.md +2 -2
  42. package/shared/commands/professor/recall.md +1 -1
  43. package/shared/commands/professor/review.md +14 -0
  44. package/shared/commands/professor/skill-export.md +3 -4
  45. package/shared/commands/professor/spotter.md +21 -4
  46. package/shared/commands/professor/stuck.md +24 -0
  47. package/shared/commands/professor/switch.md +1 -1
  48. package/shared/commands/professor/syllabus.md +1 -1
  49. package/shared/commands/professor/template-export.md +2 -2
  50. package/shared/commands/professor/worktrees.md +3 -7
  51. package/shared/hooks/pre-compact.js +4 -15
  52. package/studio/dist/assets/BetaReportPanel-BJ9HIAnz.js +1 -0
  53. package/studio/dist/assets/{Capstone-Cv9kH6LY.js → Capstone-CoUU-jP7.js} +1 -1
  54. package/studio/dist/assets/{CodeWorkspace-BwCEINWo.js → CodeWorkspace-BNrL4qvR.js} +2 -2
  55. package/studio/dist/assets/{Course-BJ_hafUP.js → Course-BMGTXus_.js} +1 -1
  56. package/studio/dist/assets/{Editor-CV0mNhlz.js → Editor-Cw9j3cnp.js} +1 -1
  57. package/studio/dist/assets/{Home-C1ry89ak.js → Home-D2woITq5.js} +1 -1
  58. package/studio/dist/assets/{Lesson-2e9TD9yL.js → Lesson-CYQhrDCZ.js} +2 -2
  59. package/studio/dist/assets/{NewCourse-BZeOg2xn.js → NewCourse-CMpwVRQb.js} +1 -1
  60. package/studio/dist/assets/{Progress-CRc_xEWC.js → Progress-2rS19Fur.js} +1 -1
  61. package/studio/dist/assets/{Review-6QEbHEfk.js → Review-SlqK-hjp.js} +1 -1
  62. package/studio/dist/assets/{Settings-NFnqVQui.js → Settings-t42u7n9H.js} +1 -1
  63. package/studio/dist/assets/{TutorPanel-DXQ6Z3BY.js → TutorPanel-DQDWIU7f.js} +3 -3
  64. package/studio/dist/assets/{abnfDiagram-WEYOIA3C-qeSjKlGM.js → abnfDiagram-WEYOIA3C-jTUmGBuB.js} +1 -1
  65. package/studio/dist/assets/{anthropic-D7NiQCUH.js → anthropic-iIm2l85r.js} +3 -3
  66. package/studio/dist/assets/{architecture-WOLXFQ4H-COG-m35l.js → architecture-WOLXFQ4H-Ch9Ld_vv.js} +1 -1
  67. package/studio/dist/assets/{architectureDiagram-ZHVYKT53-VO7uKoeD.js → architectureDiagram-ZHVYKT53-Dy-z6wsy.js} +1 -1
  68. package/studio/dist/assets/{blockDiagram-RJAE7QA3-BGJF_qiL.js → blockDiagram-RJAE7QA3-2fHVrc6N.js} +1 -1
  69. package/studio/dist/assets/{bridge-BTh0AeNX.js → bridge-Cr0q4rLp.js} +1 -1
  70. package/studio/dist/assets/{c4Diagram-XDPPPE54-C-S10RgU.js → c4Diagram-XDPPPE54-BuuKlG4V.js} +1 -1
  71. package/studio/dist/assets/{capstone-Amn6Hztq.js → capstone-DhwBeNqk.js} +1 -1
  72. package/studio/dist/assets/{card-store-BrbrtX9w.js → card-store-CVMBc-Pv.js} +1 -1
  73. package/studio/dist/assets/channel-M_JkdHrL.js +1 -0
  74. package/studio/dist/assets/{chunk-2E7WHVMD-fv-iqTyC.js → chunk-2E7WHVMD-lZdQWttk.js} +1 -1
  75. package/studio/dist/assets/{chunk-4EMNKR2T-CY9IZrgv.js → chunk-4EMNKR2T-C6kbMwke.js} +1 -1
  76. package/studio/dist/assets/{chunk-55XCU76K-DyTZz7v_.js → chunk-55XCU76K-rkBzkZog.js} +1 -1
  77. package/studio/dist/assets/{chunk-7S6XSTFO-BNMzTLRW.js → chunk-7S6XSTFO-Skg347QF.js} +1 -1
  78. package/studio/dist/assets/{chunk-AQDTSKZB-CEYANohH.js → chunk-AQDTSKZB-0pSDBJ23.js} +1 -1
  79. package/studio/dist/assets/{chunk-DSNLSHJZ-CDk5EXjH.js → chunk-DSNLSHJZ-BaeQDl2i.js} +1 -1
  80. package/studio/dist/assets/{chunk-HAGFOTED-hNAi1zh_.js → chunk-HAGFOTED-BEm00juw.js} +1 -1
  81. package/studio/dist/assets/{chunk-HNE4Q36P-COJnSYoZ.js → chunk-HNE4Q36P-DyCR-LvS.js} +1 -1
  82. package/studio/dist/assets/{chunk-NQOKRVIF-BU6KVxRr.js → chunk-NQOKRVIF-BNJ9uMmB.js} +1 -1
  83. package/studio/dist/assets/{chunk-UHW7PFBP-DYvxL9JG.js → chunk-UHW7PFBP-DqJczBLs.js} +1 -1
  84. package/studio/dist/assets/{chunk-VQ5W62QI-CuRfk65j.js → chunk-VQ5W62QI-CVWYSP5Q.js} +1 -1
  85. package/studio/dist/assets/{chunk-WQQL4CDK-KaG7x-Mb.js → chunk-WQQL4CDK-DJf0Dx05.js} +1 -1
  86. package/studio/dist/assets/{chunk-YZO67HFG-DbuqIZmk.js → chunk-YZO67HFG-BFUm_JHA.js} +1 -1
  87. package/studio/dist/assets/{chunk-ZAUX2LTX-bk4ivUmc.js → chunk-ZAUX2LTX-C5ETkkSE.js} +1 -1
  88. package/studio/dist/assets/{chunk-ZCHMQSOR-B_LElfwh.js → chunk-ZCHMQSOR-WVX9r9PZ.js} +1 -1
  89. package/studio/dist/assets/{classDiagram-v2-4MYWR3UA-DtCJ6H_X.js → classDiagram-v2-4MYWR3UA-CI77SpuR.js} +1 -1
  90. package/studio/dist/assets/{course-export-DpaOTT3a.js → course-export-C8jSPuda.js} +1 -1
  91. package/studio/dist/assets/{cynefin-EF2NZ3EQ-D9btHJvU.js → cynefin-EF2NZ3EQ-CLffmM25.js} +1 -1
  92. package/studio/dist/assets/{cynefinDiagram-WF24UDRP-CsFJuv27.js → cynefinDiagram-WF24UDRP-BvLwocOb.js} +1 -1
  93. package/studio/dist/assets/{dagre-NK4GIZVX-Cg1Duec7.js → dagre-NK4GIZVX-C2nMAVdh.js} +1 -1
  94. package/studio/dist/assets/{diagram-CBK3MWTC-_KU2m5gd.js → diagram-CBK3MWTC-Bid-pdax.js} +1 -1
  95. package/studio/dist/assets/{diagram-I5XIIV2D-5RaBrwUA.js → diagram-I5XIIV2D-DYJeAvzl.js} +1 -1
  96. package/studio/dist/assets/{diagram-ODIL2UCW-C0VKxoD3.js → diagram-ODIL2UCW-B06X2r_F.js} +1 -1
  97. package/studio/dist/assets/{diagram-PFBEJUXE-BaHcZEP2.js → diagram-PFBEJUXE-CwY--5Rz.js} +1 -1
  98. package/studio/dist/assets/{diagram-RJA3I4VV-cwSAZ90p.js → diagram-RJA3I4VV-4SqhH8zw.js} +1 -1
  99. package/studio/dist/assets/{diagram-TYUNFSNR-BJImxCEN.js → diagram-TYUNFSNR-CrW4AIM9.js} +1 -1
  100. package/studio/dist/assets/{ebnfDiagram-V4P3TTPT-OQQLRXcm.js → ebnfDiagram-V4P3TTPT-xeoCmH-d.js} +1 -1
  101. package/studio/dist/assets/{elk-QONTBCTQ-BJGhQHxQ.js → elk-QONTBCTQ-Dadmz6iS.js} +1 -1
  102. package/studio/dist/assets/{erDiagram-KYQIGQAA-D91EKSep.js → erDiagram-KYQIGQAA-CSuH0bbF.js} +1 -1
  103. package/studio/dist/assets/{eventmodeling-K75KTNOO-DgdTRgnw.js → eventmodeling-K75KTNOO-CciVfx3I.js} +1 -1
  104. package/studio/dist/assets/flowDiagram-IUGLLWBU-DgNR54qK.js +1 -0
  105. package/studio/dist/assets/{ganttDiagram-R7GPNTJY-CSYLPWEI.js → ganttDiagram-R7GPNTJY-C0wtA-2y.js} +1 -1
  106. package/studio/dist/assets/{gitGraph-VSP46ZUC-D6GWaV9y.js → gitGraph-VSP46ZUC-BXoDzaA3.js} +1 -1
  107. package/studio/dist/assets/{gitGraphDiagram-PDYL7XHY-CaV31bvT.js → gitGraphDiagram-PDYL7XHY-vZBlNRPe.js} +1 -1
  108. package/studio/dist/assets/{index-DioXTZVc.js → index-CS8XPYbB.js} +2 -2
  109. package/studio/dist/assets/{info-OHQRW6UA-BN-x8GoN.js → info-OHQRW6UA-bs2Qpf7P.js} +1 -1
  110. package/studio/dist/assets/{infoDiagram-MVDRKAX5-CIose_cy.js → infoDiagram-MVDRKAX5-rPJio6-1.js} +1 -1
  111. package/studio/dist/assets/{ishikawaDiagram-HPEHV2GE-DCihSNBw.js → ishikawaDiagram-HPEHV2GE-CXf9XFff.js} +1 -1
  112. package/studio/dist/assets/{journeyDiagram-BOSHW7O7-Bz3o3RIX.js → journeyDiagram-BOSHW7O7-C3MSH79o.js} +1 -1
  113. package/studio/dist/assets/{kanban-definition-FNSA4HC5-BUyqvlFE.js → kanban-definition-FNSA4HC5-Bdh7SB4n.js} +1 -1
  114. package/studio/dist/assets/{line-B08T8KlK.js → line-CHkoQXJF.js} +1 -1
  115. package/studio/dist/assets/{mermaid-parser.core-BYwreSUy.js → mermaid-parser.core-Do1oxD1S.js} +3 -3
  116. package/studio/dist/assets/{mermaid.core-DTO1oDmq.js → mermaid.core-DgZUD0sU.js} +4 -4
  117. package/studio/dist/assets/{mindmap-definition-6OY57UG3-BGLwKheY.js → mindmap-definition-6OY57UG3-PUaK6oE_.js} +1 -1
  118. package/studio/dist/assets/{node.browser-BISniYn8.js → node.browser-DkpMze_r.js} +1 -1
  119. package/studio/dist/assets/{ollama-De5w84tG.js → ollama-bJNp3jaD.js} +1 -1
  120. package/studio/dist/assets/{openai-compatible-Joy1oK3L.js → openai-compatible-0tmAyMlN.js} +1 -1
  121. package/studio/dist/assets/{packet-JDAUHWVQ-CDIMjHS_.js → packet-JDAUHWVQ-vf2FtqFr.js} +1 -1
  122. package/studio/dist/assets/{pegDiagram-ECIUJNBX-DWXm4mwS.js → pegDiagram-ECIUJNBX-NEVrgPTY.js} +1 -1
  123. package/studio/dist/assets/{pie-XZMESJXO-CY3QQiKZ.js → pie-XZMESJXO-BIWydVNZ.js} +1 -1
  124. package/studio/dist/assets/{pieDiagram-GNMPY74T-M1aCnI7W.js → pieDiagram-GNMPY74T-BI9_u6rT.js} +1 -1
  125. package/studio/dist/assets/{quadrantDiagram-D57K4MC3-BFFtO7U5.js → quadrantDiagram-D57K4MC3-ryNpoBC5.js} +1 -1
  126. package/studio/dist/assets/{radar-ABXABTNO-DT0W4_Xn.js → radar-ABXABTNO-D8TjnvBB.js} +1 -1
  127. package/studio/dist/assets/{railroad-I3PHUGI6-_VCpgZR5.js → railroad-I3PHUGI6-BmEDV5kf.js} +1 -1
  128. package/studio/dist/assets/{railroad-abnf-LNEFI6M7-DkSpQ5YY.js → railroad-abnf-LNEFI6M7-Cs_sArMc.js} +1 -1
  129. package/studio/dist/assets/{railroad-ebnf-DWYD2UWJ-BqWILdGb.js → railroad-ebnf-DWYD2UWJ-DYuCq46d.js} +1 -1
  130. package/studio/dist/assets/{railroad-peg-JR7BVNR7-BB0bQL-Z.js → railroad-peg-JR7BVNR7-BcXSgdMy.js} +1 -1
  131. package/studio/dist/assets/{railroadDiagram-Y72NRBL7-CjLKjIdJ.js → railroadDiagram-Y72NRBL7-DBcc8b2c.js} +1 -1
  132. package/studio/dist/assets/{rehype-katex-C-sKddsH.js → rehype-katex-BfUIq5As.js} +1 -1
  133. package/studio/dist/assets/{remark-math-C-ox67hE.js → remark-math-XvZOjmDO.js} +1 -1
  134. package/studio/dist/assets/{requirementDiagram-EQYSW5RA-CnwzZqR8.js → requirementDiagram-EQYSW5RA-CVcBgM1L.js} +1 -1
  135. package/studio/dist/assets/{sankeyDiagram-SY24JHO5-CPDugTUl.js → sankeyDiagram-SY24JHO5-DjZ81hDs.js} +1 -1
  136. package/studio/dist/assets/{sequenceDiagram-BLEHNHQU-1dLfeU9Y.js → sequenceDiagram-BLEHNHQU-BNwOa2S7.js} +1 -1
  137. package/studio/dist/assets/{stateDiagram-v2-A6IESKWZ-DR7CmEjj.js → stateDiagram-v2-A6IESKWZ-BFWa5lEx.js} +1 -1
  138. package/studio/dist/assets/{swimlanes-B3ENRKFQ-BAWB9omQ.js → swimlanes-B3ENRKFQ-LfM_Jg6Z.js} +1 -1
  139. package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-BDmpoiGp.js +8 -0
  140. package/studio/dist/assets/{timeline-definition-JXYKGLGB-DNkj8z77.js → timeline-definition-JXYKGLGB-BwGzkWgZ.js} +1 -1
  141. package/studio/dist/assets/{treeView-D4JQ5SDB-BA-ouhMb.js → treeView-D4JQ5SDB-MLk3VuRt.js} +1 -1
  142. package/studio/dist/assets/{treemap-SAJKECNS-BrrGxsAb.js → treemap-SAJKECNS-D8iSjp7L.js} +1 -1
  143. package/studio/dist/assets/{usecaseDiagram-WPOLBNNX-BB3D0Dt_.js → usecaseDiagram-WPOLBNNX-BqxpaNlT.js} +1 -1
  144. package/studio/dist/assets/{vennDiagram-Q3Y3IDCW-BrtqtKTE.js → vennDiagram-Q3Y3IDCW-CpeUDMhQ.js} +1 -1
  145. package/studio/dist/assets/{wardley-7MLQ67FV-BKkemDql.js → wardley-7MLQ67FV-tAMbCZAj.js} +1 -1
  146. package/studio/dist/assets/{wardleyDiagram-IVAIDKWA-DFnI3P5z.js → wardleyDiagram-IVAIDKWA-CDJViWzt.js} +1 -1
  147. package/studio/dist/assets/{xychartDiagram-4URNHDU4-Da0DZG_q.js → xychartDiagram-4URNHDU4-CwaQrd3_.js} +1 -1
  148. package/studio/dist/index.html +1 -1
  149. package/templates/claude/plugin.json +1 -1
  150. package/templates/gemini/GEMINI.md +18 -16
  151. package/web/server.js +3 -3
  152. package/studio/dist/assets/BetaReportPanel-8NrDLiPm.js +0 -1
  153. package/studio/dist/assets/channel-SJiUQu_N.js +0 -1
  154. package/studio/dist/assets/flowDiagram-IUGLLWBU-CVnJYMum.js +0 -1
  155. package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-qeuHru16.js +0 -8
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join, relative } from 'path';
3
+ import { resolveCourse } from './trail.js';
4
+ import { style } from '../registry-helpers.js';
5
+
6
+ const HEADER_RE = /^(\s*[-*]?\s*\*\*Capstone status\*\*\s*:?\s*).*$/mi;
7
+ const EMOJI_RE = /(⬜|🔄|✅|🔒)/;
8
+ const LIST_ROW_RE = /^\s*(?:[-*+]\s*)?(⬜|🔄|✅|🔒)/;
9
+
10
+ export const CAPSTONE_STATES = {
11
+ locked: { emoji: '🔒', label: 'Locked', header: '🔒 Locked (complete all sections to unlock)' },
12
+ active: { emoji: '🔄', label: 'In progress', header: '🔄 In progress' },
13
+ done: { emoji: '✅', label: 'Complete', header: '✅ Complete' },
14
+ };
15
+
16
+ /** Keep the `**Capstone status**` header and the capstone row telling the same story. */
17
+ export function setCapstoneStatus(text, status) {
18
+ const state = CAPSTONE_STATES[status];
19
+ if (!state) throw new Error(`Unknown capstone status "${status}" — use locked, active or done.`);
20
+ if (!HEADER_RE.test(text)) {
21
+ throw new Error('No **Capstone status** line in COURSE.md — nothing was updated.');
22
+ }
23
+
24
+ const lines = text.replace(HEADER_RE, (m, prefix) => `${prefix}${state.header}`).split('\n');
25
+ for (let i = 0; i < lines.length; i++) {
26
+ if (!/capstone/i.test(lines[i])) continue;
27
+
28
+ if (/^\s*\|.*\|\s*$/.test(lines[i])) {
29
+ const cells = lines[i].split('|');
30
+ const at = cells.findIndex((c, idx) =>
31
+ idx > 0 && idx < cells.length - 1 && EMOJI_RE.test(c) && !/capstone/i.test(c));
32
+ if (at === -1) continue;
33
+ cells[at] = ` ${state.emoji} ${state.label} `;
34
+ lines[i] = cells.join('|');
35
+ continue;
36
+ }
37
+
38
+ if (LIST_ROW_RE.test(lines[i])) {
39
+ lines[i] = lines[i].replace(EMOJI_RE, state.emoji);
40
+ }
41
+ }
42
+ return lines.join('\n');
43
+ }
44
+
45
+ export function recordCapstoneStatus({ cwd, slug, status }) {
46
+ if (!slug) throw new Error('Pass the course slug — capstone-status never guesses which course to write.');
47
+ if (!status) throw new Error('Pass --set locked|active|done — nothing was updated.');
48
+ const found = resolveCourse(cwd, slug);
49
+ const path = join(found.dir, 'COURSE.md');
50
+ if (!existsSync(path)) throw new Error(`No COURSE.md in ${found.dir}`);
51
+ const next = setCapstoneStatus(readFileSync(path, 'utf-8'), status);
52
+ writeFileSync(path, next);
53
+ return { updated: true, path, section: found.slug, status };
54
+ }
55
+
56
+ function flagValue(args, name) {
57
+ const i = args.indexOf(name);
58
+ return i !== -1 && args[i + 1] !== undefined && !args[i + 1].startsWith('--') ? args[i + 1] : null;
59
+ }
60
+
61
+ function positionals(args) {
62
+ const out = [];
63
+ for (let i = 0; i < args.length; i++) {
64
+ if (args[i] === '--set') { i++; continue; }
65
+ if (!args[i].startsWith('--')) out.push(args[i]);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export async function run({ args = [], cwd = process.cwd() } = {}) {
71
+ const slug = positionals(args)[0] || null;
72
+ const json = args.includes('--json');
73
+ try {
74
+ const r = recordCapstoneStatus({ cwd, slug, status: flagValue(args, '--set') });
75
+ if (json) console.log(JSON.stringify(r));
76
+ else console.log(style('success', `Capstone status set to ${r.status} in ${relative(cwd, r.path)}`));
77
+ } catch (err) {
78
+ if (json) console.log(JSON.stringify({ error: err.message }));
79
+ else console.error(style('error', err.message));
80
+ process.exitCode = 1;
81
+ }
82
+ }
@@ -3,7 +3,8 @@
3
3
  * way `professor:new-topic` promises, so the agent never improvises it (RON-249).
4
4
  *
5
5
  * - `programming`: a registered git worktree at `learning/<slug>/` on branch
6
- * `learning/<slug>`, verified after creation; rolled back on any failure.
6
+ * `learning/<slug>`, verified after creation; rolled back on any failure. A new
7
+ * branch is an orphan branch, so the course starts with no host files or history.
7
8
  * - other kinds: a plain `learning/<slug>/` folder.
8
9
  *
9
10
  * `skillforce save <slug> [-m msg]` commits the course worktree so its
@@ -15,7 +16,7 @@ import { isAbsolute, join, relative } from 'path';
15
16
  import { COURSE_KINDS } from '../course-reader.js';
16
17
  import { style } from '../registry-helpers.js';
17
18
 
18
- const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
19
+ export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
19
20
 
20
21
  function git(cwd, args) {
21
22
  return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -25,6 +26,23 @@ function tryGit(cwd, args) {
25
26
  try { return git(cwd, args); } catch { return null; }
26
27
  }
27
28
 
29
+ function gitOutput(cwd, args) {
30
+ try {
31
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
32
+ } catch (err) {
33
+ return `${err.stdout || ''}${err.stderr || ''}`;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Whether `git worktree add --orphan` exists, probed from the usage text (`-h` changes nothing).
39
+ * @param {string} cwd
40
+ * @returns {boolean}
41
+ */
42
+ export function supportsOrphanWorktree(cwd) {
43
+ return /--orphan\b/.test(gitOutput(cwd, ['worktree', 'add', '-h']));
44
+ }
45
+
28
46
  /** Registered worktrees as `{ path, branch }` (branch without `refs/heads/`). */
29
47
  export function listWorktrees(cwd) {
30
48
  const out = tryGit(cwd, ['worktree', 'list', '--porcelain']) || '';
@@ -44,25 +62,49 @@ function isCourseWorktree(cwd, dir, branch) {
44
62
  return listWorktrees(cwd).some((t) => t.path === real && t.branch === branch);
45
63
  }
46
64
 
47
- /** Keep `learning/` out of the host repo's `git status` without touching tracked files. */
48
- function excludeLearningDir(root, cwd) {
49
- const probe = join(cwd, 'learning');
50
- if (tryGit(root, ['check-ignore', '-q', probe]) !== null) return;
65
+ /** An existing ref that would stop git from writing `refs/heads/<branch>` (D/F conflict). */
66
+ function refBlocked(root, branch) {
67
+ const parts = branch.split('/');
68
+ for (let i = 1; i < parts.length; i++) {
69
+ const ancestor = `refs/heads/${parts.slice(0, i).join('/')}`;
70
+ if (tryGit(root, ['rev-parse', '--verify', '-q', ancestor]) !== null) return true;
71
+ }
72
+ const descendants = tryGit(
73
+ root, ['for-each-ref', '--count=1', '--format=%(refname)', `refs/heads/${branch}/`],
74
+ );
75
+ return (descendants || '') !== '';
76
+ }
77
+
78
+ /**
79
+ * Keep a path inside the host repo out of `git status` without touching tracked files.
80
+ * @returns {string|null} the exclude line ensured, or null when git already ignores the path.
81
+ */
82
+ export function excludeRepoPath(root, cwd, target) {
83
+ const abs = isAbsolute(target) ? target : join(cwd, target);
84
+ if (tryGit(root, ['check-ignore', '-q', abs]) !== null) return null;
51
85
  const commonDir = git(root, ['rev-parse', '--git-common-dir']);
52
86
  const infoDir = join(isAbsolute(commonDir) ? commonDir : join(root, commonDir), 'info');
53
87
  mkdirSync(infoDir, { recursive: true });
54
88
  const excludeFile = join(infoDir, 'exclude');
55
- const line = `/${relative(root, probe).split('\\').join('/')}/`;
89
+ const line = `/${relative(root, abs).split('\\').join('/')}/`;
56
90
  const current = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf-8') : '';
57
- if (current.split('\n').includes(line)) return;
91
+ if (current.split('\n').includes(line)) return line;
58
92
  appendFileSync(excludeFile, `${current && !current.endsWith('\n') ? '\n' : ''}${line}\n`);
93
+ return line;
94
+ }
95
+
96
+ /** Keep `learning/` out of the host repo's `git status`. */
97
+ function excludeLearningDir(root, cwd) {
98
+ return excludeRepoPath(root, cwd, join(cwd, 'learning'));
59
99
  }
60
100
 
61
101
  /**
62
102
  * Create (or reattach) the course directory. Throws with a user-facing message.
103
+ * @param {{ cwd: string, slug: string, kind: string, orphanStrategy?: 'auto'|'fallback' }} options
104
+ * `orphanStrategy: 'fallback'` forces the multi-step orphan procedure (tests only).
63
105
  * @returns {{ slug: string, kind: string, dir: string, branch: string|null, created: boolean }}
64
106
  */
65
- export function createCourse({ cwd, slug, kind }) {
107
+ export function createCourse({ cwd, slug, kind, orphanStrategy = 'auto' }) {
66
108
  if (!SLUG_RE.test(slug || '')) {
67
109
  throw new Error(`Invalid slug ${JSON.stringify(slug)} — use lowercase a-z, 0-9 and single hyphens.`);
68
110
  }
@@ -96,14 +138,29 @@ export function createCourse({ cwd, slug, kind }) {
96
138
  }
97
139
 
98
140
  const branchExists = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]) !== null;
141
+ if (!branchExists && refBlocked(root, branch)) {
142
+ throw new Error(`Cannot create the ${branch} branch — an existing branch name blocks it. ` +
143
+ 'Rename or delete it — nothing was kept.');
144
+ }
145
+ const useFallback = orphanStrategy === 'fallback' || !supportsOrphanWorktree(root);
99
146
  try {
100
- // Reattach an existing branch (course restored after its worktree was pruned).
101
- git(root, ['worktree', 'add', dir, ...(branchExists ? [branch] : ['-b', branch])]);
147
+ if (branchExists) {
148
+ // Reattach an existing branch (course restored after its worktree was pruned).
149
+ git(root, ['worktree', 'add', dir, branch]);
150
+ } else if (useFallback) {
151
+ git(root, ['worktree', 'add', '--detach', dir]);
152
+ git(dir, ['switch', '--orphan', branch]);
153
+ } else {
154
+ git(root, ['worktree', 'add', '--orphan', '-b', branch, dir]);
155
+ }
102
156
  if (!isCourseWorktree(root, dir, branch)) throw new Error('worktree is not registered after creation');
157
+ if (!branchExists && tryGit(dir, ['rev-parse', '--verify', '-q', 'HEAD']) !== null) {
158
+ throw new Error(`branch ${branch} is not orphan`);
159
+ }
103
160
  } catch (err) {
104
161
  tryGit(root, ['worktree', 'remove', '--force', dir]);
105
162
  if (!branchExists) tryGit(root, ['branch', '-D', branch]);
106
- rmSync(dir, { recursive: true, force: true });
163
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* a path through a file (ENOTDIR) has nothing to remove */ }
107
164
  tryGit(root, ['worktree', 'prune']);
108
165
  const detail = (err.stderr || err.message || '').toString().trim();
109
166
  throw new Error(`Could not create the course worktree — nothing was kept. ${detail}`);
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `skillforce import [slug|--all] [--dry-run]` (CLI-07) — bring CLI courses into Studio.
3
3
  *
4
- * Reads `learning/{slug}/` (primary) and legacy `courses/{slug}/` through `course-reader.js` and
5
- * writes them into the shared Studio SQLite DB. Everything goes through the repository or the same
6
- * StorageAdapter, so Studio sees identical rows:
4
+ * Reads `learning/{slug}/` through `course-reader.js` and writes them into the shared Studio SQLite
5
+ * DB. Everything goes through the repository or the same StorageAdapter, so Studio sees identical
6
+ * rows:
7
7
  *
8
8
  * COURSE.md → courses (title, level, kind, goal) + lessons (sections, status, dates)
9
9
  * lectures/NN-*.md → lesson_versions (ordered)
@@ -496,14 +496,14 @@ export async function run(ctx = {}) {
496
496
  const selected = slug ? courses.filter((c) => c.slug === slug) : courses;
497
497
 
498
498
  if (slug && selected.length === 0) {
499
- console.error(style('error', `No course "${slug}" found in learning/ or courses/.`));
499
+ console.error(style('error', `No course "${slug}" found in learning/.`));
500
500
  process.exitCode = 1;
501
501
  return { courses: 0, error: `not found: ${slug}` };
502
502
  }
503
503
 
504
504
  if (selected.length === 0) {
505
505
  console.log('');
506
- console.log(style('info', 'No courses found in learning/ or courses/.'));
506
+ console.log(style('info', 'No courses found in learning/.'));
507
507
  console.log('');
508
508
  return { courses: 0 };
509
509
  }
@@ -64,7 +64,7 @@ export async function run({ args = [], cwd = process.cwd(), chalk } = {}) {
64
64
  return;
65
65
  }
66
66
  console.log('');
67
- console.log(style('info', 'No courses found in learning/ or courses/.'));
67
+ console.log(style('info', 'No courses found in learning/.'));
68
68
  console.log('');
69
69
  console.log(' Start one in your agent: /professor:new-topic');
70
70
  console.log(' Or install a course: npx ronin-skillforce install <name>');
@@ -0,0 +1,414 @@
1
+ /**
2
+ * `skillforce studio --autostart on|off`, `--status` and `--stop` (RON-297).
3
+ *
4
+ * Every launchd, process-list and signal call below uses an argument array and is injectable, so
5
+ * tests never load a real LaunchAgent, never list a real process and never signal one.
6
+ */
7
+ import { execFileSync } from 'child_process';
8
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
9
+ import { homedir } from 'os';
10
+ import { dirname, isAbsolute, join } from 'path';
11
+ import { maskToken, readTokenFile, tokenFilePath } from '../../web/security.js';
12
+ import {
13
+ isPidAlive,
14
+ logFilePath,
15
+ probeStudioHealth,
16
+ readState,
17
+ removeState,
18
+ } from './studio-state.js';
19
+
20
+ export const LABEL = 'com.ronin-skillforce.studio';
21
+ export const LAUNCH_AGENT_FILE = `${LABEL}.plist`;
22
+ export const UNSUPPORTED_PLATFORM = 'Studio auto-start is only supported on macOS yet.';
23
+ export const STABLE_INSTALL_HINT = 'Install it globally first: npm i -g ronin-skillforce';
24
+
25
+ /**
26
+ * Absolute plist path. launchd only reads `~/Library/LaunchAgents`, so this uses the OS home and
27
+ * never `PROFESSOR_HOME`.
28
+ * @param {{ home?: string }} [opts]
29
+ * @returns {string}
30
+ */
31
+ export function launchAgentPath({ home = homedir() } = {}) {
32
+ return join(home, 'Library', 'LaunchAgents', LAUNCH_AGENT_FILE);
33
+ }
34
+
35
+ /** Escape the five XML entities; a home directory may legitimately contain `&`. */
36
+ function xmlEscape(value) {
37
+ return String(value)
38
+ .replace(/&/g, '&amp;')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;');
41
+ }
42
+
43
+ /** Show a path under the user's home with `~` so status output stays short. */
44
+ export function displayPath(file, { home = homedir() } = {}) {
45
+ const prefix = `${home}/`;
46
+ return file.startsWith(prefix) ? `~/${file.slice(prefix.length)}` : file;
47
+ }
48
+
49
+ /**
50
+ * Build the LaunchAgent plist. `ProgramArguments` point at the absolute Node executable and the
51
+ * absolute CLI entry, never at `npx`: launchd has no shell PATH, no network and no version pinning.
52
+ *
53
+ * @param {{ nodePath: string, cliPath: string, port: number, apiPort: number,
54
+ * professorHome?: string, pathEnv?: string, logPath: string }} opts
55
+ * @returns {string}
56
+ */
57
+ export function buildLaunchAgentPlist({
58
+ nodePath,
59
+ cliPath,
60
+ port,
61
+ apiPort,
62
+ professorHome,
63
+ pathEnv,
64
+ logPath,
65
+ } = {}) {
66
+ for (const [name, value] of Object.entries({ nodePath, cliPath, logPath })) {
67
+ if (typeof value !== 'string' || value === '') {
68
+ throw new Error(`Cannot build the LaunchAgent plist: ${name} is missing.`);
69
+ }
70
+ }
71
+ for (const [name, value] of Object.entries({ port, apiPort })) {
72
+ if (!Number.isInteger(Number(value)) || Number(value) <= 0) {
73
+ throw new Error(`Cannot build the LaunchAgent plist: ${name} is not a port.`);
74
+ }
75
+ }
76
+
77
+ const args = [
78
+ nodePath,
79
+ cliPath,
80
+ 'studio',
81
+ '--no-open',
82
+ '--strict-port',
83
+ '--port',
84
+ String(port),
85
+ '--api-port',
86
+ String(apiPort),
87
+ ];
88
+ const env = [];
89
+ if (professorHome) {
90
+ env.push(` <key>PROFESSOR_HOME</key>`, ` <string>${xmlEscape(professorHome)}</string>`);
91
+ }
92
+ if (pathEnv) {
93
+ env.push(` <key>PATH</key>`, ` <string>${xmlEscape(pathEnv)}</string>`);
94
+ }
95
+
96
+ return [
97
+ '<?xml version="1.0" encoding="UTF-8"?>',
98
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' +
99
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
100
+ '<plist version="1.0">',
101
+ '<dict>',
102
+ ' <key>Label</key>',
103
+ ` <string>${LABEL}</string>`,
104
+ ' <key>ProgramArguments</key>',
105
+ ' <array>',
106
+ ...args.map((arg) => ` <string>${xmlEscape(arg)}</string>`),
107
+ ' </array>',
108
+ ...(env.length
109
+ ? [' <key>EnvironmentVariables</key>', ' <dict>', ...env, ' </dict>']
110
+ : []),
111
+ ' <key>RunAtLoad</key>',
112
+ ' <true/>',
113
+ ' <key>KeepAlive</key>',
114
+ ' <dict>',
115
+ ' <key>SuccessfulExit</key>',
116
+ ' <false/>',
117
+ ' </dict>',
118
+ ' <key>ThrottleInterval</key>',
119
+ ' <integer>30</integer>',
120
+ ' <key>StandardOutPath</key>',
121
+ ` <string>${xmlEscape(logPath)}</string>`,
122
+ ' <key>StandardErrorPath</key>',
123
+ ` <string>${xmlEscape(logPath)}</string>`,
124
+ '</dict>',
125
+ '</plist>',
126
+ '',
127
+ ].join('\n');
128
+ }
129
+
130
+ /**
131
+ * Refuse to install an agent that points into a throwaway path. A npx cache entry is deleted
132
+ * whenever npm feels like it, which would leave a LaunchAgent that fails at every login.
133
+ *
134
+ * @param {{ cliPath: string, execPath?: string }} opts
135
+ * @returns {{ cliPath: string, nodePath: string }}
136
+ */
137
+ export function assertStableInstall({ cliPath, execPath = process.execPath } = {}) {
138
+ if (typeof cliPath !== 'string' || !isAbsolute(cliPath) || !existsSync(cliPath)) {
139
+ throw new Error(
140
+ `Studio auto-start needs a stable install: the CLI entry point (${cliPath || 'unknown'}) ` +
141
+ `was not found. ${STABLE_INSTALL_HINT}`
142
+ );
143
+ }
144
+ const unstable = [cliPath, execPath].find((value) => String(value).includes('/_npx/'));
145
+ if (unstable) {
146
+ throw new Error(
147
+ `Studio auto-start needs a stable install: this copy runs from the npx cache ` +
148
+ `(${unstable}), which can be deleted at any time. ${STABLE_INSTALL_HINT}`
149
+ );
150
+ }
151
+ return { cliPath, nodePath: execPath };
152
+ }
153
+
154
+ /**
155
+ * The three launchctl calls this feature needs, with argv arrays and no shell.
156
+ * @param {{ exec?: Function, uid?: number, platform?: string }} [opts]
157
+ * @returns {{ bootstrap: (plistPath: string) => object, bootout: () => object,
158
+ * kill: (signal?: string) => object }}
159
+ */
160
+ export function createLaunchctl({
161
+ exec = execFileSync,
162
+ uid = typeof process.getuid === 'function' ? process.getuid() : null,
163
+ platform = process.platform,
164
+ } = {}) {
165
+ const run = (args) => {
166
+ try {
167
+ const stdout = exec('launchctl', args, { stdio: 'pipe', encoding: 'utf8' });
168
+ return { ok: true, stdout: String(stdout || ''), stderr: '' };
169
+ } catch (err) {
170
+ return {
171
+ ok: false,
172
+ stdout: String(err?.stdout || ''),
173
+ stderr: String(err?.stderr || err?.message || ''),
174
+ };
175
+ }
176
+ };
177
+ const domain = `gui/${uid}`;
178
+ return {
179
+ domain,
180
+ bootstrap: (plistPath) => run(['bootstrap', domain, plistPath]),
181
+ bootout: () => run(['bootout', `${domain}/${LABEL}`]),
182
+ kill: (signal = 'SIGTERM') => run(['kill', signal, `${domain}/${LABEL}`]),
183
+ };
184
+ }
185
+
186
+ /** True for the "already loaded" answers `launchctl bootstrap` gives on a re-install. */
187
+ function isAlreadyLoaded(result) {
188
+ const text = `${result.stderr} ${result.stdout}`;
189
+ return /already|Bootstrap failed: 5|Input\/output error/i.test(text);
190
+ }
191
+
192
+ /**
193
+ * Install (or re-install) the LaunchAgent: log directory, plist, then `launchctl bootstrap`.
194
+ * Re-running is idempotent — the previous registration is removed first and retried once.
195
+ *
196
+ * @param {{ port: number, apiPort: number, cliPath: string, execPath?: string,
197
+ * env?: Record<string,string|undefined>, userHome?: string, platform?: string,
198
+ * exec?: Function, uid?: number }} opts
199
+ * @returns {{ plistPath: string, logPath: string }}
200
+ */
201
+ export function enableAutostart({
202
+ port,
203
+ apiPort,
204
+ cliPath,
205
+ execPath = process.execPath,
206
+ env = process.env,
207
+ userHome = homedir(),
208
+ platform = process.platform,
209
+ exec = execFileSync,
210
+ uid,
211
+ } = {}) {
212
+ if (platform !== 'darwin') throw new Error(UNSUPPORTED_PLATFORM);
213
+
214
+ const install = assertStableInstall({ cliPath, execPath });
215
+ const plistPath = launchAgentPath({ home: userHome });
216
+ const logPath = logFilePath({ env });
217
+ mkdirSync(dirname(logPath), { recursive: true });
218
+ mkdirSync(dirname(plistPath), { recursive: true });
219
+
220
+ const plist = buildLaunchAgentPlist({
221
+ nodePath: install.nodePath,
222
+ cliPath: install.cliPath,
223
+ port,
224
+ apiPort,
225
+ professorHome: env.PROFESSOR_HOME,
226
+ pathEnv: env.PATH,
227
+ logPath,
228
+ });
229
+ writeFileSync(plistPath, plist, { mode: 0o644 });
230
+
231
+ const launchctl = createLaunchctl({ exec, uid, platform });
232
+ launchctl.bootout();
233
+ let result = launchctl.bootstrap(plistPath);
234
+ if (!result.ok && isAlreadyLoaded(result)) {
235
+ launchctl.bootout();
236
+ result = launchctl.bootstrap(plistPath);
237
+ }
238
+ if (!result.ok) {
239
+ throw new Error(
240
+ `launchctl bootstrap failed for ${plistPath}: ${result.stderr.trim() || 'unknown error'}`
241
+ );
242
+ }
243
+ return { plistPath, logPath };
244
+ }
245
+
246
+ /**
247
+ * Remove the LaunchAgent and its plist. Safe to call when nothing was installed.
248
+ * @param {{ userHome?: string, platform?: string, exec?: Function, uid?: number }} [opts]
249
+ * @returns {{ plistPath: string, removed: boolean }}
250
+ */
251
+ export function disableAutostart({
252
+ userHome = homedir(),
253
+ platform = process.platform,
254
+ exec = execFileSync,
255
+ uid,
256
+ } = {}) {
257
+ if (platform !== 'darwin') throw new Error(UNSUPPORTED_PLATFORM);
258
+
259
+ const plistPath = launchAgentPath({ home: userHome });
260
+ createLaunchctl({ exec, uid, platform }).bootout();
261
+ const removed = existsSync(plistPath);
262
+ if (removed) rmSync(plistPath, { force: true });
263
+ return { plistPath, removed };
264
+ }
265
+
266
+ /**
267
+ * @param {{ userHome?: string }} [opts]
268
+ * @returns {{ enabled: boolean, plistPath: string }}
269
+ */
270
+ export function autostartInfo({ userHome = homedir() } = {}) {
271
+ const plistPath = launchAgentPath({ home: userHome });
272
+ return { enabled: existsSync(plistPath), plistPath };
273
+ }
274
+
275
+ /**
276
+ * Collect what `--status` reports. Reads only: no file or token is created, nothing is started.
277
+ *
278
+ * @param {{ env?: Record<string,string|undefined>, userHome?: string, kill?: Function,
279
+ * fetchImpl?: typeof fetch }} [opts]
280
+ * @returns {Promise<{ status: 'running'|'unhealthy'|'stopped', state: object|null,
281
+ * token: string|null, autostart: { enabled: boolean, plistPath: string },
282
+ * logPath: string }>}
283
+ */
284
+ export async function collectStatus({
285
+ env = process.env,
286
+ userHome = homedir(),
287
+ kill = process.kill,
288
+ fetchImpl = globalThis.fetch,
289
+ } = {}) {
290
+ const state = readState({ env });
291
+ const token = readTokenFile(tokenFilePath(env));
292
+ const alive = Boolean(state) && isPidAlive(state.pid, { kill });
293
+ const answering =
294
+ alive && (await probeStudioHealth({ apiUrl: state.apiUrl, token, fetchImpl }));
295
+ const status = answering ? 'running' : alive ? 'unhealthy' : 'stopped';
296
+ return {
297
+ status,
298
+ state,
299
+ token,
300
+ autostart: autostartInfo({ userHome }),
301
+ logPath: logFilePath({ env }),
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Render the `--status` report. The token appears in full only inside the pairing URL; the separate
307
+ * token line is masked.
308
+ *
309
+ * @param {{ status: string, state: object|null, token: string|null, pairingUrl?: string|null,
310
+ * autostart: { enabled: boolean, plistPath: string }, logPath: string,
311
+ * userHome?: string }} report
312
+ * @returns {string}
313
+ */
314
+ export function formatStatus({
315
+ status,
316
+ state,
317
+ token,
318
+ pairingUrl,
319
+ autostart,
320
+ logPath,
321
+ userHome = homedir(),
322
+ }) {
323
+ const autostartLine = ` Autostart: ${autostart.enabled ? `on (${LABEL})` : 'off'}`;
324
+ const logLine = ` Log: ${displayPath(logPath, { home: userHome })}`;
325
+
326
+ if (status === 'running' && state) {
327
+ return [
328
+ `Professor Studio: running (pid ${state.pid})`,
329
+ ` Studio URL: ${state.studioUrl}`,
330
+ pairingUrl
331
+ ? ` Pairing URL: ${pairingUrl}`
332
+ : ' Pairing URL: (no token yet — run: skillforce studio --rotate-token)',
333
+ ` Token: ${maskToken(token)}`,
334
+ autostartLine,
335
+ logLine,
336
+ ].join('\n');
337
+ }
338
+ if (status === 'unhealthy' && state) {
339
+ return [
340
+ `Professor Studio: not answering (pid ${state.pid} from studio.json)`,
341
+ ` Studio URL: ${state.studioUrl}`,
342
+ autostartLine,
343
+ logLine,
344
+ ].join('\n');
345
+ }
346
+ return ['Professor Studio: not running', autostartLine, logLine].join('\n');
347
+ }
348
+
349
+ /** The `ps -o command=` line for a pid, or `null` when the process is gone. */
350
+ function readCommandLine(pid, { exec = execFileSync } = {}) {
351
+ try {
352
+ const command = exec('ps', ['-o', 'command=', '-p', String(pid)], {
353
+ stdio: 'pipe',
354
+ encoding: 'utf8',
355
+ });
356
+ return String(command || '').trim() || null;
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ /** A pid is ours only when its command line is a Studio CLI run. */
363
+ export function isStudioCommand(command) {
364
+ if (!command) return false;
365
+ return command.includes('cli.js') && command.includes('studio');
366
+ }
367
+
368
+ /**
369
+ * Stop Studio: the LaunchAgent first when it is installed, otherwise the pid from `studio.json`.
370
+ * A pid whose command line is not a Studio CLI run is never signalled.
371
+ *
372
+ * @param {{ env?: Record<string,string|undefined>, userHome?: string, platform?: string,
373
+ * exec?: Function, uid?: number, kill?: Function }} [opts]
374
+ * @returns {Promise<{ stopped: boolean, via?: 'launchctl'|'pid', pid?: number|null,
375
+ * reason?: 'not_running'|'stale', refused?: boolean, command?: string|null }>}
376
+ */
377
+ export async function stopStudio({
378
+ env = process.env,
379
+ userHome = homedir(),
380
+ platform = process.platform,
381
+ exec = execFileSync,
382
+ uid,
383
+ kill = process.kill,
384
+ } = {}) {
385
+ const state = readState({ env });
386
+ const { plistPath } = autostartInfo({ userHome });
387
+
388
+ if (existsSync(plistPath)) {
389
+ const result = createLaunchctl({ exec, uid, platform }).kill('SIGTERM');
390
+ if (result.ok) {
391
+ removeState({ env });
392
+ return { stopped: true, via: 'launchctl', pid: state?.pid ?? null };
393
+ }
394
+ }
395
+
396
+ if (!state) return { stopped: false, reason: 'not_running' };
397
+ if (!isPidAlive(state.pid, { kill })) {
398
+ removeState({ env });
399
+ return { stopped: false, reason: 'stale', pid: state.pid };
400
+ }
401
+
402
+ const command = readCommandLine(state.pid, { exec });
403
+ if (!isStudioCommand(command)) {
404
+ return { stopped: false, refused: true, pid: state.pid, command };
405
+ }
406
+
407
+ try {
408
+ kill(state.pid, 'SIGTERM');
409
+ } catch (err) {
410
+ if (err?.code !== 'ESRCH') throw err;
411
+ }
412
+ removeState({ env });
413
+ return { stopped: true, via: 'pid', pid: state.pid };
414
+ }