@vkmikc/create-vkm-kit 4.2.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/LICENSE.md +44 -0
- package/README.md +148 -0
- package/package.json +57 -0
- package/src/asset-install.mjs +109 -0
- package/src/claude-native-memory.mjs +507 -0
- package/src/file-perms.mjs +53 -0
- package/src/hooks/_transcript-cache.mjs +223 -0
- package/src/hooks/compact-mcp-output.mjs +87 -0
- package/src/hooks/compact-tool-output.mjs +177 -0
- package/src/hooks/ensure-otel-sink.mjs +51 -0
- package/src/hooks/guard-effort-gate.mjs +209 -0
- package/src/hooks/guard-native-memory-write.mjs +129 -0
- package/src/hooks/session-start-vault-context.mjs +200 -0
- package/src/hooks/stop-vault-close-reminder.mjs +150 -0
- package/src/index.js +1547 -0
- package/src/mcp-merge.mjs +279 -0
- package/src/memory-rules.mjs +205 -0
- package/src/obscura-setup.mjs +272 -0
- package/src/ollama-setup.mjs +126 -0
- package/src/rules-merge.mjs +106 -0
- package/src/settings-io.mjs +193 -0
- package/src/settings-writers.mjs +150 -0
- package/src/skills-install.mjs +96 -0
- package/src/telemetry.mjs +154 -0
- package/src/token-saver.mjs +248 -0
- package/templates/agents/vkm-implementer.md +23 -0
- package/templates/output-styles/vkm-terse.md +23 -0
- package/templates/skills/vkm-discipline/SKILL.md +77 -0
- package/templates/skills/vkm-discipline/domains/coding.md +39 -0
- package/templates/skills/vkm-discipline/domains/data.md +37 -0
- package/templates/skills/vkm-discipline/domains/debugging.md +41 -0
- package/templates/skills/vkm-discipline/domains/design-ui.md +41 -0
- package/templates/skills/vkm-discipline/domains/expertise.md +26 -0
- package/templates/skills/vkm-discipline/domains/infra.md +35 -0
- package/templates/skills/vkm-discipline/domains/llm-artifacts.md +30 -0
- package/templates/skills/vkm-discipline/domains/security.md +37 -0
- package/templates/skills/vkm-discipline/domains/web-search.md +43 -0
- package/templates/skills/vkm-discipline/domains/writing.md +32 -0
- package/templates/skills/vkm-spec/SKILL.md +33 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Obscura headless-browser auto-setup (ADR-0051) for the obscura-web MCP (stealth web
|
|
2
|
+
// fetch + robust search). Under `--full`/`--obscura` the PINNED release is downloaded, its
|
|
3
|
+
// SHA-256 verified against a baked-in value, and extracted to ~/.vkm/obscura/.
|
|
4
|
+
//
|
|
5
|
+
// BEST-EFFORT by contract: a failed/skipped setup degrades obscura-web (its tools steer the
|
|
6
|
+
// agent to the native WebFetch/WebSearch) but NEVER breaks the install. obscura is a
|
|
7
|
+
// third-party binary from a pseudonymous author — we pin the version and verify the hash,
|
|
8
|
+
// but cannot audit the binary itself; verification REFUSES to run bytes that don't match the
|
|
9
|
+
// baked-in digest, and the CDP server is never involved (obscura-web uses per-request
|
|
10
|
+
// `obscura fetch`, so no port is opened). Non-Windows still gets a real binary, not a
|
|
11
|
+
// pipe-to-shell installer.
|
|
12
|
+
//
|
|
13
|
+
// Attribution: obscura is licensed Apache-2.0 (© its authors, github.com/h4ckf0r0day/obscura).
|
|
14
|
+
// The kit DOWNLOADS the official release to the user's machine — it does not bundle or
|
|
15
|
+
// redistribute obscura's code/binary — and credits it at install time (console note below).
|
|
16
|
+
import { execa } from "execa";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import fs from "node:fs/promises";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import pc from "picocolors";
|
|
22
|
+
|
|
23
|
+
export const OBSCURA_VERSION = "0.1.10";
|
|
24
|
+
|
|
25
|
+
/** Sentinel for an un-filled checksum: setup refuses to run an unverifiable download. */
|
|
26
|
+
export const CHECKSUM_PLACEHOLDER = "REPLACE_WITH_REAL_SHA256";
|
|
27
|
+
|
|
28
|
+
// Release assets — github.com/h4ckf0r0day/obscura/releases/tag/v0.1.10. obscura publishes NO
|
|
29
|
+
// checksum file, so each sha256 is computed by us from the pinned artifact and baked in here
|
|
30
|
+
// (fill via `node scripts/obscura-checksums.mjs`). A mismatch on download means the bytes
|
|
31
|
+
// changed upstream → setup refuses to run them. Bumping OBSCURA_VERSION requires recomputing.
|
|
32
|
+
export const OBSCURA_ASSETS = {
|
|
33
|
+
"win32-x64": {
|
|
34
|
+
asset: "obscura-x86_64-windows.zip",
|
|
35
|
+
sha256: "238eebe25f5793ff41898e1cf52dc11e8a637c0d799c69aaffe7904cbbb5a858",
|
|
36
|
+
bin: "obscura.exe"
|
|
37
|
+
},
|
|
38
|
+
"linux-x64": {
|
|
39
|
+
asset: "obscura-x86_64-linux.tar.gz",
|
|
40
|
+
sha256: "7efd9d53546b69ed6cc84a47d5c08ee7a7041ee87ab95e7310fda708608a5093",
|
|
41
|
+
bin: "obscura"
|
|
42
|
+
},
|
|
43
|
+
"linux-arm64": {
|
|
44
|
+
asset: "obscura-aarch64-linux.tar.gz",
|
|
45
|
+
sha256: "a50c154970934af3cf9fd2bec6c8a53ff76f25b0c4d9e78c286ce4bc3bca0adf",
|
|
46
|
+
bin: "obscura"
|
|
47
|
+
},
|
|
48
|
+
"darwin-x64": {
|
|
49
|
+
asset: "obscura-x86_64-macos.tar.gz",
|
|
50
|
+
sha256: "cfd74f777be7dccebe0ed1fc4b264f8c4dfb0e52cf929d88acde85365c4e2961",
|
|
51
|
+
bin: "obscura"
|
|
52
|
+
},
|
|
53
|
+
"darwin-arm64": {
|
|
54
|
+
asset: "obscura-aarch64-macos.tar.gz",
|
|
55
|
+
sha256: "a4a868cedf2fb95f2b3af2dc9dacf235eef08398f070387b9a02e65faf1f93e3",
|
|
56
|
+
bin: "obscura"
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const assetKey = (platform, arch) => `${platform}-${arch}`;
|
|
61
|
+
|
|
62
|
+
/** The kit's private obscura install dir (~/.vkm/obscura). */
|
|
63
|
+
export function obscuraHome() {
|
|
64
|
+
return path.join(os.homedir(), ".vkm", "obscura");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Expected binary path for a platform/arch, or null if unsupported. */
|
|
68
|
+
export function obscuraBinPath(platform = process.platform, arch = process.arch) {
|
|
69
|
+
const spec = OBSCURA_ASSETS[assetKey(platform, arch)];
|
|
70
|
+
return spec ? path.join(obscuraHome(), spec.bin) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** GitHub release download URL for a given asset filename. */
|
|
74
|
+
export function downloadUrl(asset) {
|
|
75
|
+
return `https://github.com/h4ckf0r0day/obscura/releases/download/v${OBSCURA_VERSION}/${asset}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve the asset spec for a platform/arch (null if none is published). */
|
|
79
|
+
export function resolveSpec(platform = process.platform, arch = process.arch) {
|
|
80
|
+
return OBSCURA_ASSETS[assetKey(platform, arch)] || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── real dependency implementations (injected in tests) ─────────────────────
|
|
84
|
+
async function fetchDownload(url, dest) {
|
|
85
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
86
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
|
|
87
|
+
await fs.writeFile(dest, Buffer.from(await res.arrayBuffer()));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function sha256File(fp) {
|
|
91
|
+
return createHash("sha256")
|
|
92
|
+
.update(await fs.readFile(fp))
|
|
93
|
+
.digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function tarExtract(archive, dest) {
|
|
97
|
+
// bsdtar (bundled on Windows 10+, macOS, and most Linux) extracts BOTH .zip and .tar.gz.
|
|
98
|
+
const res = await execa("tar", ["-xf", archive, "-C", dest], {
|
|
99
|
+
reject: false,
|
|
100
|
+
windowsHide: true
|
|
101
|
+
});
|
|
102
|
+
if (res.failed || res.exitCode !== 0) {
|
|
103
|
+
throw new Error(`extract failed (tar): ${String(res.stderr || res.shortMessage || "").trim()}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function isRunnable(bin) {
|
|
108
|
+
try {
|
|
109
|
+
const res = await execa(bin, ["--version"], {
|
|
110
|
+
reject: false,
|
|
111
|
+
timeout: 8000,
|
|
112
|
+
windowsHide: true
|
|
113
|
+
});
|
|
114
|
+
return !res.failed && res.exitCode === 0;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Find `binName` directly under root or one level down (archives may nest a top folder). */
|
|
121
|
+
async function locateBin(root, binName) {
|
|
122
|
+
const direct = path.join(root, binName);
|
|
123
|
+
try {
|
|
124
|
+
await fs.access(direct);
|
|
125
|
+
return direct;
|
|
126
|
+
} catch {
|
|
127
|
+
/* not at root */
|
|
128
|
+
}
|
|
129
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
if (!e.isDirectory()) continue;
|
|
132
|
+
const p = path.join(root, e.name, binName);
|
|
133
|
+
try {
|
|
134
|
+
await fs.access(p);
|
|
135
|
+
return p;
|
|
136
|
+
} catch {
|
|
137
|
+
/* keep looking */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Download → verify SHA-256 → extract → locate → confirm runnable. Dependency-injected so
|
|
145
|
+
* it is unit-testable without network. Never runs bytes whose digest != spec.sha256.
|
|
146
|
+
* @param {{ asset: string, sha256: string, bin: string }} spec
|
|
147
|
+
* @param {{ platform?: string, tmpDir?: string, home?: string }} [opts]
|
|
148
|
+
* @param {{ download?: typeof fetchDownload, hashFile?: typeof sha256File, extract?: typeof tarExtract, isRunnable?: typeof isRunnable }} [deps]
|
|
149
|
+
* @returns {Promise<{ status: "ready"|"failed", binPath: string|null }>}
|
|
150
|
+
*/
|
|
151
|
+
export async function installFromSpec(spec, opts = {}, deps = {}) {
|
|
152
|
+
const platform = opts.platform ?? process.platform;
|
|
153
|
+
const home = opts.home ?? obscuraHome();
|
|
154
|
+
const tmpDir = opts.tmpDir ?? os.tmpdir();
|
|
155
|
+
const {
|
|
156
|
+
download = fetchDownload,
|
|
157
|
+
hashFile = sha256File,
|
|
158
|
+
extract = tarExtract,
|
|
159
|
+
isRunnable: runnable = isRunnable
|
|
160
|
+
} = deps;
|
|
161
|
+
|
|
162
|
+
await fs.mkdir(home, { recursive: true });
|
|
163
|
+
const tmp = path.join(tmpDir, `obscura-${OBSCURA_VERSION}-${process.pid}-${spec.asset}`);
|
|
164
|
+
try {
|
|
165
|
+
await download(downloadUrl(spec.asset), tmp);
|
|
166
|
+
|
|
167
|
+
const digest = (await hashFile(tmp)).toLowerCase();
|
|
168
|
+
if (digest !== spec.sha256.toLowerCase()) {
|
|
169
|
+
console.error(
|
|
170
|
+
pc.red(
|
|
171
|
+
`obscura: SHA-256 mismatch (expected ${spec.sha256}, got ${digest}) — refusing to run it.`
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
return { status: "failed", binPath: null };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await extract(tmp, home);
|
|
178
|
+
|
|
179
|
+
const binPath = await locateBin(home, spec.bin);
|
|
180
|
+
if (!binPath) {
|
|
181
|
+
console.warn(pc.yellow(`obscura extracted but ${spec.bin} not found under ${home}`));
|
|
182
|
+
return { status: "failed", binPath: null };
|
|
183
|
+
}
|
|
184
|
+
if (platform !== "win32") {
|
|
185
|
+
try {
|
|
186
|
+
await fs.chmod(binPath, 0o755);
|
|
187
|
+
} catch {
|
|
188
|
+
/* best-effort */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (await runnable(binPath)) {
|
|
192
|
+
console.log(pc.green("obscura ready:"), pc.dim(`v${OBSCURA_VERSION} @ ${binPath}`));
|
|
193
|
+
return { status: "ready", binPath };
|
|
194
|
+
}
|
|
195
|
+
console.warn(pc.yellow("obscura extracted but not runnable — check " + binPath));
|
|
196
|
+
return { status: "failed", binPath: null };
|
|
197
|
+
} finally {
|
|
198
|
+
// Always remove the downloaded archive — on mismatch, extract failure, or success.
|
|
199
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Ensure the obscura binary is available. Never throws; returns a status the caller prints
|
|
205
|
+
* plus the resolved binary path (null → obscura-web should fall back to `obscura` on PATH).
|
|
206
|
+
* @param {boolean} dryRun
|
|
207
|
+
* @param {{ enable?: boolean, platform?: string, arch?: string }} [opts]
|
|
208
|
+
* @param {{ isRunnable?: typeof isRunnable, installImpl?: typeof installFromSpec }} [deps]
|
|
209
|
+
* @returns {Promise<{ status: "ready"|"manual"|"skipped"|"failed", binPath: string|null }>}
|
|
210
|
+
*/
|
|
211
|
+
export async function maybeInstallObscura(
|
|
212
|
+
dryRun,
|
|
213
|
+
{ enable = true, platform = process.platform, arch = process.arch } = {},
|
|
214
|
+
deps = {}
|
|
215
|
+
) {
|
|
216
|
+
if (!enable) return { status: "skipped", binPath: null };
|
|
217
|
+
const { isRunnable: runnable = isRunnable, installImpl = installFromSpec } = deps;
|
|
218
|
+
|
|
219
|
+
const spec = resolveSpec(platform, arch);
|
|
220
|
+
if (!spec) {
|
|
221
|
+
console.warn(
|
|
222
|
+
pc.yellow(`obscura: no prebuilt release for ${assetKey(platform, arch)} — install manually:`),
|
|
223
|
+
pc.dim("https://github.com/h4ckf0r0day/obscura/releases")
|
|
224
|
+
);
|
|
225
|
+
return { status: "manual", binPath: null };
|
|
226
|
+
}
|
|
227
|
+
const binPath = path.join(obscuraHome(), spec.bin);
|
|
228
|
+
|
|
229
|
+
// Respect an existing install (ours in ~/.vkm/obscura or one already on PATH).
|
|
230
|
+
if (await runnable(binPath)) {
|
|
231
|
+
console.log(pc.green("obscura already installed:"), pc.dim(binPath));
|
|
232
|
+
return { status: "ready", binPath };
|
|
233
|
+
}
|
|
234
|
+
if (await runnable("obscura")) {
|
|
235
|
+
console.log(pc.green("obscura already on PATH — using it."));
|
|
236
|
+
return { status: "ready", binPath: null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (spec.sha256 === CHECKSUM_PLACEHOLDER) {
|
|
240
|
+
// Never download-and-run an unverifiable third-party binary.
|
|
241
|
+
console.warn(
|
|
242
|
+
pc.yellow(
|
|
243
|
+
"obscura: no pinned checksum baked in yet — refusing to auto-run an unverified binary."
|
|
244
|
+
)
|
|
245
|
+
);
|
|
246
|
+
console.log(
|
|
247
|
+
pc.dim(` Install manually: ${downloadUrl(spec.asset)} → extract to ${obscuraHome()}`)
|
|
248
|
+
);
|
|
249
|
+
return { status: "manual", binPath: null };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (dryRun) {
|
|
253
|
+
console.log(
|
|
254
|
+
pc.cyan("[dry-run] would download + verify (SHA-256) + extract obscura"),
|
|
255
|
+
pc.dim(`${spec.asset} (v${OBSCURA_VERSION}) → ${obscuraHome()}`)
|
|
256
|
+
);
|
|
257
|
+
return { status: "skipped", binPath };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
console.log(pc.cyan(`Downloading obscura v${OBSCURA_VERSION} (${spec.asset}) …`));
|
|
262
|
+
console.log(
|
|
263
|
+
pc.dim(
|
|
264
|
+
" obscura © its authors — Apache-2.0 (github.com/h4ckf0r0day/obscura); official release, downloaded not bundled."
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
return await installImpl(spec, { platform });
|
|
268
|
+
} catch (e) {
|
|
269
|
+
console.warn(pc.yellow("obscura setup skipped:"), e?.message || e);
|
|
270
|
+
return { status: "failed", binPath: null };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Ollama + phi4-mini auto-setup (ADR-0047) for the vkm-spec local drafting layer.
|
|
2
|
+
// Under `--full` the whole stack installs WITHOUT questions (user decision, 4.0.0 plan):
|
|
3
|
+
// on Windows that means `winget install Ollama.Ollama --silent` when the binary is
|
|
4
|
+
// missing, then `ollama pull phi4-mini:3.8b-q4_K_M` (~2.3GB download, logged loudly).
|
|
5
|
+
// Everything here is BEST-EFFORT by contract: vkm-spec's deterministic fallback is a
|
|
6
|
+
// CI-pinned invariant, so a failed/skipped Ollama setup degrades the experience, never
|
|
7
|
+
// breaks it. Non-Windows platforms get the exact command to run instead of a pipe-to-shell
|
|
8
|
+
// install (never curl|sh on the user's behalf).
|
|
9
|
+
import { execa } from "execa";
|
|
10
|
+
import pc from "picocolors";
|
|
11
|
+
|
|
12
|
+
export const OLLAMA_MODEL = "phi4-mini:3.8b-q4_K_M";
|
|
13
|
+
/** Structured outputs via `format` need ≥0.5.13 for phi4-mini (model requirement). */
|
|
14
|
+
export const OLLAMA_MIN_VERSION = "0.5.13";
|
|
15
|
+
|
|
16
|
+
/** `ollama --version` → semver string or null (missing/unrunnable binary). */
|
|
17
|
+
export async function ollamaVersion() {
|
|
18
|
+
const res = await execa("ollama", ["--version"], { reject: false });
|
|
19
|
+
if (res.failed || res.exitCode !== 0) return null;
|
|
20
|
+
return res.stdout?.match(/(\d+\.\d+\.\d+)/)?.[1] ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Best-effort numeric compare: is `version` ≥ `min`? */
|
|
24
|
+
export function versionAtLeast(version, min) {
|
|
25
|
+
const a = String(version).split(".").map(Number);
|
|
26
|
+
const b = String(min).split(".").map(Number);
|
|
27
|
+
for (let i = 0; i < 3; i++) {
|
|
28
|
+
if ((a[i] || 0) > (b[i] || 0)) return true;
|
|
29
|
+
if ((a[i] || 0) < (b[i] || 0)) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ensure Ollama + the drafting model are available. Never throws; returns a status the
|
|
36
|
+
* caller prints in the summary.
|
|
37
|
+
* @param {boolean} dryRun
|
|
38
|
+
* @param {{ enable?: boolean, platform?: string }} [opts]
|
|
39
|
+
* @returns {Promise<"ready"|"pull-pending"|"restart-needed"|"manual"|"skipped"|"failed">}
|
|
40
|
+
*/
|
|
41
|
+
export async function maybeInstallOllama(
|
|
42
|
+
dryRun,
|
|
43
|
+
{ enable = true, platform = process.platform } = {}
|
|
44
|
+
) {
|
|
45
|
+
if (!enable) return "skipped";
|
|
46
|
+
try {
|
|
47
|
+
let version = await ollamaVersion();
|
|
48
|
+
|
|
49
|
+
if (!version) {
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
console.log(
|
|
52
|
+
pc.cyan("[dry-run] would install Ollama (winget, silent) + pull"),
|
|
53
|
+
OLLAMA_MODEL
|
|
54
|
+
);
|
|
55
|
+
return "skipped";
|
|
56
|
+
}
|
|
57
|
+
if (platform !== "win32") {
|
|
58
|
+
// Never pipe a remote script to a shell on the user's behalf.
|
|
59
|
+
console.log(
|
|
60
|
+
pc.yellow("Ollama not found — install it manually, then re-run or pull the model:")
|
|
61
|
+
);
|
|
62
|
+
console.log(pc.dim(" https://ollama.com/download → ollama pull " + OLLAMA_MODEL));
|
|
63
|
+
return "manual";
|
|
64
|
+
}
|
|
65
|
+
console.log(pc.cyan("Installing Ollama via winget (silent) …"));
|
|
66
|
+
const res = await execa(
|
|
67
|
+
"winget",
|
|
68
|
+
[
|
|
69
|
+
"install",
|
|
70
|
+
"--id",
|
|
71
|
+
"Ollama.Ollama",
|
|
72
|
+
"--silent",
|
|
73
|
+
"--accept-package-agreements",
|
|
74
|
+
"--accept-source-agreements"
|
|
75
|
+
],
|
|
76
|
+
{ reject: false, stdio: "inherit" }
|
|
77
|
+
);
|
|
78
|
+
if (res.failed || res.exitCode !== 0) {
|
|
79
|
+
console.warn(
|
|
80
|
+
pc.yellow("winget install failed — install manually: https://ollama.com/download")
|
|
81
|
+
);
|
|
82
|
+
return "failed";
|
|
83
|
+
}
|
|
84
|
+
version = await ollamaVersion();
|
|
85
|
+
if (!version) {
|
|
86
|
+
// Fresh winget installs often need a new shell for PATH; the model pull can't run yet.
|
|
87
|
+
console.log(
|
|
88
|
+
pc.yellow("Ollama installed but not on PATH yet — open a new terminal and run:"),
|
|
89
|
+
pc.dim(`ollama pull ${OLLAMA_MODEL}`)
|
|
90
|
+
);
|
|
91
|
+
return "restart-needed";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!versionAtLeast(version, OLLAMA_MIN_VERSION)) {
|
|
96
|
+
console.warn(
|
|
97
|
+
pc.yellow(
|
|
98
|
+
`Ollama ${version} < ${OLLAMA_MIN_VERSION} (needed by ${OLLAMA_MODEL}) — update it:`
|
|
99
|
+
),
|
|
100
|
+
pc.dim("https://ollama.com/download")
|
|
101
|
+
);
|
|
102
|
+
return "manual";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (dryRun) {
|
|
106
|
+
console.log(pc.cyan("[dry-run] would pull"), OLLAMA_MODEL, pc.dim("(~2.3GB, idempotent)"));
|
|
107
|
+
return "skipped";
|
|
108
|
+
}
|
|
109
|
+
console.log(pc.cyan(`Pulling ${OLLAMA_MODEL} (~2.3GB — skips if already present) …`));
|
|
110
|
+
const pull = await execa("ollama", ["pull", OLLAMA_MODEL], { reject: false, stdio: "inherit" });
|
|
111
|
+
if (pull.failed || pull.exitCode !== 0) {
|
|
112
|
+
console.warn(
|
|
113
|
+
pc.yellow(
|
|
114
|
+
"Model pull failed (network/disk?) — vkm-spec will use its deterministic fallback."
|
|
115
|
+
),
|
|
116
|
+
pc.dim(`Retry later: ollama pull ${OLLAMA_MODEL}`)
|
|
117
|
+
);
|
|
118
|
+
return "pull-pending";
|
|
119
|
+
}
|
|
120
|
+
console.log(pc.green("Ollama ready:"), pc.dim(`${OLLAMA_MODEL} (v${version})`));
|
|
121
|
+
return "ready";
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.warn(pc.yellow("Ollama setup skipped:"), e?.message || e);
|
|
124
|
+
return "failed";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Idempotent install of the managed memory-rules block into agent-config files
|
|
2
|
+
// (~/.claude/CLAUDE.md, ./AGENTS.md, .cursor/rules/*.mdc). The block is wrapped
|
|
3
|
+
// in sentinels so re-runs replace ONLY our block and never clobber user content.
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fse from "fs-extra";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import {
|
|
8
|
+
RULES_START,
|
|
9
|
+
RULES_END,
|
|
10
|
+
LEGACY_RULES_START,
|
|
11
|
+
LEGACY_RULES_END,
|
|
12
|
+
memoryRulesBlock
|
|
13
|
+
} from "./memory-rules.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Merge a managed block into existing text.
|
|
17
|
+
* - If both sentinels are present → replace everything between them (inclusive).
|
|
18
|
+
* A block written by a pre-rename release (`obsidian-memory:start/end`, ADR-0041) is
|
|
19
|
+
* recognized too and migrated in place — one reinstall converts it to the new sentinels.
|
|
20
|
+
* - Else if the file is empty → just the block.
|
|
21
|
+
* - Else → append the block after the existing content (preserving it verbatim).
|
|
22
|
+
* @param {string} existing
|
|
23
|
+
* @param {string} block - full block, sentinels included
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function mergeManagedBlock(existing, block) {
|
|
27
|
+
const blk = block.trim();
|
|
28
|
+
const text = (existing || "").replace(/\s+$/, "");
|
|
29
|
+
let s = text.indexOf(RULES_START);
|
|
30
|
+
let e = text.indexOf(RULES_END);
|
|
31
|
+
let endLen = RULES_END.length;
|
|
32
|
+
if (s === -1 || e === -1) {
|
|
33
|
+
// Dual-read (ADR-0041): accept the legacy sentinels so the rename never strands an
|
|
34
|
+
// installed block — write-new happens implicitly (blk carries the new sentinels).
|
|
35
|
+
s = text.indexOf(LEGACY_RULES_START);
|
|
36
|
+
e = text.indexOf(LEGACY_RULES_END);
|
|
37
|
+
endLen = LEGACY_RULES_END.length;
|
|
38
|
+
}
|
|
39
|
+
if (s !== -1 && e !== -1 && e > s) {
|
|
40
|
+
const before = text.slice(0, s).replace(/\s+$/, "");
|
|
41
|
+
const after = text.slice(e + endLen).replace(/^\s+/, "");
|
|
42
|
+
return (
|
|
43
|
+
[before, blk, after]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join("\n\n")
|
|
46
|
+
.replace(/\n{3,}/g, "\n\n") + "\n"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!text) return blk + "\n";
|
|
50
|
+
return text + "\n\n" + blk + "\n";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} fp
|
|
55
|
+
* @param {string} block
|
|
56
|
+
* @param {{ dryRun?: boolean, newFilePrefix?: string }} [opts]
|
|
57
|
+
*/
|
|
58
|
+
async function installRulesFile(fp, block, { dryRun = false, newFilePrefix = "" } = {}) {
|
|
59
|
+
const exists = await fse.pathExists(fp);
|
|
60
|
+
const base = exists ? await fse.readFile(fp, "utf8") : newFilePrefix;
|
|
61
|
+
const merged = mergeManagedBlock(base, block);
|
|
62
|
+
if (dryRun) {
|
|
63
|
+
console.log(pc.cyan("[dry-run] would update rules"), fp);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await fse.ensureDir(path.dirname(fp));
|
|
67
|
+
await fse.writeFile(fp, merged, "utf8");
|
|
68
|
+
console.log(pc.green(exists ? "Updated rules" : "Wrote rules"), fp);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Install the memory-rules block into the requested targets.
|
|
73
|
+
* @param {string[]} targets - any of "claude" | "agents" | "cursor" | "codex"
|
|
74
|
+
* @param {"es"|"en"} lang
|
|
75
|
+
* @param {{ home: string, cwd: string, dryRun?: boolean }} ctx
|
|
76
|
+
* @returns {Promise<string[]>} files written
|
|
77
|
+
*/
|
|
78
|
+
export async function installRules(targets, lang, { home, cwd, dryRun = false }) {
|
|
79
|
+
const block = memoryRulesBlock(lang);
|
|
80
|
+
const written = [];
|
|
81
|
+
if (targets.includes("claude")) {
|
|
82
|
+
const fp = path.join(home, ".claude", "CLAUDE.md");
|
|
83
|
+
await installRulesFile(fp, block, { dryRun });
|
|
84
|
+
written.push(fp);
|
|
85
|
+
}
|
|
86
|
+
if (targets.includes("codex")) {
|
|
87
|
+
// Codex CLI reads global instructions from ~/.codex/AGENTS.md (project-level
|
|
88
|
+
// ./AGENTS.md is covered by the "agents" target).
|
|
89
|
+
const fp = path.join(home, ".codex", "AGENTS.md");
|
|
90
|
+
await installRulesFile(fp, block, { dryRun });
|
|
91
|
+
written.push(fp);
|
|
92
|
+
}
|
|
93
|
+
if (targets.includes("agents")) {
|
|
94
|
+
const fp = path.join(cwd, "AGENTS.md");
|
|
95
|
+
await installRulesFile(fp, block, { dryRun });
|
|
96
|
+
written.push(fp);
|
|
97
|
+
}
|
|
98
|
+
if (targets.includes("cursor")) {
|
|
99
|
+
const fp = path.join(cwd, ".cursor", "rules", "obsidian-memory.mdc");
|
|
100
|
+
const frontmatter =
|
|
101
|
+
"---\ndescription: Markdown vault memory protocol (obsidian-memory-kit)\nalwaysApply: true\n---\n\n";
|
|
102
|
+
await installRulesFile(fp, block, { dryRun, newFilePrefix: frontmatter });
|
|
103
|
+
written.push(fp);
|
|
104
|
+
}
|
|
105
|
+
return written;
|
|
106
|
+
}
|