@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.
- package/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/bridge/mcp-server.cjs +14171 -0
- package/package.json +98 -0
- package/src/adapters/capabilities.ts +178 -0
- package/src/adapters/claude-code.ts +110 -0
- package/src/adapters/cline.ts +98 -0
- package/src/adapters/codex.ts +173 -0
- package/src/adapters/copilot.ts +106 -0
- package/src/adapters/cursor.ts +97 -0
- package/src/adapters/degradation.ts +95 -0
- package/src/adapters/index.ts +324 -0
- package/src/adapters/kiro-frontmatter.ts +139 -0
- package/src/adapters/kiro-inclusion.ts +86 -0
- package/src/adapters/kiro.ts +412 -0
- package/src/adapters/qdeveloper.ts +115 -0
- package/src/adapters/types.ts +81 -0
- package/src/adapters/windsurf.ts +96 -0
- package/src/admin.ts +283 -0
- package/src/asset-conventions.ts +118 -0
- package/src/attribution-backfill.ts +319 -0
- package/src/attribution-report.ts +95 -0
- package/src/attribution.ts +239 -0
- package/src/backends/github.ts +194 -0
- package/src/backends/http.ts +122 -0
- package/src/backends/index.ts +39 -0
- package/src/backends/local.ts +47 -0
- package/src/backends/s3.ts +157 -0
- package/src/backends/types.ts +59 -0
- package/src/base-cache.ts +270 -0
- package/src/browse-ui.ts +3754 -0
- package/src/browse.ts +1038 -0
- package/src/build.ts +1108 -0
- package/src/catalog.ts +204 -0
- package/src/cli-deprecated.ts +29 -0
- package/src/cli.ts +773 -0
- package/src/collection-admin.ts +287 -0
- package/src/collection-builder.ts +464 -0
- package/src/collections.ts +116 -0
- package/src/compatibility.ts +105 -0
- package/src/config.ts +743 -0
- package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
- package/src/eval.ts +1169 -0
- package/src/file-writer.ts +61 -0
- package/src/format-registry.ts +141 -0
- package/src/guild/auto-updater.ts +163 -0
- package/src/guild/backend-resolver.ts +49 -0
- package/src/guild/cli.ts +592 -0
- package/src/guild/collection-expander.ts +47 -0
- package/src/guild/global-cache.ts +247 -0
- package/src/guild/hook-generator.ts +100 -0
- package/src/guild/manifest.ts +154 -0
- package/src/guild/path-utils.ts +12 -0
- package/src/guild/sync.ts +622 -0
- package/src/guild/version-resolver.ts +42 -0
- package/src/help/metadata.ts +445 -0
- package/src/help/renderer.ts +265 -0
- package/src/help/typo-suggester.ts +25 -0
- package/src/hooks/expression.ts +493 -0
- package/src/hooks/pipeline.ts +141 -0
- package/src/import.ts +773 -0
- package/src/importers/claude-code.ts +134 -0
- package/src/importers/cline.ts +103 -0
- package/src/importers/codex.ts +140 -0
- package/src/importers/copilot.ts +103 -0
- package/src/importers/cursor.ts +105 -0
- package/src/importers/index.ts +390 -0
- package/src/importers/kiro.ts +110 -0
- package/src/importers/qdeveloper.ts +103 -0
- package/src/importers/types.ts +54 -0
- package/src/importers/windsurf.ts +104 -0
- package/src/install.ts +1005 -0
- package/src/manifest-admin.ts +306 -0
- package/src/mcp-bridge.ts +240 -0
- package/src/mutation/delta.ts +50 -0
- package/src/mutation/history.ts +66 -0
- package/src/mutation/operators.ts +524 -0
- package/src/mutation/runner.ts +332 -0
- package/src/new.ts +106 -0
- package/src/outcomes/collision.ts +127 -0
- package/src/outcomes/normalize.ts +208 -0
- package/src/outcomes/registry.ts +173 -0
- package/src/parser.ts +446 -0
- package/src/provenance-backfill-cli.ts +319 -0
- package/src/provenance-backfill.ts +520 -0
- package/src/publish.ts +354 -0
- package/src/reconcile-orchestrator.ts +502 -0
- package/src/reconcile-report-renderer.ts +176 -0
- package/src/resolve-body.ts +15 -0
- package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
- package/src/rosetta/builtins/contracts.ts +1033 -0
- package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
- package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
- package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
- package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
- package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
- package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
- package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
- package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
- package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
- package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
- package/src/rosetta/builtins/sources/cline-native.ts +176 -0
- package/src/rosetta/builtins/sources/codex-native.ts +343 -0
- package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
- package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
- package/src/rosetta/builtins/sources/index.ts +95 -0
- package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
- package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
- package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
- package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
- package/src/rosetta/builtins/sources/superpowers.ts +240 -0
- package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
- package/src/rosetta/builtins/targets/claude-code.ts +181 -0
- package/src/rosetta/builtins/targets/cline.ts +87 -0
- package/src/rosetta/builtins/targets/codex.ts +226 -0
- package/src/rosetta/builtins/targets/copilot.ts +103 -0
- package/src/rosetta/builtins/targets/cursor.ts +87 -0
- package/src/rosetta/builtins/targets/index.ts +60 -0
- package/src/rosetta/builtins/targets/kiro.ts +278 -0
- package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
- package/src/rosetta/builtins/targets/windsurf.ts +87 -0
- package/src/rosetta/canonical.ts +729 -0
- package/src/rosetta/compatibility.ts +432 -0
- package/src/rosetta/contracts.ts +329 -0
- package/src/rosetta/detector.ts +724 -0
- package/src/rosetta/diagnostics.ts +630 -0
- package/src/rosetta/engine-bootstrap.ts +103 -0
- package/src/rosetta/engine.ts +744 -0
- package/src/rosetta/index.ts +381 -0
- package/src/rosetta/inspection.ts +530 -0
- package/src/rosetta/plan.ts +448 -0
- package/src/rosetta/provenance-digest.ts +369 -0
- package/src/rosetta/reconcile.ts +812 -0
- package/src/rosetta/redaction.ts +467 -0
- package/src/rosetta/registry.ts +712 -0
- package/src/rosetta/renderers.ts +571 -0
- package/src/rosetta/request-guard.ts +335 -0
- package/src/rosetta/resolution.ts +419 -0
- package/src/rosetta/source-accounting.ts +233 -0
- package/src/rosetta/templates.ts +129 -0
- package/src/rosetta-cli.ts +717 -0
- package/src/rosetta-docs-generator.ts +793 -0
- package/src/rosetta-profiles-cli.ts +367 -0
- package/src/schemas.ts +1712 -0
- package/src/spec-coordination.ts +1141 -0
- package/src/temper.ts +747 -0
- package/src/template-bundle-loader.ts +312 -0
- package/src/template-engine.ts +53 -0
- package/src/translation-application-policy.ts +496 -0
- package/src/translation-orchestrator.ts +1013 -0
- package/src/translation-plan-applier.ts +473 -0
- package/src/tutorial.ts +305 -0
- package/src/validate.ts +1093 -0
- package/src/versioning.ts +553 -0
- package/src/wizard.ts +660 -0
- package/src/workspace.ts +237 -0
- package/templates/eval-contexts/claude-code.md.njk +6 -0
- package/templates/eval-contexts/cline.md.njk +6 -0
- package/templates/eval-contexts/copilot.md.njk +6 -0
- package/templates/eval-contexts/cursor.md.njk +6 -0
- package/templates/eval-contexts/kiro.md.njk +10 -0
- package/templates/eval-contexts/qdeveloper.md.njk +6 -0
- package/templates/eval-contexts/windsurf.md.njk +6 -0
- package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
- package/templates/harness-adapters/_base/base.md.njk +16 -0
- package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
- package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
- package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
- package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
- package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
- package/templates/harness-adapters/cline/hook.sh.njk +4 -0
- package/templates/harness-adapters/cline/mcp.json.njk +1 -0
- package/templates/harness-adapters/cline/rule.md.njk +1 -0
- package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
- package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
- package/templates/harness-adapters/codex/skill.md.njk +27 -0
- package/templates/harness-adapters/copilot/agents.md.njk +1 -0
- package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
- package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
- package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
- package/templates/harness-adapters/cursor/rule.md.njk +6 -0
- package/templates/harness-adapters/kiro/hook.json.njk +1 -0
- package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
- package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
- package/templates/harness-adapters/kiro/power.md.njk +12 -0
- package/templates/harness-adapters/kiro/steering.md.njk +16 -0
- package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
- package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
- package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
- package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
- package/templates/knowledge/hooks.yaml.njk +4 -0
- package/templates/knowledge/knowledge.md.njk +53 -0
- 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
|
+
}
|