@kolisachint/hoocode-agent 0.5.24 → 0.5.26

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 (85) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/dist/core/brand.d.ts +1 -0
  3. package/dist/core/brand.d.ts.map +1 -1
  4. package/dist/core/brand.js +1 -0
  5. package/dist/core/brand.js.map +1 -1
  6. package/dist/core/canvas/discovery.d.ts +11 -3
  7. package/dist/core/canvas/discovery.d.ts.map +1 -1
  8. package/dist/core/canvas/discovery.js +18 -3
  9. package/dist/core/canvas/discovery.js.map +1 -1
  10. package/dist/core/canvas/lifecycle.d.ts +93 -0
  11. package/dist/core/canvas/lifecycle.d.ts.map +1 -0
  12. package/dist/core/canvas/lifecycle.js +165 -0
  13. package/dist/core/canvas/lifecycle.js.map +1 -0
  14. package/dist/core/canvas/plugin-canvases.d.ts +30 -0
  15. package/dist/core/canvas/plugin-canvases.d.ts.map +1 -0
  16. package/dist/core/canvas/plugin-canvases.js +51 -0
  17. package/dist/core/canvas/plugin-canvases.js.map +1 -0
  18. package/dist/core/canvas/registry.d.ts +89 -0
  19. package/dist/core/canvas/registry.d.ts.map +1 -1
  20. package/dist/core/canvas/registry.js +205 -10
  21. package/dist/core/canvas/registry.js.map +1 -1
  22. package/dist/core/canvas/scaffold.d.ts +123 -0
  23. package/dist/core/canvas/scaffold.d.ts.map +1 -0
  24. package/dist/core/canvas/scaffold.js +376 -0
  25. package/dist/core/canvas/scaffold.js.map +1 -0
  26. package/dist/core/canvas/session.d.ts +46 -1
  27. package/dist/core/canvas/session.d.ts.map +1 -1
  28. package/dist/core/canvas/session.js +91 -2
  29. package/dist/core/canvas/session.js.map +1 -1
  30. package/dist/core/canvas/trust.d.ts +4 -5
  31. package/dist/core/canvas/trust.d.ts.map +1 -1
  32. package/dist/core/canvas/trust.js +23 -8
  33. package/dist/core/canvas/trust.js.map +1 -1
  34. package/dist/core/extensions/plugins/formats/copilot.d.ts +10 -0
  35. package/dist/core/extensions/plugins/formats/copilot.d.ts.map +1 -1
  36. package/dist/core/extensions/plugins/formats/copilot.js +36 -4
  37. package/dist/core/extensions/plugins/formats/copilot.js.map +1 -1
  38. package/dist/core/extensions/plugins/formats/jsonManifest.d.ts.map +1 -1
  39. package/dist/core/extensions/plugins/formats/jsonManifest.js +15 -2
  40. package/dist/core/extensions/plugins/formats/jsonManifest.js.map +1 -1
  41. package/dist/core/extensions/plugins/formats/shared.d.ts +30 -2
  42. package/dist/core/extensions/plugins/formats/shared.d.ts.map +1 -1
  43. package/dist/core/extensions/plugins/formats/shared.js +72 -0
  44. package/dist/core/extensions/plugins/formats/shared.js.map +1 -1
  45. package/dist/core/extensions/plugins/install.d.ts +31 -0
  46. package/dist/core/extensions/plugins/install.d.ts.map +1 -1
  47. package/dist/core/extensions/plugins/install.js +102 -12
  48. package/dist/core/extensions/plugins/install.js.map +1 -1
  49. package/dist/core/extensions/plugins/listing.d.ts.map +1 -1
  50. package/dist/core/extensions/plugins/listing.js +1 -0
  51. package/dist/core/extensions/plugins/listing.js.map +1 -1
  52. package/dist/core/extensions/plugins/manifest.d.ts +18 -0
  53. package/dist/core/extensions/plugins/manifest.d.ts.map +1 -1
  54. package/dist/core/extensions/plugins/manifest.js.map +1 -1
  55. package/dist/core/extensions/plugins/packaging.d.ts.map +1 -1
  56. package/dist/core/extensions/plugins/packaging.js +1 -0
  57. package/dist/core/extensions/plugins/packaging.js.map +1 -1
  58. package/dist/core/tools/canvas.d.ts +23 -3
  59. package/dist/core/tools/canvas.d.ts.map +1 -1
  60. package/dist/core/tools/canvas.js +99 -4
  61. package/dist/core/tools/canvas.js.map +1 -1
  62. package/dist/extensions/core/canvas.d.ts +20 -2
  63. package/dist/extensions/core/canvas.d.ts.map +1 -1
  64. package/dist/extensions/core/canvas.js +279 -36
  65. package/dist/extensions/core/canvas.js.map +1 -1
  66. package/dist/extensions/core/marketplace.d.ts.map +1 -1
  67. package/dist/extensions/core/marketplace.js +28 -1
  68. package/dist/extensions/core/marketplace.js.map +1 -1
  69. package/dist/extensions/core/scaffold.d.ts +6 -0
  70. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  71. package/dist/extensions/core/scaffold.js +6 -0
  72. package/dist/extensions/core/scaffold.js.map +1 -1
  73. package/dist/modes/interactive/interactive-mode.d.ts +11 -0
  74. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  75. package/dist/modes/interactive/interactive-mode.js +40 -0
  76. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  77. package/dist/modes/interactive/resource-display.d.ts +11 -0
  78. package/dist/modes/interactive/resource-display.d.ts.map +1 -1
  79. package/dist/modes/interactive/resource-display.js +20 -1
  80. package/dist/modes/interactive/resource-display.js.map +1 -1
  81. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  82. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  83. package/examples/extensions/sandbox/package.json +1 -1
  84. package/examples/extensions/with-deps/package.json +1 -1
  85. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,154 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.26] - 2026-08-21
4
+
5
+ ### Added
6
+
7
+ - `/new-canvas` now takes a description, not just a name, and builds what you
8
+ describe. `/new-canvas a kanban board for the release checklist` scaffolds the
9
+ extension, derives and reports a directory name, opens the canvas, and hands
10
+ the agent a brief to build it — you steer it like any other turn. The old
11
+ shapes still work: `/new-canvas my-board` gives you the template to edit by
12
+ hand and starts no build, and `/new-canvas my-board: <description>` lets you
13
+ name it yourself.
14
+
15
+ - `/canvas reload [extension]` and a `reload_canvas` tool, so a canvas can be
16
+ changed while you are looking at it. Editing an extension's code used to do
17
+ nothing at all while it was open — the running process was forked from the old
18
+ code, so neither the open page nor a newly opened second instance saw the
19
+ change, and the only way to see one was to restart the session. Now the agent
20
+ reloads after each edit and the new capability is immediately callable.
21
+
22
+ The new code is forked and asked for its declarations *before* the old process
23
+ is stopped, so an edit that does not run leaves the canvas you are looking at
24
+ exactly as it was and reports the error. Instances keep their ids and the input
25
+ they were opened with, but each gets a **new url** — the extension binds a new
26
+ port and mints a new token on every open — so the previous browser tab is dead
27
+ and the replacement url is printed.
28
+
29
+ - An arrow-key games canvas, hoocode's first canvas of its own, at
30
+ `.agents/extensions/arrow-key-games/`. Open it with
31
+ `/canvas open arrow-key-games`. Snake and a randomly carved Maze are
32
+ yours alone; **Duel** is a turn-based coin race you play against the model —
33
+ you move with the arrow keys, it moves through `invoke_canvas_action`, and the
34
+ board updates live for both of you. It was built by running
35
+ `/new-canvas create lightweight games that can be played with keyboard arrow keys`
36
+ and iterating with `reload_canvas`, which is what the two entries above are for.
37
+
38
+ - `/canvas rename <extension> <new-name>` and `/canvas remove <extension>`. A
39
+ canvas's name lives in four places — the directory (which *is* the extension
40
+ id), the canvas's own `id`, its `displayName`, and its header comment — and
41
+ getting the `id` wrong by hand drops the canvas you are looking at on the next
42
+ reload. Rename does all of it at once, closes what was open first, and prints
43
+ every line it rewrote. It only touches a string that is *entirely* the old
44
+ name, so a sentence mentioning the canvas is reported rather than rewritten.
45
+ Remove asks before deleting, refuses when there is no surface to ask on, and
46
+ both refuse a canvas that came from a plugin, pointing at `/plugin` instead.
47
+
48
+ - Reloading now reports which actions the edit **added, removed or changed**, so
49
+ writing a new action tells you whether the host can see it. Previously a typo
50
+ inside `actions: [...]` failed silently — the action just was not there.
51
+ "Nothing changed" is reported too, since silence reads as success.
52
+
53
+ - `/canvas list` names the actions of each open canvas. They were visible only to
54
+ the model, so the person steering the session could not see the surface they
55
+ were being asked about.
56
+
57
+ ### Changed
58
+
59
+ - `/new-canvas` moved from the `/new-*` scaffold family to the canvas surface. It
60
+ is no longer a file-writing command: it opens what it creates and drives the
61
+ agent loop.
62
+
63
+ - `/new-canvas` derives a much better name from a description. It was naming the
64
+ *request* rather than the thing — `create lightweight games…` became
65
+ `create-lightweight-games`, `help me compare two benchmark runs` became
66
+ `help-compare-two` — because opening words like "create", "build", "show" and
67
+ "help me" landed in the directory name, and `-ing`/`-ed` words crowded out the
68
+ nouns. On twelve realistic descriptions, seven were wrong; all of them now read
69
+ as names for the thing: `lightweight-games-keyboard`, `dashboard-flaky-tests`,
70
+ `compare-benchmark-runs`.
71
+
72
+ - The scaffolded template names itself once, in `ID` and `NAME` at the top,
73
+ instead of repeating the name in six places. Existing canvases are unaffected —
74
+ rename handles both shapes.
75
+
76
+ - The build brief now tells the model to leave the canvas's `id` alone and rename
77
+ `displayName` instead. Renaming the id drops the instance the person is
78
+ watching on the next reload — correct behaviour, since the canvas the instance
79
+ was opened against no longer exists, but a trap the model walks into because
80
+ the scaffold names the canvas after a directory derived from a sentence.
81
+
82
+ ## [0.5.25] - 2026-08-20
83
+
84
+ ### Added
85
+
86
+ - `/new-canvas <name>` — scaffold a canvas extension. Writes
87
+ `.agents/extensions/<name>/extension.mjs` (or `.github/extensions/` with
88
+ `--platform github`), pre-wired with a declared canvas, one agent-callable
89
+ action, and a token-gated loopback server. It runs as scaffolded, so
90
+ `/canvas open <name>` works immediately — no `/reload`, because canvases are
91
+ discovered when `/canvas` runs rather than loaded at session start.
92
+
93
+ Scaffolding trusts the workspace, the same grant `/plugin install --scope
94
+ project` makes and for the same reason: a canvas lands in the working tree,
95
+ which is where the trust gate looks, so without it the canvas you just asked
96
+ for would be refused with "came with this repository" seconds after you
97
+ created it. The grant is wider than the one canvas — plugins committed here
98
+ may then run hooks and MCP servers — so it is stated in the output and
99
+ `/plugin untrust` reverses it.
100
+
101
+ - Plugins can ship canvases, and `/canvas` now finds them. Both real catalog
102
+ layouts are read: the Copilot manifest's `"extensions": "<dir>"` path key
103
+ (`Redth/mobile-canvas-ghcp`) and the Agent Plugins vendor content namespace
104
+ `com.github.copilot/extensions/<id>/` (`github/awesome-copilot`). A plugin
105
+ whose root itself carries `extension.mjs` counts as one canvas named for the
106
+ plugin. Installed canvases are listed and opened exactly like hand-placed
107
+ ones, and a canvas from a project-scoped plugin goes through the same
108
+ workspace-trust gate as one in `.github/extensions/` — it arrives in every
109
+ collaborator's clone the same way.
110
+
111
+ - Subagents in the `com.github.copilot/agents/` namespace load. This is where
112
+ `github/awesome-copilot` publishes all 125 of its plugin agents; they were
113
+ previously read as nothing.
114
+
115
+ - Canvases appear in the startup and `/reload` surface — a counted cell, and a
116
+ detail row per canvas carrying either `/canvas open <id>` or the reason it is
117
+ withheld. Installing a canvas plugin used to report "1 plugin" and give no
118
+ sign a canvas existed, which is a large part of why the gap below went
119
+ unnoticed.
120
+
121
+ ### Fixed
122
+
123
+ - `github/awesome-copilot` plugins install with their content. hoocode cloned
124
+ the repository's default branch, where a `plugins/<name>/` directory holds
125
+ only `plugin.json` and `README.md` — the real content lives in top-level
126
+ trees and is materialized into each plugin directory by CI on the
127
+ `marketplace` branch, which is the branch the vendor's own installer reads.
128
+ Every entry therefore installed as an empty shell and reported that it
129
+ contributed no capabilities. The marketplace is now pinned to that branch,
130
+ and a cache left on the wrong ref is re-cloned rather than kept up to date
131
+ forever. `/plugin install arcade-canvas@awesome-copilot` now lands a working
132
+ canvas and says so.
133
+
134
+ The pin names a branch hoocode does not control, so a retired one falls back
135
+ to the default branch with a note saying entries there may be unbuilt, rather
136
+ than dropping the marketplace outright — and a fallback clone stays put
137
+ instead of being discarded and re-downloaded on every run.
138
+
139
+ - `/plugin list` and `/plugin install` refresh the curated marketplace indices
140
+ before reading them, as `SearchPlugins` already did. The human path never did,
141
+ so a stale — or, after the fix below, a *wrong-ref* — cache could persist
142
+ indefinitely: someone who only ever typed `/plugin install` would have kept
143
+ installing unbuilt stubs with no way to know why. TTL-respecting, so it is
144
+ free when the cache is fresh, and it says so before a fetch that will take a
145
+ moment.
146
+
147
+ - Starting a new session, resuming one, or forking one shows the same loaded
148
+ resources `/reload` does. The listing was rendered — extensions rebind before
149
+ the transcript is reset — and then wiped a moment later by that reset, so
150
+ every session change but `/reload` reported nothing.
151
+
3
152
  ## [0.5.24] - 2026-08-20
4
153
 
5
154
  ### Fixed
@@ -34,6 +34,7 @@ export declare const CATEGORY_GLYPH: {
34
34
  readonly themes: "◒";
35
35
  readonly context: "❯";
36
36
  readonly extensions: "⊹";
37
+ readonly canvases: "▤";
37
38
  };
38
39
  export type CategoryKey = keyof typeof CATEGORY_GLYPH;
39
40
  /** A soft dot separator used between footer/summary segments. */
@@ -1 +1 @@
1
- {"version":3,"file":"brand.d.ts","sourceRoot":"","sources":["../../src/core/brand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,4EAA0E;AAC1E,eAAO,MAAM,UAAU,WAAM,CAAC;AAE9B;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;CAUjB,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,cAAc,CAAC;AAEtD,iEAAiE;AACjE,eAAO,MAAM,WAAW,WAAM,CAAC;AAE/B,yCAAyC;AACzC,eAAO,MAAM,gBAAgB,WAAM,CAAC","sourcesContent":["/**\n * HooCode brand identity — the single source of truth for the product mark and\n * the glyph vocabulary the interactive surfaces share.\n *\n * The brand *colour* is the theme's `accent` token (see theme/dark.json,\n * theme/light.json) so it stays theme-aware and swappable; this module owns the\n * non-colour identity: the mark and the category glyphs used by the footer,\n * the startup resource summary, and anywhere a capability class is labelled.\n * Keeping them here means a future rebrand touches one file, not twenty.\n *\n * Glyphs are chosen to be single terminal cell, widely supported, and distinct\n * from the task-panel's own status/owner glyphs (◐ ◆ ◇ ▸ ⧉ ✓ ✗ ○) so the two\n * vocabularies never read as the same signal.\n *\n * This lives in `core/` rather than `modes/interactive/` because the `/plugin`\n * command extension labels the same capability classes, and extensions must not\n * reach into a mode's internals. The module is pure data — no theme, no\n * rendering — so it is safe for every surface.\n */\n\n/** The HooCode mark — a filled hexagon, rendered in the accent colour. */\nexport const BRAND_MARK = \"⬢\";\n\n/**\n * Glyphs for the capability classes a session can load. Used by the startup\n * \"resources ready\" summary and reusable anywhere a class needs a label. Each is\n * a single cell so counts and columns stay aligned.\n */\nexport const CATEGORY_GLYPH = {\n\tskills: \"✦\",\n\tcommands: \"⌘\",\n\tagents: \"◈\",\n\tmcp: \"⧉\",\n\tplugins: \"⬡\",\n\tmarketplaces: \"⊞\",\n\tthemes: \"◒\",\n\tcontext: \"❯\",\n\textensions: \"⊹\",\n} as const;\n\nexport type CategoryKey = keyof typeof CATEGORY_GLYPH;\n\n/** A soft dot separator used between footer/summary segments. */\nexport const SEGMENT_SEP = \"·\";\n\n/** Fork glyph preceding a git branch. */\nexport const GIT_BRANCH_GLYPH = \"⑂\";\n"]}
1
+ {"version":3,"file":"brand.d.ts","sourceRoot":"","sources":["../../src/core/brand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,4EAA0E;AAC1E,eAAO,MAAM,UAAU,WAAM,CAAC;AAE9B;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;CAWjB,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,cAAc,CAAC;AAEtD,iEAAiE;AACjE,eAAO,MAAM,WAAW,WAAM,CAAC;AAE/B,yCAAyC;AACzC,eAAO,MAAM,gBAAgB,WAAM,CAAC","sourcesContent":["/**\n * HooCode brand identity — the single source of truth for the product mark and\n * the glyph vocabulary the interactive surfaces share.\n *\n * The brand *colour* is the theme's `accent` token (see theme/dark.json,\n * theme/light.json) so it stays theme-aware and swappable; this module owns the\n * non-colour identity: the mark and the category glyphs used by the footer,\n * the startup resource summary, and anywhere a capability class is labelled.\n * Keeping them here means a future rebrand touches one file, not twenty.\n *\n * Glyphs are chosen to be single terminal cell, widely supported, and distinct\n * from the task-panel's own status/owner glyphs (◐ ◆ ◇ ▸ ⧉ ✓ ✗ ○) so the two\n * vocabularies never read as the same signal.\n *\n * This lives in `core/` rather than `modes/interactive/` because the `/plugin`\n * command extension labels the same capability classes, and extensions must not\n * reach into a mode's internals. The module is pure data — no theme, no\n * rendering — so it is safe for every surface.\n */\n\n/** The HooCode mark — a filled hexagon, rendered in the accent colour. */\nexport const BRAND_MARK = \"⬢\";\n\n/**\n * Glyphs for the capability classes a session can load. Used by the startup\n * \"resources ready\" summary and reusable anywhere a class needs a label. Each is\n * a single cell so counts and columns stay aligned.\n */\nexport const CATEGORY_GLYPH = {\n\tskills: \"✦\",\n\tcommands: \"⌘\",\n\tagents: \"◈\",\n\tmcp: \"⧉\",\n\tplugins: \"⬡\",\n\tmarketplaces: \"⊞\",\n\tthemes: \"◒\",\n\tcontext: \"❯\",\n\textensions: \"⊹\",\n\tcanvases: \"▤\",\n} as const;\n\nexport type CategoryKey = keyof typeof CATEGORY_GLYPH;\n\n/** A soft dot separator used between footer/summary segments. */\nexport const SEGMENT_SEP = \"·\";\n\n/** Fork glyph preceding a git branch. */\nexport const GIT_BRANCH_GLYPH = \"⑂\";\n"]}
@@ -34,6 +34,7 @@ export const CATEGORY_GLYPH = {
34
34
  themes: "◒",
35
35
  context: "❯",
36
36
  extensions: "⊹",
37
+ canvases: "▤",
37
38
  };
38
39
  /** A soft dot separator used between footer/summary segments. */
39
40
  export const SEGMENT_SEP = "·";
@@ -1 +1 @@
1
- {"version":3,"file":"brand.js","sourceRoot":"","sources":["../../src/core/brand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,4EAA0E;AAC1E,MAAM,CAAC,MAAM,UAAU,GAAG,KAAG,CAAC;AAE9B;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;IAC7B,MAAM,EAAE,KAAG;IACX,QAAQ,EAAE,KAAG;IACb,MAAM,EAAE,KAAG;IACX,GAAG,EAAE,KAAG;IACR,OAAO,EAAE,KAAG;IACZ,YAAY,EAAE,KAAG;IACjB,MAAM,EAAE,KAAG;IACX,OAAO,EAAE,KAAG;IACZ,UAAU,EAAE,KAAG;CACN,CAAC;AAIX,iEAAiE;AACjE,MAAM,CAAC,MAAM,WAAW,GAAG,IAAG,CAAC;AAE/B,yCAAyC;AACzC,MAAM,CAAC,MAAM,gBAAgB,GAAG,KAAG,CAAC","sourcesContent":["/**\n * HooCode brand identity — the single source of truth for the product mark and\n * the glyph vocabulary the interactive surfaces share.\n *\n * The brand *colour* is the theme's `accent` token (see theme/dark.json,\n * theme/light.json) so it stays theme-aware and swappable; this module owns the\n * non-colour identity: the mark and the category glyphs used by the footer,\n * the startup resource summary, and anywhere a capability class is labelled.\n * Keeping them here means a future rebrand touches one file, not twenty.\n *\n * Glyphs are chosen to be single terminal cell, widely supported, and distinct\n * from the task-panel's own status/owner glyphs (◐ ◆ ◇ ▸ ⧉ ✓ ✗ ○) so the two\n * vocabularies never read as the same signal.\n *\n * This lives in `core/` rather than `modes/interactive/` because the `/plugin`\n * command extension labels the same capability classes, and extensions must not\n * reach into a mode's internals. The module is pure data — no theme, no\n * rendering — so it is safe for every surface.\n */\n\n/** The HooCode mark — a filled hexagon, rendered in the accent colour. */\nexport const BRAND_MARK = \"⬢\";\n\n/**\n * Glyphs for the capability classes a session can load. Used by the startup\n * \"resources ready\" summary and reusable anywhere a class needs a label. Each is\n * a single cell so counts and columns stay aligned.\n */\nexport const CATEGORY_GLYPH = {\n\tskills: \"✦\",\n\tcommands: \"⌘\",\n\tagents: \"◈\",\n\tmcp: \"⧉\",\n\tplugins: \"⬡\",\n\tmarketplaces: \"⊞\",\n\tthemes: \"◒\",\n\tcontext: \"❯\",\n\textensions: \"⊹\",\n} as const;\n\nexport type CategoryKey = keyof typeof CATEGORY_GLYPH;\n\n/** A soft dot separator used between footer/summary segments. */\nexport const SEGMENT_SEP = \"·\";\n\n/** Fork glyph preceding a git branch. */\nexport const GIT_BRANCH_GLYPH = \"⑂\";\n"]}
1
+ {"version":3,"file":"brand.js","sourceRoot":"","sources":["../../src/core/brand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,4EAA0E;AAC1E,MAAM,CAAC,MAAM,UAAU,GAAG,KAAG,CAAC;AAE9B;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;IAC7B,MAAM,EAAE,KAAG;IACX,QAAQ,EAAE,KAAG;IACb,MAAM,EAAE,KAAG;IACX,GAAG,EAAE,KAAG;IACR,OAAO,EAAE,KAAG;IACZ,YAAY,EAAE,KAAG;IACjB,MAAM,EAAE,KAAG;IACX,OAAO,EAAE,KAAG;IACZ,UAAU,EAAE,KAAG;IACf,QAAQ,EAAE,KAAG;CACJ,CAAC;AAIX,iEAAiE;AACjE,MAAM,CAAC,MAAM,WAAW,GAAG,IAAG,CAAC;AAE/B,yCAAyC;AACzC,MAAM,CAAC,MAAM,gBAAgB,GAAG,KAAG,CAAC","sourcesContent":["/**\n * HooCode brand identity — the single source of truth for the product mark and\n * the glyph vocabulary the interactive surfaces share.\n *\n * The brand *colour* is the theme's `accent` token (see theme/dark.json,\n * theme/light.json) so it stays theme-aware and swappable; this module owns the\n * non-colour identity: the mark and the category glyphs used by the footer,\n * the startup resource summary, and anywhere a capability class is labelled.\n * Keeping them here means a future rebrand touches one file, not twenty.\n *\n * Glyphs are chosen to be single terminal cell, widely supported, and distinct\n * from the task-panel's own status/owner glyphs (◐ ◆ ◇ ▸ ⧉ ✓ ✗ ○) so the two\n * vocabularies never read as the same signal.\n *\n * This lives in `core/` rather than `modes/interactive/` because the `/plugin`\n * command extension labels the same capability classes, and extensions must not\n * reach into a mode's internals. The module is pure data — no theme, no\n * rendering — so it is safe for every surface.\n */\n\n/** The HooCode mark — a filled hexagon, rendered in the accent colour. */\nexport const BRAND_MARK = \"⬢\";\n\n/**\n * Glyphs for the capability classes a session can load. Used by the startup\n * \"resources ready\" summary and reusable anywhere a class needs a label. Each is\n * a single cell so counts and columns stay aligned.\n */\nexport const CATEGORY_GLYPH = {\n\tskills: \"✦\",\n\tcommands: \"⌘\",\n\tagents: \"◈\",\n\tmcp: \"⧉\",\n\tplugins: \"⬡\",\n\tmarketplaces: \"⊞\",\n\tthemes: \"◒\",\n\tcontext: \"❯\",\n\textensions: \"⊹\",\n\tcanvases: \"▤\",\n} as const;\n\nexport type CategoryKey = keyof typeof CATEGORY_GLYPH;\n\n/** A soft dot separator used between footer/summary segments. */\nexport const SEGMENT_SEP = \"·\";\n\n/** Fork glyph preceding a git branch. */\nexport const GIT_BRANCH_GLYPH = \"⑂\";\n"]}
@@ -53,8 +53,16 @@ export interface DiscoveredCanvasExtension {
53
53
  */
54
54
  export declare function canvasSearchRoots(cwd: string, homeDir: string): CanvasSearchRoot[];
55
55
  /**
56
- * Discover canvas extensions across `roots`. First root wins on an id collision,
57
- * so a project-local extension shadows a same-named user-scope one.
56
+ * Discover canvas extensions across `roots`, then append `extra`. First wins on
57
+ * an id collision, so a project-local extension shadows a same-named user-scope
58
+ * one — and anything a person placed in a search root by hand shadows a
59
+ * same-named canvas that arrived inside a package.
60
+ *
61
+ * `extra` is how plugin-shipped canvases join the same list (see
62
+ * `plugin-canvases.ts`): a plugin's canvas directories are named by its manifest
63
+ * rather than by their position under a search root, so they cannot be expressed
64
+ * as a root — but once resolved they are ordinary discovered extensions, and
65
+ * every consumer downstream is better off not knowing the difference.
58
66
  */
59
- export declare function discoverCanvasExtensions(roots: CanvasSearchRoot[]): DiscoveredCanvasExtension[];
67
+ export declare function discoverCanvasExtensions(roots: CanvasSearchRoot[], extra?: readonly DiscoveredCanvasExtension[]): DiscoveredCanvasExtension[];
60
68
  //# sourceMappingURL=discovery.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAKH,gCAAgC;AAChC,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD,+DAA+D;AAC/D,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;AAExD,gDAAgD;AAChD,MAAM,WAAW,gBAAgB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,WAAW,CAAC;CACnB;AAED,gCAAgC;AAChC,MAAM,WAAW,yBAAyB;IACzC,uDAAuD;IACvD,EAAE,EAAE,MAAM,CAAC;IACX,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,WAAW,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAMlF;AAqBD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,gBAAgB,EAAE,GAAG,yBAAyB,EAAE,CAW/F","sourcesContent":["/**\n * Canvas extension discovery.\n *\n * Design: `docs/canvas-extensions-design.md` §4.1, §4.3. GitHub's\n * `docs/extensions.md` is specific: the CLI \"scans `.github/extensions/`\n * (project) and the user's copilot config extensions directory for\n * subdirectories containing `extension.mjs`\", the entry file is required and must\n * be named `extension.mjs`, and only ES modules are supported. All 23 extensions\n * in `github/awesome-copilot` comply.\n *\n * So discovery keys off `<dir>/extension.mjs` and nothing else. In particular it\n * does not read `package.json`: 3 of those 23 extensions ship without one, none\n * ships `node_modules`, and where a `package.json` does exist its `main` is\n * always `extension.mjs` anyway. Keying off it would add a failure mode without\n * adding information.\n *\n * The extension id is the directory name, matching how the catalog and the\n * Copilot app refer to extensions (`pr-artifact-explorer`).\n *\n * Some extensions also carry a `copilot-extension.json`, but it is neither\n * required nor informative: 8 of the 23 catalog extensions have one, every\n * instance holds exactly `{ name, version }`, and `name` always equals the\n * directory name. Reading it would add a parse and a disagreement case without\n * telling us anything the directory name does not.\n */\n\nimport { readdirSync, statSync } from \"node:fs\";\nimport * as path from \"node:path\";\n\n/** Required entry file name. */\nexport const CANVAS_ENTRY_FILE = \"extension.mjs\";\n\n/** Where a canvas extension came from, in precedence order. */\nexport type CanvasScope = \"agents\" | \"project\" | \"user\";\n\n/** Directory searched for canvas extensions. */\nexport interface CanvasSearchRoot {\n\tdir: string;\n\tscope: CanvasScope;\n}\n\n/** One discovered extension. */\nexport interface DiscoveredCanvasExtension {\n\t/** Directory name, used as the provider identifier. */\n\tid: string;\n\t/** Absolute path to the extension directory. */\n\tdir: string;\n\t/** Absolute path to `extension.mjs`. */\n\tentry: string;\n\tscope: CanvasScope;\n}\n\n/**\n * Search roots in precedence order. `.agents/` first per\n * `docs/plugin-format-mapping.md` §0; the Copilot conventions follow as\n * compatibility inputs.\n *\n * `.github/extensions/` travels with a clone, so anything found there is subject\n * to the workspace-trust gate before it is ever forked (design doc §5). Discovery\n * itself is read-only and always safe to run.\n */\nexport function canvasSearchRoots(cwd: string, homeDir: string): CanvasSearchRoot[] {\n\treturn [\n\t\t{ dir: path.join(cwd, \".agents\", \"extensions\"), scope: \"agents\" },\n\t\t{ dir: path.join(cwd, \".github\", \"extensions\"), scope: \"project\" },\n\t\t{ dir: path.join(homeDir, \".copilot\", \"extensions\"), scope: \"user\" },\n\t];\n}\n\nfunction subdirectories(dir: string): string[] {\n\ttry {\n\t\treturn readdirSync(dir, { withFileTypes: true })\n\t\t\t.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())\n\t\t\t.map((entry) => entry.name)\n\t\t\t.sort();\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction hasEntryFile(dir: string): boolean {\n\ttry {\n\t\treturn statSync(path.join(dir, CANVAS_ENTRY_FILE)).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * Discover canvas extensions across `roots`. First root wins on an id collision,\n * so a project-local extension shadows a same-named user-scope one.\n */\nexport function discoverCanvasExtensions(roots: CanvasSearchRoot[]): DiscoveredCanvasExtension[] {\n\tconst found = new Map<string, DiscoveredCanvasExtension>();\n\tfor (const root of roots) {\n\t\tfor (const id of subdirectories(root.dir)) {\n\t\t\tif (found.has(id)) continue;\n\t\t\tconst dir = path.join(root.dir, id);\n\t\t\tif (!hasEntryFile(dir)) continue;\n\t\t\tfound.set(id, { id, dir, entry: path.join(dir, CANVAS_ENTRY_FILE), scope: root.scope });\n\t\t}\n\t}\n\treturn [...found.values()];\n}\n"]}
1
+ {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAKH,gCAAgC;AAChC,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD,+DAA+D;AAC/D,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;AAExD,gDAAgD;AAChD,MAAM,WAAW,gBAAgB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,WAAW,CAAC;CACnB;AAED,gCAAgC;AAChC,MAAM,WAAW,yBAAyB;IACzC,uDAAuD;IACvD,EAAE,EAAE,MAAM,CAAC;IACX,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,WAAW,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAMlF;AAqBD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CACvC,KAAK,EAAE,gBAAgB,EAAE,EACzB,KAAK,GAAE,SAAS,yBAAyB,EAAO,GAC9C,yBAAyB,EAAE,CAgB7B","sourcesContent":["/**\n * Canvas extension discovery.\n *\n * Design: `docs/canvas-extensions-design.md` §4.1, §4.3. GitHub's\n * `docs/extensions.md` is specific: the CLI \"scans `.github/extensions/`\n * (project) and the user's copilot config extensions directory for\n * subdirectories containing `extension.mjs`\", the entry file is required and must\n * be named `extension.mjs`, and only ES modules are supported. All 23 extensions\n * in `github/awesome-copilot` comply.\n *\n * So discovery keys off `<dir>/extension.mjs` and nothing else. In particular it\n * does not read `package.json`: 3 of those 23 extensions ship without one, none\n * ships `node_modules`, and where a `package.json` does exist its `main` is\n * always `extension.mjs` anyway. Keying off it would add a failure mode without\n * adding information.\n *\n * The extension id is the directory name, matching how the catalog and the\n * Copilot app refer to extensions (`pr-artifact-explorer`).\n *\n * Some extensions also carry a `copilot-extension.json`, but it is neither\n * required nor informative: 8 of the 23 catalog extensions have one, every\n * instance holds exactly `{ name, version }`, and `name` always equals the\n * directory name. Reading it would add a parse and a disagreement case without\n * telling us anything the directory name does not.\n */\n\nimport { readdirSync, statSync } from \"node:fs\";\nimport * as path from \"node:path\";\n\n/** Required entry file name. */\nexport const CANVAS_ENTRY_FILE = \"extension.mjs\";\n\n/** Where a canvas extension came from, in precedence order. */\nexport type CanvasScope = \"agents\" | \"project\" | \"user\";\n\n/** Directory searched for canvas extensions. */\nexport interface CanvasSearchRoot {\n\tdir: string;\n\tscope: CanvasScope;\n}\n\n/** One discovered extension. */\nexport interface DiscoveredCanvasExtension {\n\t/** Directory name, used as the provider identifier. */\n\tid: string;\n\t/** Absolute path to the extension directory. */\n\tdir: string;\n\t/** Absolute path to `extension.mjs`. */\n\tentry: string;\n\tscope: CanvasScope;\n}\n\n/**\n * Search roots in precedence order. `.agents/` first per\n * `docs/plugin-format-mapping.md` §0; the Copilot conventions follow as\n * compatibility inputs.\n *\n * `.github/extensions/` travels with a clone, so anything found there is subject\n * to the workspace-trust gate before it is ever forked (design doc §5). Discovery\n * itself is read-only and always safe to run.\n */\nexport function canvasSearchRoots(cwd: string, homeDir: string): CanvasSearchRoot[] {\n\treturn [\n\t\t{ dir: path.join(cwd, \".agents\", \"extensions\"), scope: \"agents\" },\n\t\t{ dir: path.join(cwd, \".github\", \"extensions\"), scope: \"project\" },\n\t\t{ dir: path.join(homeDir, \".copilot\", \"extensions\"), scope: \"user\" },\n\t];\n}\n\nfunction subdirectories(dir: string): string[] {\n\ttry {\n\t\treturn readdirSync(dir, { withFileTypes: true })\n\t\t\t.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())\n\t\t\t.map((entry) => entry.name)\n\t\t\t.sort();\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction hasEntryFile(dir: string): boolean {\n\ttry {\n\t\treturn statSync(path.join(dir, CANVAS_ENTRY_FILE)).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * Discover canvas extensions across `roots`, then append `extra`. First wins on\n * an id collision, so a project-local extension shadows a same-named user-scope\n * one — and anything a person placed in a search root by hand shadows a\n * same-named canvas that arrived inside a package.\n *\n * `extra` is how plugin-shipped canvases join the same list (see\n * `plugin-canvases.ts`): a plugin's canvas directories are named by its manifest\n * rather than by their position under a search root, so they cannot be expressed\n * as a root — but once resolved they are ordinary discovered extensions, and\n * every consumer downstream is better off not knowing the difference.\n */\nexport function discoverCanvasExtensions(\n\troots: CanvasSearchRoot[],\n\textra: readonly DiscoveredCanvasExtension[] = [],\n): DiscoveredCanvasExtension[] {\n\tconst found = new Map<string, DiscoveredCanvasExtension>();\n\tfor (const root of roots) {\n\t\tfor (const id of subdirectories(root.dir)) {\n\t\t\tif (found.has(id)) continue;\n\t\t\tconst dir = path.join(root.dir, id);\n\t\t\tif (!hasEntryFile(dir)) continue;\n\t\t\tfound.set(id, { id, dir, entry: path.join(dir, CANVAS_ENTRY_FILE), scope: root.scope });\n\t\t}\n\t}\n\tfor (const extension of extra) {\n\t\tif (found.has(extension.id)) continue;\n\t\tif (!hasEntryFile(extension.dir)) continue;\n\t\tfound.set(extension.id, extension);\n\t}\n\treturn [...found.values()];\n}\n"]}
@@ -63,10 +63,18 @@ function hasEntryFile(dir) {
63
63
  }
64
64
  }
65
65
  /**
66
- * Discover canvas extensions across `roots`. First root wins on an id collision,
67
- * so a project-local extension shadows a same-named user-scope one.
66
+ * Discover canvas extensions across `roots`, then append `extra`. First wins on
67
+ * an id collision, so a project-local extension shadows a same-named user-scope
68
+ * one — and anything a person placed in a search root by hand shadows a
69
+ * same-named canvas that arrived inside a package.
70
+ *
71
+ * `extra` is how plugin-shipped canvases join the same list (see
72
+ * `plugin-canvases.ts`): a plugin's canvas directories are named by its manifest
73
+ * rather than by their position under a search root, so they cannot be expressed
74
+ * as a root — but once resolved they are ordinary discovered extensions, and
75
+ * every consumer downstream is better off not knowing the difference.
68
76
  */
69
- export function discoverCanvasExtensions(roots) {
77
+ export function discoverCanvasExtensions(roots, extra = []) {
70
78
  const found = new Map();
71
79
  for (const root of roots) {
72
80
  for (const id of subdirectories(root.dir)) {
@@ -78,6 +86,13 @@ export function discoverCanvasExtensions(roots) {
78
86
  found.set(id, { id, dir, entry: path.join(dir, CANVAS_ENTRY_FILE), scope: root.scope });
79
87
  }
80
88
  }
89
+ for (const extension of extra) {
90
+ if (found.has(extension.id))
91
+ continue;
92
+ if (!hasEntryFile(extension.dir))
93
+ continue;
94
+ found.set(extension.id, extension);
95
+ }
81
96
  return [...found.values()];
82
97
  }
83
98
  //# sourceMappingURL=discovery.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"discovery.js","sourceRoot":"","sources":["../../../src/core/canvas/discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAChD,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,gCAAgC;AAChC,MAAM,CAAC,MAAM,iBAAiB,GAAG,eAAe,CAAC;AAsBjD;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,OAAe,EAAsB;IACnF,OAAO;QACN,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,YAAY,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE;QACjE,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,YAAY,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE;QAClE,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE;KACpE,CAAC;AAAA,CACF;AAED,SAAS,cAAc,CAAC,GAAW,EAAY;IAC9C,IAAI,CAAC;QACJ,OAAO,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;aAC9C,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,IAAI,KAAK,CAAC,cAAc,EAAE,CAAC;aAChE,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC;aAC1B,IAAI,EAAE,CAAC;IACV,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,EAAE,CAAC;IACX,CAAC;AAAA,CACD;AAED,SAAS,YAAY,CAAC,GAAW,EAAW;IAC3C,IAAI,CAAC;QACJ,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAC7D,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,KAAK,CAAC;IACd,CAAC;AAAA,CACD;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,KAAyB,EAA+B;IAChG,MAAM,KAAK,GAAG,IAAI,GAAG,EAAqC,CAAC;IAC3D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,KAAK,MAAM,EAAE,IAAI,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YAC3C,IAAI,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;gBAAE,SAAS;YAC5B,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YACpC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC;gBAAE,SAAS;YACjC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,iBAAiB,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;QACzF,CAAC;IACF,CAAC;IACD,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;AAAA,CAC3B","sourcesContent":["/**\n * Canvas extension discovery.\n *\n * Design: `docs/canvas-extensions-design.md` §4.1, §4.3. GitHub's\n * `docs/extensions.md` is specific: the CLI \"scans `.github/extensions/`\n * (project) and the user's copilot config extensions directory for\n * subdirectories containing `extension.mjs`\", the entry file is required and must\n * be named `extension.mjs`, and only ES modules are supported. All 23 extensions\n * in `github/awesome-copilot` comply.\n *\n * So discovery keys off `<dir>/extension.mjs` and nothing else. In particular it\n * does not read `package.json`: 3 of those 23 extensions ship without one, none\n * ships `node_modules`, and where a `package.json` does exist its `main` is\n * always `extension.mjs` anyway. Keying off it would add a failure mode without\n * adding information.\n *\n * The extension id is the directory name, matching how the catalog and the\n * Copilot app refer to extensions (`pr-artifact-explorer`).\n *\n * Some extensions also carry a `copilot-extension.json`, but it is neither\n * required nor informative: 8 of the 23 catalog extensions have one, every\n * instance holds exactly `{ name, version }`, and `name` always equals the\n * directory name. Reading it would add a parse and a disagreement case without\n * telling us anything the directory name does not.\n */\n\nimport { readdirSync, statSync } from \"node:fs\";\nimport * as path from \"node:path\";\n\n/** Required entry file name. */\nexport const CANVAS_ENTRY_FILE = \"extension.mjs\";\n\n/** Where a canvas extension came from, in precedence order. */\nexport type CanvasScope = \"agents\" | \"project\" | \"user\";\n\n/** Directory searched for canvas extensions. */\nexport interface CanvasSearchRoot {\n\tdir: string;\n\tscope: CanvasScope;\n}\n\n/** One discovered extension. */\nexport interface DiscoveredCanvasExtension {\n\t/** Directory name, used as the provider identifier. */\n\tid: string;\n\t/** Absolute path to the extension directory. */\n\tdir: string;\n\t/** Absolute path to `extension.mjs`. */\n\tentry: string;\n\tscope: CanvasScope;\n}\n\n/**\n * Search roots in precedence order. `.agents/` first per\n * `docs/plugin-format-mapping.md` §0; the Copilot conventions follow as\n * compatibility inputs.\n *\n * `.github/extensions/` travels with a clone, so anything found there is subject\n * to the workspace-trust gate before it is ever forked (design doc §5). Discovery\n * itself is read-only and always safe to run.\n */\nexport function canvasSearchRoots(cwd: string, homeDir: string): CanvasSearchRoot[] {\n\treturn [\n\t\t{ dir: path.join(cwd, \".agents\", \"extensions\"), scope: \"agents\" },\n\t\t{ dir: path.join(cwd, \".github\", \"extensions\"), scope: \"project\" },\n\t\t{ dir: path.join(homeDir, \".copilot\", \"extensions\"), scope: \"user\" },\n\t];\n}\n\nfunction subdirectories(dir: string): string[] {\n\ttry {\n\t\treturn readdirSync(dir, { withFileTypes: true })\n\t\t\t.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())\n\t\t\t.map((entry) => entry.name)\n\t\t\t.sort();\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction hasEntryFile(dir: string): boolean {\n\ttry {\n\t\treturn statSync(path.join(dir, CANVAS_ENTRY_FILE)).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * Discover canvas extensions across `roots`. First root wins on an id collision,\n * so a project-local extension shadows a same-named user-scope one.\n */\nexport function discoverCanvasExtensions(roots: CanvasSearchRoot[]): DiscoveredCanvasExtension[] {\n\tconst found = new Map<string, DiscoveredCanvasExtension>();\n\tfor (const root of roots) {\n\t\tfor (const id of subdirectories(root.dir)) {\n\t\t\tif (found.has(id)) continue;\n\t\t\tconst dir = path.join(root.dir, id);\n\t\t\tif (!hasEntryFile(dir)) continue;\n\t\t\tfound.set(id, { id, dir, entry: path.join(dir, CANVAS_ENTRY_FILE), scope: root.scope });\n\t\t}\n\t}\n\treturn [...found.values()];\n}\n"]}
1
+ {"version":3,"file":"discovery.js","sourceRoot":"","sources":["../../../src/core/canvas/discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAChD,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,gCAAgC;AAChC,MAAM,CAAC,MAAM,iBAAiB,GAAG,eAAe,CAAC;AAsBjD;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,OAAe,EAAsB;IACnF,OAAO;QACN,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,YAAY,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE;QACjE,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,YAAY,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE;QAClE,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,YAAY,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE;KACpE,CAAC;AAAA,CACF;AAED,SAAS,cAAc,CAAC,GAAW,EAAY;IAC9C,IAAI,CAAC;QACJ,OAAO,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;aAC9C,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,IAAI,KAAK,CAAC,cAAc,EAAE,CAAC;aAChE,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC;aAC1B,IAAI,EAAE,CAAC;IACV,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,EAAE,CAAC;IACX,CAAC;AAAA,CACD;AAED,SAAS,YAAY,CAAC,GAAW,EAAW;IAC3C,IAAI,CAAC;QACJ,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAC7D,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,KAAK,CAAC;IACd,CAAC;AAAA,CACD;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,wBAAwB,CACvC,KAAyB,EACzB,KAAK,GAAyC,EAAE,EAClB;IAC9B,MAAM,KAAK,GAAG,IAAI,GAAG,EAAqC,CAAC;IAC3D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,KAAK,MAAM,EAAE,IAAI,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YAC3C,IAAI,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;gBAAE,SAAS;YAC5B,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YACpC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC;gBAAE,SAAS;YACjC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,iBAAiB,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;QACzF,CAAC;IACF,CAAC;IACD,KAAK,MAAM,SAAS,IAAI,KAAK,EAAE,CAAC;QAC/B,IAAI,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;YAAE,SAAS;QACtC,IAAI,CAAC,YAAY,CAAC,SAAS,CAAC,GAAG,CAAC;YAAE,SAAS;QAC3C,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;IACpC,CAAC;IACD,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;AAAA,CAC3B","sourcesContent":["/**\n * Canvas extension discovery.\n *\n * Design: `docs/canvas-extensions-design.md` §4.1, §4.3. GitHub's\n * `docs/extensions.md` is specific: the CLI \"scans `.github/extensions/`\n * (project) and the user's copilot config extensions directory for\n * subdirectories containing `extension.mjs`\", the entry file is required and must\n * be named `extension.mjs`, and only ES modules are supported. All 23 extensions\n * in `github/awesome-copilot` comply.\n *\n * So discovery keys off `<dir>/extension.mjs` and nothing else. In particular it\n * does not read `package.json`: 3 of those 23 extensions ship without one, none\n * ships `node_modules`, and where a `package.json` does exist its `main` is\n * always `extension.mjs` anyway. Keying off it would add a failure mode without\n * adding information.\n *\n * The extension id is the directory name, matching how the catalog and the\n * Copilot app refer to extensions (`pr-artifact-explorer`).\n *\n * Some extensions also carry a `copilot-extension.json`, but it is neither\n * required nor informative: 8 of the 23 catalog extensions have one, every\n * instance holds exactly `{ name, version }`, and `name` always equals the\n * directory name. Reading it would add a parse and a disagreement case without\n * telling us anything the directory name does not.\n */\n\nimport { readdirSync, statSync } from \"node:fs\";\nimport * as path from \"node:path\";\n\n/** Required entry file name. */\nexport const CANVAS_ENTRY_FILE = \"extension.mjs\";\n\n/** Where a canvas extension came from, in precedence order. */\nexport type CanvasScope = \"agents\" | \"project\" | \"user\";\n\n/** Directory searched for canvas extensions. */\nexport interface CanvasSearchRoot {\n\tdir: string;\n\tscope: CanvasScope;\n}\n\n/** One discovered extension. */\nexport interface DiscoveredCanvasExtension {\n\t/** Directory name, used as the provider identifier. */\n\tid: string;\n\t/** Absolute path to the extension directory. */\n\tdir: string;\n\t/** Absolute path to `extension.mjs`. */\n\tentry: string;\n\tscope: CanvasScope;\n}\n\n/**\n * Search roots in precedence order. `.agents/` first per\n * `docs/plugin-format-mapping.md` §0; the Copilot conventions follow as\n * compatibility inputs.\n *\n * `.github/extensions/` travels with a clone, so anything found there is subject\n * to the workspace-trust gate before it is ever forked (design doc §5). Discovery\n * itself is read-only and always safe to run.\n */\nexport function canvasSearchRoots(cwd: string, homeDir: string): CanvasSearchRoot[] {\n\treturn [\n\t\t{ dir: path.join(cwd, \".agents\", \"extensions\"), scope: \"agents\" },\n\t\t{ dir: path.join(cwd, \".github\", \"extensions\"), scope: \"project\" },\n\t\t{ dir: path.join(homeDir, \".copilot\", \"extensions\"), scope: \"user\" },\n\t];\n}\n\nfunction subdirectories(dir: string): string[] {\n\ttry {\n\t\treturn readdirSync(dir, { withFileTypes: true })\n\t\t\t.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())\n\t\t\t.map((entry) => entry.name)\n\t\t\t.sort();\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction hasEntryFile(dir: string): boolean {\n\ttry {\n\t\treturn statSync(path.join(dir, CANVAS_ENTRY_FILE)).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * Discover canvas extensions across `roots`, then append `extra`. First wins on\n * an id collision, so a project-local extension shadows a same-named user-scope\n * one — and anything a person placed in a search root by hand shadows a\n * same-named canvas that arrived inside a package.\n *\n * `extra` is how plugin-shipped canvases join the same list (see\n * `plugin-canvases.ts`): a plugin's canvas directories are named by its manifest\n * rather than by their position under a search root, so they cannot be expressed\n * as a root — but once resolved they are ordinary discovered extensions, and\n * every consumer downstream is better off not knowing the difference.\n */\nexport function discoverCanvasExtensions(\n\troots: CanvasSearchRoot[],\n\textra: readonly DiscoveredCanvasExtension[] = [],\n): DiscoveredCanvasExtension[] {\n\tconst found = new Map<string, DiscoveredCanvasExtension>();\n\tfor (const root of roots) {\n\t\tfor (const id of subdirectories(root.dir)) {\n\t\t\tif (found.has(id)) continue;\n\t\t\tconst dir = path.join(root.dir, id);\n\t\t\tif (!hasEntryFile(dir)) continue;\n\t\t\tfound.set(id, { id, dir, entry: path.join(dir, CANVAS_ENTRY_FILE), scope: root.scope });\n\t\t}\n\t}\n\tfor (const extension of extra) {\n\t\tif (found.has(extension.id)) continue;\n\t\tif (!hasEntryFile(extension.dir)) continue;\n\t\tfound.set(extension.id, extension);\n\t}\n\treturn [...found.values()];\n}\n"]}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Renaming and removing a canvas extension.
3
+ *
4
+ * Design: `docs/canvas-extensions-design.md` §13.6. These exist because a canvas
5
+ * has more identity than a file does, and it is spread across four places: the
6
+ * directory name (which *is* the extension id, since discovery keys off
7
+ * position), the `id` a canvas declares, its `displayName`, and whatever the
8
+ * header comment tells the reader to type. Renaming by hand means getting all
9
+ * four right, and getting the `id` wrong is not a typo — it drops the canvas the
10
+ * person is looking at on the next reload, because the instance was opened
11
+ * against a canvas that no longer exists.
12
+ *
13
+ * So the rewriting is deliberately narrow. It changes the three places the
14
+ * scaffold puts the name and nothing else, then **reports every other line the
15
+ * old name still appears on** rather than guessing at prose. A rename that
16
+ * silently edited a description would be worse than one that admits what it left
17
+ * behind.
18
+ */
19
+ import type { CanvasSearchRoot, DiscoveredCanvasExtension } from "./discovery.js";
20
+ /** One line the rename changed. */
21
+ export interface CanvasRewrite {
22
+ /** 1-based, so it can be read straight off an editor gutter. */
23
+ line: number;
24
+ before: string;
25
+ after: string;
26
+ }
27
+ /** What {@link renameCanvasExtension} did. */
28
+ export interface CanvasRenameResult {
29
+ from: string;
30
+ to: string;
31
+ /** Workspace-relative where possible, for a message a person can act on. */
32
+ dir: string;
33
+ rewrites: CanvasRewrite[];
34
+ /**
35
+ * Lines where the old name survives, because they are prose rather than
36
+ * identity. Surfaced, never silently edited.
37
+ */
38
+ leftovers: number[];
39
+ }
40
+ /**
41
+ * Why a canvas cannot be renamed or removed in place.
42
+ *
43
+ * `packaged` is the interesting one: a plugin's canvases are named by its
44
+ * manifest rather than by where they sit, so moving the directory would either
45
+ * do nothing or break the plugin. That is `/plugin`'s job, not this one's.
46
+ */
47
+ export type CanvasLifecycleRefusal = {
48
+ reason: "invalid-name";
49
+ detail: string;
50
+ } | {
51
+ reason: "exists";
52
+ detail: string;
53
+ } | {
54
+ reason: "packaged";
55
+ detail: string;
56
+ } | {
57
+ reason: "unwritable";
58
+ detail: string;
59
+ };
60
+ /**
61
+ * Whether this extension is one we may move or delete.
62
+ *
63
+ * The test is positional and deliberately so: an extension is ours to edit when
64
+ * its directory sits *directly* inside one of the search roots, which is exactly
65
+ * the layout `discoverCanvasExtensions` walks. Anything else arrived inside a
66
+ * package — resolved through a manifest by `plugin-canvases.ts` — and its
67
+ * location is that package's business.
68
+ */
69
+ export declare function canvasHomeRoot(extension: DiscoveredCanvasExtension, roots: readonly CanvasSearchRoot[]): CanvasSearchRoot | undefined;
70
+ /**
71
+ * Rename a canvas extension: its directory, and the name inside its code.
72
+ *
73
+ * The caller is responsible for closing open instances first. This does not do
74
+ * it, because closing is the *session's* concern and this module has no session —
75
+ * but skipping it leaves an instance pointing at a directory that has moved.
76
+ */
77
+ export declare function renameCanvasExtension(extension: DiscoveredCanvasExtension, to: string, roots: readonly CanvasSearchRoot[]): CanvasRenameResult | CanvasLifecycleRefusal;
78
+ /** What {@link removeCanvasExtension} deleted. */
79
+ export interface CanvasRemoveResult {
80
+ id: string;
81
+ dir: string;
82
+ }
83
+ /**
84
+ * Delete a canvas extension's directory.
85
+ *
86
+ * As with rename, the caller closes open instances first — a deleted directory
87
+ * whose child is still forked leaves a process serving code that no longer
88
+ * exists on disk, which is the most confusing state of all.
89
+ */
90
+ export declare function removeCanvasExtension(extension: DiscoveredCanvasExtension, roots: readonly CanvasSearchRoot[]): CanvasRemoveResult | CanvasLifecycleRefusal;
91
+ /** Narrow a lifecycle return value to its refusal case. */
92
+ export declare function isCanvasRefusal(value: object): value is CanvasLifecycleRefusal;
93
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAIlF,mCAAmC;AACnC,MAAM,WAAW,aAAa;IAC7B,gEAAgE;IAChE,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACd;AAED,8CAA8C;AAC9C,MAAM,WAAW,kBAAkB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B;;;OAGG;IACH,SAAS,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,sBAAsB,GAC/B;IAAE,MAAM,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,MAAM,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5C;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC7B,SAAS,EAAE,yBAAyB,EACpC,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAChC,gBAAgB,GAAG,SAAS,CAG9B;AAwDD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACpC,SAAS,EAAE,yBAAyB,EACpC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAChC,kBAAkB,GAAG,sBAAsB,CA4C7C;AAED,kDAAkD;AAClD,MAAM,WAAW,kBAAkB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACpC,SAAS,EAAE,yBAAyB,EACpC,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAChC,kBAAkB,GAAG,sBAAsB,CAc7C;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,sBAAsB,CAE9E","sourcesContent":["/**\n * Renaming and removing a canvas extension.\n *\n * Design: `docs/canvas-extensions-design.md` §13.6. These exist because a canvas\n * has more identity than a file does, and it is spread across four places: the\n * directory name (which *is* the extension id, since discovery keys off\n * position), the `id` a canvas declares, its `displayName`, and whatever the\n * header comment tells the reader to type. Renaming by hand means getting all\n * four right, and getting the `id` wrong is not a typo — it drops the canvas the\n * person is looking at on the next reload, because the instance was opened\n * against a canvas that no longer exists.\n *\n * So the rewriting is deliberately narrow. It changes the three places the\n * scaffold puts the name and nothing else, then **reports every other line the\n * old name still appears on** rather than guessing at prose. A rename that\n * silently edited a description would be worse than one that admits what it left\n * behind.\n */\n\nimport { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport * as path from \"node:path\";\nimport type { CanvasSearchRoot, DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { CANVAS_ENTRY_FILE } from \"./discovery.js\";\nimport { validateCanvasName } from \"./scaffold.js\";\n\n/** One line the rename changed. */\nexport interface CanvasRewrite {\n\t/** 1-based, so it can be read straight off an editor gutter. */\n\tline: number;\n\tbefore: string;\n\tafter: string;\n}\n\n/** What {@link renameCanvasExtension} did. */\nexport interface CanvasRenameResult {\n\tfrom: string;\n\tto: string;\n\t/** Workspace-relative where possible, for a message a person can act on. */\n\tdir: string;\n\trewrites: CanvasRewrite[];\n\t/**\n\t * Lines where the old name survives, because they are prose rather than\n\t * identity. Surfaced, never silently edited.\n\t */\n\tleftovers: number[];\n}\n\n/**\n * Why a canvas cannot be renamed or removed in place.\n *\n * `packaged` is the interesting one: a plugin's canvases are named by its\n * manifest rather than by where they sit, so moving the directory would either\n * do nothing or break the plugin. That is `/plugin`'s job, not this one's.\n */\nexport type CanvasLifecycleRefusal =\n\t| { reason: \"invalid-name\"; detail: string }\n\t| { reason: \"exists\"; detail: string }\n\t| { reason: \"packaged\"; detail: string }\n\t| { reason: \"unwritable\"; detail: string };\n\n/**\n * Whether this extension is one we may move or delete.\n *\n * The test is positional and deliberately so: an extension is ours to edit when\n * its directory sits *directly* inside one of the search roots, which is exactly\n * the layout `discoverCanvasExtensions` walks. Anything else arrived inside a\n * package — resolved through a manifest by `plugin-canvases.ts` — and its\n * location is that package's business.\n */\nexport function canvasHomeRoot(\n\textension: DiscoveredCanvasExtension,\n\troots: readonly CanvasSearchRoot[],\n): CanvasSearchRoot | undefined {\n\tconst parent = path.dirname(path.resolve(extension.dir));\n\treturn roots.find((root) => path.resolve(root.dir) === parent);\n}\n\n/**\n * Swap the old name for the new one where it is *identity* rather than prose.\n *\n * Two rules, and the first is the interesting one: **a string literal whose\n * entire content is the old name**. That covers `id`, `displayName`, `title` and\n * the scaffold's `const ID = \"…\"` without knowing any of their names, and it\n * does not touch a sentence that merely mentions the canvas — `\"the board is the\n * point of the board\"` is not the string `\"board\"`. A whole-word replacement\n * would have rewritten that sentence, which is worse than leaving it.\n *\n * The second rule keeps the instructions in a comment honest: `/canvas open\n * <old>` would otherwise tell the next reader to type a name that no longer\n * resolves.\n *\n * Anything else is left and reported. The scaffold names itself in exactly three\n * places, all covered here, so a canvas that has not been renamed by hand comes\n * through with nothing left over.\n */\nfunction rewriteEntrySource(source: string, from: string, to: string): { source: string; rewrites: CanvasRewrite[] } {\n\tconst name = escapeForRegExp(from);\n\tconst substitutions: [RegExp, string][] = [\n\t\t// A quoted string that is *only* the old name. Backticks included, but only\n\t\t// without interpolation — `${x}-${from}` is a computed value, not a literal.\n\t\t[new RegExp(`([\"'])${name}\\\\1`, \"g\"), `$1${to}$1`],\n\t\t[new RegExp(\"`\" + name + \"`\", \"g\"), `\\`${to}\\``],\n\t\t// Instructions in a comment: /canvas open <old>, /canvas reload <old>, …\n\t\t[new RegExp(`(/canvas\\\\s+(?:open|reload|rename|remove|close)\\\\s+)${name}\\\\b`, \"g\"), `$1${to}`],\n\t];\n\n\tconst before = source.split(\"\\n\");\n\tconst after = before.map((line) =>\n\t\tsubstitutions.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), line),\n\t);\n\n\tconst rewrites: CanvasRewrite[] = [];\n\tafter.forEach((line, index) => {\n\t\tif (line !== before[index]) rewrites.push({ line: index + 1, before: before[index] as string, after: line });\n\t});\n\treturn { source: after.join(\"\\n\"), rewrites };\n}\n\n/** Lines still mentioning `name` after the rewrite. */\nfunction remainingMentions(source: string, name: string): number[] {\n\tconst lines: number[] = [];\n\tsource.split(\"\\n\").forEach((line, index) => {\n\t\tif (line.includes(name)) lines.push(index + 1);\n\t});\n\treturn lines;\n}\n\nfunction escapeForRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Rename a canvas extension: its directory, and the name inside its code.\n *\n * The caller is responsible for closing open instances first. This does not do\n * it, because closing is the *session's* concern and this module has no session —\n * but skipping it leaves an instance pointing at a directory that has moved.\n */\nexport function renameCanvasExtension(\n\textension: DiscoveredCanvasExtension,\n\tto: string,\n\troots: readonly CanvasSearchRoot[],\n): CanvasRenameResult | CanvasLifecycleRefusal {\n\tconst invalid = validateCanvasName(to);\n\tif (invalid) return { reason: \"invalid-name\", detail: invalid };\n\tif (to === extension.id) return { reason: \"invalid-name\", detail: `it is already called \"${to}\"` };\n\n\tconst root = canvasHomeRoot(extension, roots);\n\tif (!root) {\n\t\treturn {\n\t\t\treason: \"packaged\",\n\t\t\tdetail: `\"${extension.id}\" came from an installed plugin, which names its canvases in its own manifest. Renaming it here would break the plugin; use /plugin instead.`,\n\t\t};\n\t}\n\n\tconst target = path.join(root.dir, to);\n\tif (existsSync(target)) return { reason: \"exists\", detail: `${target} already exists` };\n\n\tconst entry = path.join(extension.dir, CANVAS_ENTRY_FILE);\n\tlet source: string;\n\ttry {\n\t\tsource = readFileSync(entry, \"utf8\");\n\t} catch (cause) {\n\t\treturn {\n\t\t\treason: \"unwritable\",\n\t\t\tdetail: `could not read ${entry}: ${cause instanceof Error ? cause.message : String(cause)}`,\n\t\t};\n\t}\n\n\tconst rewritten = rewriteEntrySource(source, extension.id, to);\n\ttry {\n\t\t// Write before moving: if the write fails the directory has not moved and\n\t\t// nothing is half-renamed.\n\t\tif (rewritten.source !== source) writeFileSync(entry, rewritten.source, \"utf8\");\n\t\trenameSync(extension.dir, target);\n\t} catch (cause) {\n\t\treturn { reason: \"unwritable\", detail: cause instanceof Error ? cause.message : String(cause) };\n\t}\n\n\treturn {\n\t\tfrom: extension.id,\n\t\tto,\n\t\tdir: target,\n\t\trewrites: rewritten.rewrites,\n\t\tleftovers: remainingMentions(rewritten.source, extension.id),\n\t};\n}\n\n/** What {@link removeCanvasExtension} deleted. */\nexport interface CanvasRemoveResult {\n\tid: string;\n\tdir: string;\n}\n\n/**\n * Delete a canvas extension's directory.\n *\n * As with rename, the caller closes open instances first — a deleted directory\n * whose child is still forked leaves a process serving code that no longer\n * exists on disk, which is the most confusing state of all.\n */\nexport function removeCanvasExtension(\n\textension: DiscoveredCanvasExtension,\n\troots: readonly CanvasSearchRoot[],\n): CanvasRemoveResult | CanvasLifecycleRefusal {\n\tconst root = canvasHomeRoot(extension, roots);\n\tif (!root) {\n\t\treturn {\n\t\t\treason: \"packaged\",\n\t\t\tdetail: `\"${extension.id}\" came from an installed plugin. Deleting its directory would leave the plugin broken rather than uninstalled; use /plugin instead.`,\n\t\t};\n\t}\n\ttry {\n\t\trmSync(extension.dir, { recursive: true, force: true });\n\t} catch (cause) {\n\t\treturn { reason: \"unwritable\", detail: cause instanceof Error ? cause.message : String(cause) };\n\t}\n\treturn { id: extension.id, dir: extension.dir };\n}\n\n/** Narrow a lifecycle return value to its refusal case. */\nexport function isCanvasRefusal(value: object): value is CanvasLifecycleRefusal {\n\treturn \"reason\" in value;\n}\n"]}
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Renaming and removing a canvas extension.
3
+ *
4
+ * Design: `docs/canvas-extensions-design.md` §13.6. These exist because a canvas
5
+ * has more identity than a file does, and it is spread across four places: the
6
+ * directory name (which *is* the extension id, since discovery keys off
7
+ * position), the `id` a canvas declares, its `displayName`, and whatever the
8
+ * header comment tells the reader to type. Renaming by hand means getting all
9
+ * four right, and getting the `id` wrong is not a typo — it drops the canvas the
10
+ * person is looking at on the next reload, because the instance was opened
11
+ * against a canvas that no longer exists.
12
+ *
13
+ * So the rewriting is deliberately narrow. It changes the three places the
14
+ * scaffold puts the name and nothing else, then **reports every other line the
15
+ * old name still appears on** rather than guessing at prose. A rename that
16
+ * silently edited a description would be worse than one that admits what it left
17
+ * behind.
18
+ */
19
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
20
+ import * as path from "node:path";
21
+ import { CANVAS_ENTRY_FILE } from "./discovery.js";
22
+ import { validateCanvasName } from "./scaffold.js";
23
+ /**
24
+ * Whether this extension is one we may move or delete.
25
+ *
26
+ * The test is positional and deliberately so: an extension is ours to edit when
27
+ * its directory sits *directly* inside one of the search roots, which is exactly
28
+ * the layout `discoverCanvasExtensions` walks. Anything else arrived inside a
29
+ * package — resolved through a manifest by `plugin-canvases.ts` — and its
30
+ * location is that package's business.
31
+ */
32
+ export function canvasHomeRoot(extension, roots) {
33
+ const parent = path.dirname(path.resolve(extension.dir));
34
+ return roots.find((root) => path.resolve(root.dir) === parent);
35
+ }
36
+ /**
37
+ * Swap the old name for the new one where it is *identity* rather than prose.
38
+ *
39
+ * Two rules, and the first is the interesting one: **a string literal whose
40
+ * entire content is the old name**. That covers `id`, `displayName`, `title` and
41
+ * the scaffold's `const ID = "…"` without knowing any of their names, and it
42
+ * does not touch a sentence that merely mentions the canvas — `"the board is the
43
+ * point of the board"` is not the string `"board"`. A whole-word replacement
44
+ * would have rewritten that sentence, which is worse than leaving it.
45
+ *
46
+ * The second rule keeps the instructions in a comment honest: `/canvas open
47
+ * <old>` would otherwise tell the next reader to type a name that no longer
48
+ * resolves.
49
+ *
50
+ * Anything else is left and reported. The scaffold names itself in exactly three
51
+ * places, all covered here, so a canvas that has not been renamed by hand comes
52
+ * through with nothing left over.
53
+ */
54
+ function rewriteEntrySource(source, from, to) {
55
+ const name = escapeForRegExp(from);
56
+ const substitutions = [
57
+ // A quoted string that is *only* the old name. Backticks included, but only
58
+ // without interpolation — `${x}-${from}` is a computed value, not a literal.
59
+ [new RegExp(`(["'])${name}\\1`, "g"), `$1${to}$1`],
60
+ [new RegExp("`" + name + "`", "g"), `\`${to}\``],
61
+ // Instructions in a comment: /canvas open <old>, /canvas reload <old>, …
62
+ [new RegExp(`(/canvas\\s+(?:open|reload|rename|remove|close)\\s+)${name}\\b`, "g"), `$1${to}`],
63
+ ];
64
+ const before = source.split("\n");
65
+ const after = before.map((line) => substitutions.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), line));
66
+ const rewrites = [];
67
+ after.forEach((line, index) => {
68
+ if (line !== before[index])
69
+ rewrites.push({ line: index + 1, before: before[index], after: line });
70
+ });
71
+ return { source: after.join("\n"), rewrites };
72
+ }
73
+ /** Lines still mentioning `name` after the rewrite. */
74
+ function remainingMentions(source, name) {
75
+ const lines = [];
76
+ source.split("\n").forEach((line, index) => {
77
+ if (line.includes(name))
78
+ lines.push(index + 1);
79
+ });
80
+ return lines;
81
+ }
82
+ function escapeForRegExp(value) {
83
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84
+ }
85
+ /**
86
+ * Rename a canvas extension: its directory, and the name inside its code.
87
+ *
88
+ * The caller is responsible for closing open instances first. This does not do
89
+ * it, because closing is the *session's* concern and this module has no session —
90
+ * but skipping it leaves an instance pointing at a directory that has moved.
91
+ */
92
+ export function renameCanvasExtension(extension, to, roots) {
93
+ const invalid = validateCanvasName(to);
94
+ if (invalid)
95
+ return { reason: "invalid-name", detail: invalid };
96
+ if (to === extension.id)
97
+ return { reason: "invalid-name", detail: `it is already called "${to}"` };
98
+ const root = canvasHomeRoot(extension, roots);
99
+ if (!root) {
100
+ return {
101
+ reason: "packaged",
102
+ detail: `"${extension.id}" came from an installed plugin, which names its canvases in its own manifest. Renaming it here would break the plugin; use /plugin instead.`,
103
+ };
104
+ }
105
+ const target = path.join(root.dir, to);
106
+ if (existsSync(target))
107
+ return { reason: "exists", detail: `${target} already exists` };
108
+ const entry = path.join(extension.dir, CANVAS_ENTRY_FILE);
109
+ let source;
110
+ try {
111
+ source = readFileSync(entry, "utf8");
112
+ }
113
+ catch (cause) {
114
+ return {
115
+ reason: "unwritable",
116
+ detail: `could not read ${entry}: ${cause instanceof Error ? cause.message : String(cause)}`,
117
+ };
118
+ }
119
+ const rewritten = rewriteEntrySource(source, extension.id, to);
120
+ try {
121
+ // Write before moving: if the write fails the directory has not moved and
122
+ // nothing is half-renamed.
123
+ if (rewritten.source !== source)
124
+ writeFileSync(entry, rewritten.source, "utf8");
125
+ renameSync(extension.dir, target);
126
+ }
127
+ catch (cause) {
128
+ return { reason: "unwritable", detail: cause instanceof Error ? cause.message : String(cause) };
129
+ }
130
+ return {
131
+ from: extension.id,
132
+ to,
133
+ dir: target,
134
+ rewrites: rewritten.rewrites,
135
+ leftovers: remainingMentions(rewritten.source, extension.id),
136
+ };
137
+ }
138
+ /**
139
+ * Delete a canvas extension's directory.
140
+ *
141
+ * As with rename, the caller closes open instances first — a deleted directory
142
+ * whose child is still forked leaves a process serving code that no longer
143
+ * exists on disk, which is the most confusing state of all.
144
+ */
145
+ export function removeCanvasExtension(extension, roots) {
146
+ const root = canvasHomeRoot(extension, roots);
147
+ if (!root) {
148
+ return {
149
+ reason: "packaged",
150
+ detail: `"${extension.id}" came from an installed plugin. Deleting its directory would leave the plugin broken rather than uninstalled; use /plugin instead.`,
151
+ };
152
+ }
153
+ try {
154
+ rmSync(extension.dir, { recursive: true, force: true });
155
+ }
156
+ catch (cause) {
157
+ return { reason: "unwritable", detail: cause instanceof Error ? cause.message : String(cause) };
158
+ }
159
+ return { id: extension.id, dir: extension.dir };
160
+ }
161
+ /** Narrow a lifecycle return value to its refusal case. */
162
+ export function isCanvasRefusal(value) {
163
+ return "reason" in value;
164
+ }
165
+ //# sourceMappingURL=lifecycle.js.map