specrails-core 4.11.3 → 4.12.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/README.md +56 -20
- package/bin/specrails-core.mjs +343 -20
- package/bin/tui-installer.mjs +103 -52
- package/dist/installer/cli.js +1 -1
- package/dist/installer/cli.js.map +1 -1
- package/dist/installer/commands/doctor.js +486 -24
- package/dist/installer/commands/doctor.js.map +1 -1
- package/dist/installer/commands/framework.js +49 -7
- package/dist/installer/commands/framework.js.map +1 -1
- package/dist/installer/commands/init.js +423 -25
- package/dist/installer/commands/init.js.map +1 -1
- package/dist/installer/commands/update.js +36 -9
- package/dist/installer/commands/update.js.map +1 -1
- package/dist/installer/phases/framework-lifecycle.js +125 -0
- package/dist/installer/phases/framework-lifecycle.js.map +1 -0
- package/dist/installer/phases/install-config.js +157 -5
- package/dist/installer/phases/install-config.js.map +1 -1
- package/dist/installer/phases/manifest.js +27 -2
- package/dist/installer/phases/manifest.js.map +1 -1
- package/dist/installer/phases/prereqs.js +57 -2
- package/dist/installer/phases/prereqs.js.map +1 -1
- package/dist/installer/phases/provider-detect.js +116 -6
- package/dist/installer/phases/provider-detect.js.map +1 -1
- package/dist/installer/phases/scaffold.js +1222 -12
- package/dist/installer/phases/scaffold.js.map +1 -1
- package/dist/installer/runtime/kimi.js +255 -0
- package/dist/installer/runtime/kimi.js.map +1 -0
- package/dist/installer/util/paths.js +12 -0
- package/dist/installer/util/paths.js.map +1 -1
- package/dist/installer/util/registry.js +234 -14
- package/dist/installer/util/registry.js.map +1 -1
- package/docs/README.md +1 -0
- package/docs/deployment.md +6 -7
- package/docs/getting-started.md +11 -7
- package/docs/installation.md +34 -16
- package/docs/plugin-architecture.md +11 -8
- package/docs/updating.md +21 -3
- package/docs/user-docs/cli-reference.md +43 -22
- package/docs/user-docs/codex-vs-claude-code.md +11 -9
- package/docs/user-docs/faq.md +1 -1
- package/docs/user-docs/getting-started-codex.md +5 -8
- package/docs/user-docs/getting-started-kimi.md +423 -0
- package/docs/user-docs/installation.md +49 -14
- package/docs/user-docs/quick-start.md +11 -8
- package/docs/windows.md +29 -4
- package/integration-contract.json +85 -13
- package/package.json +9 -5
- package/schemas/profile.v1.json +67 -5
- package/templates/kimi/specrails/run-skill.mjs +3005 -0
- package/templates/kimi/specrails/vendor/js-yaml/LICENSE +21 -0
- package/templates/kimi/specrails/vendor/js-yaml/NOTICE.md +16 -0
- package/templates/kimi/specrails/vendor/js-yaml/js-yaml.mjs +3856 -0
- package/templates/profiles/kimi-default.json +15 -0
|
@@ -4,6 +4,7 @@ import { InstallerError } from '../util/errors.js';
|
|
|
4
4
|
import { ok, step } from '../util/logger.js';
|
|
5
5
|
import { pathExists, readTextFile } from '../util/fs.js';
|
|
6
6
|
import { derivedPaths } from '../phases/provider-detect.js';
|
|
7
|
+
import { assertFrameworkVersionComplete, resolveRequiredFrameworkProviders, } from '../phases/framework-lifecycle.js';
|
|
7
8
|
import { assembleProjectWorkspace, ensureCurrentSymlink } from '../phases/scaffold.js';
|
|
8
9
|
import { ensureFramework } from './init.js';
|
|
9
10
|
function requireString(value, flagName) {
|
|
@@ -12,13 +13,47 @@ function requireString(value, flagName) {
|
|
|
12
13
|
}
|
|
13
14
|
return value;
|
|
14
15
|
}
|
|
16
|
+
const SAFE_FRAMEWORK_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
|
|
17
|
+
/**
|
|
18
|
+
* Framework versions become directory names under the shared framework store.
|
|
19
|
+
* Keep this deliberately narrower than an arbitrary path segment so a version
|
|
20
|
+
* accepted on POSIX remains safe when the same registry/store is consumed on
|
|
21
|
+
* Windows. It accepts normal semver, prerelease, and build identifiers while
|
|
22
|
+
* rejecting separators, dot segments, controls, and traversal payloads.
|
|
23
|
+
*/
|
|
24
|
+
function parseFrameworkVersion(value) {
|
|
25
|
+
const version = requireString(value, 'version');
|
|
26
|
+
if (version === '.' ||
|
|
27
|
+
version === '..' ||
|
|
28
|
+
version.includes('\0') ||
|
|
29
|
+
!SAFE_FRAMEWORK_VERSION.test(version)) {
|
|
30
|
+
throw new InstallerError(`--version must be a safe framework version identifier, got: ${JSON.stringify(version)}`, 40);
|
|
31
|
+
}
|
|
32
|
+
return version;
|
|
33
|
+
}
|
|
15
34
|
function parseProvider(value) {
|
|
16
35
|
const v = requireString(value, 'provider');
|
|
17
|
-
if (v !== 'claude' && v !== 'codex' && v !== 'gemini') {
|
|
18
|
-
throw new InstallerError(`--provider value must be 'claude', 'codex', or '
|
|
36
|
+
if (v !== 'claude' && v !== 'codex' && v !== 'gemini' && v !== 'kimi') {
|
|
37
|
+
throw new InstallerError(`--provider value must be 'claude', 'codex', 'gemini', or 'kimi', got: ${v}`, 40);
|
|
19
38
|
}
|
|
20
39
|
return v;
|
|
21
40
|
}
|
|
41
|
+
function parseProviders(value) {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return [];
|
|
44
|
+
const raw = requireString(value, 'providers');
|
|
45
|
+
const providers = [];
|
|
46
|
+
for (const item of raw.split(',')) {
|
|
47
|
+
const provider = item.trim();
|
|
48
|
+
if (provider.length === 0)
|
|
49
|
+
continue;
|
|
50
|
+
providers.push(parseProvider(provider));
|
|
51
|
+
}
|
|
52
|
+
if (providers.length === 0) {
|
|
53
|
+
throw new InstallerError('--providers must contain at least one provider', 40);
|
|
54
|
+
}
|
|
55
|
+
return [...new Set(providers)];
|
|
56
|
+
}
|
|
22
57
|
/**
|
|
23
58
|
* Resolves the specrails-core package root (where templates/ + commands/ live).
|
|
24
59
|
* - Published: `<pkg>/dist/installer/commands/framework.js`
|
|
@@ -42,7 +77,7 @@ export async function runInstallFramework(flags) {
|
|
|
42
77
|
const scriptDir = resolveScriptDir();
|
|
43
78
|
const frameworkDir = path.resolve(requireString(flags['framework-dir'], 'framework-dir'));
|
|
44
79
|
const provider = parseProvider(flags.provider);
|
|
45
|
-
const version =
|
|
80
|
+
const version = parseFrameworkVersion(flags.version);
|
|
46
81
|
const { providerDir } = derivedPaths(provider);
|
|
47
82
|
const swapCurrent = flags['no-swap'] !== true;
|
|
48
83
|
step(`Materializing framework ${version} (${provider}) → ${frameworkDir}`);
|
|
@@ -70,11 +105,18 @@ export async function runInstallFramework(flags) {
|
|
|
70
105
|
*/
|
|
71
106
|
export async function runSwapCurrent(flags) {
|
|
72
107
|
const frameworkDir = path.resolve(requireString(flags['framework-dir'], 'framework-dir'));
|
|
73
|
-
const version =
|
|
108
|
+
const version = parseFrameworkVersion(flags.version);
|
|
109
|
+
const requested = parseProviders(flags.providers);
|
|
110
|
+
const required = resolveRequiredFrameworkProviders({
|
|
111
|
+
frameworkDir,
|
|
112
|
+
requested,
|
|
113
|
+
registryHome: process.env.SPECRAILS_REGISTRY_HOME,
|
|
114
|
+
});
|
|
74
115
|
step(`Swapping framework current → ${version} (${frameworkDir})`);
|
|
116
|
+
const providers = assertFrameworkVersionComplete(frameworkDir, version, required);
|
|
75
117
|
ensureCurrentSymlink(frameworkDir, version);
|
|
76
|
-
ok(`current → ${version}`);
|
|
77
|
-
return { frameworkDir, version };
|
|
118
|
+
ok(`current → ${version} (${providers.join(', ')})`);
|
|
119
|
+
return { frameworkDir, version, providers };
|
|
78
120
|
}
|
|
79
121
|
/**
|
|
80
122
|
* `specrails-core assemble` — SYMLINK the materialized framework subtrees into a
|
|
@@ -86,7 +128,7 @@ export async function runAssemble(flags) {
|
|
|
86
128
|
const workspace = path.resolve(requireString(flags.workspace, 'workspace'));
|
|
87
129
|
const frameworkDir = path.resolve(requireString(flags['framework-dir'], 'framework-dir'));
|
|
88
130
|
const provider = parseProvider(flags.provider);
|
|
89
|
-
const version =
|
|
131
|
+
const version = parseFrameworkVersion(flags.version);
|
|
90
132
|
const codeRoot = path.resolve(requireString(flags['code-root'], 'code-root'));
|
|
91
133
|
const { providerDir } = derivedPaths(provider);
|
|
92
134
|
// Defence-in-depth: the framework must be materialized + `current` pointed at
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"framework.js","sourceRoot":"","sources":["../../../src/installer/commands/framework.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAA;AAC5B,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAExC,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAClD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAA;AAC5C,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAGxD,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAC3D,OAAO,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;
|
|
1
|
+
{"version":3,"file":"framework.js","sourceRoot":"","sources":["../../../src/installer/commands/framework.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,14 +1,102 @@
|
|
|
1
|
+
import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSync, rmSync, } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
1
3
|
import path from 'node:path';
|
|
2
4
|
import { fileURLToPath } from 'node:url';
|
|
3
5
|
import { InstallerError } from '../util/errors.js';
|
|
4
6
|
import { runCommand } from '../util/exec.js';
|
|
5
7
|
import { info, ok, step } from '../util/logger.js';
|
|
6
|
-
import {
|
|
8
|
+
import { isDir, isSymlink, listDir, pathExists, readTextFile, removePath, writeFileLf, } from '../util/fs.js';
|
|
7
9
|
import { loadInstallConfig, resolveConfigPath, } from '../phases/install-config.js';
|
|
8
10
|
import { checkPrerequisites } from '../phases/prereqs.js';
|
|
9
11
|
import { derivedPaths } from '../phases/provider-detect.js';
|
|
10
|
-
import {
|
|
12
|
+
import { materializeFrameworkVersion } from '../phases/framework-lifecycle.js';
|
|
13
|
+
import { assembleProjectWorkspace, installFramework, } from '../phases/scaffold.js';
|
|
11
14
|
import { frameworkRoot, resolveArtifacts } from '../util/registry.js';
|
|
15
|
+
const WORKSPACE_PROVIDER_ORDER = [
|
|
16
|
+
'claude',
|
|
17
|
+
'codex',
|
|
18
|
+
'gemini',
|
|
19
|
+
'kimi',
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Snapshot the provider/role inventory exposed by one live workspace before a
|
|
23
|
+
* global framework version swap. This matters on Windows: directory junctions
|
|
24
|
+
* resolve their target when they are created, so a workspace link made through
|
|
25
|
+
* `framework/current` must be recreated after `current` moves. Keeping the
|
|
26
|
+
* exact visible `sr-*` set also avoids widening or narrowing a project's
|
|
27
|
+
* optional role selection during that refresh.
|
|
28
|
+
*/
|
|
29
|
+
export function snapshotWorkspaceProviderSelections(workspace) {
|
|
30
|
+
const selections = {};
|
|
31
|
+
for (const provider of WORKSPACE_PROVIDER_ORDER) {
|
|
32
|
+
const { providerDir } = derivedPaths(provider);
|
|
33
|
+
const providerRoot = path.join(workspace, providerDir);
|
|
34
|
+
if (!pathExists(providerRoot) ||
|
|
35
|
+
!workspaceHasManagedProviderLink(providerRoot, provider)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const roleRoot = provider === 'codex'
|
|
39
|
+
? path.join(workspace, providerDir, 'skills', 'rails')
|
|
40
|
+
: provider === 'kimi'
|
|
41
|
+
? path.join(workspace, providerDir, 'skills')
|
|
42
|
+
: path.join(workspace, providerDir, 'agents');
|
|
43
|
+
const ids = listDir(roleRoot)
|
|
44
|
+
.filter((entry) => provider === 'claude' || provider === 'gemini'
|
|
45
|
+
? path.basename(entry).endsWith('.md')
|
|
46
|
+
: isDir(entry))
|
|
47
|
+
.map((entry) => {
|
|
48
|
+
const name = path.basename(entry);
|
|
49
|
+
return name.endsWith('.md') ? name.slice(0, -3) : name;
|
|
50
|
+
})
|
|
51
|
+
.filter((id) => /^sr-[a-z0-9-]+$/.test(id));
|
|
52
|
+
selections[provider] = [...new Set(ids)].sort();
|
|
53
|
+
}
|
|
54
|
+
return selections;
|
|
55
|
+
}
|
|
56
|
+
function workspaceHasManagedProviderLink(providerRoot, provider) {
|
|
57
|
+
const directoryLinks = provider === 'claude'
|
|
58
|
+
? ['commands', 'skills', 'rules']
|
|
59
|
+
: provider === 'codex'
|
|
60
|
+
? ['skills']
|
|
61
|
+
: provider === 'gemini'
|
|
62
|
+
? ['commands']
|
|
63
|
+
: ['rules', 'specrails'];
|
|
64
|
+
if (directoryLinks.some((relative) => isSymlink(path.join(providerRoot, relative)))) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
const granularRoot = provider === 'kimi'
|
|
68
|
+
? path.join(providerRoot, 'skills')
|
|
69
|
+
: path.join(providerRoot, 'agents');
|
|
70
|
+
return listDir(granularRoot).some((entry) => isSymlink(entry));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Refresh every provider already represented in the project plus the provider
|
|
74
|
+
* selected by the current operation. POSIX symlinks continue to follow
|
|
75
|
+
* `framework/current`; Windows junctions do not reliably preserve that
|
|
76
|
+
* indirection, so rebuilding all live links is required for true
|
|
77
|
+
* multi-provider version parity.
|
|
78
|
+
*/
|
|
79
|
+
export function reassembleWorkspaceProviders(input) {
|
|
80
|
+
const providers = WORKSPACE_PROVIDER_ORDER.filter((provider) => provider === input.selectedProvider ||
|
|
81
|
+
Object.prototype.hasOwnProperty.call(input.previousSelections, provider));
|
|
82
|
+
for (const provider of providers) {
|
|
83
|
+
const { providerDir } = derivedPaths(provider);
|
|
84
|
+
assembleProjectWorkspace({
|
|
85
|
+
workspace: input.workspace,
|
|
86
|
+
frameworkDir: input.frameworkDir,
|
|
87
|
+
provider,
|
|
88
|
+
providerDir,
|
|
89
|
+
version: input.version,
|
|
90
|
+
codeRoot: input.codeRoot,
|
|
91
|
+
scriptDir: input.scriptDir,
|
|
92
|
+
selectedAgents: provider === input.selectedProvider
|
|
93
|
+
? input.selectedAgents ?? input.previousSelections[provider]
|
|
94
|
+
: input.previousSelections[provider],
|
|
95
|
+
copyStatics: input.copyStatics,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return providers;
|
|
99
|
+
}
|
|
12
100
|
/**
|
|
13
101
|
* Entry called by cli.ts. Returns a {@link InitResult} on success;
|
|
14
102
|
* throws a typed error (translated to an exit code by the outer CLI)
|
|
@@ -18,11 +106,25 @@ export async function runInit(flags) {
|
|
|
18
106
|
const scriptDir = resolveScriptDir();
|
|
19
107
|
const version = readVersion(scriptDir);
|
|
20
108
|
const repoRoot = path.resolve(typeof flags['root-dir'] === 'string' ? flags['root-dir'] : process.cwd());
|
|
21
|
-
const autoYes = flags.yes === true;
|
|
109
|
+
const autoYes = flags.yes === true || flags.y === true;
|
|
22
110
|
const skipPrereqs = process.env.SPECRAILS_SKIP_PREREQS === '1';
|
|
111
|
+
// Validate an explicit provider before consulting --from-config. The TUI
|
|
112
|
+
// intentionally re-enters init with BOTH flags; accepting that flow is safe
|
|
113
|
+
// only when the generated config agrees with the original explicit choice.
|
|
114
|
+
let explicitProvider;
|
|
115
|
+
if (flags.provider !== undefined) {
|
|
116
|
+
if (typeof flags.provider !== 'string' ||
|
|
117
|
+
(flags.provider !== 'claude' &&
|
|
118
|
+
flags.provider !== 'codex' &&
|
|
119
|
+
flags.provider !== 'gemini' &&
|
|
120
|
+
flags.provider !== 'kimi')) {
|
|
121
|
+
throw new InstallerError(`--provider value must be 'claude', 'codex', 'gemini', or 'kimi', got: ${String(flags.provider)}`, 40);
|
|
122
|
+
}
|
|
123
|
+
explicitProvider = flags.provider;
|
|
124
|
+
}
|
|
23
125
|
// --from-config: read provider + tier from yaml.
|
|
24
126
|
const fromConfigFlag = flags['from-config'];
|
|
25
|
-
let providerHint;
|
|
127
|
+
let providerHint = explicitProvider;
|
|
26
128
|
let tierHint;
|
|
27
129
|
let selectedAgentsHint;
|
|
28
130
|
if (fromConfigFlag !== undefined) {
|
|
@@ -30,21 +132,18 @@ export async function runInit(flags) {
|
|
|
30
132
|
const resolved = resolveConfigPath(repoRoot, explicitPath);
|
|
31
133
|
const config = loadInstallConfig(resolved);
|
|
32
134
|
if (config) {
|
|
135
|
+
if (explicitProvider && explicitProvider !== config.provider) {
|
|
136
|
+
throw new InstallerError(`--provider '${explicitProvider}' conflicts with provider '${config.provider}' in ${resolved}`, 40);
|
|
137
|
+
}
|
|
33
138
|
providerHint = config.provider;
|
|
34
139
|
tierHint = config.tier;
|
|
35
140
|
selectedAgentsHint = config.agents.selected;
|
|
36
141
|
info(`Loaded install config from ${resolved}`);
|
|
37
142
|
}
|
|
38
143
|
else {
|
|
39
|
-
info(`install-config.yaml not found at ${resolved} — falling back to auto-detection`);
|
|
144
|
+
info(`install-config.yaml not found at ${resolved} — falling back to ${explicitProvider ? `explicit provider '${explicitProvider}'` : 'auto-detection'}`);
|
|
40
145
|
}
|
|
41
146
|
}
|
|
42
|
-
else if (typeof flags.provider === 'string') {
|
|
43
|
-
if (flags.provider !== 'claude' && flags.provider !== 'codex' && flags.provider !== 'gemini') {
|
|
44
|
-
throw new InstallerError(`--provider value must be 'claude', 'codex', or 'gemini', got: ${flags.provider}`, 40);
|
|
45
|
-
}
|
|
46
|
-
providerHint = flags.provider;
|
|
47
|
-
}
|
|
48
147
|
if (flags.quick === true) {
|
|
49
148
|
tierHint = 'quick';
|
|
50
149
|
}
|
|
@@ -75,8 +174,21 @@ export async function runInit(flags) {
|
|
|
75
174
|
providers: [prereqs.provider],
|
|
76
175
|
coreVersion: version,
|
|
77
176
|
});
|
|
177
|
+
// An existing Core-owned relocated entry is also the durable provider
|
|
178
|
+
// inventory. Merge this install's provider without reallocating legacy
|
|
179
|
+
// in-repo installs; Desktop-owned entries remain read-only in the resolver.
|
|
180
|
+
if (artifactRoot !== codeRoot) {
|
|
181
|
+
resolveArtifacts(repoRoot, {
|
|
182
|
+
allocate: true,
|
|
183
|
+
allocator: 'core-standalone',
|
|
184
|
+
home: process.env.SPECRAILS_REGISTRY_HOME,
|
|
185
|
+
providers: [prereqs.provider],
|
|
186
|
+
coreVersion: version,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
78
189
|
// In-repo when the resolution did NOT relocate the artifacts out of the repo.
|
|
79
190
|
const inRepo = artifactRoot === codeRoot;
|
|
191
|
+
const previousSelections = snapshotWorkspaceProviderSelections(artifactRoot);
|
|
80
192
|
// ─── Phase 2 + 3 ──────────────────────────────────────────────────────
|
|
81
193
|
// Bundled-framework flow: materialize the provider-INVARIANT framework ONCE
|
|
82
194
|
// under `<home>/.specrails/framework/<version>/<providerDir>/`, point `current`
|
|
@@ -94,15 +206,15 @@ export async function runInit(flags) {
|
|
|
94
206
|
version,
|
|
95
207
|
selectedAgents: selectedAgentsHint,
|
|
96
208
|
});
|
|
97
|
-
|
|
209
|
+
reassembleWorkspaceProviders({
|
|
98
210
|
workspace: artifactRoot,
|
|
99
211
|
frameworkDir: fwDir,
|
|
100
|
-
provider: prereqs.provider,
|
|
101
|
-
providerDir,
|
|
102
212
|
version,
|
|
103
213
|
codeRoot,
|
|
104
214
|
scriptDir,
|
|
215
|
+
selectedProvider: prereqs.provider,
|
|
105
216
|
selectedAgents: selectedAgentsHint,
|
|
217
|
+
previousSelections,
|
|
106
218
|
// In-repo: COPY the framework statics as real, committable files. Relocated:
|
|
107
219
|
// symlink from the framework store (O(1) update on a version swap).
|
|
108
220
|
copyStatics: inRepo,
|
|
@@ -114,11 +226,12 @@ export async function runInit(flags) {
|
|
|
114
226
|
ok(`Linked ${providerDir}/ from framework ${version} + seeded project layer`);
|
|
115
227
|
}
|
|
116
228
|
// openspec STAYS in the repo (codeRoot) — unchanged behaviour.
|
|
117
|
-
await installOpenSpecProject(codeRoot, prereqs.provider);
|
|
229
|
+
await installOpenSpecProject(codeRoot, prereqs.provider, artifactRoot);
|
|
118
230
|
const tier = tierHint ?? 'full';
|
|
119
231
|
if (tier === 'full') {
|
|
120
232
|
step('Next steps');
|
|
121
|
-
|
|
233
|
+
const enrichCommand = prereqs.provider === 'kimi' ? '/skill:specrails-enrich' : '/specrails:enrich';
|
|
234
|
+
info(`Run \`${enrichCommand}\` inside your selected provider to complete your setup.`);
|
|
122
235
|
}
|
|
123
236
|
else {
|
|
124
237
|
step('Installation complete');
|
|
@@ -148,17 +261,28 @@ export async function runInit(flags) {
|
|
|
148
261
|
* ensureCurrentSymlink(frameworkDir, version) // single atomic swap at the end
|
|
149
262
|
*/
|
|
150
263
|
export function ensureFramework(input) {
|
|
151
|
-
|
|
264
|
+
if (input.swapCurrent === false) {
|
|
265
|
+
installFramework({
|
|
266
|
+
scriptDir: input.scriptDir,
|
|
267
|
+
frameworkDir: input.frameworkDir,
|
|
268
|
+
provider: input.provider,
|
|
269
|
+
providerDir: input.providerDir,
|
|
270
|
+
version: input.version,
|
|
271
|
+
selectedAgents: input.selectedAgents,
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// `current` is global, not scoped to this project/provider. Carry forward the
|
|
276
|
+
// requested provider, every globally registered provider, and every provider
|
|
277
|
+
// represented by the old current version; materialize all without swapping,
|
|
278
|
+
// validate the complete destination, then move the pointer exactly once.
|
|
279
|
+
materializeFrameworkVersion({
|
|
152
280
|
scriptDir: input.scriptDir,
|
|
153
281
|
frameworkDir: input.frameworkDir,
|
|
154
|
-
provider: input.provider,
|
|
155
|
-
providerDir: input.providerDir,
|
|
156
282
|
version: input.version,
|
|
157
|
-
|
|
283
|
+
requested: [input.provider, ...(input.requiredProviders ?? [])],
|
|
284
|
+
registryHome: input.registryHome,
|
|
158
285
|
});
|
|
159
|
-
if (input.swapCurrent !== false) {
|
|
160
|
-
ensureCurrentSymlink(input.frameworkDir, input.version);
|
|
161
|
-
}
|
|
162
286
|
}
|
|
163
287
|
/**
|
|
164
288
|
* Resolves the package root (where VERSION, templates/, commands/ live).
|
|
@@ -225,7 +349,9 @@ function readPinnedVersion(scriptDir, key, fallback) {
|
|
|
225
349
|
export function buildOpenSpecInvocation(repoRoot, provider, env = process.env, pinnedVersion = readPinnedVersion(resolveScriptDir(), 'openspec', '1.4.1')) {
|
|
226
350
|
const bin = env.SPECRAILS_OPENSPEC_BIN;
|
|
227
351
|
const node = env.SPECRAILS_OPENSPEC_NODE;
|
|
228
|
-
const initArgs =
|
|
352
|
+
const initArgs = provider === 'kimi'
|
|
353
|
+
? ['init', '--tools', provider, '--profile', 'custom', repoRoot]
|
|
354
|
+
: ['init', '--tools', provider, repoRoot];
|
|
229
355
|
if (bin && node) {
|
|
230
356
|
// Form 1: bundled offline — run the node CLI through the given node exe.
|
|
231
357
|
return { bin: node, args: [bin, ...initArgs] };
|
|
@@ -247,22 +373,294 @@ export function buildOpenSpecInvocation(repoRoot, provider, env = process.env, p
|
|
|
247
373
|
],
|
|
248
374
|
};
|
|
249
375
|
}
|
|
250
|
-
|
|
376
|
+
export const KIMI_REQUIRED_OPENSPEC_SKILLS = [
|
|
377
|
+
'openspec-propose',
|
|
378
|
+
'openspec-explore',
|
|
379
|
+
'openspec-new-change',
|
|
380
|
+
'openspec-continue-change',
|
|
381
|
+
'openspec-apply-change',
|
|
382
|
+
'openspec-ff-change',
|
|
383
|
+
'openspec-sync-specs',
|
|
384
|
+
'openspec-archive-change',
|
|
385
|
+
'openspec-bulk-archive-change',
|
|
386
|
+
'openspec-verify-change',
|
|
387
|
+
'openspec-onboard',
|
|
388
|
+
];
|
|
389
|
+
const OPENSPEC_ALL_WORKFLOWS = [
|
|
390
|
+
'propose',
|
|
391
|
+
'explore',
|
|
392
|
+
'new',
|
|
393
|
+
'continue',
|
|
394
|
+
'apply',
|
|
395
|
+
'ff',
|
|
396
|
+
'sync',
|
|
397
|
+
'archive',
|
|
398
|
+
'bulk-archive',
|
|
399
|
+
'verify',
|
|
400
|
+
'onboard',
|
|
401
|
+
];
|
|
402
|
+
/**
|
|
403
|
+
* Move only OpenSpec-owned Kimi workflow directories from either upstream
|
|
404
|
+
* location into the artifact workspace. Every destination is copied to a
|
|
405
|
+
* sibling temporary directory and renamed, so readers never observe a partial
|
|
406
|
+
* skill. A distinct newly generated source refreshes the managed destination;
|
|
407
|
+
* an in-place corrected destination is retained as-is.
|
|
408
|
+
*/
|
|
409
|
+
export function normalizeKimiOpenSpecSkills(repoRoot, artifactRoot) {
|
|
410
|
+
const canonicalRepoRoot = realpathSync(repoRoot);
|
|
411
|
+
const canonicalArtifactRoot = ensureRealDirectoryPath(artifactRoot);
|
|
412
|
+
const destinationRoot = ensureRealDirectoryPath(path.join(canonicalArtifactRoot, '.kimi-code', 'skills'));
|
|
413
|
+
const correctedSourceRoot = path.join(canonicalRepoRoot, '.kimi-code', 'skills');
|
|
414
|
+
const legacySourceRoot = path.join(canonicalRepoRoot, '.kimi', 'skills');
|
|
415
|
+
const correctedIsDestination = path.resolve(correctedSourceRoot) === path.resolve(destinationRoot);
|
|
416
|
+
const installed = [];
|
|
417
|
+
const sources = new Map();
|
|
418
|
+
// Validate the full inventory before mutating either tree.
|
|
419
|
+
for (const skillName of KIMI_REQUIRED_OPENSPEC_SKILLS) {
|
|
420
|
+
const destination = path.join(destinationRoot, skillName);
|
|
421
|
+
const correctedSource = path.join(correctedSourceRoot, skillName);
|
|
422
|
+
const legacySource = path.join(legacySourceRoot, skillName);
|
|
423
|
+
const sourceCandidate = !correctedIsDestination && pathExists(path.join(correctedSource, 'SKILL.md'))
|
|
424
|
+
? correctedSource
|
|
425
|
+
: pathExists(path.join(legacySource, 'SKILL.md'))
|
|
426
|
+
? legacySource
|
|
427
|
+
: null;
|
|
428
|
+
const source = sourceCandidate === null
|
|
429
|
+
? null
|
|
430
|
+
: validateSafeSkillTree(sourceCandidate, skillName);
|
|
431
|
+
sources.set(skillName, source);
|
|
432
|
+
if (source) {
|
|
433
|
+
validateReplaceableDestination(destination, skillName);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (lstatExists(destination)) {
|
|
437
|
+
validateSafeSkillTree(destination, skillName);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (pathExists(destination)) {
|
|
441
|
+
throw new InstallerError(`Kimi OpenSpec skill ${skillName} exists without SKILL.md; refusing to overwrite it`, 50);
|
|
442
|
+
}
|
|
443
|
+
throw new InstallerError(`OpenSpec did not generate required Kimi skill ${skillName}; ` +
|
|
444
|
+
'retry `npx specrails-core update --provider kimi`', 50);
|
|
445
|
+
}
|
|
446
|
+
for (const skillName of KIMI_REQUIRED_OPENSPEC_SKILLS) {
|
|
447
|
+
const destination = path.join(destinationRoot, skillName);
|
|
448
|
+
const source = sources.get(skillName) ?? null;
|
|
449
|
+
if (source) {
|
|
450
|
+
const temporary = mkdtempSync(path.join(destinationRoot, `.${skillName}.specrails-tmp-`));
|
|
451
|
+
const backupContainer = mkdtempSync(path.join(destinationRoot, `.${skillName}.specrails-backup-`));
|
|
452
|
+
const backup = path.join(backupContainer, 'previous');
|
|
453
|
+
copyValidatedSkillTree(source, temporary);
|
|
454
|
+
try {
|
|
455
|
+
validateSafeSkillTree(temporary, skillName);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
removePath(temporary);
|
|
459
|
+
removePath(backupContainer);
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
const hadDestination = lstatExists(destination);
|
|
463
|
+
try {
|
|
464
|
+
if (hadDestination)
|
|
465
|
+
renameSync(destination, backup);
|
|
466
|
+
renameSync(temporary, destination);
|
|
467
|
+
removePath(backupContainer);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
removePath(temporary);
|
|
471
|
+
if (!lstatExists(destination) && lstatExists(backup)) {
|
|
472
|
+
renameSync(backup, destination);
|
|
473
|
+
}
|
|
474
|
+
removePath(backupContainer);
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
installed.push(skillName);
|
|
479
|
+
}
|
|
480
|
+
// Remove only the generated, known workflow directories after every
|
|
481
|
+
// destination has been validated. Unknown/user content keeps both trees.
|
|
482
|
+
for (const skillName of KIMI_REQUIRED_OPENSPEC_SKILLS) {
|
|
483
|
+
const legacy = path.join(legacySourceRoot, skillName);
|
|
484
|
+
if (path.resolve(legacy) !== path.resolve(path.join(destinationRoot, skillName))) {
|
|
485
|
+
removePath(legacy);
|
|
486
|
+
}
|
|
487
|
+
if (path.resolve(correctedSourceRoot) !== path.resolve(destinationRoot)) {
|
|
488
|
+
removePath(path.join(correctedSourceRoot, skillName));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
removeDirectoryIfEmpty(legacySourceRoot);
|
|
492
|
+
removeDirectoryIfEmpty(path.dirname(legacySourceRoot));
|
|
493
|
+
if (path.resolve(correctedSourceRoot) !== path.resolve(destinationRoot)) {
|
|
494
|
+
removeDirectoryIfEmpty(correctedSourceRoot);
|
|
495
|
+
removeDirectoryIfEmpty(path.dirname(correctedSourceRoot));
|
|
496
|
+
}
|
|
497
|
+
return installed;
|
|
498
|
+
}
|
|
499
|
+
function lstatExists(target) {
|
|
500
|
+
try {
|
|
501
|
+
lstatSync(target);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Create a directory tree without ever traversing a caller-controlled symlink.
|
|
510
|
+
* Existing ancestor aliases (for example macOS `/var` → `/private/var`) are
|
|
511
|
+
* canonicalized once; every newly appended component must then be a real dir.
|
|
512
|
+
*/
|
|
513
|
+
function ensureRealDirectoryPath(directory) {
|
|
514
|
+
const absolute = path.resolve(directory);
|
|
515
|
+
const missing = [];
|
|
516
|
+
let anchor = absolute;
|
|
517
|
+
while (!lstatExists(anchor)) {
|
|
518
|
+
const parent = path.dirname(anchor);
|
|
519
|
+
if (parent === anchor) {
|
|
520
|
+
throw new InstallerError(`cannot resolve directory root for ${absolute}`, 50);
|
|
521
|
+
}
|
|
522
|
+
missing.unshift(path.basename(anchor));
|
|
523
|
+
anchor = parent;
|
|
524
|
+
}
|
|
525
|
+
const anchorMetadata = lstatSync(anchor);
|
|
526
|
+
if (!anchorMetadata.isDirectory() ||
|
|
527
|
+
anchorMetadata.isSymbolicLink()) {
|
|
528
|
+
throw new InstallerError(`Kimi OpenSpec destination must be a real directory: ${absolute}`, 50);
|
|
529
|
+
}
|
|
530
|
+
let cursor = realpathSync(anchor);
|
|
531
|
+
for (const component of missing) {
|
|
532
|
+
cursor = path.join(cursor, component);
|
|
533
|
+
if (!lstatExists(cursor))
|
|
534
|
+
mkdirSync(cursor, { mode: 0o700 });
|
|
535
|
+
const metadata = lstatSync(cursor);
|
|
536
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
537
|
+
throw new InstallerError(`Kimi OpenSpec destination parent must not be a symlink: ${cursor}`, 50);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return cursor;
|
|
541
|
+
}
|
|
542
|
+
function validateReplaceableDestination(destination, skillName) {
|
|
543
|
+
if (!lstatExists(destination))
|
|
544
|
+
return;
|
|
545
|
+
const metadata = lstatSync(destination);
|
|
546
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
547
|
+
throw new InstallerError(`Kimi OpenSpec destination ${skillName} must be a real directory`, 50);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function validateSafeSkillTree(source, skillName) {
|
|
551
|
+
let canonicalRoot;
|
|
552
|
+
try {
|
|
553
|
+
const rootMetadata = lstatSync(source);
|
|
554
|
+
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
|
|
555
|
+
throw new Error('skill root is not a real directory');
|
|
556
|
+
}
|
|
557
|
+
canonicalRoot = realpathSync(source);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
throw new InstallerError(`unsafe Kimi OpenSpec skill ${skillName}: ${error.message}`, 50);
|
|
561
|
+
}
|
|
562
|
+
const walk = (directory) => {
|
|
563
|
+
for (const name of readdirSync(directory)) {
|
|
564
|
+
const entry = path.join(directory, name);
|
|
565
|
+
const metadata = lstatSync(entry);
|
|
566
|
+
if (metadata.isSymbolicLink()) {
|
|
567
|
+
throw new InstallerError(`unsafe Kimi OpenSpec skill ${skillName}: symlink ${entry}`, 50);
|
|
568
|
+
}
|
|
569
|
+
const relative = path.relative(canonicalRoot, realpathSync(entry));
|
|
570
|
+
if (relative === '..' ||
|
|
571
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
572
|
+
path.isAbsolute(relative)) {
|
|
573
|
+
throw new InstallerError(`unsafe Kimi OpenSpec skill ${skillName}: path escapes source`, 50);
|
|
574
|
+
}
|
|
575
|
+
if (metadata.isDirectory()) {
|
|
576
|
+
walk(entry);
|
|
577
|
+
}
|
|
578
|
+
else if (!metadata.isFile()) {
|
|
579
|
+
throw new InstallerError(`unsafe Kimi OpenSpec skill ${skillName}: unsupported file type ${entry}`, 50);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
walk(canonicalRoot);
|
|
584
|
+
const skillFile = path.join(canonicalRoot, 'SKILL.md');
|
|
585
|
+
if (!lstatExists(skillFile)) {
|
|
586
|
+
throw new InstallerError(`generated Kimi skill ${skillName} is missing SKILL.md`, 50);
|
|
587
|
+
}
|
|
588
|
+
const skillMetadata = lstatSync(skillFile);
|
|
589
|
+
if (!skillMetadata.isFile() || skillMetadata.isSymbolicLink()) {
|
|
590
|
+
throw new InstallerError(`generated Kimi skill ${skillName} has unsafe SKILL.md`, 50);
|
|
591
|
+
}
|
|
592
|
+
return canonicalRoot;
|
|
593
|
+
}
|
|
594
|
+
function copyValidatedSkillTree(source, destination) {
|
|
595
|
+
for (const name of readdirSync(source)) {
|
|
596
|
+
const from = path.join(source, name);
|
|
597
|
+
const to = path.join(destination, name);
|
|
598
|
+
const metadata = lstatSync(from);
|
|
599
|
+
if (metadata.isSymbolicLink()) {
|
|
600
|
+
throw new InstallerError(`refusing to copy Kimi OpenSpec symlink ${from}`, 50);
|
|
601
|
+
}
|
|
602
|
+
if (metadata.isDirectory()) {
|
|
603
|
+
mkdirSync(to, { mode: 0o700 });
|
|
604
|
+
copyValidatedSkillTree(from, to);
|
|
605
|
+
}
|
|
606
|
+
else if (metadata.isFile()) {
|
|
607
|
+
copyFileSync(from, to);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
throw new InstallerError(`refusing to copy unsupported Kimi OpenSpec file ${from}`, 50);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function removeDirectoryIfEmpty(dir) {
|
|
615
|
+
if (isDir(dir) && listDir(dir).length === 0)
|
|
616
|
+
removePath(dir);
|
|
617
|
+
}
|
|
618
|
+
export async function installOpenSpecProject(repoRoot, provider, artifactRoot = repoRoot) {
|
|
251
619
|
if (process.env.SPECRAILS_SKIP_OPENSPEC_INIT === '1') {
|
|
620
|
+
if (provider === 'kimi') {
|
|
621
|
+
const generatedRoots = [
|
|
622
|
+
path.join(repoRoot, '.kimi', 'skills'),
|
|
623
|
+
path.join(repoRoot, '.kimi-code', 'skills'),
|
|
624
|
+
];
|
|
625
|
+
const hasGeneratedOutput = generatedRoots.some((root) => listDir(root).some((entry) => isDir(entry) && path.basename(entry).startsWith('openspec-')));
|
|
626
|
+
if (hasGeneratedOutput)
|
|
627
|
+
normalizeKimiOpenSpecSkills(repoRoot, artifactRoot);
|
|
628
|
+
}
|
|
252
629
|
info('Skipping OpenSpec project init (SPECRAILS_SKIP_OPENSPEC_INIT=1)');
|
|
253
630
|
return;
|
|
254
631
|
}
|
|
255
632
|
const { bin, args } = buildOpenSpecInvocation(repoRoot, provider);
|
|
633
|
+
let temporaryConfigHome = null;
|
|
634
|
+
let commandEnv;
|
|
635
|
+
if (provider === 'kimi') {
|
|
636
|
+
temporaryConfigHome = mkdtempSync(path.join(os.tmpdir(), 'specrails-openspec-kimi-'));
|
|
637
|
+
writeFileLf(path.join(temporaryConfigHome, 'openspec', 'config.json'), `${JSON.stringify({
|
|
638
|
+
profile: 'custom',
|
|
639
|
+
delivery: 'skills',
|
|
640
|
+
workflows: OPENSPEC_ALL_WORKFLOWS,
|
|
641
|
+
featureFlags: {},
|
|
642
|
+
}, null, 2)}\n`);
|
|
643
|
+
commandEnv = { ...process.env, XDG_CONFIG_HOME: temporaryConfigHome };
|
|
644
|
+
}
|
|
256
645
|
step('Phase 3c: Installing OpenSpec');
|
|
257
646
|
try {
|
|
258
647
|
await runCommand(bin, args, {
|
|
259
648
|
cwd: repoRoot,
|
|
260
649
|
timeoutMs: 180000,
|
|
650
|
+
env: commandEnv,
|
|
261
651
|
});
|
|
652
|
+
if (provider === 'kimi') {
|
|
653
|
+
normalizeKimiOpenSpecSkills(repoRoot, artifactRoot);
|
|
654
|
+
}
|
|
262
655
|
ok(`OpenSpec project files installed (${provider})`);
|
|
263
656
|
}
|
|
264
657
|
catch (err) {
|
|
265
658
|
throw new InstallerError(`OpenSpec init failed: ${err.message}`, 50);
|
|
266
659
|
}
|
|
660
|
+
finally {
|
|
661
|
+
if (temporaryConfigHome) {
|
|
662
|
+
rmSync(temporaryConfigHome, { recursive: true, force: true });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
267
665
|
}
|
|
268
666
|
//# sourceMappingURL=init.js.map
|