novahiz 0.4.0 → 0.4.1
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 +29 -0
- package/README.md +19 -2
- package/adapters/opencode/agent/novahiz.md +1 -0
- package/bin/novahiz.mjs +31 -1
- package/install/install.mjs +1 -1
- package/install/install.mjs.novahiz-bak +592 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.4.1] - 2026-10-05
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`npm install -g novahiz` works on npm 11.16+**, where install scripts no
|
|
13
|
+
longer count as approved unless they appear in `allowScripts` and a global
|
|
14
|
+
install has no `package.json` to record that approval in — npm prints
|
|
15
|
+
`npm warn allow-scripts novahiz (...)` and, depending on the version, skips
|
|
16
|
+
the postinstall so nothing gets configured. The recommended command is
|
|
17
|
+
`npm install -g --allow-scripts=novahiz novahiz`;
|
|
18
|
+
`npm config set allow-scripts=novahiz --location=user` makes it permanent so
|
|
19
|
+
plain `npm install -g novahiz` works afterwards.
|
|
20
|
+
- **The published 0.4.0 tarball still carried the dry-run postinstall**
|
|
21
|
+
(`node install/install.mjs --dry-run`) — the `--yes` republish never reached
|
|
22
|
+
the registry, so even an allowed postinstall only printed the next step
|
|
23
|
+
instead of installing. 0.4.1 publishes `postinstall: node install/install.mjs
|
|
24
|
+
--yes`.
|
|
25
|
+
- **`novahiz` finishes the setup itself when npm skipped the postinstall**: the
|
|
26
|
+
first run of the CLI finds no `~/.config/novahiz/.novahiz-install.json`
|
|
27
|
+
manifest and executes `install/install.mjs --yes` once before the command
|
|
28
|
+
runs. `-v`, `--version`, `--help`, `-h` and `NOVAHIZ_AUTOINSTALL=0` skip the
|
|
29
|
+
bootstrap; a repo checkout (outside `node_modules`) is never touched.
|
|
30
|
+
|
|
31
|
+
### Docs
|
|
32
|
+
|
|
33
|
+
- README quick start rewritten: the one-liner carries `--allow-scripts`, both
|
|
34
|
+
fallbacks are listed, and the `ENOENT: package.json` trap of running the flag
|
|
35
|
+
without a package name is called out.
|
|
36
|
+
|
|
8
37
|
## [0.4.0] - 2026-10-05
|
|
9
38
|
|
|
10
39
|
### Added
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
17 catégories, 96 skills, 11 gate rules, 7 MCP providers — tout déterministe, tout local, tout JSON.
|
|
6
6
|
|
|
7
|
+
> **Version 0.4.1** — publiée sur npm (`npm install -g --allow-scripts=novahiz novahiz` installe et configure tout : core, plugin, agent, skills packs et Playwright MCP). `CHANGELOG.md` et `scripts/` sont dans le tarball.
|
|
8
|
+
|
|
7
9
|
```
|
|
8
10
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
9
11
|
│ │
|
|
@@ -42,11 +44,26 @@
|
|
|
42
44
|
### Option 1 — En une ligne (recommandé)
|
|
43
45
|
|
|
44
46
|
```bash
|
|
45
|
-
npm install -g novahiz
|
|
47
|
+
npm install -g --allow-scripts=novahiz novahiz
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Une commande, tout est en place : CLI, core (skills, plugin, agent, commands), skills packs et Playwright MCP.
|
|
51
|
+
|
|
52
|
+
**Pourquoi ce drapeau ?** npm 11.16+ ne considère plus les scripts de lifecycle (`postinstall`) comme autorisés tant qu'ils ne figurent pas dans `allowScripts`, et une installation globale n'a pas de `package.json` où l'inscrire : npm affiche alors `npm warn allow-scripts novahiz (...)`, et selon la version il saute le script — auquel cas `novahiz-install` n'a jamais tourné et rien n'est configuré. Le drapeau autorise explicitement ce paquet. Replis :
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# valeur permanente : `npm install -g novahiz` marche ensuite sans drapeau
|
|
56
|
+
npm config set allow-scripts=novahiz --location=user
|
|
57
|
+
|
|
58
|
+
# paquet déjà installé sans configuration : une commande suffit
|
|
46
59
|
novahiz-install --yes
|
|
47
60
|
```
|
|
48
61
|
|
|
49
|
-
|
|
62
|
+
npm antérieur à 11.16 exécute les scripts par défaut : `npm install -g novahiz` suffit. Et si le blocage a eu lieu quand même, le premier `novahiz` lancé termine l'installation lui-même (désactivable avec `NOVAHIZ_AUTOINSTALL=0`).
|
|
63
|
+
|
|
64
|
+
> Piège : `npm install -g --allow-scripts=novahiz` **sans** le nom du paquet installe le dossier courant et échoue avec `ENOENT: package.json`. Le nom du paquet est obligatoire : `npm install -g --allow-scripts=novahiz novahiz`.
|
|
65
|
+
|
|
66
|
+
Puis vérifiez :
|
|
50
67
|
|
|
51
68
|
```bash
|
|
52
69
|
novahiz doctor # Santé : 15 checks (17 avec --deep)
|
|
@@ -30,3 +30,4 @@ Rules:
|
|
|
30
30
|
- Be honest. Avoid false good ideas. Keep a critical stance. Zero simulation: never pretend to have run, tested, or verified something you did not.
|
|
31
31
|
- Criticize the request when it is inconsistent, ambiguous, risky, or suboptimal, and propose an alternative.
|
|
32
32
|
- Gate reload: when a call fails with "Novahiz gate blocked", do not stop and do not ask the user. Execute the GATE RELOAD block from the error verbatim: load every skill it names with `skill({ name: "..." })`, then retry the exact same call once and continue the user's task where it left off. If the identical skills are reported missing again, the loads did not register — run `novahiz doctor`, report honestly to the user, and stop. Never bypass the gate with a shell write, an alternate tool, or NOVAHIZ_GATE.
|
|
33
|
+
- Browser rule (system): when the user asks for an external web browser, always use the physical browser (`playwright`: `browser_navigate`, `browser_find`, `browser_take_screenshot`, `browser_snapshot`). This mode stays mandatory by default until the user explicitly asks for the integrated OpenCode desktop browser (`tools.browser`).
|
package/bin/novahiz.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
4
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
7
|
|
|
6
8
|
// engines: node >=22.18. Node strips types only OUTSIDE node_modules
|
|
@@ -21,6 +23,34 @@ const underNodeModules = root.replace(/\\/g, "/").toLowerCase().includes("/node_
|
|
|
21
23
|
const dist = join(root, "dist", "cli.js");
|
|
22
24
|
const src = join(root, "src", "cli.ts");
|
|
23
25
|
|
|
26
|
+
// npm 11.16+ blocks install scripts unless `allowScripts` covers them, and a
|
|
27
|
+
// global install (`npm install -g`) has no package.json to record that
|
|
28
|
+
// approval in — so the postinstall can be skipped silently and the machine is
|
|
29
|
+
// left half configured. Finishing the job here means the package works
|
|
30
|
+
// whether or not npm let the lifecycle script through: the first real `novahiz`
|
|
31
|
+
// run installs the core (skills, plugin, agent, MCP), then behaves normally.
|
|
32
|
+
// Opt out with NOVAHIZ_AUTOINSTALL=0. Only a package installed from npm is
|
|
33
|
+
// touched; a repo checkout keeps its explicit `novahiz-install`.
|
|
34
|
+
if (underNodeModules && process.env.NOVAHIZ_AUTOINSTALL !== "0") {
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
const infoOnly = args.length > 0 && args.every((arg) => ["-v", "-V", "--version", "--help", "-h"].includes(arg));
|
|
37
|
+
const home = process.env.NOVAHIZ_HOME ? resolve(process.env.NOVAHIZ_HOME) : join(homedir(), ".config", "novahiz");
|
|
38
|
+
if (!infoOnly && !existsSync(join(home, ".novahiz-install.json"))) {
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
"novahiz: npm skipped the postinstall script (allow-scripts), so the setup has not run yet.\n" +
|
|
41
|
+
"novahiz: installing now — re-run it any time with `novahiz-install`.\n"
|
|
42
|
+
);
|
|
43
|
+
const setup = spawnSync(process.execPath, [join(root, "install", "install.mjs"), "--yes"], {
|
|
44
|
+
stdio: "inherit",
|
|
45
|
+
cwd: root,
|
|
46
|
+
env: process.env
|
|
47
|
+
});
|
|
48
|
+
if (setup.status !== 0) {
|
|
49
|
+
process.stderr.write("novahiz: automatic setup did not complete; run `novahiz-install` to see why.\n");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
let target;
|
|
25
55
|
if (underNodeModules) {
|
|
26
56
|
if (!existsSync(dist)) {
|
package/install/install.mjs
CHANGED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
copyFileWithBackup,
|
|
8
|
+
copyInto,
|
|
9
|
+
defaultConfig,
|
|
10
|
+
detectedHarnesses,
|
|
11
|
+
loadManifest,
|
|
12
|
+
mergeBackups,
|
|
13
|
+
mergeCreated,
|
|
14
|
+
nodeVersionOk,
|
|
15
|
+
NovahizHome,
|
|
16
|
+
opencodeConfigDir,
|
|
17
|
+
parseArgs,
|
|
18
|
+
playwrightBrowserFlag,
|
|
19
|
+
readJson,
|
|
20
|
+
repoRoot,
|
|
21
|
+
saveManifest,
|
|
22
|
+
skillNamesIn,
|
|
23
|
+
spawnHost,
|
|
24
|
+
which,
|
|
25
|
+
writeJson
|
|
26
|
+
} from "./lib.mjs";
|
|
27
|
+
import { createPrompt } from "./prompt.mjs";
|
|
28
|
+
|
|
29
|
+
const CORE_ITEMS = [
|
|
30
|
+
"src",
|
|
31
|
+
"catalog",
|
|
32
|
+
"bin",
|
|
33
|
+
"install",
|
|
34
|
+
"mcp",
|
|
35
|
+
"adapters",
|
|
36
|
+
"skills",
|
|
37
|
+
"docs",
|
|
38
|
+
"package.json",
|
|
39
|
+
"tsconfig.json",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"README.md",
|
|
42
|
+
"NOTICE.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"novahiz.config.example.json"
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const flags = parseArgs(process.argv.slice(2));
|
|
49
|
+
const dryRun = Boolean(flags["dry-run"]);
|
|
50
|
+
const force = Boolean(flags.force);
|
|
51
|
+
const withSkills = !flags["no-skills"];
|
|
52
|
+
const root = repoRoot(import.meta.url);
|
|
53
|
+
const home = NovahizHome(flags);
|
|
54
|
+
const configDir = flags.scope === "project" ? resolve(".opencode") : opencodeConfigDir();
|
|
55
|
+
const skillsDir = join(configDir, "skills");
|
|
56
|
+
const pluginsDir = join(configDir, "plugins");
|
|
57
|
+
|
|
58
|
+
const note = (message) => process.stdout.write(`${dryRun ? "[dry-run] " : ""}${message}\n`);
|
|
59
|
+
|
|
60
|
+
note(`novahiz home: ${home}`);
|
|
61
|
+
note(`opencode config: ${configDir}`);
|
|
62
|
+
|
|
63
|
+
if (!nodeVersionOk()) {
|
|
64
|
+
const message = `Node ${process.versions.node} is too old. Node 22.18 or later is required.`;
|
|
65
|
+
if (dryRun) {
|
|
66
|
+
// postinstall (`npm install novahiz`) runs this in dry-run mode and must
|
|
67
|
+
// never fail the parent install on an older Node — warn and keep going.
|
|
68
|
+
note(`WARNING: ${message} The novahiz CLI will not run until Node is upgraded.`);
|
|
69
|
+
} else {
|
|
70
|
+
process.stderr.write(`${message}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const harnessDirs = { opencode: configDir };
|
|
76
|
+
const harnessPackages = { opencode: "opencode-ai" };
|
|
77
|
+
const detected = detectedHarnesses(harnessDirs);
|
|
78
|
+
|
|
79
|
+
const yes = Boolean(flags.yes) || Boolean(flags["yes"]);
|
|
80
|
+
const interactive = !yes && !dryRun && (Boolean(flags.interactive) || process.stdin.isTTY === true);
|
|
81
|
+
let providersChoice = null;
|
|
82
|
+
|
|
83
|
+
if (interactive) {
|
|
84
|
+
const prompt = createPrompt();
|
|
85
|
+
const providers = readJson(join(root, "catalog", "providers.json"), []);
|
|
86
|
+
process.stdout.write("\nnovahiz setup\n");
|
|
87
|
+
process.stdout.write(` Home: ${home}\n`);
|
|
88
|
+
process.stdout.write(` opencode config: ${configDir}\n`);
|
|
89
|
+
process.stdout.write(` Skills: ${skillsDir}\n`);
|
|
90
|
+
process.stdout.write(` Plugin: ${join(pluginsDir, "novahiz-plugin.ts")}\n`);
|
|
91
|
+
process.stdout.write(` Agent: ${join(configDir, "agent", "novahiz.md")}\n`);
|
|
92
|
+
process.stdout.write("\nProviders (optional, installed on your machine, never copied into the repo):\n");
|
|
93
|
+
for (const provider of providers) {
|
|
94
|
+
const source = provider.source ? ` ${provider.source}` : "";
|
|
95
|
+
process.stdout.write(` [${provider.kind}] ${provider.id} - ${provider.purpose ?? ""}${source}\n`);
|
|
96
|
+
}
|
|
97
|
+
process.stdout.write("\n");
|
|
98
|
+
const proceed = await prompt.confirm("Install the Novahiz core (skills, plugin, agent)?", true);
|
|
99
|
+
if (!proceed) {
|
|
100
|
+
prompt.close();
|
|
101
|
+
process.stdout.write("Aborted. Nothing was written.\n");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
providersChoice = await prompt.confirm("Install provider packages and their prerequisites now?", false);
|
|
105
|
+
prompt.close();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Harness resolution: --harness flag > detection (opencode is the only
|
|
109
|
+
// supported harness; --yes and non-interactive runs take the detection,
|
|
110
|
+
// falling back to opencode so a fresh machine still gets a baseline).
|
|
111
|
+
const flagHarnesses =
|
|
112
|
+
typeof flags.harness === "string"
|
|
113
|
+
? flags.harness
|
|
114
|
+
.split(",")
|
|
115
|
+
.map((value) => value.trim().toLowerCase())
|
|
116
|
+
.filter((value) => value === "opencode")
|
|
117
|
+
: [];
|
|
118
|
+
const configured = [
|
|
119
|
+
...new Set(
|
|
120
|
+
flagHarnesses.length > 0
|
|
121
|
+
? flagHarnesses
|
|
122
|
+
: detected.length > 0
|
|
123
|
+
? detected
|
|
124
|
+
: ["opencode"]
|
|
125
|
+
)
|
|
126
|
+
];
|
|
127
|
+
note(`Harnesses to configure: ${configured.length > 0 ? configured.join(", ") : "(none)"}`);
|
|
128
|
+
|
|
129
|
+
// Auto-install the CLI of every selected harness that is entirely absent
|
|
130
|
+
// (no config dir and no binary). A harness already on the machine - CLI or
|
|
131
|
+
// desktop config dir - is left alone, and a harness that was not selected
|
|
132
|
+
// is never installed nor configured. Failures stay non-blocking, like the
|
|
133
|
+
// historical opencode behaviour.
|
|
134
|
+
for (const name of configured) {
|
|
135
|
+
const hasCli = which(name);
|
|
136
|
+
const hasDir = existsSync(harnessDirs[name]);
|
|
137
|
+
if (hasCli) {
|
|
138
|
+
note(`${name} detected.`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (hasDir) {
|
|
142
|
+
note(`${name} config present in ${harnessDirs[name]}; ${name} CLI not on PATH (optional).`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const pkg = harnessPackages[name];
|
|
146
|
+
if (dryRun) {
|
|
147
|
+
note(`Would install ${name} globally (npm install -g ${pkg}).`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
note(`${name} not detected. Global installation...`);
|
|
151
|
+
const installResult = spawnHost("npm", ["install", "-g", pkg], { stdio: "inherit" });
|
|
152
|
+
if (installResult.status !== 0) {
|
|
153
|
+
note(`WARNING: Failed to install ${name} automatically (non-blocking). Run: npm install -g ${pkg}`);
|
|
154
|
+
} else {
|
|
155
|
+
note(`${name} installed successfully.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!dryRun) {
|
|
160
|
+
for (const name of configured) {
|
|
161
|
+
if (!existsSync(harnessDirs[name])) mkdirSync(harnessDirs[name], { recursive: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const created = [];
|
|
166
|
+
const backups = [];
|
|
167
|
+
let coreCopied = false;
|
|
168
|
+
let configCreated = false;
|
|
169
|
+
|
|
170
|
+
const sameRoot = resolve(root) === resolve(home);
|
|
171
|
+
if (!sameRoot) {
|
|
172
|
+
note(`Copying core to ${home}`);
|
|
173
|
+
if (!dryRun) mkdirSync(home, { recursive: true });
|
|
174
|
+
for (const item of CORE_ITEMS) {
|
|
175
|
+
const source = join(root, item);
|
|
176
|
+
if (!existsSync(source)) continue;
|
|
177
|
+
if (dryRun) {
|
|
178
|
+
note(` + ${item}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const target = join(home, item);
|
|
182
|
+
if (statSync(source).isDirectory()) {
|
|
183
|
+
const result = copyInto(source, target, true);
|
|
184
|
+
created.push(...result.created);
|
|
185
|
+
backups.push(...result.backups);
|
|
186
|
+
} else {
|
|
187
|
+
const result = copyFileWithBackup(source, target, true);
|
|
188
|
+
if (result.created) created.push(result.created);
|
|
189
|
+
if (result.backup) backups.push(result.backup);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
coreCopied = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (withSkills && !configured.includes("opencode")) {
|
|
196
|
+
note("Skipping opencode skills copy (opencode not selected).");
|
|
197
|
+
}
|
|
198
|
+
if (withSkills && configured.includes("opencode")) {
|
|
199
|
+
const skillsSource = existsSync(join(home, "skills")) ? join(home, "skills") : join(root, "skills");
|
|
200
|
+
if (existsSync(skillsSource)) {
|
|
201
|
+
// Dedup only against roots the catalog actually scans (see
|
|
202
|
+
// skillRoots): ~/.agents/skills is the only external root.
|
|
203
|
+
const externalRoots = [join(homedir(), ".agents", "skills")];
|
|
204
|
+
const alreadyInstalled = skillNamesIn(externalRoots);
|
|
205
|
+
// renamed: the outer `force` (--force, config overwrite) lives in the
|
|
206
|
+
// same function scope — shadowing it here was correct but unreadable.
|
|
207
|
+
const forceSkills = Boolean(flags["force-skills"]);
|
|
208
|
+
const entries = readdirSync(skillsSource, { withFileTypes: true })
|
|
209
|
+
.filter((entry) => entry.isDirectory())
|
|
210
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
211
|
+
const skipped = forceSkills
|
|
212
|
+
? []
|
|
213
|
+
: entries.filter((entry) => alreadyInstalled.has(entry.name)).map((entry) => entry.name);
|
|
214
|
+
const toCopy = entries.filter((entry) => !skipped.includes(entry.name));
|
|
215
|
+
note(`Installing skills in ${skillsDir} (${toCopy.length} to copy, ${skipped.length} already present elsewhere)`);
|
|
216
|
+
if (!dryRun) {
|
|
217
|
+
let total = 0;
|
|
218
|
+
let added = 0;
|
|
219
|
+
let saved = 0;
|
|
220
|
+
for (const entry of toCopy) {
|
|
221
|
+
const result = copyInto(join(skillsSource, entry.name), join(skillsDir, entry.name), true);
|
|
222
|
+
created.push(...result.created);
|
|
223
|
+
backups.push(...result.backups);
|
|
224
|
+
total += result.total;
|
|
225
|
+
added += result.created.length;
|
|
226
|
+
saved += result.backups.length;
|
|
227
|
+
}
|
|
228
|
+
note(` ${total} files, ${added} new, ${saved} backups`);
|
|
229
|
+
}
|
|
230
|
+
if (skipped.length > 0) {
|
|
231
|
+
note(` already present in another root, not copied: ${skipped.join(", ")}`);
|
|
232
|
+
note(" Restart with --force-skills to copy them anyway.");
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
note(`No skills folder found at ${skillsSource}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pluginSource = join(home, "adapters", "opencode", "novahiz-plugin.ts");
|
|
240
|
+
const pluginTarget = join(pluginsDir, "novahiz-plugin.ts");
|
|
241
|
+
if (configured.includes("opencode") && existsSync(pluginSource)) {
|
|
242
|
+
note(`Installing opencode plugin in ${pluginTarget}`);
|
|
243
|
+
if (!dryRun) {
|
|
244
|
+
const result = copyFileWithBackup(pluginSource, pluginTarget, true);
|
|
245
|
+
if (result.created) created.push(result.created);
|
|
246
|
+
if (result.backup) backups.push(result.backup);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const agentSource = existsSync(join(home, "adapters", "opencode", "agent", "novahiz.md"))
|
|
251
|
+
? join(home, "adapters", "opencode", "agent", "novahiz.md")
|
|
252
|
+
: join(root, "adapters", "opencode", "agent", "novahiz.md");
|
|
253
|
+
const agentTarget = join(configDir, "agent", "novahiz.md");
|
|
254
|
+
if (configured.includes("opencode") && existsSync(agentSource)) {
|
|
255
|
+
note(`Installing Novahiz agent in ${agentTarget}`);
|
|
256
|
+
if (!dryRun) {
|
|
257
|
+
const result = copyFileWithBackup(agentSource, agentTarget, true);
|
|
258
|
+
if (result.created) created.push(result.created);
|
|
259
|
+
if (result.backup) backups.push(result.backup);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const commandsSource = existsSync(join(home, "adapters", "opencode", "commands"))
|
|
264
|
+
? join(home, "adapters", "opencode", "commands")
|
|
265
|
+
: join(root, "adapters", "opencode", "commands");
|
|
266
|
+
const commandsTarget = join(configDir, "commands");
|
|
267
|
+
if (configured.includes("opencode") && existsSync(commandsSource)) {
|
|
268
|
+
note(`Installing Novahiz commands in ${commandsTarget}`);
|
|
269
|
+
if (!dryRun) {
|
|
270
|
+
const result = copyInto(commandsSource, commandsTarget, true);
|
|
271
|
+
created.push(...result.created);
|
|
272
|
+
backups.push(...result.backups);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const configPath = join(home, "novahiz.config.json");
|
|
277
|
+
if (force || !existsSync(configPath)) {
|
|
278
|
+
note(`Writing ${configPath}`);
|
|
279
|
+
if (!dryRun) {
|
|
280
|
+
const existedBefore = existsSync(configPath);
|
|
281
|
+
if (existedBefore) {
|
|
282
|
+
const backup = `${configPath}.novahiz-bak`;
|
|
283
|
+
if (!existsSync(backup)) cpSync(configPath, backup);
|
|
284
|
+
backups.push({ path: configPath, backup });
|
|
285
|
+
}
|
|
286
|
+
writeJson(configPath, defaultConfig());
|
|
287
|
+
configCreated = !existedBefore;
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
note(`Existing config preserved: ${configPath}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!dryRun) {
|
|
294
|
+
const previous = loadManifest(home);
|
|
295
|
+
const pkgVersion = readJson(join(root, "package.json"), {}).version ?? "0.0.0";
|
|
296
|
+
saveManifest(home, {
|
|
297
|
+
version: pkgVersion,
|
|
298
|
+
installedAt: new Date().toISOString(),
|
|
299
|
+
harness: "opencode",
|
|
300
|
+
harnesses: [...configured],
|
|
301
|
+
configDir,
|
|
302
|
+
home,
|
|
303
|
+
coreCopied: previous.coreCopied || coreCopied,
|
|
304
|
+
configCreated: previous.configCreated || configCreated,
|
|
305
|
+
created: mergeCreated(previous.created, created),
|
|
306
|
+
backups: mergeBackups(previous.backups, backups)
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!dryRun) {
|
|
311
|
+
const cli = join(home, "src", "cli.ts");
|
|
312
|
+
if (existsSync(cli)) {
|
|
313
|
+
note("Building catalog (sync)");
|
|
314
|
+
const result = spawnSync(process.execPath, [cli, "sync"], {
|
|
315
|
+
encoding: "utf8",
|
|
316
|
+
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
317
|
+
});
|
|
318
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
319
|
+
if (result.status !== 0 && result.stderr) process.stderr.write(result.stderr);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!dryRun) {
|
|
324
|
+
const cli = join(home, "src", "cli.ts");
|
|
325
|
+
const config = readJson(join(home, "novahiz.config.json"), {});
|
|
326
|
+
const autoInstall =
|
|
327
|
+
providersChoice !== null
|
|
328
|
+
? providersChoice
|
|
329
|
+
: Boolean(flags["install-providers"]) || config?.providers?.autoInstall === true;
|
|
330
|
+
note("Checking dependencies");
|
|
331
|
+
const check = spawnSync(process.execPath, [cli, "deps"], {
|
|
332
|
+
encoding: "utf8",
|
|
333
|
+
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
334
|
+
});
|
|
335
|
+
if (check.stdout) process.stdout.write(check.stdout);
|
|
336
|
+
if (autoInstall) {
|
|
337
|
+
note("Installing dependencies and providers (MCP, skills, commands)");
|
|
338
|
+
const result = spawnSync(process.execPath, [cli, "deps", "--install", "--yes"], {
|
|
339
|
+
encoding: "utf8",
|
|
340
|
+
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
341
|
+
});
|
|
342
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
343
|
+
if (result.status !== 0 && result.stderr) process.stderr.write(result.stderr);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Install MCP servers globally.
|
|
348
|
+
// 2026-10-05: security-mcp was removed on purpose and the mcp-cron shim
|
|
349
|
+
// before it — reinstating either would resurrect a removed server against
|
|
350
|
+
// the user's explicit decision (never reinstall, ever). The purge below
|
|
351
|
+
// enforces the removal on every run, and providers.disabled in
|
|
352
|
+
// novahiz.config.json keeps security out of the registry. The only entry
|
|
353
|
+
// here is Playwright MCP: installing it up front means no npx download on
|
|
354
|
+
// first use (latency, offline failure), and the version pin comes from
|
|
355
|
+
// catalog/providers.json so the doctor R6 drift check stays aligned.
|
|
356
|
+
const playwrightPkg = (() => {
|
|
357
|
+
try {
|
|
358
|
+
const providers = readJson(join(root, "catalog", "providers.json"), []);
|
|
359
|
+
const entry = providers.find((provider) => provider.id === "playwright");
|
|
360
|
+
const command = Array.isArray(entry?.command) ? entry.command : [];
|
|
361
|
+
const pin = command.find((token) => typeof token === "string" && token.includes("@playwright/mcp"));
|
|
362
|
+
return typeof pin === "string" ? pin : "@playwright/mcp@0.0.82";
|
|
363
|
+
} catch {
|
|
364
|
+
return "@playwright/mcp@0.0.82";
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
const mcpServers = [
|
|
368
|
+
{ pkg: playwrightPkg, bin: "playwright-mcp", name: "playwright" },
|
|
369
|
+
];
|
|
370
|
+
// `novahiz-docs` is a local file run through `node` (no package, no shim:
|
|
371
|
+
// the config points straight at <home>/mcp/novahiz-docs/index.mjs).
|
|
372
|
+
// `lodestone` is a local file run through `node` too (house clean-room
|
|
373
|
+
// server, zero npm dependency): no package, no shim.
|
|
374
|
+
// `clepsydre` is a local file run through `node` (house scheduler, zero npm
|
|
375
|
+
// dependency): no package, no shim.
|
|
376
|
+
|
|
377
|
+
if (!dryRun) {
|
|
378
|
+
note("\nMCP servers: playwright installed globally; removed servers stay removed.");
|
|
379
|
+
for (const server of mcpServers) {
|
|
380
|
+
const check = spawnSync(process.platform === "win32" ? "where" : "which", [server.bin], {
|
|
381
|
+
encoding: "utf8",
|
|
382
|
+
stdio: "pipe"
|
|
383
|
+
});
|
|
384
|
+
if (check.status !== 0) {
|
|
385
|
+
note(` Installing ${server.pkg}...`);
|
|
386
|
+
const result = spawnHost("npm", ["install", "-g", server.pkg], {
|
|
387
|
+
stdio: "inherit"
|
|
388
|
+
});
|
|
389
|
+
if (result.status !== 0) {
|
|
390
|
+
note(` WARNING: Failed to install ${server.pkg} (non-blocking)`);
|
|
391
|
+
} else {
|
|
392
|
+
note(` ${server.pkg} installed`);
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
note(` ${server.name} already installed`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Removed on purpose: purge any trace of the removed servers on every run
|
|
400
|
+
// so a stray reinstall cannot survive (security-mcp, former mcp-cron).
|
|
401
|
+
for (const pkg of ["security-mcp", "mcp-cron"]) {
|
|
402
|
+
const probe = spawnSync(process.platform === "win32" ? "where" : "which", [pkg], {
|
|
403
|
+
encoding: "utf8",
|
|
404
|
+
stdio: "pipe"
|
|
405
|
+
});
|
|
406
|
+
if (probe.status === 0) {
|
|
407
|
+
note(` ${pkg} found but removed on purpose — uninstalling (never reinstall)...`);
|
|
408
|
+
const removal = spawnHost("npm", ["uninstall", "-g", pkg], { stdio: "inherit" });
|
|
409
|
+
note(removal.status === 0 ? ` ${pkg} uninstalled` : ` WARNING: could not uninstall ${pkg} — run: npm uninstall -g ${pkg}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Official Dart/Flutter MCP ships with the Dart SDK (dart mcp-server), not npm.
|
|
414
|
+
const dartCheck = spawnSync(process.platform === "win32" ? "where" : "which", ["dart"], {
|
|
415
|
+
encoding: "utf8",
|
|
416
|
+
stdio: "pipe"
|
|
417
|
+
});
|
|
418
|
+
if (dartCheck.status === 0) {
|
|
419
|
+
note(" dart (mcp-server) available via Dart SDK");
|
|
420
|
+
} else {
|
|
421
|
+
note(" WARNING: Dart SDK not on PATH — MCP `dart` will not start until `dart` is installed");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Optional official Flutter/Dart skill packs (upstream install, never vendored).
|
|
426
|
+
const skillPacks = [
|
|
427
|
+
{ id: "flutter-skills", repo: "flutter/agent-plugins" },
|
|
428
|
+
{ id: "dart-skills", repo: "dart-lang/skills" },
|
|
429
|
+
];
|
|
430
|
+
if (!dryRun && flags["flutter-skills"]) {
|
|
431
|
+
note("\nInstalling official Flutter/Dart skill packs...");
|
|
432
|
+
for (const pack of skillPacks) {
|
|
433
|
+
note(` ${pack.repo}...`);
|
|
434
|
+
const result = spawnHost(
|
|
435
|
+
"npx",
|
|
436
|
+
["-y", "skills", "add", pack.repo, "--skill", "*", "-g", "-a", "opencode", "-y"],
|
|
437
|
+
{ stdio: "inherit" }
|
|
438
|
+
);
|
|
439
|
+
if (result.status !== 0) {
|
|
440
|
+
note(` WARNING: Failed to install ${pack.repo} (non-blocking)`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} else if (!dryRun) {
|
|
444
|
+
note("\nSkipping Flutter/Dart skill packs (pass --flutter-skills to install from official repos).");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Install opencode plugins globally.
|
|
448
|
+
// 2026-09-26: intentionally empty — @mohak34/opencode-notifier and
|
|
449
|
+
// @tarquinen/opencode-dcp were uninstalled on purpose; reinstating them
|
|
450
|
+
// would resurrect a removed compression/notify stack against the user's
|
|
451
|
+
// decision. Keep this list empty unless a new plugin is explicitly wanted.
|
|
452
|
+
const plugins = [];
|
|
453
|
+
|
|
454
|
+
if (!dryRun && plugins.length > 0) {
|
|
455
|
+
note("\nInstalling opencode plugins...");
|
|
456
|
+
for (const plugin of plugins) {
|
|
457
|
+
note(` Installing ${plugin}...`);
|
|
458
|
+
const result = spawnHost("npm", ["install", "-g", plugin], {
|
|
459
|
+
stdio: "inherit"
|
|
460
|
+
});
|
|
461
|
+
if (result.status !== 0) {
|
|
462
|
+
note(` WARNING: Failed to install ${plugin} (non-blocking)`);
|
|
463
|
+
} else {
|
|
464
|
+
note(` ${plugin} installed`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Generate opencode.jsonc
|
|
470
|
+
if (!configured.includes("opencode")) {
|
|
471
|
+
note("Skipping opencode.jsonc (opencode not selected).");
|
|
472
|
+
}
|
|
473
|
+
if (!dryRun && configured.includes("opencode")) {
|
|
474
|
+
const configPath = join(configDir, "opencode.jsonc");
|
|
475
|
+
if (!existsSync(configPath)) {
|
|
476
|
+
note(`\nCreating ${configPath}`);
|
|
477
|
+
const agentsSkillsDir = join(homedir(), ".agents", "skills");
|
|
478
|
+
const agentsSkillsDirLegacy = join(homedir(), ".config", ".agents", "skills");
|
|
479
|
+
|
|
480
|
+
const openCodeConfig = {
|
|
481
|
+
"$schema": "https://opencode.ai/config.json",
|
|
482
|
+
"mcp": {
|
|
483
|
+
"novahiz-docs": {
|
|
484
|
+
"type": "local",
|
|
485
|
+
"command": ["node", join(home, "mcp", "novahiz-docs", "index.mjs")],
|
|
486
|
+
"enabled": true
|
|
487
|
+
},
|
|
488
|
+
"novahiz-search": {
|
|
489
|
+
"type": "local",
|
|
490
|
+
"command": ["node", join(home, "mcp", "lodestone", "index.mjs")],
|
|
491
|
+
"timeout": 120000,
|
|
492
|
+
"enabled": true
|
|
493
|
+
},
|
|
494
|
+
// `novahiz-scheduler` is the local scheduler (mcp/clepsydre, zero npm
|
|
495
|
+
// dependency): it ships ready to run, no setup step.
|
|
496
|
+
"novahiz-scheduler": {
|
|
497
|
+
"type": "local",
|
|
498
|
+
"command": ["node", join(home, "mcp", "clepsydre", "index.mjs")],
|
|
499
|
+
"timeout": 120000,
|
|
500
|
+
"enabled": true
|
|
501
|
+
},
|
|
502
|
+
"playwright": {
|
|
503
|
+
"type": "local",
|
|
504
|
+
// See playwrightBrowserFlag() in lib.mjs for the platform rule.
|
|
505
|
+
"command": ["npx", "-y", "@playwright/mcp@0.0.82", playwrightBrowserFlag()],
|
|
506
|
+
"enabled": true
|
|
507
|
+
},
|
|
508
|
+
"dart": {
|
|
509
|
+
"type": "local",
|
|
510
|
+
"command": ["dart", "mcp-server"],
|
|
511
|
+
"enabled": true
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
"skills": {
|
|
515
|
+
"paths": [skillsDir]
|
|
516
|
+
},
|
|
517
|
+
"plugin": [
|
|
518
|
+
join(home, "adapters", "opencode", "novahiz-plugin.ts")
|
|
519
|
+
],
|
|
520
|
+
"compaction": {
|
|
521
|
+
"auto": true,
|
|
522
|
+
"prune": true,
|
|
523
|
+
"reserved": 10000
|
|
524
|
+
},
|
|
525
|
+
// PowerShell 7 (pwsh) is NOT bundled with Windows — Windows ships
|
|
526
|
+
// Windows PowerShell 5.1 (powershell.exe). Writing a hard "pwsh" gave
|
|
527
|
+
// every machine without PS7 an opencode config whose shell does not
|
|
528
|
+
// exist; probe and fall back to the one that always does. bash is
|
|
529
|
+
// universal on macOS/Linux.
|
|
530
|
+
"shell": process.platform === "win32" ? (which("pwsh") ? "pwsh" : "powershell") : "bash"
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// Official skills land in ~/.agents/skills (skills CLI) — load them too.
|
|
534
|
+
for (const dir of [agentsSkillsDir, agentsSkillsDirLegacy]) {
|
|
535
|
+
if (existsSync(dir) && !openCodeConfig.skills.paths.includes(dir)) {
|
|
536
|
+
openCodeConfig.skills.paths.push(dir);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
writeFileSync(configPath, JSON.stringify(openCodeConfig, null, 2) + "\n", "utf8");
|
|
541
|
+
note(` ${configPath} created`);
|
|
542
|
+
} else {
|
|
543
|
+
note(`\n${join(configDir, "opencode.jsonc")} already exists, not overwritten`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!dryRun) {
|
|
548
|
+
process.stdout.write(`\nNovahiz installed in ${home}.\n`);
|
|
549
|
+
if (configured.length > 0) {
|
|
550
|
+
process.stdout.write(`Restart opencode to activate the plugin and MCP server.\n`);
|
|
551
|
+
}
|
|
552
|
+
process.stdout.write("Gate can be disabled with the NOVAHIZ_GATE=off environment variable.\n");
|
|
553
|
+
|
|
554
|
+
// Auto-update dependencies
|
|
555
|
+
note("Checking for dependency updates...");
|
|
556
|
+
const pkgPath = join(home, "package.json");
|
|
557
|
+
if (existsSync(pkgPath)) {
|
|
558
|
+
const npmCheck = spawnHost("npm", ["outdated", "--json"], {
|
|
559
|
+
cwd: home,
|
|
560
|
+
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
561
|
+
});
|
|
562
|
+
if (npmCheck.stdout && npmCheck.stdout.trim().length > 2) {
|
|
563
|
+
note("Updates available, installing...");
|
|
564
|
+
const npmUpdate = spawnHost("npm", ["update"], {
|
|
565
|
+
cwd: home,
|
|
566
|
+
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
567
|
+
});
|
|
568
|
+
if (npmUpdate.stdout) process.stdout.write(npmUpdate.stdout);
|
|
569
|
+
if (npmUpdate.status !== 0 && npmUpdate.stderr) process.stderr.write(npmUpdate.stderr);
|
|
570
|
+
note("Dependencies updated.");
|
|
571
|
+
} else {
|
|
572
|
+
note("Dependencies up to date.");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
process.stdout.write("\nDry-run complete, no changes written.\n");
|
|
577
|
+
process.stdout.write(
|
|
578
|
+
"Next step: run `novahiz-install` (or `novahiz setup`) once to install everything — core skills, plugin, agent, provider skill packs (impeccable, flutter, dart, expo), and MCP servers. Then restart opencode.\n"
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
main().catch((error) => {
|
|
584
|
+
process.stderr.write(`${String(error)}\n`);
|
|
585
|
+
// postinstall (`npm install novahiz`) runs --dry-run: an unexpected crash
|
|
586
|
+
// must not fail the parent install.
|
|
587
|
+
if (parseArgs(process.argv.slice(2))["dry-run"]) {
|
|
588
|
+
process.stderr.write("Dry-run failed (non-blocking; the npm install continues).\n");
|
|
589
|
+
process.exit(0);
|
|
590
|
+
}
|
|
591
|
+
process.exit(1);
|
|
592
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "novahiz",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Enforce AI agent skills before every file edit: prompt classifier, skill catalog, and pre-edit gate.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"ci:local": "node scripts/ci-local.mjs",
|
|
53
53
|
"typecheck": "tsc --noEmit",
|
|
54
54
|
"prepare": "tsc --noEmit && tsc -p tsconfig.build.json",
|
|
55
|
-
"postinstall": "node install/install.mjs --
|
|
55
|
+
"postinstall": "node install/install.mjs --yes || exit 0"
|
|
56
56
|
},
|
|
57
57
|
"files": [
|
|
58
58
|
"src",
|