@thinkingsage/kanon 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. package/templates/knowledge/mcp-servers.yaml.njk +2 -0
package/src/install.ts ADDED
@@ -0,0 +1,1005 @@
1
+ import {
2
+ copyFile,
3
+ exists,
4
+ mkdir,
5
+ readdir,
6
+ readFile,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import * as p from "@clack/prompts";
11
+ import chalk from "chalk";
12
+ import { parseKiroSteeringFile } from "./adapters/kiro-frontmatter";
13
+ import type { KiroInclusionMode } from "./adapters/kiro-inclusion";
14
+ import { resolveBackend } from "./backends/index";
15
+ import { generateCatalog } from "./catalog";
16
+ import { loadForgeConfig, resolveBackendConfigs } from "./config";
17
+ import { GlobalCache } from "./guild/global-cache";
18
+ import type {
19
+ CatalogEntry,
20
+ HarnessName,
21
+ VersionManifest,
22
+ WorkspaceProject,
23
+ } from "./schemas";
24
+ import { SUPPORTED_HARNESSES } from "./schemas";
25
+ import { serializeManifest } from "./versioning";
26
+ import { loadWorkspaceConfig } from "./workspace";
27
+
28
+ export interface InstallOptions {
29
+ artifactName?: string;
30
+ harness?: HarnessName;
31
+ all?: boolean;
32
+ force?: boolean;
33
+ dryRun?: boolean;
34
+ source?: string;
35
+ fromRelease?: string;
36
+ /** Named backend from kanon.config.yaml, e.g. "internal" for S3 */
37
+ backend?: string;
38
+ /** Artifact version (semver) to record in the version manifest */
39
+ version?: string;
40
+ /** Path to the source knowledge artifact directory */
41
+ sourcePath?: string;
42
+ /** Install only for a specific workspace project */
43
+ project?: string;
44
+ /** Max number of always-mode Kiro steering files to install; undefined or -1 means no limit */
45
+ maxAlways?: number;
46
+ }
47
+
48
+ /** Written alongside installed files to record install provenance. */
49
+ export interface ForgeManifestEntry {
50
+ name: string;
51
+ harness: HarnessName;
52
+ version: string;
53
+ backend: string;
54
+ installedAt: string;
55
+ }
56
+
57
+ export interface InstallPlan {
58
+ files: Array<{ source: string; destination: string; overwrite: boolean }>;
59
+ harnesses: HarnessName[];
60
+ artifacts: string[];
61
+ }
62
+
63
+ // Harness install path mappings
64
+ const HARNESS_INSTALL_PATHS: Record<HarnessName, string> = {
65
+ kiro: ".kiro",
66
+ "claude-code": ".",
67
+ codex: ".",
68
+ copilot: ".",
69
+ cursor: ".",
70
+ windsurf: ".",
71
+ cline: ".",
72
+ qdeveloper: ".",
73
+ };
74
+
75
+ async function collectFiles(dir: string, base: string = ""): Promise<string[]> {
76
+ const results: string[] = [];
77
+ const entries = await readdir(dir, { withFileTypes: true });
78
+ for (const entry of entries) {
79
+ const rel = base ? `${base}/${entry.name}` : entry.name;
80
+ if (entry.isDirectory()) {
81
+ results.push(...(await collectFiles(join(dir, entry.name), rel)));
82
+ } else {
83
+ results.push(rel);
84
+ }
85
+ }
86
+ return results;
87
+ }
88
+
89
+ /**
90
+ * Determine if an install-relative file path is a Kiro steering file that
91
+ * should be scanned for inclusion mode. Matches:
92
+ * - `<name>.md` (top-level steering)
93
+ * - `steering/<name>.md` (power-format steering)
94
+ */
95
+ function isKiroSteeringPath(relativePath: string): boolean {
96
+ if (!relativePath.endsWith(".md")) return false;
97
+ // Top-level: <name>.md (no directory separator)
98
+ if (!relativePath.includes("/")) return true;
99
+ // Under steering/: steering/<anything>.md
100
+ if (relativePath.startsWith("steering/")) return true;
101
+ return false;
102
+ }
103
+
104
+ export interface PreScanResult {
105
+ alwaysFiles: string[];
106
+ warnings: string[];
107
+ }
108
+
109
+ /**
110
+ * Pre-install scan: parse all would-be-installed Kiro .md steering files
111
+ * from the source directory and bucket them by inclusion mode.
112
+ * Parse failures, null frontmatter, and missing inclusion are conservatively
113
+ * bucketed as "always" per Req 7.6, with a warning recorded.
114
+ */
115
+ async function preInstallScan(
116
+ srcDir: string,
117
+ files: string[],
118
+ ): Promise<PreScanResult> {
119
+ const alwaysFiles: string[] = [];
120
+ const warnings: string[] = [];
121
+
122
+ for (const file of files) {
123
+ if (!isKiroSteeringPath(file)) continue;
124
+
125
+ const filePath = join(srcDir, file);
126
+ let content: string;
127
+ try {
128
+ content = await readFile(filePath, "utf-8");
129
+ } catch {
130
+ // Cannot read → bucket as always (conservative)
131
+ alwaysFiles.push(file);
132
+ warnings.push(`Could not read "${file}" — treating as inclusion: always`);
133
+ continue;
134
+ }
135
+
136
+ const result = parseKiroSteeringFile(content, filePath);
137
+
138
+ if (!result.ok) {
139
+ // Parse failure → bucket as always
140
+ alwaysFiles.push(file);
141
+ warnings.push(
142
+ `Failed to parse frontmatter in "${file}" (${result.message}) — treating as inclusion: always`,
143
+ );
144
+ continue;
145
+ }
146
+
147
+ if (result.frontmatter === null || !result.frontmatter.inclusion) {
148
+ // No frontmatter or missing inclusion → bucket as always
149
+ alwaysFiles.push(file);
150
+ warnings.push(
151
+ `"${file}" is missing an explicit inclusion mode — treating as inclusion: always`,
152
+ );
153
+ continue;
154
+ }
155
+
156
+ if (result.frontmatter.inclusion === "always") {
157
+ alwaysFiles.push(file);
158
+ }
159
+ // fileMatch and manual files are not counted toward the always limit
160
+ }
161
+
162
+ return { alwaysFiles, warnings };
163
+ }
164
+
165
+ /**
166
+ * Enforce --max-always: if the always count exceeds the limit, print offenders
167
+ * and exit with code 1. Works identically for dry-run and real-run (Req 7.2).
168
+ */
169
+ function enforceMaxAlways(
170
+ maxAlways: number | undefined,
171
+ scanResult: PreScanResult,
172
+ harnessLabel: string,
173
+ ): void {
174
+ // Emit warnings regardless of limit
175
+ for (const w of scanResult.warnings) {
176
+ console.error(chalk.yellow(` ⚠ ${w}`));
177
+ }
178
+
179
+ if (maxAlways === undefined) return;
180
+
181
+ if (scanResult.alwaysFiles.length > maxAlways) {
182
+ console.error(
183
+ chalk.red(
184
+ `\nError: ${scanResult.alwaysFiles.length} Kiro steering file(s) with inclusion: always would exceed --max-always=${maxAlways} for ${harnessLabel}.`,
185
+ ),
186
+ );
187
+ console.error(chalk.red("Offending files:"));
188
+ for (const f of scanResult.alwaysFiles) {
189
+ console.error(chalk.red(` • ${f}`));
190
+ }
191
+ process.exit(1);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Post-install inclusion summary: re-parse only the files copied in this
197
+ * invocation at the install destination, bucket by inclusion mode, and print
198
+ * the per-mode summary to stderr. Only prints if there were Kiro steering files.
199
+ *
200
+ * Parse failures, null frontmatter, and missing inclusion are conservatively
201
+ * bucketed as "always" per Req 7.6, with a warning emitted.
202
+ */
203
+ async function postInstallInclusionSummary(
204
+ installBase: string,
205
+ installedFiles: string[],
206
+ ): Promise<void> {
207
+ const byMode: Record<KiroInclusionMode, number> = {
208
+ always: 0,
209
+ fileMatch: 0,
210
+ manual: 0,
211
+ };
212
+ const warnings: string[] = [];
213
+ let steeringFileCount = 0;
214
+
215
+ for (const file of installedFiles) {
216
+ if (!isKiroSteeringPath(file)) continue;
217
+ steeringFileCount++;
218
+
219
+ const filePath = join(installBase, file);
220
+ let content: string;
221
+ try {
222
+ content = await readFile(filePath, "utf-8");
223
+ } catch {
224
+ byMode.always++;
225
+ warnings.push(`Could not read "${file}" — treating as inclusion: always`);
226
+ continue;
227
+ }
228
+
229
+ const result = parseKiroSteeringFile(content, filePath);
230
+
231
+ if (!result.ok) {
232
+ byMode.always++;
233
+ warnings.push(
234
+ `Failed to parse frontmatter in "${file}" (${result.message}) — treating as inclusion: always`,
235
+ );
236
+ continue;
237
+ }
238
+
239
+ if (result.frontmatter === null || !result.frontmatter.inclusion) {
240
+ byMode.always++;
241
+ warnings.push(
242
+ `"${file}" is missing an explicit inclusion mode — treating as inclusion: always`,
243
+ );
244
+ continue;
245
+ }
246
+
247
+ byMode[result.frontmatter.inclusion]++;
248
+ }
249
+
250
+ // Only print if there were Kiro steering files installed
251
+ if (steeringFileCount === 0) return;
252
+
253
+ // Emit warnings for parse failures / missing inclusion (Req 7.6)
254
+ for (const w of warnings) {
255
+ console.error(chalk.yellow(` ⚠ ${w}`));
256
+ }
257
+
258
+ // Print the summary
259
+ const lines: string[] = [];
260
+ lines.push("");
261
+ lines.push(chalk.cyan("Kiro Inclusion Summary:"));
262
+ lines.push(` Total steering files: ${steeringFileCount}`);
263
+ lines.push(` By mode:`);
264
+ for (const mode of ["always", "fileMatch", "manual"] as KiroInclusionMode[]) {
265
+ const count = byMode[mode];
266
+ if (count > 0) {
267
+ lines.push(` ${mode}: ${count}`);
268
+ }
269
+ }
270
+ const progressiveCount = byMode.fileMatch + byMode.manual;
271
+ const pct = Math.round((progressiveCount / steeringFileCount) * 100);
272
+ lines.push(` Progressive ratio: ${pct}% (fileMatch + manual)`);
273
+ console.error(lines.join("\n"));
274
+ }
275
+
276
+ export async function install(options: InstallOptions): Promise<void> {
277
+ const distDir = options.source ? join(options.source, "dist") : "dist";
278
+ const { artifactName, harness, all, force, dryRun } = options;
279
+
280
+ if (!artifactName) {
281
+ console.error(
282
+ chalk.red("Error: Artifact name is required for direct install."),
283
+ );
284
+ process.exit(1);
285
+ }
286
+
287
+ // Determine which harnesses to install
288
+ let targetHarnesses: HarnessName[];
289
+ if (all) {
290
+ targetHarnesses = [...SUPPORTED_HARNESSES].filter((h) =>
291
+ existsSync(join(distDir, h, artifactName)),
292
+ );
293
+ } else if (harness) {
294
+ targetHarnesses = [harness];
295
+ } else {
296
+ console.error(chalk.red("Error: Specify --harness <name> or --all."));
297
+ process.exit(1);
298
+ }
299
+
300
+ let totalFiles = 0;
301
+ for (const h of targetHarnesses) {
302
+ const srcDir = join(distDir, h, artifactName);
303
+ if (!(await exists(srcDir))) {
304
+ console.error(
305
+ chalk.red(
306
+ `Error: Artifact "${artifactName}" not built for harness "${h}". Run \`kanon build --harness ${h}\` first.`,
307
+ ),
308
+ );
309
+ process.exit(1);
310
+ }
311
+
312
+ const files = await collectFiles(srcDir);
313
+ const installBase = HARNESS_INSTALL_PATHS[h];
314
+ const installedFiles: string[] = [];
315
+
316
+ // Pre-install scan for Kiro harness: enforce --max-always before any writes
317
+ if (h === "kiro") {
318
+ const scanResult = await preInstallScan(srcDir, files);
319
+ enforceMaxAlways(options.maxAlways, scanResult, `harness "${h}"`);
320
+ }
321
+
322
+ if (dryRun) {
323
+ console.error(
324
+ chalk.cyan(`[dry-run] Would install ${files.length} files for ${h}:`),
325
+ );
326
+ }
327
+
328
+ for (const file of files) {
329
+ const src = join(srcDir, file);
330
+ const dest = join(installBase, file);
331
+ const destExists = await exists(dest);
332
+
333
+ if (destExists && !force && !dryRun) {
334
+ console.error(
335
+ chalk.yellow(
336
+ ` Skipping ${dest} (already exists, use --force to overwrite)`,
337
+ ),
338
+ );
339
+ continue;
340
+ }
341
+
342
+ if (dryRun) {
343
+ console.error(` ${src} → ${dest}${destExists ? " (overwrite)" : ""}`);
344
+ } else {
345
+ const destDir = dirname(dest);
346
+ await mkdir(destDir, { recursive: true });
347
+ await copyFile(src, dest);
348
+ console.error(` ${chalk.green("✓")} ${dest}`);
349
+ installedFiles.push(file);
350
+ }
351
+ totalFiles++;
352
+ }
353
+
354
+ // Write version manifest alongside installed files
355
+ if (!dryRun && installedFiles.length > 0) {
356
+ const version =
357
+ options.version ??
358
+ (await extractVersionFromFiles(srcDir, files)) ??
359
+ "0.1.0";
360
+ const sourcePath = options.sourcePath ?? srcDir;
361
+ await writeVersionManifest(
362
+ artifactName,
363
+ version,
364
+ h,
365
+ sourcePath,
366
+ installedFiles,
367
+ installBase,
368
+ );
369
+ }
370
+
371
+ // Post-install inclusion summary for Kiro harness (Req 7.1, 7.6)
372
+ if (h === "kiro" && installedFiles.length > 0) {
373
+ await postInstallInclusionSummary(installBase, installedFiles);
374
+ }
375
+ }
376
+
377
+ if (!dryRun) {
378
+ console.error(
379
+ chalk.green(
380
+ `\n✓ Installed ${totalFiles} files for ${targetHarnesses.join(", ")}`,
381
+ ),
382
+ );
383
+ }
384
+ }
385
+
386
+ function existsSync(path: string): boolean {
387
+ try {
388
+ Bun.file(path);
389
+ return require("node:fs").existsSync(path);
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
394
+
395
+ const HARNESS_DETECT_PATHS: Partial<Record<HarnessName, string>> = {
396
+ kiro: ".kiro",
397
+ cursor: ".cursor",
398
+ windsurf: ".windsurf",
399
+ "claude-code": ".claude",
400
+ codex: ".codex",
401
+ cline: ".clinerules",
402
+ qdeveloper: ".q",
403
+ copilot: ".github",
404
+ };
405
+
406
+ export async function runInteractiveInstaller(
407
+ catalog: CatalogEntry[],
408
+ distDir: string,
409
+ ): Promise<void> {
410
+ p.intro(chalk.cyan("Kanon Interactive Installer"));
411
+
412
+ if (catalog.length === 0) {
413
+ p.cancel("No artifacts found in catalog. Run `kanon build` first.");
414
+ process.exit(1);
415
+ }
416
+
417
+ // Select artifacts
418
+ const artifactChoices = catalog.map((entry) => ({
419
+ value: entry.name,
420
+ label: entry.displayName,
421
+ hint: entry.description,
422
+ }));
423
+
424
+ const selectedArtifacts = await p.multiselect({
425
+ message: "Select artifacts to install:",
426
+ options: artifactChoices,
427
+ required: true,
428
+ });
429
+
430
+ if (p.isCancel(selectedArtifacts)) {
431
+ p.cancel("Installation cancelled.");
432
+ process.exit(0);
433
+ }
434
+
435
+ // Detect harnesses in cwd
436
+ const detectedHarnesses: HarnessName[] = [];
437
+ for (const [h, path] of Object.entries(HARNESS_DETECT_PATHS)) {
438
+ if (await exists(path)) {
439
+ detectedHarnesses.push(h as HarnessName);
440
+ }
441
+ }
442
+
443
+ const harnessChoices = SUPPORTED_HARNESSES.map((h) => ({
444
+ value: h,
445
+ label: h,
446
+ hint: detectedHarnesses.includes(h) ? "(detected)" : undefined,
447
+ }));
448
+
449
+ const selectedHarnesses = await p.multiselect({
450
+ message: "Select target harnesses:",
451
+ options: harnessChoices,
452
+ initialValues: detectedHarnesses,
453
+ required: true,
454
+ });
455
+
456
+ if (p.isCancel(selectedHarnesses)) {
457
+ p.cancel("Installation cancelled.");
458
+ process.exit(0);
459
+ }
460
+
461
+ // Confirmation
462
+ const confirmed = await p.confirm({
463
+ message: `Install ${(selectedArtifacts as string[]).length} artifact(s) for ${(selectedHarnesses as string[]).length} harness(es)?`,
464
+ });
465
+
466
+ if (p.isCancel(confirmed) || !confirmed) {
467
+ p.cancel("Installation cancelled.");
468
+ process.exit(0);
469
+ }
470
+
471
+ // Install each artifact for each harness
472
+ for (const artifact of selectedArtifacts as string[]) {
473
+ for (const h of selectedHarnesses as HarnessName[]) {
474
+ await install({
475
+ artifactName: artifact,
476
+ harness: h,
477
+ force: true,
478
+ source: distDir === "dist" ? undefined : distDir,
479
+ });
480
+ }
481
+ }
482
+
483
+ p.outro(chalk.green("Installation complete!"));
484
+ }
485
+
486
+ export async function installCommand(
487
+ artifact?: string,
488
+ options?: Record<string, unknown>,
489
+ ): Promise<void> {
490
+ const opts = options || {};
491
+
492
+ // --- Global install path: route to GlobalCache ---
493
+ if (opts.global) {
494
+ if (!artifact) {
495
+ console.error(
496
+ chalk.red("Error: Artifact name is required for global install."),
497
+ );
498
+ process.exit(1);
499
+ }
500
+
501
+ const config = await loadForgeConfig();
502
+ const backendConfigs = resolveBackendConfigs(config);
503
+
504
+ const backendName = (opts.backend as string | undefined) ?? "github";
505
+ const backendConfig = backendConfigs.get(backendName);
506
+ if (!backendConfig) {
507
+ const available = [...backendConfigs.keys()].join(", ");
508
+ console.error(
509
+ chalk.red(
510
+ `Unknown backend "${backendName}". Available backends: ${available}`,
511
+ ),
512
+ );
513
+ process.exit(1);
514
+ }
515
+
516
+ const fromRelease = opts.fromRelease as string | undefined;
517
+ const backend = resolveBackend(backendConfig, fromRelease);
518
+ const cache = new GlobalCache();
519
+
520
+ // Determine version: use --from-release tag, or fetch latest from backend
521
+ let version: string;
522
+ if (fromRelease) {
523
+ version = fromRelease;
524
+ } else {
525
+ try {
526
+ const versions = await backend.listVersions();
527
+ if (versions.length === 0) {
528
+ console.error(
529
+ chalk.red(
530
+ `No versions available for "${artifact}" from backend "${backend.label}".`,
531
+ ),
532
+ );
533
+ process.exit(1);
534
+ }
535
+ // Pick the latest (last) version from the sorted list
536
+ version = versions[versions.length - 1];
537
+ } catch (err: unknown) {
538
+ const reason = err instanceof Error ? err.message : String(err);
539
+ console.error(
540
+ chalk.red(
541
+ `Error: Failed to reach backend "${backend.label}": ${reason}`,
542
+ ),
543
+ );
544
+ process.exit(1);
545
+ }
546
+ }
547
+
548
+ // Skip if same version already cached (Req 1.4)
549
+ if (await cache.has(artifact, version)) {
550
+ console.error(
551
+ chalk.yellow(
552
+ `"${artifact}" v${version} is already cached. Skipping installation.`,
553
+ ),
554
+ );
555
+ return;
556
+ }
557
+
558
+ // Fetch and store each harness into the global cache (Req 1.1, 1.5, 1.6)
559
+ const targetHarnesses: HarnessName[] = opts.harness
560
+ ? [opts.harness as HarnessName]
561
+ : [...SUPPORTED_HARNESSES];
562
+
563
+ let storedCount = 0;
564
+ for (const h of targetHarnesses) {
565
+ try {
566
+ const tempDir = await backend.fetchArtifact(artifact, h, version);
567
+ await cache.store(artifact, version, h, tempDir, backend.label);
568
+ console.error(
569
+ ` ${chalk.green("✓")} ${artifact}@${version} → ${h} (global cache)`,
570
+ );
571
+ storedCount++;
572
+ } catch (err: unknown) {
573
+ const reason = err instanceof Error ? err.message : String(err);
574
+ console.error(chalk.yellow(` Skipping ${h}: ${reason}`));
575
+ }
576
+ }
577
+
578
+ if (storedCount > 0) {
579
+ console.error(
580
+ chalk.green(
581
+ `\n✓ Globally installed "${artifact}" v${version} for ${storedCount} harness(es).`,
582
+ ),
583
+ );
584
+ } else {
585
+ console.error(
586
+ chalk.red(
587
+ `Error: Failed to install any harness for "${artifact}" from backend "${backend.label}".`,
588
+ ),
589
+ );
590
+ process.exit(1);
591
+ }
592
+ return;
593
+ }
594
+
595
+ // Resolve backend if --backend or --from-release is specified
596
+ const backendName = opts.backend as string | undefined;
597
+ const fromRelease = opts.fromRelease as string | undefined;
598
+
599
+ if (backendName || fromRelease) {
600
+ // Load config and resolve the requested backend
601
+ const config = await loadForgeConfig();
602
+ const backendConfigs = resolveBackendConfigs(config);
603
+
604
+ let resolvedBackendName = backendName ?? "github";
605
+ let backendConfig = backendConfigs.get(resolvedBackendName);
606
+
607
+ // --from-release maps to the github backend
608
+ if (fromRelease && !backendConfig) {
609
+ backendConfig = { type: "github" as const, repo: "", releasePrefix: "" };
610
+ resolvedBackendName = "github";
611
+ }
612
+
613
+ if (!backendConfig) {
614
+ console.error(
615
+ chalk.red(
616
+ `Unknown backend "${resolvedBackendName}". Declare it in kanon.config.yaml under install.backends.`,
617
+ ),
618
+ );
619
+ process.exit(1);
620
+ }
621
+
622
+ const backend = resolveBackend(backendConfig, fromRelease);
623
+
624
+ if (!artifact) {
625
+ // Fetch catalog from remote backend for interactive install
626
+ const catalog = await backend.fetchCatalog();
627
+ await runInteractiveInstaller(catalog, "dist");
628
+ return;
629
+ }
630
+
631
+ // Fetch the artifact dist from the remote backend
632
+ const targetHarnesses: HarnessName[] = opts.harness
633
+ ? [opts.harness as HarnessName]
634
+ : [...SUPPORTED_HARNESSES];
635
+
636
+ for (const h of targetHarnesses) {
637
+ try {
638
+ const localDir = await backend.fetchArtifact(artifact, h, fromRelease);
639
+ const maxAlwaysVal = opts.maxAlways as number | undefined;
640
+ await install({
641
+ artifactName: artifact,
642
+ harness: h,
643
+ force: opts.force as boolean | undefined,
644
+ dryRun: opts.dryRun as boolean | undefined,
645
+ source: localDir,
646
+ maxAlways: maxAlwaysVal === -1 ? undefined : maxAlwaysVal,
647
+ });
648
+
649
+ // Write .forge-manifest.json
650
+ if (!opts.dryRun) {
651
+ await writeForgeManifest(
652
+ artifact,
653
+ h,
654
+ fromRelease ?? "latest",
655
+ backend.label,
656
+ );
657
+ }
658
+ } catch (err: unknown) {
659
+ const msg = err instanceof Error ? err.message : String(err);
660
+ console.error(chalk.yellow(` Skipping ${h}: ${msg}`));
661
+ }
662
+ }
663
+ return;
664
+ }
665
+
666
+ if (!artifact) {
667
+ // Interactive mode — local
668
+ const catalog = await generateCatalog("knowledge");
669
+ await runInteractiveInstaller(catalog, "dist");
670
+ return;
671
+ }
672
+
673
+ // --- Workspace-aware install path ---
674
+ const projectFilter = opts.project as string | undefined;
675
+ const maxAlways = opts.maxAlways as number | undefined;
676
+ const wsRoot = process.cwd();
677
+ const wsResult = await loadWorkspaceConfig(wsRoot);
678
+
679
+ if (wsResult) {
680
+ await installWithWorkspace(wsResult.config, wsRoot, artifact, {
681
+ harness: opts.harness as HarnessName | undefined,
682
+ all: opts.all as boolean | undefined,
683
+ force: opts.force as boolean | undefined,
684
+ dryRun: opts.dryRun as boolean | undefined,
685
+ source: opts.source as string | undefined,
686
+ project: projectFilter,
687
+ maxAlways: maxAlways === -1 ? undefined : maxAlways,
688
+ });
689
+ return;
690
+ }
691
+
692
+ await install({
693
+ artifactName: artifact,
694
+ harness: opts.harness as HarnessName | undefined,
695
+ all: opts.all as boolean | undefined,
696
+ force: opts.force as boolean | undefined,
697
+ dryRun: opts.dryRun as boolean | undefined,
698
+ source: opts.source as string | undefined,
699
+ fromRelease: opts.fromRelease as string | undefined,
700
+ maxAlways: maxAlways === -1 ? undefined : maxAlways,
701
+ });
702
+ }
703
+
704
+ /**
705
+ * Workspace-aware install: install artifacts into each project's root directory.
706
+ * Supports --project flag for single-project install.
707
+ * Writes per-project-harness-artifact version manifests.
708
+ * Prints summary grouped by project.
709
+ */
710
+ async function installWithWorkspace(
711
+ wsConfig: import("./schemas").WorkspaceConfig,
712
+ wsRoot: string,
713
+ artifactName: string,
714
+ options: {
715
+ harness?: HarnessName;
716
+ all?: boolean;
717
+ force?: boolean;
718
+ dryRun?: boolean;
719
+ source?: string;
720
+ project?: string;
721
+ maxAlways?: number;
722
+ },
723
+ ): Promise<void> {
724
+ const {
725
+ harness,
726
+ all,
727
+ force,
728
+ dryRun,
729
+ source,
730
+ project: projectFilter,
731
+ maxAlways,
732
+ } = options;
733
+ const distDir = source ? join(source, "dist") : "dist";
734
+
735
+ // Filter projects if --project is specified
736
+ let targetProjects = wsConfig.projects;
737
+ if (projectFilter) {
738
+ targetProjects = wsConfig.projects.filter((p) => p.name === projectFilter);
739
+ if (targetProjects.length === 0) {
740
+ const available = wsConfig.projects.map((p) => p.name).join(", ");
741
+ console.error(
742
+ chalk.red(
743
+ `Error: Unknown project "${projectFilter}". Available projects: ${available}`,
744
+ ),
745
+ );
746
+ process.exit(1);
747
+ }
748
+ }
749
+
750
+ // Track summary per project
751
+ const summary: Array<{ project: string; harness: string; files: number }> =
752
+ [];
753
+
754
+ for (const proj of targetProjects) {
755
+ // Determine which harnesses to install for this project
756
+ let projectHarnesses: HarnessName[];
757
+ if (all) {
758
+ projectHarnesses = proj.harnesses.filter((h) =>
759
+ existsSync(join(distDir, h, artifactName)),
760
+ );
761
+ } else if (harness) {
762
+ projectHarnesses = proj.harnesses.includes(harness) ? [harness] : [];
763
+ } else {
764
+ // Default: install for all harnesses configured for this project
765
+ projectHarnesses = proj.harnesses.filter((h) =>
766
+ existsSync(join(distDir, h, artifactName)),
767
+ );
768
+ }
769
+
770
+ if (projectHarnesses.length === 0) continue;
771
+
772
+ // Check artifact include/exclude filters
773
+ const artifactNames = [artifactName];
774
+ const filteredNames = filterArtifactsForWorkspaceProject(
775
+ artifactNames,
776
+ proj,
777
+ );
778
+ if (filteredNames.length === 0) continue;
779
+
780
+ const projectRoot = resolve(wsRoot, proj.root);
781
+
782
+ for (const h of projectHarnesses) {
783
+ const srcDir = join(distDir, h, artifactName);
784
+ if (!(await exists(srcDir))) {
785
+ console.error(
786
+ chalk.yellow(
787
+ ` Skipping ${artifactName}/${h} for project "${proj.name}" — not built. Run \`kanon build --harness ${h}\` first.`,
788
+ ),
789
+ );
790
+ continue;
791
+ }
792
+
793
+ const files = await collectFiles(srcDir);
794
+ const installBase = join(projectRoot, HARNESS_INSTALL_PATHS[h]);
795
+ const installedFiles: string[] = [];
796
+
797
+ // Pre-install scan for Kiro harness: enforce --max-always before any writes
798
+ if (h === "kiro") {
799
+ const scanResult = await preInstallScan(srcDir, files);
800
+ enforceMaxAlways(
801
+ maxAlways,
802
+ scanResult,
803
+ `harness "${h}" in project "${proj.name}"`,
804
+ );
805
+ }
806
+
807
+ if (dryRun) {
808
+ console.error(
809
+ chalk.cyan(
810
+ `[dry-run] Would install ${files.length} files for ${h} in project "${proj.name}":`,
811
+ ),
812
+ );
813
+ }
814
+
815
+ for (const file of files) {
816
+ const src = join(srcDir, file);
817
+ const dest = join(installBase, file);
818
+ const destExists = await exists(dest);
819
+
820
+ if (destExists && !force && !dryRun) {
821
+ console.error(
822
+ chalk.yellow(
823
+ ` Skipping ${dest} (already exists, use --force to overwrite)`,
824
+ ),
825
+ );
826
+ continue;
827
+ }
828
+
829
+ if (dryRun) {
830
+ console.error(
831
+ ` ${src} → ${dest}${destExists ? " (overwrite)" : ""}`,
832
+ );
833
+ } else {
834
+ const destDir = dest.substring(0, dest.lastIndexOf("/"));
835
+ await mkdir(destDir, { recursive: true });
836
+ await copyFile(src, dest);
837
+ console.error(` ${chalk.green("✓")} ${dest}`);
838
+ installedFiles.push(file);
839
+ }
840
+ }
841
+
842
+ // Write per-project-harness-artifact version manifest
843
+ if (!dryRun && installedFiles.length > 0) {
844
+ const version =
845
+ (await extractVersionFromFiles(srcDir, files)) ?? "0.1.0";
846
+ const sourcePath = srcDir;
847
+ await writeVersionManifest(
848
+ artifactName,
849
+ version,
850
+ h,
851
+ sourcePath,
852
+ installedFiles,
853
+ installBase,
854
+ );
855
+ }
856
+
857
+ // Post-install inclusion summary for Kiro harness (Req 7.1, 7.6)
858
+ if (h === "kiro" && installedFiles.length > 0) {
859
+ await postInstallInclusionSummary(installBase, installedFiles);
860
+ }
861
+
862
+ summary.push({
863
+ project: proj.name,
864
+ harness: h,
865
+ files: dryRun ? files.length : installedFiles.length,
866
+ });
867
+ }
868
+ }
869
+
870
+ // Print summary grouped by project
871
+ if (summary.length > 0) {
872
+ console.error("");
873
+ console.error(chalk.green("✓ Workspace install summary:"));
874
+ const grouped = new Map<
875
+ string,
876
+ Array<{ harness: string; files: number }>
877
+ >();
878
+ for (const entry of summary) {
879
+ const existing = grouped.get(entry.project) ?? [];
880
+ existing.push({ harness: entry.harness, files: entry.files });
881
+ grouped.set(entry.project, existing);
882
+ }
883
+ for (const [projectName, entries] of grouped) {
884
+ const totalFiles = entries.reduce((sum, e) => sum + e.files, 0);
885
+ const harnessNames = entries.map((e) => e.harness).join(", ");
886
+ console.error(
887
+ ` ${chalk.bold(projectName)}: ${totalFiles} file(s) for ${harnessNames}`,
888
+ );
889
+ }
890
+ } else {
891
+ console.error(chalk.yellow("No files installed for any project."));
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Filter artifacts based on a workspace project's include/exclude configuration.
897
+ */
898
+ function filterArtifactsForWorkspaceProject(
899
+ allArtifactNames: string[],
900
+ project: WorkspaceProject,
901
+ ): string[] {
902
+ let names = [...allArtifactNames];
903
+
904
+ if (project.artifacts?.include) {
905
+ const includeSet = new Set(project.artifacts.include);
906
+ names = names.filter((n) => includeSet.has(n));
907
+ }
908
+
909
+ if (project.artifacts?.exclude) {
910
+ const excludeSet = new Set(project.artifacts.exclude);
911
+ names = names.filter((n) => !excludeSet.has(n));
912
+ }
913
+
914
+ return names;
915
+ }
916
+
917
+ async function writeForgeManifest(
918
+ name: string,
919
+ harness: HarnessName,
920
+ version: string,
921
+ backendLabel: string,
922
+ ): Promise<void> {
923
+ const installBase = HARNESS_INSTALL_PATHS[harness];
924
+ const manifestPath = join(installBase, ".forge-manifest.json");
925
+ let existing: ForgeManifestEntry[] = [];
926
+
927
+ if (await exists(manifestPath)) {
928
+ try {
929
+ existing = JSON.parse(
930
+ await readFile(manifestPath, "utf-8"),
931
+ ) as ForgeManifestEntry[];
932
+ } catch {
933
+ existing = [];
934
+ }
935
+ }
936
+
937
+ // Update or add the entry for this artifact+harness
938
+ const entry: ForgeManifestEntry = {
939
+ name,
940
+ harness,
941
+ version,
942
+ backend: backendLabel,
943
+ installedAt: new Date().toISOString(),
944
+ };
945
+
946
+ const idx = existing.findIndex(
947
+ (e) => e.name === name && e.harness === harness,
948
+ );
949
+ if (idx >= 0) {
950
+ existing[idx] = entry;
951
+ } else {
952
+ existing.push(entry);
953
+ }
954
+
955
+ await mkdir(installBase, { recursive: true });
956
+ await writeFile(manifestPath, JSON.stringify(existing, null, 2), "utf-8");
957
+ }
958
+
959
+ /**
960
+ * Write a `.forge-manifest.json` alongside installed files using the VersionManifest schema.
961
+ * Records artifact name, version, harness, source path, timestamp, and file list.
962
+ */
963
+ async function writeVersionManifest(
964
+ artifactName: string,
965
+ version: string,
966
+ harnessName: HarnessName,
967
+ sourcePath: string,
968
+ files: string[],
969
+ installBase: string,
970
+ ): Promise<void> {
971
+ const manifest: VersionManifest = {
972
+ artifactName,
973
+ version,
974
+ harnessName,
975
+ sourcePath,
976
+ installedAt: new Date().toISOString(),
977
+ files,
978
+ };
979
+
980
+ const manifestPath = join(installBase, ".forge-manifest.json");
981
+ await mkdir(installBase, { recursive: true });
982
+ await writeFile(manifestPath, serializeManifest(manifest), "utf-8");
983
+ }
984
+
985
+ /**
986
+ * Attempt to extract a version string from compiled files by looking for
987
+ * the `<!-- forge:version X.Y.Z -->` comment in markdown files.
988
+ */
989
+ async function extractVersionFromFiles(
990
+ srcDir: string,
991
+ files: string[],
992
+ ): Promise<string | undefined> {
993
+ for (const file of files) {
994
+ if (file.endsWith(".md")) {
995
+ try {
996
+ const content = await readFile(join(srcDir, file), "utf-8");
997
+ const match = content.match(/<!-- forge:version (\d+\.\d+\.\d+) -->/);
998
+ if (match) return match[1];
999
+ } catch {
1000
+ // Skip unreadable files
1001
+ }
1002
+ }
1003
+ }
1004
+ return undefined;
1005
+ }