@rubytech/create-maxy-code 0.1.22 → 0.1.24

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 (173) hide show
  1. package/dist/__tests__/samba-provision.test.js +202 -0
  2. package/dist/index.js +127 -73
  3. package/dist/samba-provision.js +215 -0
  4. package/dist/uninstall.js +160 -3
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/admin/PLUGIN.md +4 -0
  7. package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +47 -0
  8. package/payload/platform/plugins/admin/skills/commitment-followthrough/SKILL.md +60 -0
  9. package/payload/platform/plugins/admin/skills/file-presentation/SKILL.md +67 -0
  10. package/payload/platform/plugins/admin/skills/session-management/SKILL.md +62 -0
  11. package/payload/platform/plugins/deep-research/.claude-plugin/plugin.json +1 -1
  12. package/payload/platform/plugins/deep-research/PLUGIN.md +7 -1
  13. package/payload/platform/plugins/deep-research/recipes/README.md +36 -0
  14. package/payload/platform/plugins/deep-research/skills/academic-verify/SKILL.md +75 -0
  15. package/payload/platform/plugins/deep-research/skills/book-mirror/SKILL.md +68 -0
  16. package/payload/platform/plugins/deep-research/skills/data-research/SKILL.md +108 -0
  17. package/payload/platform/plugins/deep-research/skills/strategic-reading/SKILL.md +69 -0
  18. package/payload/platform/plugins/docs/references/deployment.md +23 -2
  19. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +1 -1
  20. package/payload/platform/plugins/email/mcp/dist/scripts/email-fetch.d.ts +7 -2
  21. package/payload/platform/plugins/email/mcp/dist/scripts/email-fetch.d.ts.map +1 -1
  22. package/payload/platform/plugins/email/mcp/dist/scripts/email-fetch.js +7 -2
  23. package/payload/platform/plugins/email/mcp/dist/scripts/email-fetch.js.map +1 -1
  24. package/payload/platform/plugins/email/references/email-reference.md +4 -4
  25. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +2 -0
  26. package/payload/platform/plugins/memory/PLUGIN.md +6 -0
  27. package/payload/platform/plugins/memory/skills/archive-crawler/SKILL.md +67 -0
  28. package/payload/platform/plugins/memory/skills/concept-synthesis/SKILL.md +80 -0
  29. package/payload/platform/plugins/memory/skills/conversation-archive/SKILL.md +2 -0
  30. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +2 -0
  31. package/payload/platform/plugins/scheduling/PLUGIN.md +4 -1
  32. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.d.ts +7 -3
  33. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.d.ts.map +1 -1
  34. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +7 -3
  35. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  36. package/payload/platform/plugins/scheduling/skills/briefing/SKILL.md +75 -0
  37. package/payload/platform/plugins/scheduling/skills/daily-prep/SKILL.md +61 -0
  38. package/payload/platform/plugins/workflows/PLUGIN.md +2 -2
  39. package/payload/platform/plugins/workflows/skills/workflow-manager/SKILL.md +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/http-server.js +14 -1
  42. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +14 -0
  44. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +9 -2
  46. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  47. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts +25 -1
  48. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  49. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +54 -3
  50. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  51. package/payload/platform/templates/agents/admin/IDENTITY.md +39 -291
  52. package/payload/platform/templates/agents/admin/SOUL.md +4 -4
  53. package/payload/platform/templates/specialists/agents/content-producer.md +24 -69
  54. package/payload/platform/templates/specialists/agents/database-operator.md +49 -155
  55. package/payload/platform/templates/specialists/agents/personal-assistant.md +27 -177
  56. package/payload/platform/templates/specialists/agents/project-manager.md +29 -96
  57. package/payload/platform/templates/specialists/agents/research-assistant.md +36 -78
  58. package/payload/premium-plugins/real-agency/agents/compliance.md +14 -0
  59. package/payload/premium-plugins/real-agency/agents/negotiator.md +22 -0
  60. package/payload/premium-plugins/real-agency/agents/valuer.md +16 -0
  61. package/payload/premium-plugins/real-agency/plugins/estate-business/.claude-plugin/plugin.json +1 -1
  62. package/payload/premium-plugins/real-agency/plugins/estate-business/PLUGIN.md +29 -13
  63. package/payload/premium-plugins/real-agency/plugins/estate-business/skills/commission-calculator/SKILL.md +40 -0
  64. package/payload/premium-plugins/real-agency/plugins/estate-business/skills/month-end-close/SKILL.md +69 -0
  65. package/payload/premium-plugins/real-agency/plugins/estate-business/skills/payment-batch-stager/SKILL.md +42 -0
  66. package/payload/premium-plugins/real-agency/plugins/estate-business/skills/period-reconciler/SKILL.md +42 -0
  67. package/payload/premium-plugins/real-agency/plugins/estate-sales/.claude-plugin/plugin.json +1 -1
  68. package/payload/premium-plugins/real-agency/plugins/estate-sales/PLUGIN.md +27 -13
  69. package/payload/premium-plugins/real-agency/plugins/estate-sales/skills/chase-progression/SKILL.md +107 -0
  70. package/payload/premium-plugins/real-agency/plugins/estate-sales/skills/risk-scorer/SKILL.md +42 -0
  71. package/payload/premium-plugins/real-agency/plugins/leads/.claude-plugin/plugin.json +1 -1
  72. package/payload/premium-plugins/real-agency/plugins/leads/PLUGIN.md +24 -10
  73. package/payload/premium-plugins/real-agency/plugins/leads/skills/chain-progression-tracker/SKILL.md +51 -0
  74. package/payload/premium-plugins/real-agency/plugins/leads/skills/diary-builder/SKILL.md +38 -0
  75. package/payload/premium-plugins/real-agency/plugins/leads/skills/enquiry-triage/SKILL.md +36 -0
  76. package/payload/premium-plugins/real-agency/plugins/leads/skills/morning-round/SKILL.md +72 -0
  77. package/payload/premium-plugins/real-agency/plugins/listings/.claude-plugin/plugin.json +1 -1
  78. package/payload/premium-plugins/real-agency/plugins/listings/PLUGIN.md +43 -12
  79. package/payload/premium-plugins/real-agency/plugins/listings/skills/comparable-finder/SKILL.md +52 -0
  80. package/payload/premium-plugins/real-agency/plugins/listings/skills/epc-checker/SKILL.md +38 -0
  81. package/payload/premium-plugins/real-agency/plugins/listings/skills/listing-copy-writer/SKILL.md +55 -0
  82. package/payload/premium-plugins/real-agency/plugins/listings/skills/local-market-stats/SKILL.md +33 -0
  83. package/payload/premium-plugins/real-agency/plugins/listings/skills/new-instruction/SKILL.md +78 -0
  84. package/payload/premium-plugins/real-agency/plugins/listings/skills/particulars-builder/SKILL.md +48 -0
  85. package/payload/premium-plugins/real-agency/plugins/listings/skills/portal-launch-scheduler/SKILL.md +49 -0
  86. package/payload/premium-plugins/real-agency/plugins/listings/skills/pricing-scenario-builder/SKILL.md +35 -0
  87. package/payload/premium-plugins/real-agency/plugins/listings/skills/supplier-booker/SKILL.md +39 -0
  88. package/payload/premium-plugins/real-agency/plugins/listings/skills/talk-track-composer/SKILL.md +36 -0
  89. package/payload/premium-plugins/real-agency/plugins/listings/skills/terms-of-business-drafter/SKILL.md +54 -0
  90. package/payload/premium-plugins/real-agency/plugins/listings/skills/valuation-prep/SKILL.md +69 -0
  91. package/payload/premium-plugins/real-agency/plugins/loop/PLUGIN.md +20 -0
  92. package/payload/premium-plugins/real-agency/plugins/loop/skills/compliance-flag-checker/SKILL.md +53 -0
  93. package/payload/premium-plugins/real-agency/plugins/loop/skills/priority-ranker/SKILL.md +40 -0
  94. package/payload/premium-plugins/real-agency/plugins/loop/skills/tone-matched-drafter/SKILL.md +53 -0
  95. package/payload/premium-plugins/real-agency/plugins/loop/skills/variance-narrator/SKILL.md +50 -0
  96. package/payload/premium-plugins/real-agency/plugins/loop/skills/vendor-research/SKILL.md +54 -0
  97. package/payload/server/public/assets/{Checkbox-B79fVxpA.js → Checkbox-D1OQD43b.js} +1 -1
  98. package/payload/server/public/assets/admin-czNBxWor.js +216 -0
  99. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-D8e59YJ0.js → architectureDiagram-Q4EWVU46-BcwgT80u.js} +1 -1
  100. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CxaDkc0A.js → blockDiagram-DXYQGD6D-BMSyZUQA.js} +1 -1
  101. package/payload/server/public/assets/{brand-Cg9t5U6J.css → brand-2cku8WFs.css} +1 -1
  102. package/payload/server/public/assets/{brand-jT16ErmC.js → brand-CSQuxS9w.js} +1 -1
  103. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-D0PAvq-q.js → c4Diagram-AHTNJAMY-DPRGY1jJ.js} +1 -1
  104. package/payload/server/public/assets/channel-fxEghWew.js +1 -0
  105. package/payload/server/public/assets/{chunk-336JU56O-B-CXn-Et.js → chunk-336JU56O-B7oQ3g1c.js} +2 -2
  106. package/payload/server/public/assets/{chunk-426QAEUC-BLzCQHKA.js → chunk-426QAEUC-C1P0yFXw.js} +1 -1
  107. package/payload/server/public/assets/{chunk-4TB4RGXK-Bql1UwLT.js → chunk-4TB4RGXK-LI7kOJd0.js} +1 -1
  108. package/payload/server/public/assets/{chunk-5FUZZQ4R-CQK7jBtX.js → chunk-5FUZZQ4R-CXQRGTQE.js} +1 -1
  109. package/payload/server/public/assets/{chunk-5PVQY5BW-AJc1-lvX.js → chunk-5PVQY5BW-NSyzpXRy.js} +1 -1
  110. package/payload/server/public/assets/{chunk-EDXVE4YY-Cf3E3THL.js → chunk-EDXVE4YY-voNwxbDs.js} +1 -1
  111. package/payload/server/public/assets/{chunk-ENJZ2VHE-BNx6z6hJ.js → chunk-ENJZ2VHE-CMEMPzYY.js} +1 -1
  112. package/payload/server/public/assets/{chunk-ICPOFSXX-DBUEFs2-.js → chunk-ICPOFSXX-hEbwu-pe.js} +1 -1
  113. package/payload/server/public/assets/{chunk-OYMX7WX6-Csx2p315.js → chunk-OYMX7WX6-DxskDrLs.js} +1 -1
  114. package/payload/server/public/assets/{chunk-U2HBQHQK-x17h7UYW.js → chunk-U2HBQHQK-D7TKgUo0.js} +1 -1
  115. package/payload/server/public/assets/{chunk-X2U36JSP--Lkl5yjV.js → chunk-X2U36JSP-BvPUQEPm.js} +1 -1
  116. package/payload/server/public/assets/{chunk-YZCP3GAM-C4GsNX8A.js → chunk-YZCP3GAM-BY-RWQUW.js} +1 -1
  117. package/payload/server/public/assets/{chunk-ZZ45TVLE-YrhUPmZc.js → chunk-ZZ45TVLE-DZvOYDY6.js} +1 -1
  118. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BsWzGW0N.js +1 -0
  119. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BGVa3h90.js +1 -0
  120. package/payload/server/public/assets/clone-Khvocke2.js +1 -0
  121. package/payload/server/public/assets/{dagre-YVALPG-M.js → dagre-Bt-fpckL.js} +1 -1
  122. package/payload/server/public/assets/{dagre-KV5264BT-D6JU6DW_.js → dagre-KV5264BT-Cnj0mUZl.js} +1 -1
  123. package/payload/server/public/assets/data-DBd-Buhp.js +1 -0
  124. package/payload/server/public/assets/device-url-actions-Bjz3Xzbm.js +33 -0
  125. package/payload/server/public/assets/{diagram-5BDNPKRD-yeO06N5Q.js → diagram-5BDNPKRD-DjLzvOlx.js} +1 -1
  126. package/payload/server/public/assets/{diagram-G4DWMVQ6-DzbVT_BC.js → diagram-G4DWMVQ6-DTfuRd-T.js} +1 -1
  127. package/payload/server/public/assets/{diagram-MMDJMWI5-DwYO5VZF.js → diagram-MMDJMWI5-BaL2mCnx.js} +1 -1
  128. package/payload/server/public/assets/{diagram-TYMM5635-BLUcdkDS.js → diagram-TYMM5635-C5InWY5R.js} +1 -1
  129. package/payload/server/public/assets/{erDiagram-SMLLAGMA-BiEUB19e.js → erDiagram-SMLLAGMA-DO7BXTpn.js} +1 -1
  130. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-TILIKxOp.js → flowDiagram-DWJPFMVM-DDdAKfLf.js} +1 -1
  131. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-B7cGzYqT.js → ganttDiagram-T4ZO3ILL-arJD8Utm.js} +1 -1
  132. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DFOxN5bc.js → gitGraphDiagram-UUTBAWPF-C55GH-OS.js} +1 -1
  133. package/payload/server/public/assets/graph-DUtVdnZ6.js +1 -0
  134. package/payload/server/public/assets/graph-labels-Dxfue-fP.js +1 -0
  135. package/payload/server/public/assets/{graphlib-BBibixaA.js → graphlib-DL9PM7Ex.js} +1 -1
  136. package/payload/server/public/assets/{infoDiagram-42DDH7IO-nH2azhY8.js → infoDiagram-42DDH7IO-BMSGqUbG.js} +1 -1
  137. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-WD3tfqFi.js → ishikawaDiagram-UXIWVN3A-Dw6BZ6BG.js} +1 -1
  138. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-LUkaVSqw.js → journeyDiagram-VCZTEJTY-DrywUGXw.js} +1 -1
  139. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-Dk-lYgpJ.js → kanban-definition-6JOO6SKY-DuwtVBBc.js} +1 -1
  140. package/payload/server/public/assets/{line-BDv6CEnp.js → line-JAksyKHj.js} +1 -1
  141. package/payload/server/public/assets/{mermaid-parser.core-D2XsSGgp.js → mermaid-parser.core-BMq-ApBW.js} +1 -1
  142. package/payload/server/public/assets/{mermaid.core-FyN-UmQV.js → mermaid.core-tH4oX0Kh.js} +3 -3
  143. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-BRAHEUIS.js → mindmap-definition-QFDTVHPH-D1OiiJga.js} +1 -1
  144. package/payload/server/public/assets/page-BZpoS7iR.js +1 -0
  145. package/payload/server/public/assets/{page-CTbSJbem.js → page-CkvBvezS.js} +2 -2
  146. package/payload/server/public/assets/{pieDiagram-DEJITSTG-BqibVC2X.js → pieDiagram-DEJITSTG-Ckwm69PW.js} +1 -1
  147. package/payload/server/public/assets/{public-BDUZIabs.js → public-C-dTMgXu.js} +5 -5
  148. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-DNuExGnr.js → quadrantDiagram-34T5L4WZ-COw3yZ1j.js} +1 -1
  149. package/payload/server/public/assets/{requirementDiagram-MS252O5E-5JXTdydh.js → requirementDiagram-MS252O5E-DqGzM4K-.js} +1 -1
  150. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-B_8rhvcR.js → sankeyDiagram-XADWPNL6-D-l1c_Pl.js} +1 -1
  151. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BznkBgjf.js → sequenceDiagram-FGHM5R23-BeIi0DtJ.js} +1 -1
  152. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-BeAZOQfs.js → stateDiagram-FHFEXIEX-C-jgegLk.js} +1 -1
  153. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BaMs8Znv.js +1 -0
  154. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-CpJAs-Vw.js → timeline-definition-GMOUNBTQ-BGFKkYmi.js} +1 -1
  155. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BzH3ItkG.js → vennDiagram-DHZGUBPP-5NuIhJLS.js} +1 -1
  156. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-ax9AgwA1.js → wardleyDiagram-NUSXRM2D-Be9ytVut.js} +1 -1
  157. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-CV6vt_tW.js → xychartDiagram-5P7HB3ND-DCyHg41R.js} +1 -1
  158. package/payload/server/public/data.html +5 -5
  159. package/payload/server/public/graph.html +6 -6
  160. package/payload/server/public/index.html +8 -8
  161. package/payload/server/public/public.html +5 -5
  162. package/payload/server/server.js +62 -101
  163. package/payload/server/public/assets/admin-CXLuiXFU.js +0 -216
  164. package/payload/server/public/assets/channel-BU_eIdRB.js +0 -1
  165. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMpM1d2b.js +0 -1
  166. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-D_XbuPVj.js +0 -1
  167. package/payload/server/public/assets/clone-BBT00JUO.js +0 -1
  168. package/payload/server/public/assets/data-BdwO_kv-.js +0 -1
  169. package/payload/server/public/assets/device-url-actions-C8dD0ydz.js +0 -33
  170. package/payload/server/public/assets/graph-DpgsOhUZ.js +0 -1
  171. package/payload/server/public/assets/graph-labels-DJ717p00.js +0 -1
  172. package/payload/server/public/assets/page-BWHYktEF.js +0 -1
  173. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-iVlXKz7S.js +0 -1
@@ -0,0 +1,215 @@
1
+ // Task 034 — Samba provisioning for the brand Pi filesystem.
2
+ //
3
+ // Same shape as apt-resolve.ts: pure decision functions in this file, no I/O;
4
+ // the installer wraps them with the side-effecting spawnSync + log lines. The
5
+ // pure layer is fully unit-tested; the wrapper is exercised end-to-end on a
6
+ // real Pi during install/uninstall verification.
7
+ //
8
+ // What this module owns:
9
+ // 1. Pick the LAN interface to bind smbd to (loopback excluded).
10
+ // 2. Render the brand-scoped Samba stanza per the spec in the task brief.
11
+ // 3. Merge that stanza into an existing /etc/samba/smb.conf idempotently —
12
+ // replace if a stanza for this brand already exists, otherwise append.
13
+ // 4. Remove this brand's stanza on uninstall while leaving every peer brand's
14
+ // stanza intact (peer-brand isolation is the structural invariant).
15
+ // 5. Report whether any brand stanza remains so the uninstall step can
16
+ // decide whether to apt-purge samba.
17
+ // ---------------------------------------------------------------------------
18
+ // Brand-scoped Samba stanza
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Render the `[<brand>]` share stanza. Exact directives are the task's spec
22
+ * verbatim: share rooted at `sharePath`, writable by `admin` only, force-uid
23
+ * to admin so files created via SMB are owned by the same uid as files
24
+ * created over SSH, and standard create/dir masks for an ext4 home directory.
25
+ */
26
+ export function renderBrandStanza(input) {
27
+ return [
28
+ `[${input.brand}]`,
29
+ ` path = ${input.sharePath}`,
30
+ ` read only = no`,
31
+ ` valid users = admin`,
32
+ ` force user = admin`,
33
+ ` browseable = yes`,
34
+ ` create mask = 0664`,
35
+ ` directory mask = 0775`,
36
+ ``,
37
+ ].join("\n");
38
+ }
39
+ /**
40
+ * Render the `[global]` section. `interfaces = lo <lan>` plus `bind interfaces
41
+ * only = yes` is the LAN-only posture: smbd accepts connections on the LAN
42
+ * interface and loopback, nothing else. Cloudflare tunnels carry HTTPS only,
43
+ * so this is the structural guarantee that SMB never leaves the LAN even if
44
+ * the firewall is misconfigured upstream.
45
+ */
46
+ export function renderGlobalSection(input) {
47
+ return [
48
+ `[global]`,
49
+ ` workgroup = WORKGROUP`,
50
+ ` server string = %h Samba`,
51
+ ` server role = standalone server`,
52
+ ` interfaces = lo ${input.lanInterface}`,
53
+ ` bind interfaces only = yes`,
54
+ ` log file = /var/log/samba/log.%m`,
55
+ ` max log size = 1000`,
56
+ ` panic action = /usr/share/samba/panic-action %d`,
57
+ ` passdb backend = tdbsam`,
58
+ ` unix password sync = no`,
59
+ ` map to guest = bad user`,
60
+ ` usershare allow guests = no`,
61
+ ``,
62
+ ].join("\n");
63
+ }
64
+ /**
65
+ * Build a complete smb.conf from scratch — globals + one brand stanza. Used
66
+ * only when no existing config is present (fresh apt install always writes
67
+ * one, so this branch fires on weird edge cases like operator-deleted conf
68
+ * files). Normal path is `mergeSmbConf` against the apt-shipped default.
69
+ */
70
+ export function renderFullSmbConf(input) {
71
+ return renderGlobalSection({ lanInterface: input.lanInterface }) +
72
+ renderBrandStanza({ brand: input.brand, sharePath: input.sharePath });
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // LAN interface detection
76
+ // ---------------------------------------------------------------------------
77
+ /**
78
+ * Pick the LAN interface to bind smbd to. Preference order: wlan0, eth0, then
79
+ * the first non-loopback interface with a non-internal IPv4 address. Returns
80
+ * null when no such interface exists — the caller treats that as a hard
81
+ * failure (the Pi has no LAN connectivity; SMB has nothing to bind to).
82
+ *
83
+ * Input is the shape returned by `os.networkInterfaces()` so the test can pass
84
+ * realistic fixtures without spinning up real interfaces.
85
+ */
86
+ export function pickLanInterface(ifaces) {
87
+ const hasIPv4 = (name) => {
88
+ const addrs = ifaces[name];
89
+ if (!addrs)
90
+ return false;
91
+ return addrs.some((a) => a.family === "IPv4" && !a.internal);
92
+ };
93
+ if (hasIPv4("wlan0"))
94
+ return "wlan0";
95
+ if (hasIPv4("eth0"))
96
+ return "eth0";
97
+ for (const name of Object.keys(ifaces)) {
98
+ if (name === "lo")
99
+ continue;
100
+ if (hasIPv4(name))
101
+ return name;
102
+ }
103
+ return null;
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // smb.conf merge / remove
107
+ // ---------------------------------------------------------------------------
108
+ /**
109
+ * Find the start and end byte offsets of a `[<section>]` block in an smb.conf.
110
+ * End is the index just before the next `[…]` header or EOF, whichever comes
111
+ * first. Returns null when no such section exists.
112
+ *
113
+ * Section names are matched case-sensitively because Samba itself is
114
+ * case-sensitive for share names on Linux filesystems.
115
+ */
116
+ function findSectionRange(conf, section) {
117
+ const lines = conf.split("\n");
118
+ const header = `[${section}]`;
119
+ let startLine = -1;
120
+ for (let i = 0; i < lines.length; i++) {
121
+ if (lines[i].trim() === header) {
122
+ startLine = i;
123
+ break;
124
+ }
125
+ }
126
+ if (startLine === -1)
127
+ return null;
128
+ let endLine = lines.length;
129
+ for (let i = startLine + 1; i < lines.length; i++) {
130
+ if (/^\[[^\]]+\]\s*$/.test(lines[i].trim())) {
131
+ endLine = i;
132
+ break;
133
+ }
134
+ }
135
+ const start = lines.slice(0, startLine).reduce((n, l) => n + l.length + 1, 0);
136
+ const sectionText = lines.slice(startLine, endLine).join("\n") + (endLine < lines.length ? "\n" : "");
137
+ return { start, end: start + sectionText.length };
138
+ }
139
+ /**
140
+ * Merge globals + brand stanza into an existing smb.conf. Idempotent: a
141
+ * second call with the same inputs produces byte-identical output.
142
+ *
143
+ * - `[global]`: replace verbatim with our rendered globals. The apt-shipped
144
+ * `[global]` is a good starting point but doesn't carry our LAN-only
145
+ * directives; replacing it is the only way to guarantee `bind interfaces
146
+ * only = yes` is in effect.
147
+ * - `[<brand>]`: replace if present, append at end of file otherwise. Peer
148
+ * brand stanzas (other `[…]` blocks) are preserved verbatim.
149
+ */
150
+ export function mergeSmbConf(input) {
151
+ const { existing, brand, sharePath, lanInterface } = input;
152
+ const newGlobals = renderGlobalSection({ lanInterface });
153
+ const newBrand = renderBrandStanza({ brand, sharePath });
154
+ let conf = existing;
155
+ // Replace or insert the global section.
156
+ const globalRange = findSectionRange(conf, "global");
157
+ if (globalRange) {
158
+ conf = conf.slice(0, globalRange.start) + newGlobals + conf.slice(globalRange.end);
159
+ }
160
+ else {
161
+ conf = newGlobals + (conf.startsWith("\n") ? conf : conf);
162
+ }
163
+ // Replace or insert the brand stanza.
164
+ const brandRange = findSectionRange(conf, brand);
165
+ if (brandRange) {
166
+ conf = conf.slice(0, brandRange.start) + newBrand + conf.slice(brandRange.end);
167
+ }
168
+ else {
169
+ // Append with a single trailing newline guarantee.
170
+ if (!conf.endsWith("\n"))
171
+ conf += "\n";
172
+ conf += newBrand;
173
+ }
174
+ return conf;
175
+ }
176
+ /**
177
+ * Remove `[<brand>]` from an smb.conf. Returns the input unchanged when no
178
+ * such stanza exists. Other sections (global, other brands) are preserved
179
+ * byte-for-byte. Used by the uninstall path.
180
+ */
181
+ export function removeBrandStanza(input) {
182
+ const { existing, brand } = input;
183
+ const range = findSectionRange(existing, brand);
184
+ if (!range)
185
+ return existing;
186
+ return existing.slice(0, range.start) + existing.slice(range.end);
187
+ }
188
+ /**
189
+ * True when at least one non-global stanza remains in the smb.conf. Used by
190
+ * uninstall to decide whether to apt-purge samba: only purge when no brand
191
+ * stanza is left.
192
+ */
193
+ export function hasAnyBrandStanza(conf) {
194
+ const lines = conf.split("\n");
195
+ for (const line of lines) {
196
+ const m = line.trim().match(/^\[([^\]]+)\]$/);
197
+ if (m && m[1].toLowerCase() !== "global")
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // Step-marker contract
204
+ // ---------------------------------------------------------------------------
205
+ /**
206
+ * The four install-invariant markers the installer (and uninstall, set-pin)
207
+ * emit. Locked here so a typo at a call site cannot silently drift from the
208
+ * spec. The state vocabulary is also locked: `ok` (step succeeded), `fail:
209
+ * <stderr>` (step threw — installer aborts), `deferred reason=<…>` (step
210
+ * intentionally skipped because a precondition wasn't met).
211
+ */
212
+ export const SAMBA_STEPS = ["apt", "conf", "user", "units"];
213
+ export function formatSambaMarker(step, state) {
214
+ return `[install-invariant] samba-provision-${step} ${state}`;
215
+ }
package/dist/uninstall.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, appendFileSyn
3
3
  import { resolve, join, dirname } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createInterface } from "node:readline";
6
+ import { removeBrandStanza, hasAnyBrandStanza } from "./samba-provision.js";
6
7
  const HOME = homedir();
7
8
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
8
9
  // Brand manifest — read from payload to derive brand-specific installation paths.
@@ -23,7 +24,7 @@ catch (err) {
23
24
  const INSTALL_DIR = resolve(HOME, BRAND.installDir);
24
25
  const CONFIG_DIR = resolve(HOME, BRAND.configDir);
25
26
  const LOG_FILE = join("/tmp", `${BRAND.productName.toLowerCase().replace(/\s+/g, "-")}-uninstall-${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
26
- const TOTAL = "10";
27
+ const TOTAL = "11";
27
28
  // ---------------------------------------------------------------------------
28
29
  // Logging — timestamped to console AND persistent log file in /tmp
29
30
  // (Log lives in /tmp because the uninstall deletes the config directory)
@@ -233,6 +234,80 @@ function stopServices() {
233
234
  console.log(" Stopped Ollama");
234
235
  }
235
236
  // ---------------------------------------------------------------------------
237
+ // Step 1b: Strip the legacy installer-registered cron block
238
+ //
239
+ // Task 039 retired `installCrons()` in src/index.ts. Older installs (every
240
+ // brand prior to the Task 039 release) wrote three minute-cadence entries
241
+ // between `# BEGIN <BRAND> CRONS` and `# END <BRAND> CRONS`. This step
242
+ // removes that block on uninstall so an upgraded device is fully torn down
243
+ // and a subsequent reinstall does not get re-seeded by cron recreating
244
+ // `data/accounts/<accountId>/logs/` every 60s (which tripped seed-neo4j.sh's
245
+ // stub-account-dirs guard — see Task 039 problem statement). Idempotent:
246
+ // no-op when the block is absent. Gated on Linux to mirror the installer's
247
+ // `installCrons` which early-returned on every other platform.
248
+ // ---------------------------------------------------------------------------
249
+ function removeLegacyCronBlock() {
250
+ log("1b", "Removing legacy cron block...");
251
+ if (!isLinux()) {
252
+ console.log(" Skipped — non-Linux platform.");
253
+ return;
254
+ }
255
+ if (!commandExists("crontab")) {
256
+ console.log(" crontab not available — nothing to strip.");
257
+ return;
258
+ }
259
+ const current = spawnSync("crontab", ["-l"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
260
+ // Non-zero status (or empty stdout) on `crontab -l` means the user has no
261
+ // crontab at all — nothing to strip, and writing back would create one.
262
+ if (current.status !== 0 || !current.stdout) {
263
+ console.log(" Cron block: none present");
264
+ return;
265
+ }
266
+ const beginMarker = `# BEGIN ${BRAND.productName.toUpperCase()} CRONS`;
267
+ const endMarker = `# END ${BRAND.productName.toUpperCase()} CRONS`;
268
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
269
+ const blockPattern = new RegExp(`${escapeRegex(beginMarker)}[\\s\\S]*?${escapeRegex(endMarker)}\\n?`, "g");
270
+ // Count entry lines (non-comment, non-blank) between markers for the
271
+ // operator-visible log. matchAll returns all block bodies in one pass.
272
+ const extractPattern = new RegExp(`${escapeRegex(beginMarker)}([\\s\\S]*?)${escapeRegex(endMarker)}`, "g");
273
+ let entryCount = 0;
274
+ for (const match of current.stdout.matchAll(extractPattern)) {
275
+ entryCount += match[1]
276
+ .split("\n")
277
+ .filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"))
278
+ .length;
279
+ }
280
+ if (entryCount === 0 && !blockPattern.test(current.stdout)) {
281
+ console.log(" Cron block: none present");
282
+ return;
283
+ }
284
+ const stripped = current.stdout.replace(blockPattern, "").trimEnd();
285
+ if (stripped.length === 0) {
286
+ // Block was the only content — remove the crontab outright so `crontab -l`
287
+ // reports "no crontab for admin" (Task 039 success criterion). Writing an
288
+ // empty buffer via `crontab -` leaves a zero-byte crontab on Debian.
289
+ const removed = spawnSync("crontab", ["-r"], { stdio: "pipe" });
290
+ if (removed.status !== 0) {
291
+ console.log(` Cron block: stripped ${entryCount} entries but crontab -r failed — ${(removed.stderr ?? "").toString().trim()}`);
292
+ logFile(` crontab -r failed: ${removed.stderr}`);
293
+ return;
294
+ }
295
+ }
296
+ else {
297
+ const write = spawnSync("crontab", ["-"], {
298
+ input: stripped + "\n",
299
+ encoding: "utf-8",
300
+ stdio: ["pipe", "pipe", "pipe"],
301
+ });
302
+ if (write.status !== 0) {
303
+ console.log(` Cron block: write failed — ${(write.stderr ?? "").trim()}`);
304
+ logFile(` crontab write failed: ${write.stderr}`);
305
+ return;
306
+ }
307
+ }
308
+ console.log(` Cron block: removed ${entryCount} entries`);
309
+ }
310
+ // ---------------------------------------------------------------------------
236
311
  // Step 2: Delete Cloudflare tunnel
237
312
  // ---------------------------------------------------------------------------
238
313
  function deleteCloudflareTunnel() {
@@ -709,10 +784,90 @@ function removeOllama() {
709
784
  // Models directory (~/.ollama/) is removed in step 4 (removeAppDirs)
710
785
  }
711
786
  // ---------------------------------------------------------------------------
712
- // Step 10: Restore hostname
787
+ // Task 034 Samba teardown. Symmetric to provisionSamba in index.ts.
788
+ //
789
+ // Peer-brand discipline: smb.conf, the smbpasswd entry, and the samba apt
790
+ // package are device-wide singletons shared by every brand that ships an
791
+ // SMB share. Drop only this brand's stanza on every uninstall; stop+disable
792
+ // units / smbpasswd -x admin / apt-purge samba run only when no brand stanza
793
+ // remains in smb.conf AND no peer brand is detected. This mirrors the
794
+ // Neo4j / cloudflared / Ollama treatment in steps 5–9 above.
795
+ // ---------------------------------------------------------------------------
796
+ function removeSamba() {
797
+ log("10", "Removing Samba share...");
798
+ if (!isLinux()) {
799
+ console.log(" Not Linux — skipping.");
800
+ return;
801
+ }
802
+ const SMB_CONF = "/etc/samba/smb.conf";
803
+ if (!existsSync(SMB_CONF)) {
804
+ console.log(" /etc/samba/smb.conf not present — nothing to remove.");
805
+ return;
806
+ }
807
+ let existing = "";
808
+ try {
809
+ const cat = spawnSync("sudo", ["cat", SMB_CONF], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
810
+ if (cat.status === 0)
811
+ existing = cat.stdout ?? "";
812
+ }
813
+ catch {
814
+ console.log(" Could not read smb.conf — skipping stanza removal.");
815
+ return;
816
+ }
817
+ const stripped = removeBrandStanza({ existing, brand: BRAND.hostname });
818
+ if (stripped !== existing) {
819
+ const tee = spawnSync("sudo", ["tee", SMB_CONF], { input: stripped, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 });
820
+ if (tee.status === 0) {
821
+ console.log(` Removed [${BRAND.hostname}] stanza from ${SMB_CONF}`);
822
+ }
823
+ else {
824
+ console.log(` Failed to write stripped smb.conf: ${(tee.stderr ?? "").trim()}`);
825
+ }
826
+ }
827
+ else {
828
+ console.log(` No [${BRAND.hostname}] stanza found in ${SMB_CONF}`);
829
+ }
830
+ // Reload smbd so the brand share disappears from the running config without
831
+ // dropping connections to peer brand shares. `reload` is best-effort: the
832
+ // operator has already torn down the brand; failing to reload would leave a
833
+ // dangling share name but nothing routable to its (now-deleted) sharePath.
834
+ spawnSync("sudo", ["systemctl", "reload", "smbd"], { stdio: "pipe", timeout: 10_000 });
835
+ // Per-brand cleanup stops here when a peer brand stanza still references the
836
+ // share — disabling smbd or purging samba would take the peer's share down.
837
+ const peer = peerBrandPresent();
838
+ if (hasAnyBrandStanza(stripped) || peer) {
839
+ const reason = peer ? `peer brand present (${peer})` : "other brand stanza remains";
840
+ console.log(` Leaving smbd/nmbd + samba package in place — ${reason}`);
841
+ return;
842
+ }
843
+ // No brand stanza, no peer brand — full device-wide teardown.
844
+ try {
845
+ spawnSync("sudo", ["systemctl", "disable", "--now", "smbd", "nmbd"], { stdio: "pipe", timeout: 30_000 });
846
+ console.log(" Stopped + disabled smbd, nmbd");
847
+ }
848
+ catch (err) {
849
+ console.log(` Failed to disable smbd/nmbd: ${err instanceof Error ? err.message : String(err)}`);
850
+ }
851
+ // Drop the admin smbpasswd entry. `smbpasswd -x` exits 0 on success, non-zero
852
+ // if the user isn't in the passdb — both are acceptable end-states.
853
+ spawnSync("sudo", ["smbpasswd", "-x", "admin"], { stdio: "pipe", timeout: 10_000 });
854
+ try {
855
+ shell("apt-get", ["remove", "--purge", "-y", "samba", "samba-common-bin"], { sudo: true, timeout: 120_000 });
856
+ console.log(" Purged samba package.");
857
+ }
858
+ catch (err) {
859
+ console.log(` apt-get purge samba failed: ${err instanceof Error ? err.message : String(err)}`);
860
+ console.log(" Run manually: sudo apt-get remove --purge -y samba samba-common-bin");
861
+ }
862
+ // Drop the smbpasswd sudoers grant once no brand stanza references it.
863
+ spawnSync("sudo", ["rm", "-f", "/etc/sudoers.d/maxy-samba"], { stdio: "pipe", timeout: 5_000 });
864
+ console.log(" Removed /etc/sudoers.d/maxy-samba");
865
+ }
866
+ // ---------------------------------------------------------------------------
867
+ // Step 11: Restore hostname
713
868
  // ---------------------------------------------------------------------------
714
869
  function restoreHostname() {
715
- log("10", "Restoring hostname...");
870
+ log("11", "Restoring hostname...");
716
871
  if (!isLinux()) {
717
872
  console.log(" Not Linux — skipping.");
718
873
  return;
@@ -810,6 +965,7 @@ export async function runUninstall(options) {
810
965
  const failures = [];
811
966
  const steps = [
812
967
  { name: "Stop services", fn: stopServices },
968
+ { name: "Remove legacy cron block", fn: removeLegacyCronBlock },
813
969
  { name: "Delete Cloudflare tunnel", fn: deleteCloudflareTunnel },
814
970
  ...(options.exportPath
815
971
  ? [{ name: "Export data", fn: () => exportData(options.exportPath) }]
@@ -821,6 +977,7 @@ export async function runUninstall(options) {
821
977
  { name: "Remove system configuration", fn: removeSystemConfig },
822
978
  { name: "Remove systemd service", fn: removeSystemdService },
823
979
  { name: "Remove Ollama", fn: removeOllama },
980
+ { name: "Remove Samba share", fn: removeSamba },
824
981
  { name: "Restore hostname", fn: restoreHostname },
825
982
  ];
826
983
  for (const step of steps) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -69,6 +69,10 @@ Tools are available via the `admin` MCP server.
69
69
  | Manage specialists | User asks to install or remove a specialist subagent, or activate/deactivate premium plugin agents | `skills/specialist-management/SKILL.md` |
70
70
  | Generate print-quality PDF | User asks to create a PDF document, one-pager, brochure, or any HTML intended for print/download | `skills/a4-print-documents/SKILL.md` |
71
71
  | Plain-English explanation | User asks to explain, define, or pre-empts ("explain in plain English", "what does X mean", "define X", "I don't understand", "what is this"), or admin is about to return a reply containing a term not in the operator's prior turn | `skills/plainly/SKILL.md` |
72
+ | Manage admin users | User asks to add, remove, or list admins on this account, or change an admin PIN | `skills/admin-user-management/SKILL.md` |
73
+ | Session reset / continue / resume | User asks to clear the session, start fresh, continue the last session, or pick up where they left off | `skills/session-management/SKILL.md` |
74
+ | Commitment follow-through | A `<commitment-detected>` block appears in the prompt; the owner has just made a commitment that needs a backing mechanism | `skills/commitment-followthrough/SKILL.md` |
75
+ | Show or download a file | User asks to view, attach, or download a file or document, or the current turn produces a document the owner needs to review | `skills/file-presentation/SKILL.md` |
72
76
 
73
77
  ## Hooks
74
78
 
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: admin-user-management
3
+ description: "Add, remove, list admins on this account, and update an admin PIN. Triggers when the owner asks to invite another admin, remove an admin, see who has admin access, or change a PIN."
4
+ ---
5
+
6
+ # Admin user management
7
+
8
+ This skill manages who has admin access to this account. It wraps four MCP tools and carries the discipline that keeps the three identity stores in lockstep. The owner (or any current admin) can manage admins; roles are labels only, with no capability differences enforced.
9
+
10
+ ## The four tools
11
+
12
+ | Tool | Purpose |
13
+ |---|---|
14
+ | `admin-add` | Add a new admin. Requires a name. PIN is optional: omit it and a unique 4-digit PIN is generated. PIN must be at least 4 digits and unique across all users on the device. |
15
+ | `admin-remove` | Remove an admin by `userId` (find IDs via `admin-list`). The last admin on the account cannot be removed. The user's device-level entry is retained so they can still administer other accounts. |
16
+ | `admin-list` | List all admins on this account with names and roles. |
17
+ | `admin-update-pin` | Update an admin's PIN. Defaults to the calling admin if no `userId` is given. PIN must be at least 4 digits and unique across all users on the device. |
18
+
19
+ ## The three-store invariant
20
+
21
+ Admin identity lives in three places that must stay in lockstep:
22
+
23
+ 1. `account.json` `admins[]`: account-level role record.
24
+ 2. `users.json`: device-level PIN authentication. This is the source of truth at login.
25
+ 3. The Neo4j graph: `:AdminUser` + `:Person` + `OWNS` + `ADMIN_OF` edges. Display and graph identity.
26
+
27
+ `admin-add` writes all three. `admin-update-pin` writes `users.json` only (the other stores carry no PIN). If any leg fails, the tool returns `is_error: true` and `server.log` carries a `[admin-auth-store] action=… userId=… result=fail store=…` line naming which leg failed and what was already written. When you see that line, tell the owner the record is half-written and may need manual reconciliation.
28
+
29
+ ## Never write `account.json` directly
30
+
31
+ All account-level mutations (`tier`, `outputStyle`, `thinkingView`, `effort`, `enabledPlugins`, `admins[]`, agent settings) go through the dedicated MCP tools: `account-update`, `plugin-toggle-enabled`, `admin-add`, `admin-remove`. The pre-tool-use hook denies direct `Edit` or `Write` on `account.json` server-side; this rule is the explanation for the denial. `tier` and `purchasedPlugins` derive from a Rubytech-signed entitlement payload on commercial installs, so a hand-edit is silently void.
32
+
33
+ ## `admin-add` retries: re-pass any user-stated PIN
34
+
35
+ If `admin-add` returns an error (tier cap reached, PIN collision) and the owner resolves the blocker (upgrade tier, remove an existing admin), the retried `admin-add` MUST re-pass the PIN the owner originally stated as the `pin` parameter. Omitting `pin` on the retry auto-generates a different 4-digit PIN, silently substituting what the owner asked for. The resulting `admin-update-pin` correction loop is exactly the failure mode this rule exists to prevent.
36
+
37
+ ## Standard flow
38
+
39
+ 1. Read the request. Identify which of the four operations applies.
40
+ 2. For `admin-add`, gather the name. The PIN is optional; if the owner stated one, use it; otherwise let the tool generate one. For `admin-remove`, call `admin-list` first if the owner did not give a `userId`.
41
+ 3. Call the tool.
42
+ 4. If the tool succeeds, confirm the result in plain English. Include the PIN in the reply for `admin-add` so the owner can share it with the new admin.
43
+ 5. If the tool returns an error, surface the failure and the `[admin-auth-store]` line from `server.log` if present. Do not retry the same call; resolve the underlying blocker first.
44
+
45
+ ## What this skill does not do
46
+
47
+ It does not manage tier upgrades. It does not change account-level settings. It does not manage public agents or plugins. Each of those has its own skill.
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: commitment-followthrough
3
+ description: "Handles the platform-detected commitment signal: offers to track or automate the commitment, waits for owner confirmation, then picks the right backing mechanism (scheduled event, task, or workflow). Loads when the system prompt contains a `<commitment-detected>` block."
4
+ ---
5
+
6
+ # Commitment follow-through
7
+
8
+ This skill activates when the platform identifies that the owner has just made a commitment: something they said they would do. The skill's job is to offer the right backing mechanism, wait for the owner's confirmation, then create it. Without a backing mechanism, a commitment is a broken promise; with one, the agent can hold the owner to their word.
9
+
10
+ ## The trigger
11
+
12
+ A `<commitment-detected>` block appears in the system prompt. The block names what the platform classifier extracted: the commitment text, the suggested mechanism (scheduled event, task, or workflow), and any time, recipient, or topic the classifier inferred.
13
+
14
+ ## How to offer
15
+
16
+ Surface a brief, conversational offer. Name the commitment, name the suggested mechanism, and ask the owner to confirm, modify, or dismiss. One or two sentences. Not a form. Not a list of options. Examples:
17
+
18
+ - "I'll set a reminder for Tuesday morning to chase the Acme invoice. Confirm?"
19
+ - "Want me to create a task for the photo brief so it doesn't slip?"
20
+ - "Shall I schedule the weekly check-in with Daniel on Mondays at 9?"
21
+
22
+ ## Never act without confirmation
23
+
24
+ The owner must say "yes" or give specifics before any tool call. If they dismiss ("no thanks", "I'll handle it"), acknowledge briefly and continue with whatever they were discussing. Do not re-offer for the same commitment.
25
+
26
+ If they modify ("make it next Monday instead"), incorporate the changes and confirm before creating.
27
+
28
+ ## The two-rule discipline
29
+
30
+ This skill is the operator's side of the two-rule contract on commitments.
31
+
32
+ 1. **You never state a future commitment** ("I'll flag", "I'll check", "I'll remind") **without immediately creating the mechanism to fulfil it.** A commitment without a backing mechanism is a broken promise.
33
+ 2. **When the owner makes a commitment, offer to back it with a mechanism**, but do not create it without confirmation.
34
+
35
+ Rule 1 is yours; rule 2 is this skill's job.
36
+
37
+ ## Picking the mechanism
38
+
39
+ Use the most appropriate tool:
40
+
41
+ - `schedule-event` for time-bound reminders (every Monday, on Tuesday, by Friday).
42
+ - `task-create` for open-ended obligations (chase X, finish Y, prepare Z).
43
+ - `workflow-create` for multi-step processes that recur (monthly close-out, new-instruction onboarding).
44
+
45
+ ## Verify executor capabilities before promising a workflow
46
+
47
+ Before committing to build a workflow, check that every step maps to a capability the executor actually has. Tool steps need the named plugin and tool to exist. Agentic LLM steps (steps that browse, click, fill forms, or interact with external systems) need the relevant MCP server command available in `PATH`. If a step requires a capability that does not exist, say so upfront: do not promise a workflow you cannot build. The owner can either narrow the scope or wait for the capability to ship.
48
+
49
+ ## After creation
50
+
51
+ Tell the owner what was created (task ID, scheduled event time, workflow name) so they have something they can reference, modify, or cancel later. Then resume the conversation.
52
+
53
+ ## Failure modes
54
+
55
+ - **`<commitment-detected>` block names a mechanism that does not exist** (e.g. `workflow-create` while workflows are not enabled on this account). Surface the gap and offer the next-best mechanism.
56
+ - **Owner approves but the create tool fails.** Surface the error literally. Do not silently substitute a different mechanism.
57
+
58
+ ## What this skill does not do
59
+
60
+ It does not detect commitments itself; the platform does that and injects the block. It does not act on the owner's behalf without explicit confirmation. It does not create mechanisms for commitments the owner did not actually make; if the platform's classification looks wrong, ignore the block and continue.
@@ -0,0 +1,67 @@
1
+ ---
2
+ name: file-presentation
3
+ description: "Render a file or document inline for the owner to view, edit, and download. Triggers when the owner asks to see, attach, or download a file, when content is generated that the owner needs to review (summary, report, draft), or when a downloadable artefact is the right output."
4
+ ---
5
+
6
+ # File presentation
7
+
8
+ This skill makes a file visible and downloadable inside the chat. It is the only sanctioned path for delivering file content to the owner. Synthesised content (a summary, a report, a draft) follows the same path: render via `document-editor` so the owner can review and download.
9
+
10
+ ## The trigger
11
+
12
+ The owner asks to view, review, attach, or download a file or a document, or the current turn produces a document that the owner needs to see. Example phrasings:
13
+
14
+ - "show me the file"
15
+ - "attach the report"
16
+ - "let me download that"
17
+ - "preview the document"
18
+ - "send me the brochure"
19
+ - "save that as a markdown file"
20
+
21
+ ## How to render
22
+
23
+ Call `render-component` with:
24
+
25
+ ```
26
+ name: "document-editor"
27
+ data: {
28
+ title: "<owner-facing title>",
29
+ content: "<the file content as markdown>"
30
+ }
31
+ ```
32
+
33
+ The component gives the owner a render-review-edit-download flow: they see the content inline, can edit it in place, and can download it as a `.md` file directly from the component. Do not use `memory-write`, `memory-ingest`, or other tools to deliver file content to the owner; those tools have different purposes and skipping the render-review-download flow takes control away from the owner.
34
+
35
+ ## Character sanitisation: the silent-failure rule
36
+
37
+ The document-editor's markdown parser fails silently on these typographical characters:
38
+
39
+ | Character | Unicode | Replace with |
40
+ |---|---|---|
41
+ | em-dash | U+2014 | hyphen with spaces |
42
+ | en-dash | U+2013 | hyphen with spaces |
43
+ | left curly double quote | U+201C | straight double quote |
44
+ | right curly double quote | U+201D | straight double quote |
45
+ | left curly single quote | U+2018 | straight single quote |
46
+ | right curly single quote | U+2019 | straight single quote |
47
+ | horizontal ellipsis | U+2026 | three periods |
48
+
49
+ Strip and replace these characters in the `content` field before the `render-component` call. The owner does not see a partial render or a warning; the content just silently breaks at the offending character. Currency symbols (£, €), accented characters, and other Unicode are unaffected: use them normally.
50
+
51
+ ## When the file is a binary
52
+
53
+ For PDF, image, audio, or any binary deliverable, use `file-attach` plus `render-component` with `name: file-attachment` and pass the returned path. The document-editor component is for editable markdown text only.
54
+
55
+ ## For static-site delivery
56
+
57
+ When the owner has uploaded a `.zip` containing HTML and assets and wants it hosted, this skill is not the right path. Delegate to `content-producer` on turn one; the specialist owns the `unzip-attachment` then `publish-site` chain.
58
+
59
+ ## Failure modes
60
+
61
+ - **Content is too large for the component** (typically over 50,000 characters of markdown). Surface the size and offer to split into sections, save as an attachment, or render only the first section with a follow-up for the rest.
62
+ - **`render-component` returns an error.** Surface the error literally. Do not paste the content into chat as a fallback; the owner asked for the render-review-download flow, not a wall of text.
63
+ - **The content contains characters the parser rejects after the sanitisation pass** (rare; usually a non-printable control character). Strip the offending characters and surface a one-line warning naming the position.
64
+
65
+ ## What this skill does not do
66
+
67
+ It does not write the content to the graph. It does not save the content to disk. The component handles the download path; the owner downloads what they edited in the component.