create-byan-agent 2.53.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/install/bin/create-byan-agent-v2.js +93 -1
- package/install/lib/global-skills-sync.js +118 -0
- package/install/lib/home-credentials.js +91 -0
- package/install/lib/install-engine.js +297 -0
- package/install/lib/yanstaller/updater.js +52 -3
- package/install/src/webui/api.js +70 -48
- package/install/src/webui/public/app.js +15 -1
- package/install/src/webui/server.js +6 -1
- package/install/templates/.claude/hooks/lib/plain-language.js +19 -0
- package/install/templates/.claude/hooks/lib/skill-freshness.js +83 -0
- package/install/templates/.claude/hooks/skill-freshness-check.js +48 -0
- package/install/templates/.claude/settings.json +4 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +8 -2
- package/install/templates/docs/intelligent-dispatch.md +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.56.0] - 2026-07-21
|
|
13
|
+
|
|
14
|
+
### Changed — installation refondue : un moteur, deux visages
|
|
15
|
+
- Nouveau moteur d'installation `install/lib/install-engine.js` : options ->
|
|
16
|
+
actions reelles (detection, copie `_byan` + `.claude`, configurations natives
|
|
17
|
+
Claude/Codex, dependances MCP, controle des copies globales de skills,
|
|
18
|
+
verification finale). La progression est honnete par construction : un
|
|
19
|
+
evenement d'etape = une action reellement executee. Defauts sans question :
|
|
20
|
+
Claude + Codex quand detectes, tous les agents, soul createur, rtk installe
|
|
21
|
+
s'il manque, configuration memorisee reutilisee. Nom du projet et repertoire
|
|
22
|
+
cible en options. Teste sur fixtures disque (7 cas).
|
|
23
|
+
- `npx create-byan-agent` = installation automatique directe (plus d'interview).
|
|
24
|
+
Options : `--name`, `--dir`, `--no-launch`, `--no-rtk` ; l'ancienne interview
|
|
25
|
+
reste accessible via `--legacy`. En fin d'installation, Claude Code est lance
|
|
26
|
+
(canal byan-channel quand la version du CLI le supporte, sinon `claude`
|
|
27
|
+
normal ; commande affichee en terminal non interactif).
|
|
28
|
+
- WebUI d'installation branchee sur le MEME moteur : les boutons installer et
|
|
29
|
+
mettre a jour executaient une simulation (etapes diffusees autour de pauses,
|
|
30
|
+
echec de l'updater avale puis succes affiche) — ils executent desormais le
|
|
31
|
+
moteur et l'updater reels, la progression WebSocket relaie chaque etape
|
|
32
|
+
executee, et l'ecran d'options porte nom du projet + repertoire. 3 tests par
|
|
33
|
+
injection d'un moteur factice.
|
|
34
|
+
- Memoire d'installation dans le home : `install/lib/home-credentials.js`
|
|
35
|
+
(`~/.byan/credentials.json`, memes cles que le resolveur du serveur MCP,
|
|
36
|
+
fichier en mode 600, fusion sans effacement de secret). L'installation
|
|
37
|
+
reutilise ce qui est memorise et n'exige plus de re-saisie machine par
|
|
38
|
+
machine.
|
|
39
|
+
- Le client Leantime du serveur MCP resout sa config via `resolve-config`
|
|
40
|
+
(env -> `~/.byan/credentials.json`) au lieu de lire seulement
|
|
41
|
+
l'environnement de lancement — le fichier memorise l'atteint enfin. Cadrage
|
|
42
|
+
Google documente (cle de service account memorisee ; OAuth interactif hors
|
|
43
|
+
perimetre serveur).
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## [2.55.0] - 2026-07-21
|
|
47
|
+
|
|
48
|
+
### Added — l'installation propose la synchro des copies globales divergentes
|
|
49
|
+
- Constate sur le terrain : l'installation pose des skills projet a jour, mais
|
|
50
|
+
une copie manuelle homonyme sous `~/.claude/skills/` (plus ancienne) peut
|
|
51
|
+
rester celle que la commande `/<skill>` charge — les nouveautes fraichement
|
|
52
|
+
installees restent invisibles. La garde de fraicheur (2.54.0) le signale a la
|
|
53
|
+
session suivante ; l'installeur, lui, voit la divergence au moment ou il la
|
|
54
|
+
cree et a un terminal pour poser la question.
|
|
55
|
+
- Nouveau module `install/lib/global-skills-sync.js` : en fin d'installation
|
|
56
|
+
Claude, compare les skills fraichement poses avec les copies globales
|
|
57
|
+
homonymes (reutilise `compareSkills` du payload `.claude` livre par le meme
|
|
58
|
+
paquet — zero duplication de la comparaison) et, en terminal interactif,
|
|
59
|
+
propose la synchronisation projet -> global avec consentement explicite.
|
|
60
|
+
Refus ou terminal non interactif : signalement clair + la commande exacte a
|
|
61
|
+
copier ; rien n'est ecrit dans le home sans accord recueilli pendant ce run.
|
|
62
|
+
- Le chemin `update` (corrige en 2.54.1) recoit le meme controle en mode
|
|
63
|
+
signalement : le resultat porte la liste des skills globaux divergents et la
|
|
64
|
+
commande de synchro est affichee.
|
|
65
|
+
- Tests jest : 6 cas (divergence detectee via le comparateur livre, oui ->
|
|
66
|
+
copie effective, non -> rien ecrit, non-interactif -> rien ecrit + commande,
|
|
67
|
+
pas de dossier global -> silencieux, copies identiques -> silencieux).
|
|
68
|
+
|
|
69
|
+
## [2.54.1] - 2026-07-21
|
|
70
|
+
|
|
71
|
+
### Fixed — la commande update ne deployait pas .claude/
|
|
72
|
+
- Cause racine constatee sur le terrain et verifiee dans le code (updater.js
|
|
73
|
+
lignes 30-31, manifest.js : seule la racine `_byan/` etait comparee et
|
|
74
|
+
copiee). Tout ce qui vit sous `.claude/` — les skills (dont le rail
|
|
75
|
+
auto-dispatch 2.53.0), les workflows natifs, les hooks (dont la garde de
|
|
76
|
+
fraicheur 2.54.0), settings.json et le `.mcp.json` regenere par le setup
|
|
77
|
+
natif — restait hors du perimetre de `update`. Un projet pouvait donc etre
|
|
78
|
+
"a jour" avec un `.claude/` vieux de plusieurs mois : rail absent, MCP
|
|
79
|
+
absent, garde absente.
|
|
80
|
+
- Correctif : `update()` rafraichit aussi `.claude/` comme le fait
|
|
81
|
+
l'installation complete — sauvegarde dediee `.claude.backup-<ts>` (prefixe
|
|
82
|
+
distinct de `_byan.backup-` pour ne pas polluer la purge/rollback), copie
|
|
83
|
+
`templates/.claude` -> projet avec ecrasement, puis `setupClaudeNative`
|
|
84
|
+
(regenere `.mcp.json`, installe les dependances du serveur MCP, cable les
|
|
85
|
+
githooks). En cas d'echec, le `.claude/` d'origine est restaure.
|
|
86
|
+
- Testabilite : `options.templateDir` et `options.nativeSetup` injectables ;
|
|
87
|
+
nouveau test fonctionnel `install/__tests__/yanstaller/updater.test.js`
|
|
88
|
+
(4 cas : deploiement du rail, projet sans `.claude`, rollback, deja a jour).
|
|
89
|
+
- La sortie de la commande affiche desormais le rafraichissement `.claude` et
|
|
90
|
+
le chemin de sa sauvegarde.
|
|
91
|
+
|
|
92
|
+
## [2.54.0] - 2026-07-21
|
|
93
|
+
|
|
94
|
+
### Added — Garde de fraicheur des skills (SessionStart)
|
|
95
|
+
- Nouveau hook `skill-freshness-check.js` (coeur pur `lib/skill-freshness.js`) :
|
|
96
|
+
au demarrage de session, compare chaque `.claude/skills/<n>/SKILL.md` du
|
|
97
|
+
projet avec la copie globale homonyme `~/.claude/skills/<n>/SKILL.md`, par
|
|
98
|
+
CONTENU (l'egalite d'octets dit "fidele" ; une date recente ne dit rien).
|
|
99
|
+
En cas de divergence, injecte un signalement borne (6 noms max) avec la
|
|
100
|
+
commande de synchro exacte. Silencieux quand tout est fidele, quand la copie
|
|
101
|
+
globale n'existe pas, ou quand le projet n'a pas de skills ; sort en 0 dans
|
|
102
|
+
tous les chemins (ne bloque pas une session).
|
|
103
|
+
- Ferme le piege constate le 2026-07-21 : une copie globale du 30 juin masquait
|
|
104
|
+
le skill projet — le rail auto-dispatch livre en 2.53.0 ne se declenchait pas
|
|
105
|
+
car `/byan-byan` chargeait la copie perimee. Point verifie aupres de la doc
|
|
106
|
+
officielle Claude Code : la regle de priorite skills user vs projet en cas de
|
|
107
|
+
collision de nom n'y est pas clairement documentee (le hook signale donc le
|
|
108
|
+
FAIT de la divergence, sans affirmer une regle de chargement).
|
|
109
|
+
- Aucune ecriture automatique dans `~/.claude/skills` : une variante user-level
|
|
110
|
+
peut etre deliberee — le hook signale, l'humain tranche.
|
|
111
|
+
- Cablage : enregistre sous SessionStart dans `.claude/settings.json` (repo +
|
|
112
|
+
template) ; les deux fichiers shippent via le miroir `template-sync.js`.
|
|
113
|
+
|
|
12
114
|
## [2.53.0] - 2026-07-21
|
|
13
115
|
|
|
14
116
|
### Added — Rail automatique : workflow natif byan-auto-dispatch (F2)
|
|
@@ -1320,6 +1320,20 @@ async function install(options = {}) {
|
|
|
1320
1320
|
)
|
|
1321
1321
|
);
|
|
1322
1322
|
}
|
|
1323
|
+
|
|
1324
|
+
// Stale global copies check : a same-named ~/.claude/skills/<n> that
|
|
1325
|
+
// diverges from what was JUST installed can be the copy the slash command
|
|
1326
|
+
// loads — the fresh features would stay invisible. Ask (TTY) or notice
|
|
1327
|
+
// (non-TTY) ; no silent write into the user's home, ever.
|
|
1328
|
+
try {
|
|
1329
|
+
const { offerGlobalSkillsSync } = require('../lib/global-skills-sync');
|
|
1330
|
+
const ask = process.stdout.isTTY
|
|
1331
|
+
? async (message) => (await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: true }])).ok
|
|
1332
|
+
: null;
|
|
1333
|
+
await offerGlobalSkillsSync(projectRoot, templateDir, { ask, log: (l) => console.log(chalk.yellow(l)) });
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
console.log(chalk.gray(` [INFO] Global skills check skipped: ${error.message}`));
|
|
1336
|
+
}
|
|
1323
1337
|
}
|
|
1324
1338
|
|
|
1325
1339
|
if (needsCodex) {
|
|
@@ -1887,13 +1901,77 @@ async function install(options = {}) {
|
|
|
1887
1901
|
console.log(chalk.blue('Happy agent building! '));
|
|
1888
1902
|
}
|
|
1889
1903
|
|
|
1904
|
+
// Default path since 2.56.0: the zero-question automatic install running the
|
|
1905
|
+
// shared engine (install/lib/install-engine.js) — the same engine the web
|
|
1906
|
+
// wizard calls. Claude+Codex by default (when detected), every agent, creator
|
|
1907
|
+
// soul, rtk installed when missing, stored home credentials reused. The legacy
|
|
1908
|
+
// ~20-question interview stays reachable behind --legacy only.
|
|
1909
|
+
async function installAuto(options) {
|
|
1910
|
+
const engine = require('../lib/install-engine');
|
|
1911
|
+
const projectRoot = path.resolve(options.dir || process.cwd());
|
|
1912
|
+
|
|
1913
|
+
console.log(chalk.cyan.bold(`\n BYAN ${BYAN_VERSION} — installation automatique`));
|
|
1914
|
+
console.log(chalk.gray(` Projet : ${options.name || path.basename(projectRoot)} | Repertoire : ${projectRoot}`));
|
|
1915
|
+
console.log(chalk.gray(' (interview complete : --legacy ; assistant web : create-byan-agent web)\n'));
|
|
1916
|
+
|
|
1917
|
+
let spinner = null;
|
|
1918
|
+
const isTTY = Boolean(process.stdout.isTTY);
|
|
1919
|
+
const ask = isTTY
|
|
1920
|
+
? async (message) => (await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: true }])).ok
|
|
1921
|
+
: null;
|
|
1922
|
+
|
|
1923
|
+
try {
|
|
1924
|
+
const result = await engine.runInstall({
|
|
1925
|
+
projectRoot,
|
|
1926
|
+
projectName: options.name || undefined,
|
|
1927
|
+
rtk: options.rtk !== false,
|
|
1928
|
+
}, {
|
|
1929
|
+
onStep: ({ index, total, label }) => {
|
|
1930
|
+
if (spinner) spinner.succeed();
|
|
1931
|
+
spinner = ora(`[${index}/${total}] ${label}`).start();
|
|
1932
|
+
},
|
|
1933
|
+
log: (line) => { if (spinner) spinner.info(chalk.gray(line)).start(); else console.log(chalk.gray(line)); },
|
|
1934
|
+
ask,
|
|
1935
|
+
});
|
|
1936
|
+
if (spinner) spinner.succeed();
|
|
1937
|
+
|
|
1938
|
+
console.log('');
|
|
1939
|
+
console.log(chalk.green.bold(` Installation terminee — verification ${result.verify.passed}/${result.verify.total}`));
|
|
1940
|
+
|
|
1941
|
+
// End-of-install launch (F5): start Claude Code, with the byan-channel
|
|
1942
|
+
// when this CLI version supports it. --no-launch or a non-interactive
|
|
1943
|
+
// terminal prints the command instead of running it.
|
|
1944
|
+
if (result.launch) {
|
|
1945
|
+
if (options.launch !== false && isTTY) {
|
|
1946
|
+
console.log(chalk.cyan(` Lancement de Claude Code : ${result.launch.command}\n`));
|
|
1947
|
+
const { spawnSync } = require('child_process');
|
|
1948
|
+
spawnSync(result.launch.command, { stdio: 'inherit', shell: true });
|
|
1949
|
+
} else {
|
|
1950
|
+
console.log(chalk.gray(` Pour lancer Claude Code : ${result.launch.command}`));
|
|
1951
|
+
}
|
|
1952
|
+
} else {
|
|
1953
|
+
console.log(chalk.gray(' Claude Code introuvable sur cette machine — installe-le puis lance `claude` dans le projet.'));
|
|
1954
|
+
}
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
if (spinner) spinner.fail();
|
|
1957
|
+
console.error(chalk.red.bold('\nInstallation en echec :'));
|
|
1958
|
+
console.error(chalk.red(` ${error.message}`));
|
|
1959
|
+
process.exit(1);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1890
1963
|
// CLI Program
|
|
1891
1964
|
program
|
|
1892
1965
|
.name('create-byan-agent')
|
|
1893
1966
|
.description('Install BYAN v2.2.0 - Builder of YAN with Model Selector and multi-platform support')
|
|
1894
1967
|
.version(BYAN_VERSION)
|
|
1895
1968
|
.option('--skip-version-check', 'Bypass the npm freshness guard (not recommended)')
|
|
1896
|
-
.
|
|
1969
|
+
.option('--name <name>', 'Nom du projet (defaut : nom du dossier)')
|
|
1970
|
+
.option('--dir <dir>', 'Repertoire d installation (defaut : dossier courant)')
|
|
1971
|
+
.option('--no-launch', 'Ne pas lancer Claude Code en fin d installation')
|
|
1972
|
+
.option('--no-rtk', 'Ne pas installer rtk automatiquement')
|
|
1973
|
+
.option('--legacy', 'Interview complete d origine (l ancien parcours a questions)')
|
|
1974
|
+
.action(async (options) => (options.legacy ? install(options) : installAuto(options)));
|
|
1897
1975
|
|
|
1898
1976
|
// Update Command (Yanstaller v3)
|
|
1899
1977
|
program
|
|
@@ -1957,6 +2035,20 @@ program
|
|
|
1957
2035
|
if (result.filesSkipped > 0) {
|
|
1958
2036
|
console.log(chalk.yellow(` Files skipped: ${result.filesSkipped} (user-modified)`));
|
|
1959
2037
|
}
|
|
2038
|
+
if (result.claudeRefreshed) {
|
|
2039
|
+
console.log(chalk.green(' Claude Code: .claude/ refreshed (skills, workflows, hooks) + native setup (.mcp.json, MCP deps)'));
|
|
2040
|
+
if (result.claudeBackupPath) {
|
|
2041
|
+
console.log(chalk.gray(` .claude backup: ${path.basename(result.claudeBackupPath)}`));
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
if (result.globalSkillsDiverged && result.globalSkillsDiverged.length > 0) {
|
|
2045
|
+
const { syncCommand } = require('../lib/global-skills-sync');
|
|
2046
|
+
console.log(chalk.yellow(` [!] Copies globales ~/.claude/skills divergentes : ${result.globalSkillsDiverged.join(', ')}`));
|
|
2047
|
+
console.log(chalk.yellow(' Elles peuvent masquer les skills fraichement mis a jour. Pour les synchroniser :'));
|
|
2048
|
+
for (const line of syncCommand(result.globalSkillsDiverged).split('\n')) {
|
|
2049
|
+
console.log(chalk.gray(` ${line}`));
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
1960
2052
|
if (result.backupPath) {
|
|
1961
2053
|
console.log(chalk.gray(` Backup: ${path.basename(result.backupPath)}`));
|
|
1962
2054
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GLOBAL SKILLS SYNC — closes the "stale global copy masks the fresh install"
|
|
5
|
+
* trap at INSTALL TIME.
|
|
6
|
+
*
|
|
7
|
+
* Field failure (2026-07-21): the installer laid a fully up-to-date
|
|
8
|
+
* .claude/skills/ in the project, but the user's manual copies under
|
|
9
|
+
* ~/.claude/skills/ (same names, months old) kept being the ones the slash
|
|
10
|
+
* command loaded — the freshly installed features stayed invisible. The
|
|
11
|
+
* session-start freshness hook flags this at the NEXT session; the installer
|
|
12
|
+
* can flag it at the very moment it creates the situation, and it has a TTY to
|
|
13
|
+
* ask. Red line kept from the hook: no silent write into the user's home —
|
|
14
|
+
* consent is asked, refusal or a non-interactive terminal degrades to a clear
|
|
15
|
+
* notice with the exact command.
|
|
16
|
+
*
|
|
17
|
+
* The comparison core is NOT duplicated: we require compareSkills from the
|
|
18
|
+
* .claude payload this very package ships (templates/.claude/hooks/lib/
|
|
19
|
+
* skill-freshness.js), the same tested module the session hook uses.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs-extra');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
function loadComparator(templateDir) {
|
|
27
|
+
return require(path.join(templateDir, '.claude', 'hooks', 'lib', 'skill-freshness.js'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compare the project's freshly installed skills with same-named global copies.
|
|
32
|
+
* Silent-safe: any failure (no global dir, unreadable comparator) yields an
|
|
33
|
+
* empty result — this check must never break an install.
|
|
34
|
+
*
|
|
35
|
+
* @returns {Promise<{diverged: string[], checked: number}>}
|
|
36
|
+
*/
|
|
37
|
+
async function checkGlobalSkills(projectRoot, templateDir, { homeDir = os.homedir() } = {}) {
|
|
38
|
+
try {
|
|
39
|
+
const globalSkillsDir = path.join(homeDir, '.claude', 'skills');
|
|
40
|
+
if (!await fs.pathExists(globalSkillsDir)) return { diverged: [], checked: 0 };
|
|
41
|
+
const { compareSkills } = loadComparator(templateDir);
|
|
42
|
+
return compareSkills({
|
|
43
|
+
projectSkillsDir: path.join(projectRoot, '.claude', 'skills'),
|
|
44
|
+
globalSkillsDir,
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
return { diverged: [], checked: 0 };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Copy the project SKILL.md over the global copy, per name. Only called after
|
|
53
|
+
* explicit consent. Returns the names actually synced (a per-name failure is
|
|
54
|
+
* skipped, never thrown).
|
|
55
|
+
*/
|
|
56
|
+
async function syncGlobalSkills(projectRoot, names, { homeDir = os.homedir() } = {}) {
|
|
57
|
+
const synced = [];
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
try {
|
|
60
|
+
const src = path.join(projectRoot, '.claude', 'skills', name, 'SKILL.md');
|
|
61
|
+
const dest = path.join(homeDir, '.claude', 'skills', name, 'SKILL.md');
|
|
62
|
+
await fs.copy(src, dest, { overwrite: true });
|
|
63
|
+
synced.push(name);
|
|
64
|
+
} catch {
|
|
65
|
+
// one bad copy must not abort the others
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return synced;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The exact copy-paste command shown when we do not write ourselves. */
|
|
72
|
+
function syncCommand(names, { homeDir = os.homedir() } = {}) {
|
|
73
|
+
const globalDir = path.join(homeDir, '.claude', 'skills');
|
|
74
|
+
return names
|
|
75
|
+
.map((n) => `cp .claude/skills/${n}/SKILL.md ${path.join(globalDir, n, 'SKILL.md')}`)
|
|
76
|
+
.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* End-of-install offer.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} projectRoot
|
|
83
|
+
* @param {string} templateDir
|
|
84
|
+
* @param {Object} opts
|
|
85
|
+
* @param {(question: string) => Promise<boolean>} [opts.ask] - Consent
|
|
86
|
+
* collector (inquirer in the CLI). Absent (non-interactive) -> never write,
|
|
87
|
+
* notice only.
|
|
88
|
+
* @param {(line: string) => void} [opts.log]
|
|
89
|
+
* @returns {Promise<{diverged: string[], synced: string[]}>}
|
|
90
|
+
*/
|
|
91
|
+
async function offerGlobalSkillsSync(projectRoot, templateDir, { ask = null, homeDir = os.homedir(), log = console.log } = {}) {
|
|
92
|
+
const { diverged } = await checkGlobalSkills(projectRoot, templateDir, { homeDir });
|
|
93
|
+
if (diverged.length === 0) return { diverged: [], synced: [] };
|
|
94
|
+
|
|
95
|
+
log(` [!] ${diverged.length} skill(s) globaux divergents dans ~/.claude/skills : ${diverged.join(', ')}`);
|
|
96
|
+
log(' Ces copies datent d\'avant cette installation et peuvent etre celles que la');
|
|
97
|
+
log(' commande /<skill> charge — les nouveautes resteraient invisibles.');
|
|
98
|
+
|
|
99
|
+
let synced = [];
|
|
100
|
+
if (typeof ask === 'function') {
|
|
101
|
+
const yes = await ask('Synchroniser ces copies globales depuis le projet (sauvegarde du contenu : aucune — elles seront ecrasees) ?');
|
|
102
|
+
if (yes) {
|
|
103
|
+
synced = await syncGlobalSkills(projectRoot, diverged, { homeDir });
|
|
104
|
+
log(` [OK] ${synced.length} copie(s) globale(s) synchronisee(s) depuis le projet`);
|
|
105
|
+
return { diverged, synced };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
log(' Rien n\'a ete modifie dans ~/.claude. Pour synchroniser toi-meme :');
|
|
109
|
+
for (const line of syncCommand(diverged, { homeDir }).split('\n')) log(` ${line}`);
|
|
110
|
+
return { diverged, synced };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
checkGlobalSkills,
|
|
115
|
+
syncGlobalSkills,
|
|
116
|
+
syncCommand,
|
|
117
|
+
offerGlobalSkillsSync,
|
|
118
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HOME CREDENTIALS — the write side of the per-user install memory.
|
|
5
|
+
*
|
|
6
|
+
* The byan MCP server already RESOLVES its config from
|
|
7
|
+
* ~/.byan/credentials.json (env -> file -> default, see
|
|
8
|
+
* _byan/mcp/byan-mcp-server/lib/resolve-config.js). This module is the
|
|
9
|
+
* installer-side counterpart: read what a previous install stored, merge in
|
|
10
|
+
* what the user provides once, and never ask again on the next machine-wide
|
|
11
|
+
* install. os.homedir() covers Linux/macOS/Windows.
|
|
12
|
+
*
|
|
13
|
+
* Guarantees: reading never throws (missing/garbage file -> {}), writing is a
|
|
14
|
+
* merge (unknown keys already in the file are preserved), the file lands with
|
|
15
|
+
* mode 0600 (it holds tokens), and no value is ever logged by this module.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs-extra');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
// Same key set the MCP server resolver understands — one vocabulary, two sides.
|
|
23
|
+
const KNOWN_KEYS = Object.freeze([
|
|
24
|
+
'BYAN_API_URL',
|
|
25
|
+
'BYAN_API_TOKEN',
|
|
26
|
+
'LEANTIME_API_URL',
|
|
27
|
+
'LEANTIME_API_TOKEN',
|
|
28
|
+
'GOOGLE_APPLICATION_CREDENTIALS',
|
|
29
|
+
'GDOC_TEMPLATE_ID',
|
|
30
|
+
'GDOC_LOGO_PNG_URL',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function credentialsPath(homeDir = os.homedir()) {
|
|
34
|
+
return path.join(homeDir, '.byan', 'credentials.json');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read the stored credentials. Missing or invalid file -> {}. */
|
|
38
|
+
function readCredentials({ homeDir = os.homedir() } = {}) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = fs.readJSONSync(credentialsPath(homeDir));
|
|
41
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Merge-write: existing keys are kept unless the patch overrides them with a
|
|
49
|
+
* non-empty value. Empty/undefined patch values never erase a stored secret.
|
|
50
|
+
* Returns the merged object actually written.
|
|
51
|
+
*/
|
|
52
|
+
function writeCredentials(patch, { homeDir = os.homedir() } = {}) {
|
|
53
|
+
const current = readCredentials({ homeDir });
|
|
54
|
+
const merged = { ...current };
|
|
55
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
56
|
+
// Only known keys are written from a patch. The patch can reach here from
|
|
57
|
+
// an HTTP body (the web wizard) ; an unknown key would be junk in the
|
|
58
|
+
// secret store. Keys already present in the file are preserved (the spread
|
|
59
|
+
// above), unknown ones just cannot be ADDED by a patch.
|
|
60
|
+
if (!KNOWN_KEYS.includes(key)) continue;
|
|
61
|
+
if (typeof value === 'string' && value.trim() !== '') merged[key] = value;
|
|
62
|
+
}
|
|
63
|
+
const file = credentialsPath(homeDir);
|
|
64
|
+
fs.ensureDirSync(path.dirname(file));
|
|
65
|
+
try {
|
|
66
|
+
fs.chmodSync(path.dirname(file), 0o700); // the dir holds only secrets
|
|
67
|
+
} catch {
|
|
68
|
+
// best-effort (Windows ACLs) — the 0600 file mode below is the real guard
|
|
69
|
+
}
|
|
70
|
+
fs.writeJSONSync(file, merged, { spaces: 2, mode: 0o600 });
|
|
71
|
+
try {
|
|
72
|
+
fs.chmodSync(file, 0o600); // writeJSON mode applies on create only; enforce on rewrite
|
|
73
|
+
} catch {
|
|
74
|
+
// chmod best-effort (e.g. Windows ACLs) — the write itself succeeded
|
|
75
|
+
}
|
|
76
|
+
return merged;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The keys among KNOWN_KEYS that are present and non-empty in the store. */
|
|
80
|
+
function storedKeys({ homeDir = os.homedir() } = {}) {
|
|
81
|
+
const current = readCredentials({ homeDir });
|
|
82
|
+
return KNOWN_KEYS.filter((k) => typeof current[k] === 'string' && current[k].trim() !== '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
KNOWN_KEYS,
|
|
87
|
+
credentialsPath,
|
|
88
|
+
readCredentials,
|
|
89
|
+
writeCredentials,
|
|
90
|
+
storedKeys,
|
|
91
|
+
};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* INSTALL ENGINE — the single real installation engine, one engine for two
|
|
5
|
+
* faces (terminal CLI and local web wizard).
|
|
6
|
+
*
|
|
7
|
+
* Why this module exists (field record, 2026-07-21): the whole install logic
|
|
8
|
+
* lived inline in a 2103-line interactive bin, so the web installer could not
|
|
9
|
+
* call it and shipped a SIMULATION instead (9 broadcast steps around sleep()
|
|
10
|
+
* calls). Three install/update gaps were fixed in three days because the logic
|
|
11
|
+
* had no single owner. This engine is that owner.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
* - options in, real actions out. No prompt in here, ever: consent-needing
|
|
15
|
+
* choices arrive AS options (the faces collect them).
|
|
16
|
+
* - progress is honest by construction: onStep fires once per step and each
|
|
17
|
+
* step body performs the real action it names. No step, no broadcast.
|
|
18
|
+
* - side steps that must not kill an install (rtk, skills sync, credentials)
|
|
19
|
+
* record ok:false instead of throwing; core steps (copy, config) throw.
|
|
20
|
+
*
|
|
21
|
+
* Defaults are the zero-question install: Claude + Codex when detected, every
|
|
22
|
+
* agent, creator soul, rtk installed when missing.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs-extra');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const { execSync, spawnSync } = require('child_process');
|
|
29
|
+
const yaml = require('js-yaml');
|
|
30
|
+
|
|
31
|
+
const { setupClaudeNative } = require('./claude-native-setup');
|
|
32
|
+
const { setupCodexNative } = require('./codex-native-setup');
|
|
33
|
+
const { offerGlobalSkillsSync } = require('./global-skills-sync');
|
|
34
|
+
const homeCreds = require('./home-credentials');
|
|
35
|
+
|
|
36
|
+
// Gen3 by-type dirs copied from templates/_byan (same list as the legacy bin).
|
|
37
|
+
const BYAN_DIRS = ['agent', 'workflow', 'connaissance', 'command', 'worker', 'memoire',
|
|
38
|
+
'core', 'bmb', 'bmm', 'tea', 'cis', '_config', 'data'];
|
|
39
|
+
|
|
40
|
+
function defaultTemplateDir() {
|
|
41
|
+
const p = path.join(__dirname, '..', 'templates');
|
|
42
|
+
return fs.existsSync(p) ? p : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commandExists(cmd) {
|
|
46
|
+
try {
|
|
47
|
+
execSync(`command -v ${cmd}`, { stdio: 'ignore', shell: '/bin/sh' });
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Detection: platforms on this machine + stored credentials. Facts only. */
|
|
55
|
+
function detectEnvironment({ homeDir = os.homedir() } = {}) {
|
|
56
|
+
return {
|
|
57
|
+
claude: fs.existsSync(path.join(homeDir, '.claude')) || commandExists('claude'),
|
|
58
|
+
codex: fs.existsSync(path.join(homeDir, '.codex')) || commandExists('codex'),
|
|
59
|
+
rtk: commandExists('rtk'),
|
|
60
|
+
storedCredentialKeys: homeCreds.storedKeys({ homeDir }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Claude Code launch command for the end of install. The byan-channel is a
|
|
66
|
+
* Claude Code research-preview feature behind an explicit flag; when the
|
|
67
|
+
* installed CLI does not know the flag, the plain `claude` command is the
|
|
68
|
+
* fallback. We only BUILD the command here — executing it is the caller's move.
|
|
69
|
+
*/
|
|
70
|
+
function claudeLaunchCommand() {
|
|
71
|
+
if (!commandExists('claude')) return null;
|
|
72
|
+
let helpText = '';
|
|
73
|
+
try {
|
|
74
|
+
helpText = execSync('claude --help', { encoding: 'utf8', timeout: 8000 });
|
|
75
|
+
} catch {
|
|
76
|
+
return { command: 'claude', channel: false };
|
|
77
|
+
}
|
|
78
|
+
const supportsChannel = helpText.includes('dangerously-load-development-channels');
|
|
79
|
+
return supportsChannel
|
|
80
|
+
? { command: 'claude --dangerously-load-development-channels server:byan-channel', channel: true }
|
|
81
|
+
: { command: 'claude', channel: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run the full installation.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} options
|
|
88
|
+
* @param {string} options.projectRoot target directory (created if absent)
|
|
89
|
+
* @param {string} [options.projectName] defaults to basename(projectRoot)
|
|
90
|
+
* @param {object} [options.platforms] {claude, codex} — defaults to detection
|
|
91
|
+
* @param {string} [options.templateDir]
|
|
92
|
+
* @param {string} [options.userName='Developer']
|
|
93
|
+
* @param {string} [options.language='Francais']
|
|
94
|
+
* @param {boolean} [options.rtk=true] install rtk when missing
|
|
95
|
+
* @param {object} [options.credentials] values to persist in ~/.byan/credentials.json
|
|
96
|
+
* @param {string} [options.homeDir]
|
|
97
|
+
* @param {object} [hooks]
|
|
98
|
+
* @param {(step:{index:number,total:number,id:string,label:string})=>void} [hooks.onStep]
|
|
99
|
+
* @param {(line:string)=>void} [hooks.log]
|
|
100
|
+
* @param {(q:string)=>Promise<boolean>} [hooks.ask] consent collector for the
|
|
101
|
+
* global-skills offer (absent -> notice only, nothing written in home)
|
|
102
|
+
* @param {Function} [hooks.claudeSetup] [hooks.codexSetup] [hooks.rtkInstall] test injection
|
|
103
|
+
* @returns {Promise<{ok:boolean, steps:Array, verify:{passed:number,total:number,failed:string[]}, launch:{command:string,channel:boolean}|null}>}
|
|
104
|
+
*/
|
|
105
|
+
async function runInstall(options = {}, hooks = {}) {
|
|
106
|
+
const {
|
|
107
|
+
projectRoot,
|
|
108
|
+
projectName = path.basename(options.projectRoot || ''),
|
|
109
|
+
templateDir = defaultTemplateDir(),
|
|
110
|
+
userName = 'Developer',
|
|
111
|
+
language = 'Francais',
|
|
112
|
+
rtk = true,
|
|
113
|
+
credentials = null,
|
|
114
|
+
homeDir = os.homedir(),
|
|
115
|
+
} = options;
|
|
116
|
+
const onStep = hooks.onStep || (() => {});
|
|
117
|
+
const log = hooks.log || (() => {});
|
|
118
|
+
const claudeSetup = hooks.claudeSetup || setupClaudeNative;
|
|
119
|
+
const codexSetup = hooks.codexSetup || setupCodexNative;
|
|
120
|
+
|
|
121
|
+
if (!projectRoot) throw new Error('projectRoot est requis');
|
|
122
|
+
if (!templateDir || !fs.existsSync(path.join(templateDir, '_byan'))) {
|
|
123
|
+
throw new Error(`templates introuvables (${templateDir || 'aucun chemin'})`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Detection is injectable so tests do not depend on what THIS machine has.
|
|
127
|
+
const detected = hooks.detect ? hooks.detect() : detectEnvironment({ homeDir });
|
|
128
|
+
const platforms = options.platforms || { claude: detected.claude, codex: detected.codex };
|
|
129
|
+
const byanDir = path.join(projectRoot, '_byan');
|
|
130
|
+
const results = [];
|
|
131
|
+
|
|
132
|
+
// Step plan is computed FIRST so index/total are stable and honest.
|
|
133
|
+
const plan = [
|
|
134
|
+
{ id: 'detect', label: 'Detection de l\'environnement' },
|
|
135
|
+
{ id: 'copy-byan', label: 'Copie de la plateforme _byan/' },
|
|
136
|
+
{ id: 'config', label: 'Ecriture de la configuration du projet' },
|
|
137
|
+
];
|
|
138
|
+
if (platforms.claude) plan.push({ id: 'claude', label: 'Installation Claude Code (.claude + serveur MCP + .mcp.json)' });
|
|
139
|
+
if (platforms.codex) plan.push({ id: 'codex', label: 'Installation Codex (~/.codex)' });
|
|
140
|
+
if (credentials && Object.keys(credentials).length) plan.push({ id: 'credentials', label: 'Memorisation de la configuration (~/.byan/credentials.json)' });
|
|
141
|
+
if (platforms.claude) plan.push({ id: 'skills-sync', label: 'Controle des copies globales de skills' });
|
|
142
|
+
if (rtk) plan.push({ id: 'rtk', label: 'Verification / installation de rtk' });
|
|
143
|
+
plan.push({ id: 'verify', label: 'Verification finale' });
|
|
144
|
+
|
|
145
|
+
let index = 0;
|
|
146
|
+
const step = async (id, label, fn, { critical = true } = {}) => {
|
|
147
|
+
index += 1;
|
|
148
|
+
onStep({ index, total: plan.length, id, label });
|
|
149
|
+
try {
|
|
150
|
+
const detail = await fn();
|
|
151
|
+
results.push({ id, ok: true, detail: detail || '' });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
results.push({ id, ok: false, detail: err.message });
|
|
154
|
+
if (critical) throw err;
|
|
155
|
+
log(`[!] etape ${id} en echec (non bloquant) : ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
await step('detect', plan[0].label, async () => {
|
|
160
|
+
log(`Claude: ${detected.claude ? 'present' : 'absent'} ; Codex: ${detected.codex ? 'present' : 'absent'} ; rtk: ${detected.rtk ? 'present' : 'absent'}`);
|
|
161
|
+
if (detected.storedCredentialKeys.length) {
|
|
162
|
+
log(`Configuration memorisee reutilisee (${detected.storedCredentialKeys.length} cle(s), rien a re-saisir)`);
|
|
163
|
+
}
|
|
164
|
+
return `claude=${detected.claude} codex=${detected.codex} rtk=${detected.rtk}`;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await step('copy-byan', 'Copie de la plateforme _byan/', async () => {
|
|
168
|
+
const src = path.join(templateDir, '_byan');
|
|
169
|
+
await fs.ensureDir(byanDir);
|
|
170
|
+
let copied = 0;
|
|
171
|
+
for (const dir of BYAN_DIRS) {
|
|
172
|
+
const s = path.join(src, dir);
|
|
173
|
+
if (await fs.pathExists(s)) {
|
|
174
|
+
await fs.copy(s, path.join(byanDir, dir), { overwrite: true });
|
|
175
|
+
copied += 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const file of await fs.readdir(src)) {
|
|
179
|
+
const full = path.join(src, file);
|
|
180
|
+
if ((await fs.stat(full)).isFile()) await fs.copy(full, path.join(byanDir, file), { overwrite: true });
|
|
181
|
+
}
|
|
182
|
+
return `${copied} dossiers copies`;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await step('config', 'Ecriture de la configuration du projet', async () => {
|
|
186
|
+
const bmbDir = path.join(byanDir, 'bmb');
|
|
187
|
+
await fs.ensureDir(bmbDir);
|
|
188
|
+
const configContent = {
|
|
189
|
+
bmb_creations_output_folder: '{project-root}/_byan-output/bmb-creations',
|
|
190
|
+
user_name: userName,
|
|
191
|
+
communication_language: language,
|
|
192
|
+
document_output_language: language,
|
|
193
|
+
output_folder: '{project-root}/_byan-output',
|
|
194
|
+
project_name: projectName,
|
|
195
|
+
platform: Object.entries(platforms).filter(([, v]) => v).map(([k]) => k).join(',') || 'none',
|
|
196
|
+
install_mode: 'engine-auto',
|
|
197
|
+
byan_version: readOwnVersion(),
|
|
198
|
+
};
|
|
199
|
+
await fs.writeFile(path.join(bmbDir, 'config.yaml'), yaml.dump(configContent), 'utf8');
|
|
200
|
+
return 'config.yaml ecrit';
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (platforms.claude) {
|
|
204
|
+
await step('claude', 'Installation Claude Code', async () => {
|
|
205
|
+
const claudeSource = path.join(templateDir, '.claude');
|
|
206
|
+
if (await fs.pathExists(claudeSource)) {
|
|
207
|
+
await fs.ensureDir(path.join(projectRoot, '.claude', 'rules'));
|
|
208
|
+
await fs.copy(claudeSource, path.join(projectRoot, '.claude'), { overwrite: true });
|
|
209
|
+
}
|
|
210
|
+
await claudeSetup(projectRoot);
|
|
211
|
+
return '.claude copie + configuration native (hooks, skills, .mcp.json, dependances MCP)';
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (platforms.codex) {
|
|
216
|
+
await step('codex', 'Installation Codex', async () => {
|
|
217
|
+
await codexSetup(projectRoot, { templateDir, force: true });
|
|
218
|
+
return 'squelettes .codex + config';
|
|
219
|
+
}, { critical: false });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (credentials && Object.keys(credentials).length) {
|
|
223
|
+
await step('credentials', 'Memorisation de la configuration', async () => {
|
|
224
|
+
const merged = homeCreds.writeCredentials(credentials, { homeDir });
|
|
225
|
+
return `${Object.keys(merged).length} cle(s) en memoire dans ${homeCreds.credentialsPath(homeDir)}`;
|
|
226
|
+
}, { critical: false });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (platforms.claude) {
|
|
230
|
+
await step('skills-sync', 'Controle des copies globales de skills', async () => {
|
|
231
|
+
const r = await offerGlobalSkillsSync(projectRoot, templateDir, { ask: hooks.ask || null, homeDir, log });
|
|
232
|
+
return r.diverged.length === 0 ? 'copies globales fideles' : `${r.diverged.length} divergente(s), ${r.synced.length} synchronisee(s)`;
|
|
233
|
+
}, { critical: false });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (rtk) {
|
|
237
|
+
await step('rtk', 'Verification / installation de rtk', async () => {
|
|
238
|
+
if (detected.rtk) return 'rtk deja present';
|
|
239
|
+
const rtkInstall = hooks.rtkInstall || (() => {
|
|
240
|
+
// The official rtk setup script shipped with this package. Non-TTY
|
|
241
|
+
// safe: it runs unattended; a failure is reported, never fatal.
|
|
242
|
+
const script = path.join(__dirname, '..', 'setup-rtk.js');
|
|
243
|
+
const r = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 300000 });
|
|
244
|
+
if (r.status !== 0) throw new Error(`setup-rtk sortie ${r.status}: ${(r.stderr || '').slice(0, 200)}`);
|
|
245
|
+
return 'rtk installe';
|
|
246
|
+
});
|
|
247
|
+
return rtkInstall();
|
|
248
|
+
}, { critical: false });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let verify = { passed: 0, total: 0, failed: [] };
|
|
252
|
+
await step('verify', 'Verification finale', async () => {
|
|
253
|
+
const checks = [
|
|
254
|
+
{ name: 'Dossier agents', p: path.join(byanDir, 'agent') },
|
|
255
|
+
{ name: 'Agent BYAN', p: path.join(byanDir, 'agent', 'byan', 'byan.md') },
|
|
256
|
+
{ name: 'Workflows', p: path.join(byanDir, 'workflow') },
|
|
257
|
+
{ name: 'Config', p: path.join(byanDir, 'bmb', 'config.yaml') },
|
|
258
|
+
];
|
|
259
|
+
if (platforms.claude) {
|
|
260
|
+
checks.push(
|
|
261
|
+
{ name: 'CLAUDE.md', p: path.join(projectRoot, '.claude', 'CLAUDE.md') },
|
|
262
|
+
{ name: 'Regles Claude', p: path.join(projectRoot, '.claude', 'rules') },
|
|
263
|
+
{ name: 'Skill byan-byan', p: path.join(projectRoot, '.claude', 'skills', 'byan-byan', 'SKILL.md') },
|
|
264
|
+
{ name: 'Workflow auto-dispatch', p: path.join(projectRoot, '.claude', 'workflows', 'byan-auto-dispatch.js') },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const failed = [];
|
|
268
|
+
for (const c of checks) {
|
|
269
|
+
if (!await fs.pathExists(c.p)) failed.push(c.name);
|
|
270
|
+
}
|
|
271
|
+
verify = { passed: checks.length - failed.length, total: checks.length, failed };
|
|
272
|
+
if (failed.length) throw new Error(`verification incomplete : ${failed.join(', ')}`);
|
|
273
|
+
return `${verify.passed}/${verify.total} controles OK`;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
ok: results.every((r) => r.ok || !['detect', 'copy-byan', 'config', 'claude', 'verify'].includes(r.id)),
|
|
278
|
+
steps: results,
|
|
279
|
+
verify,
|
|
280
|
+
launch: claudeLaunchCommand(),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function readOwnVersion() {
|
|
285
|
+
try {
|
|
286
|
+
return fs.readJSONSync(path.join(__dirname, '..', '..', 'package.json')).version;
|
|
287
|
+
} catch {
|
|
288
|
+
return 'unknown';
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = {
|
|
293
|
+
runInstall,
|
|
294
|
+
detectEnvironment,
|
|
295
|
+
claudeLaunchCommand,
|
|
296
|
+
BYAN_DIRS,
|
|
297
|
+
};
|
|
@@ -12,6 +12,7 @@ const path = require('path');
|
|
|
12
12
|
const { compareVersions, getLatestVersion } = require('../utils/version-compare');
|
|
13
13
|
const { diffFiles } = require('../utils/file-differ');
|
|
14
14
|
const { readManifest, writeManifest, generateManifest, detectUserModifications } = require('../utils/manifest');
|
|
15
|
+
const { setupClaudeNative } = require('../claude-native-setup');
|
|
15
16
|
const backuper = require('./backuper');
|
|
16
17
|
const logger = require('../utils/logger');
|
|
17
18
|
|
|
@@ -137,11 +138,15 @@ async function update(projectRoot, options = {}) {
|
|
|
137
138
|
backupPath: null,
|
|
138
139
|
filesUpdated: 0,
|
|
139
140
|
filesAdded: 0,
|
|
140
|
-
filesSkipped: 0
|
|
141
|
+
filesSkipped: 0,
|
|
142
|
+
claudeRefreshed: false,
|
|
143
|
+
claudeBackupPath: null
|
|
141
144
|
};
|
|
142
145
|
}
|
|
143
146
|
|
|
144
|
-
|
|
147
|
+
// templateDir is injectable so the flow is testable without touching the
|
|
148
|
+
// packaged templates (and so a caller can point at a staged template set).
|
|
149
|
+
const templateDir = options.templateDir || getTemplateDir();
|
|
145
150
|
if (!templateDir) {
|
|
146
151
|
throw new Error('Template directory not found. Is the package installed correctly?');
|
|
147
152
|
}
|
|
@@ -170,6 +175,10 @@ async function update(projectRoot, options = {}) {
|
|
|
170
175
|
let filesUpdated = 0;
|
|
171
176
|
let filesAdded = 0;
|
|
172
177
|
let filesSkipped = 0;
|
|
178
|
+
let claudeRefreshed = false;
|
|
179
|
+
let claudeBackupPath = null;
|
|
180
|
+
let globalSkillsDiverged = [];
|
|
181
|
+
const installedClaude = path.join(projectRoot, '.claude');
|
|
173
182
|
|
|
174
183
|
try {
|
|
175
184
|
// Apply updated files (skip user-modified unless forced)
|
|
@@ -195,6 +204,38 @@ async function update(projectRoot, options = {}) {
|
|
|
195
204
|
filesAdded++;
|
|
196
205
|
}
|
|
197
206
|
|
|
207
|
+
// --- .claude refresh -------------------------------------------------
|
|
208
|
+
// The update must deploy what the full install deploys. Historically it
|
|
209
|
+
// only diffed _byan/, so everything living under .claude/ (skills with the
|
|
210
|
+
// auto-dispatch rail, native workflows, hooks, settings, and the .mcp.json
|
|
211
|
+
// regenerated by the native setup) was NEVER refreshed by `update` — an
|
|
212
|
+
// up-to-date _byan/ next to a months-old .claude/. Same contract as the
|
|
213
|
+
// full install (copy with overwrite), with a dedicated backup first. The
|
|
214
|
+
// backup uses its own `.claude.backup-` prefix : reusing the `_byan.backup-`
|
|
215
|
+
// prefix would make pruneBackups/rollback treat it as a _byan backup.
|
|
216
|
+
const templateClaude = path.join(templateDir, '.claude');
|
|
217
|
+
if (await fs.pathExists(templateClaude) && await fs.pathExists(installedClaude)) {
|
|
218
|
+
claudeBackupPath = path.join(projectRoot, `.claude.backup-${Date.now()}`);
|
|
219
|
+
await fs.copy(installedClaude, claudeBackupPath);
|
|
220
|
+
await fs.copy(templateClaude, installedClaude, { overwrite: true });
|
|
221
|
+
// Same native pass as the full install : regenerates .mcp.json, installs
|
|
222
|
+
// the MCP server dependencies, wires git hooks. Injectable for tests.
|
|
223
|
+
const nativeSetup = options.nativeSetup || setupClaudeNative;
|
|
224
|
+
await nativeSetup(projectRoot);
|
|
225
|
+
claudeRefreshed = true;
|
|
226
|
+
logger.debug(`.claude refreshed from templates (backup: ${claudeBackupPath})`);
|
|
227
|
+
|
|
228
|
+
// Stale global copies check (notice-only on the update path : no TTY
|
|
229
|
+
// conversation here, and the home is never written without consent).
|
|
230
|
+
// The CLI surfaces the diverged names + the exact sync command.
|
|
231
|
+
try {
|
|
232
|
+
const { checkGlobalSkills } = require('../global-skills-sync');
|
|
233
|
+
globalSkillsDiverged = (await checkGlobalSkills(projectRoot, templateDir)).diverged;
|
|
234
|
+
} catch {
|
|
235
|
+
// the check must never fail an update
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
198
239
|
// Regenerate manifest with new state
|
|
199
240
|
const newManifest = await generateManifest(installedByan, latest);
|
|
200
241
|
await writeManifest(projectRoot, newManifest);
|
|
@@ -206,6 +247,11 @@ async function update(projectRoot, options = {}) {
|
|
|
206
247
|
// Rollback on failure
|
|
207
248
|
logger.error(`Update failed, restoring backup: ${error.message}`);
|
|
208
249
|
await backuper.restore(backupResult.backupPath, installedByan);
|
|
250
|
+
if (claudeBackupPath && await fs.pathExists(claudeBackupPath)) {
|
|
251
|
+
await fs.remove(installedClaude);
|
|
252
|
+
await fs.copy(claudeBackupPath, installedClaude);
|
|
253
|
+
logger.error('.claude restored from its pre-update backup');
|
|
254
|
+
}
|
|
209
255
|
throw error;
|
|
210
256
|
}
|
|
211
257
|
|
|
@@ -216,7 +262,10 @@ async function update(projectRoot, options = {}) {
|
|
|
216
262
|
backupPath: backupResult.backupPath,
|
|
217
263
|
filesUpdated,
|
|
218
264
|
filesAdded,
|
|
219
|
-
filesSkipped
|
|
265
|
+
filesSkipped,
|
|
266
|
+
claudeRefreshed,
|
|
267
|
+
claudeBackupPath,
|
|
268
|
+
globalSkillsDiverged
|
|
220
269
|
};
|
|
221
270
|
}
|
|
222
271
|
|
package/install/src/webui/api.js
CHANGED
|
@@ -53,6 +53,18 @@ function json(res, statusCode, data) {
|
|
|
53
53
|
res.end(JSON.stringify(data));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Confine an HTTP-supplied install/update directory to the server's launch
|
|
57
|
+
// directory. A relative value resolves inside base; base itself, or any path
|
|
58
|
+
// that escapes it (../, absolute elsewhere), collapses back to base. The
|
|
59
|
+
// installer targets the project the wizard was launched in — nothing else.
|
|
60
|
+
function confineToBase(candidate, base) {
|
|
61
|
+
if (!candidate || typeof candidate !== 'string') return base;
|
|
62
|
+
const resolved = path.resolve(base, candidate);
|
|
63
|
+
const rel = path.relative(base, resolved);
|
|
64
|
+
const escapes = rel === '' ? false : (rel.startsWith('..') || path.isAbsolute(rel));
|
|
65
|
+
return escapes ? base : resolved;
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
function readPackageVersion() {
|
|
57
69
|
try {
|
|
58
70
|
const pkg = JSON.parse(
|
|
@@ -141,39 +153,54 @@ const routes = {
|
|
|
141
153
|
});
|
|
142
154
|
},
|
|
143
155
|
|
|
156
|
+
// Runs the REAL install engine (install/lib/install-engine.js) — the same
|
|
157
|
+
// one the CLI uses. Every progress broadcast maps 1:1 to a real action; the
|
|
158
|
+
// previous handler here simulated 9 steps around sleep() calls and only
|
|
159
|
+
// created directories, which is exactly the dishonest-progress trap the
|
|
160
|
+
// engine forbids. `server.installEngine` is injectable for tests.
|
|
144
161
|
'POST install': async (req, res, server) => {
|
|
145
162
|
json(res, 200, { status: 'started' });
|
|
146
163
|
|
|
147
164
|
const config = req.body || {};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
'Installing core module',
|
|
154
|
-
'Installing selected modules',
|
|
155
|
-
'Configuring platforms',
|
|
156
|
-
'Generating agent stubs',
|
|
157
|
-
'Writing configuration',
|
|
158
|
-
'Validating installation'
|
|
159
|
-
];
|
|
165
|
+
// projectDir comes from the HTTP body — it must NOT be an arbitrary path
|
|
166
|
+
// sink (the engine writes files and spawns processes). Confine it to the
|
|
167
|
+
// directory the server was launched in: the wizard installs "here", the
|
|
168
|
+
// exact intent of `create-byan-agent web` run in a project.
|
|
169
|
+
const projectRoot = confineToBase(config.projectDir, server.projectRoot);
|
|
160
170
|
|
|
161
171
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
172
|
+
const installEngine = server.installEngine || require('../../lib/install-engine');
|
|
173
|
+
// The wizard form sends platforms as an array (['claude','codex']); the
|
|
174
|
+
// engine takes an object. An empty selection means "let the engine detect".
|
|
175
|
+
const platformList = Array.isArray(config.platforms) ? config.platforms : null;
|
|
176
|
+
const platforms = platformList && platformList.length
|
|
177
|
+
? { claude: platformList.includes('claude'), codex: platformList.includes('codex') }
|
|
178
|
+
: (config.platforms && !Array.isArray(config.platforms) ? config.platforms : undefined);
|
|
179
|
+
const result = await installEngine.runInstall({
|
|
180
|
+
projectRoot,
|
|
181
|
+
projectName: config.projectName || undefined,
|
|
182
|
+
platforms,
|
|
183
|
+
userName: config.userName || undefined,
|
|
184
|
+
language: config.language || undefined,
|
|
185
|
+
rtk: config.rtk !== false,
|
|
186
|
+
credentials: config.credentials || null,
|
|
187
|
+
}, {
|
|
188
|
+
onStep: ({ index, total, label }) => {
|
|
189
|
+
server.broadcastProgress(index, total, label);
|
|
190
|
+
server.broadcastLog('info', label);
|
|
191
|
+
},
|
|
192
|
+
log: (line) => server.broadcastLog('info', line),
|
|
193
|
+
// No consent prompt over HTTP: the global-skills offer degrades to a
|
|
194
|
+
// notice with the exact command (same non-interactive contract as CI).
|
|
195
|
+
ask: null,
|
|
196
|
+
});
|
|
170
197
|
|
|
171
|
-
server.
|
|
172
|
-
|
|
173
|
-
message: 'BYAN installed successfully',
|
|
198
|
+
server.broadcastComplete(result.ok, {
|
|
199
|
+
message: result.ok ? 'Installation BYAN terminee' : 'Installation terminee avec des etapes en echec',
|
|
174
200
|
projectRoot,
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
verify: result.verify,
|
|
202
|
+
steps: result.steps,
|
|
203
|
+
launch: result.launch,
|
|
177
204
|
});
|
|
178
205
|
} catch (err) {
|
|
179
206
|
server.broadcastLog('error', err.message);
|
|
@@ -181,34 +208,29 @@ const routes = {
|
|
|
181
208
|
}
|
|
182
209
|
},
|
|
183
210
|
|
|
211
|
+
// Runs the REAL updater (yanstaller.update, which since 2.54.1 refreshes
|
|
212
|
+
// _byan AND .claude + native setup). The previous handler simulated 6 steps,
|
|
213
|
+
// called update with a wrong argument, swallowed its failure and broadcast
|
|
214
|
+
// success regardless — the result is now the truth, including failures.
|
|
184
215
|
'POST update': async (req, res, server) => {
|
|
185
216
|
json(res, 200, { status: 'started' });
|
|
186
|
-
|
|
187
|
-
const steps = [
|
|
188
|
-
'Checking current version',
|
|
189
|
-
'Creating backup',
|
|
190
|
-
'Downloading update',
|
|
191
|
-
'Applying changes',
|
|
192
|
-
'Merging configuration',
|
|
193
|
-
'Validating update'
|
|
194
|
-
];
|
|
217
|
+
const projectRoot = confineToBase(req.body && req.body.projectDir, server.projectRoot);
|
|
195
218
|
|
|
196
219
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
server.broadcastLog('info', steps[i] + '...');
|
|
200
|
-
await sleep(400);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (yanstaller && yanstaller.update) {
|
|
204
|
-
try {
|
|
205
|
-
await yanstaller.update('latest');
|
|
206
|
-
} catch (err) {
|
|
207
|
-
server.broadcastLog('warn', `Update module: ${err.message}`);
|
|
208
|
-
}
|
|
220
|
+
if (!yanstaller || !yanstaller.update) {
|
|
221
|
+
throw new Error('Module de mise a jour indisponible dans ce paquet');
|
|
209
222
|
}
|
|
210
|
-
|
|
211
|
-
server.
|
|
223
|
+
server.broadcastProgress(1, 2, 'Mise a jour en cours (_byan + .claude + configuration native)');
|
|
224
|
+
const result = await (server.updater || yanstaller.update)(projectRoot, {});
|
|
225
|
+
server.broadcastProgress(2, 2, 'Terminee');
|
|
226
|
+
server.broadcastComplete(true, {
|
|
227
|
+
message: `Mise a jour ${result.previousVersion} -> ${result.newVersion}`,
|
|
228
|
+
filesUpdated: result.filesUpdated,
|
|
229
|
+
filesAdded: result.filesAdded,
|
|
230
|
+
filesSkipped: result.filesSkipped,
|
|
231
|
+
claudeRefreshed: result.claudeRefreshed === true,
|
|
232
|
+
globalSkillsDiverged: result.globalSkillsDiverged || [],
|
|
233
|
+
});
|
|
212
234
|
} catch (err) {
|
|
213
235
|
server.broadcastLog('error', err.message);
|
|
214
236
|
server.broadcastComplete(false, { message: err.message });
|
|
@@ -200,6 +200,15 @@ class ByanApp {
|
|
|
200
200
|
<label for="cfg-name">Your Name</label>
|
|
201
201
|
<input id="cfg-name" type="text" value="${this.escapeHtml(this.config.userName)}" placeholder="Yan">
|
|
202
202
|
</div>
|
|
203
|
+
<div class="form-group">
|
|
204
|
+
<label for="cfg-project-name">Project Name</label>
|
|
205
|
+
<input id="cfg-project-name" type="text" value="${this.escapeHtml(this.config.projectName || '')}" placeholder="mon-projet">
|
|
206
|
+
</div>
|
|
207
|
+
<div class="form-group">
|
|
208
|
+
<label for="cfg-project-dir">Project Directory</label>
|
|
209
|
+
<input id="cfg-project-dir" type="text" value="${this.escapeHtml(this.config.projectDir || this.status?.projectRoot || '')}" placeholder="/chemin/vers/le/projet">
|
|
210
|
+
<div class="form-hint">Defaults to the directory the wizard was launched from.</div>
|
|
211
|
+
</div>
|
|
203
212
|
<div class="form-group">
|
|
204
213
|
<label for="cfg-lang">Communication Language</label>
|
|
205
214
|
<select id="cfg-lang">
|
|
@@ -239,9 +248,13 @@ class ByanApp {
|
|
|
239
248
|
readConfigForm() {
|
|
240
249
|
const nameEl = document.getElementById('cfg-name');
|
|
241
250
|
const langEl = document.getElementById('cfg-lang');
|
|
251
|
+
const projNameEl = document.getElementById('cfg-project-name');
|
|
252
|
+
const projDirEl = document.getElementById('cfg-project-dir');
|
|
242
253
|
|
|
243
254
|
if (nameEl) this.config.userName = nameEl.value.trim() || 'User';
|
|
244
255
|
if (langEl) this.config.language = langEl.value;
|
|
256
|
+
if (projNameEl) this.config.projectName = projNameEl.value.trim();
|
|
257
|
+
if (projDirEl) this.config.projectDir = projDirEl.value.trim();
|
|
245
258
|
|
|
246
259
|
const platforms = [];
|
|
247
260
|
document.querySelectorAll('input[name="platform"]:checked').forEach(el => platforms.push(el.value));
|
|
@@ -264,7 +277,8 @@ class ByanApp {
|
|
|
264
277
|
['Language', this.config.language],
|
|
265
278
|
['Platforms', this.config.platforms.join(', ') || 'auto-detect'],
|
|
266
279
|
['Modules', this.config.modules.join(', ')],
|
|
267
|
-
['Project
|
|
280
|
+
['Project Name', this.config.projectName || '(nom du dossier)'],
|
|
281
|
+
['Project Root', this.config.projectDir || this.status?.projectRoot || '(auto)']
|
|
268
282
|
];
|
|
269
283
|
|
|
270
284
|
container.innerHTML = rows.map(([k, v]) =>
|
|
@@ -50,7 +50,12 @@ class ByanWebUI {
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
return new Promise((resolve) => {
|
|
53
|
-
|
|
53
|
+
// Bind to loopback ONLY. Without a host argument Node binds to :: (every
|
|
54
|
+
// interface), which made the installer LAN-reachable — and since it runs
|
|
55
|
+
// the real install engine (file writes + child processes), that exposed
|
|
56
|
+
// an arbitrary-write surface to the local network. A local installer has
|
|
57
|
+
// no business listening off-host.
|
|
58
|
+
this.server.listen(this.port, '127.0.0.1', () => {
|
|
54
59
|
const addr = this.server.address();
|
|
55
60
|
const assignedPort = (addr && typeof addr === 'object') ? addr.port : this.port;
|
|
56
61
|
const url = `http://localhost:${assignedPort}`;
|
|
@@ -61,6 +61,25 @@ const METAPHOR_OFFENDERS = Object.freeze([
|
|
|
61
61
|
label: 'outil "vivant/mort"',
|
|
62
62
|
good: 'le fait observe : "repond" / "ne repond pas" + le resultat exact',
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
// BYAN in-house doctrine shorthand dropped raw in prose (field complaint,
|
|
66
|
+
// 2026-07-21). "le rail" means the byan-auto-dispatch workflow; a reader
|
|
67
|
+
// without the internal doctrine cannot decode it — same offence as raw
|
|
68
|
+
// English jargon (IA-26). The article-bound match spares ordinary French
|
|
69
|
+
// ("un rail de guidage", "rail ferroviaire").
|
|
70
|
+
id: 'jargon-doctrine-rail',
|
|
71
|
+
re: /\b(?:le|ce|du)\s+rail\b(?!\s+(?:de|d'|ferroviaire))/i,
|
|
72
|
+
label: '"le rail" (jargon doctrine)',
|
|
73
|
+
good: 'dis-le en clair : le workflow automatique de dispatch (byan-auto-dispatch)',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
// "voie/option armee" means: option enabled AND Codex linked. Say the fact.
|
|
77
|
+
// The noun-bound match spares the ordinary word ("l'armee").
|
|
78
|
+
id: 'jargon-doctrine-armee',
|
|
79
|
+
re: /\b(?:voie|option|lane)\s+(?:est\s+|n'est\s+pas\s+)?arm[ée]e?s?\b/i,
|
|
80
|
+
label: 'voie/option "armee" (jargon doctrine)',
|
|
81
|
+
good: 'dis le fait : option activee ET Codex connecte (ou pas)',
|
|
82
|
+
},
|
|
64
83
|
]);
|
|
65
84
|
|
|
66
85
|
// A word boundary that treats accented letters as part of a word, so \btier\b
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// skill-freshness — the pure core of the stale-global-skill guard.
|
|
4
|
+
//
|
|
5
|
+
// The trap this closes (observed 2026-07-21): a manual copy of a skill under
|
|
6
|
+
// ~/.claude/skills/<name>/SKILL.md drifts silently, and the slash command can
|
|
7
|
+
// load THAT copy instead of the project's .claude/skills/<name>/SKILL.md — so a
|
|
8
|
+
// feature shipped in the project skill stays invisible (the 2.53.0 auto-dispatch
|
|
9
|
+
// rail did not fire because of a June 30 global copy). The official Claude Code
|
|
10
|
+
// docs do not clearly settle the name-collision priority between the two levels
|
|
11
|
+
// (checked 2026-07-21), so this guard flags the FACT — the two files differ —
|
|
12
|
+
// and hands the human the exact sync command; it does not claim a loading rule
|
|
13
|
+
// and it never writes into ~/.claude itself (a user-level variant may be
|
|
14
|
+
// deliberate; the human decides).
|
|
15
|
+
//
|
|
16
|
+
// Pure: comparison + decision + message. The I/O shell (SessionStart hook)
|
|
17
|
+
// feeds it directories and prints the reminder.
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
// Compare the project's skills with same-named global copies, by CONTENT.
|
|
23
|
+
// A newer mtime does not mean "up to date"; byte equality means "faithful".
|
|
24
|
+
// Returns { diverged: [name...], checked: <count> }; every fs error on one
|
|
25
|
+
// skill is swallowed (that skill is simply not compared) so a permission oddity
|
|
26
|
+
// can never break the caller.
|
|
27
|
+
function compareSkills({ projectSkillsDir, globalSkillsDir }) {
|
|
28
|
+
const out = { diverged: [], checked: 0 };
|
|
29
|
+
let names;
|
|
30
|
+
try {
|
|
31
|
+
names = fs.readdirSync(projectSkillsDir, { withFileTypes: true })
|
|
32
|
+
.filter((e) => e.isDirectory())
|
|
33
|
+
.map((e) => e.name);
|
|
34
|
+
} catch {
|
|
35
|
+
return out; // project has no skills dir -> nothing to compare
|
|
36
|
+
}
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
try {
|
|
39
|
+
const proj = path.join(projectSkillsDir, name, 'SKILL.md');
|
|
40
|
+
const glob = path.join(globalSkillsDir, name, 'SKILL.md');
|
|
41
|
+
if (!fs.existsSync(glob)) continue; // no global twin -> no masking risk
|
|
42
|
+
const a = fs.readFileSync(proj);
|
|
43
|
+
const b = fs.readFileSync(glob);
|
|
44
|
+
out.checked += 1;
|
|
45
|
+
if (!a.equals(b)) out.diverged.push(name);
|
|
46
|
+
} catch {
|
|
47
|
+
// unreadable pair -> skip silently (never block a session start)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Bounded, factual reminder. Empty string when nothing diverged (the hook then
|
|
54
|
+
// prints nothing at all). Names are capped so a badly drifted machine does not
|
|
55
|
+
// flood the context; the sync command is exact and copy-pastable.
|
|
56
|
+
const MAX_NAMES = 6;
|
|
57
|
+
|
|
58
|
+
// A skill dir name lands verbatim in the injected context and in the suggested
|
|
59
|
+
// cp command. Legit names are kebab-case; strip control chars and whitespace
|
|
60
|
+
// runs from anything else so a crafted dir name cannot shape the reminder.
|
|
61
|
+
function cleanName(n) {
|
|
62
|
+
return String(n).replace(/[\u0000-\u001F\u007F]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatReminder(diverged, { projectSkillsDir = '.claude/skills', globalSkillsDir = '~/.claude/skills' } = {}) {
|
|
66
|
+
if (!Array.isArray(diverged) || diverged.length === 0) return '';
|
|
67
|
+
const names = diverged.map(cleanName);
|
|
68
|
+
const shown = names.slice(0, MAX_NAMES).join(', ');
|
|
69
|
+
const more = names.length > MAX_NAMES ? ` (+${names.length - MAX_NAMES} autres)` : '';
|
|
70
|
+
const one = names[0];
|
|
71
|
+
return [
|
|
72
|
+
`Skills globaux divergents detectes : ${shown}${more}.`,
|
|
73
|
+
`La copie ${globalSkillsDir}/<nom>/SKILL.md differe du skill projet — elle peut etre celle que la commande /<nom> charge (constate sur ce piege le 2026-07-21).`,
|
|
74
|
+
`Synchronise depuis le projet (ex: cp ${projectSkillsDir}/${one}/SKILL.md ${globalSkillsDir}/${one}/SKILL.md) ou supprime la copie globale si elle n'est pas voulue.`,
|
|
75
|
+
`Rien n'est modifie automatiquement : une variante user-level peut etre deliberee.`,
|
|
76
|
+
].join(' ');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
compareSkills,
|
|
81
|
+
formatReminder,
|
|
82
|
+
MAX_NAMES,
|
|
83
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// SessionStart hook — stale-global-skill guard (I/O shell over
|
|
5
|
+
// lib/skill-freshness.js).
|
|
6
|
+
//
|
|
7
|
+
// At each session start, compare the project's .claude/skills/<n>/SKILL.md with
|
|
8
|
+
// the same-named ~/.claude/skills/<n>/SKILL.md. On a content divergence, inject
|
|
9
|
+
// one bounded factual reminder (names + exact sync command). Silent when
|
|
10
|
+
// everything is faithful, when there is no global copy, or when the project has
|
|
11
|
+
// no skills. NEVER blocks a session: exit 0 on every path, including internal
|
|
12
|
+
// errors. It never writes into ~/.claude — it reports, the human decides.
|
|
13
|
+
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const freshness = require('./lib/skill-freshness');
|
|
17
|
+
|
|
18
|
+
function buildContext(projectDir, homeDir) {
|
|
19
|
+
const projectSkillsDir = path.join(projectDir, '.claude', 'skills');
|
|
20
|
+
const globalSkillsDir = path.join(homeDir, '.claude', 'skills');
|
|
21
|
+
const { diverged } = freshness.compareSkills({ projectSkillsDir, globalSkillsDir });
|
|
22
|
+
return freshness.formatReminder(diverged, {
|
|
23
|
+
projectSkillsDir: '.claude/skills',
|
|
24
|
+
globalSkillsDir: path.join(homeDir, '.claude', 'skills'),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (require.main === module) {
|
|
29
|
+
try {
|
|
30
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
31
|
+
const ctx = buildContext(projectDir, os.homedir());
|
|
32
|
+
if (ctx) {
|
|
33
|
+
process.stdout.write(
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
hookSpecificOutput: {
|
|
36
|
+
hookEventName: 'SessionStart',
|
|
37
|
+
additionalContext: ctx,
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// A guard must never take a session down with it.
|
|
44
|
+
}
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { buildContext };
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
{
|
|
16
16
|
"type": "command",
|
|
17
17
|
"command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/soul-memory-check.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-freshness-check.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
|
|
18
22
|
}
|
|
19
23
|
]
|
|
20
24
|
}
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
// format against projets.acadenice.com. Items tagged VERIFY@F0 are the ones the
|
|
22
22
|
// recon could not confirm without a live call.
|
|
23
23
|
|
|
24
|
+
// Config resolution goes through resolve-config (env -> ~/.byan/credentials.json
|
|
25
|
+
// -> empty), the same chain the rest of the server uses. Reading process.env
|
|
26
|
+
// directly here was the gap that made the per-user credentials file invisible
|
|
27
|
+
// to the Leantime client.
|
|
28
|
+
import { resolveConfig } from './resolve-config.js';
|
|
29
|
+
|
|
24
30
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
25
31
|
const RPC_PATH = '/api/jsonrpc';
|
|
26
32
|
|
|
@@ -62,11 +68,11 @@ const DEFAULT_STATUS_MAP = {
|
|
|
62
68
|
};
|
|
63
69
|
|
|
64
70
|
function apiBase() {
|
|
65
|
-
return (
|
|
71
|
+
return (resolveConfig().LEANTIME_API_URL || '').replace(/\/+$/, '');
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
function apiToken() {
|
|
69
|
-
return
|
|
75
|
+
return resolveConfig().LEANTIME_API_TOKEN || '';
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
// Leantime authenticates the JSON-RPC API with its own header, NOT an
|
|
@@ -86,3 +86,14 @@ dev leaf holds the Bash tool and is what actually invokes `codex exec`.
|
|
|
86
86
|
`codex mcp-server` is wired, Codex becomes a tool Claude can call mid-reasoning
|
|
87
87
|
(and Codex can call BYAN tools back) — the closest thing to live. It drops in as a
|
|
88
88
|
new transport behind the same F2 seam, with no change to F1 or the loop.
|
|
89
|
+
|
|
90
|
+
## Config des integrations (Leantime, Google) — une seule memoire
|
|
91
|
+
|
|
92
|
+
Depuis 2.56.0, le client Leantime du serveur MCP resout sa config par la meme
|
|
93
|
+
chaine que le reste du serveur : variables d'environnement d'abord, puis
|
|
94
|
+
`~/.byan/credentials.json`, ecrit par l'installeur a la premiere saisie. Google
|
|
95
|
+
suit le meme fichier (`GOOGLE_APPLICATION_CREDENTIALS`, `GDOC_TEMPLATE_ID`,
|
|
96
|
+
`GDOC_LOGO_PNG_URL`, deja compris par `resolve-config.js`) : l'acces Google
|
|
97
|
+
passe par une cle de service account dont le CHEMIN est memorise — le portage
|
|
98
|
+
d'un OAuth interactif complet dans le serveur MCP reste hors perimetre (le
|
|
99
|
+
serveur tourne sans navigateur), c'est le cadrage assume.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-byan-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.56.0",
|
|
4
4
|
"description": "BYAN v2.8 - Intelligent AI agent creator with ELO trust system + scientific fact-check + Hermes universal dispatcher + native Claude Code integration (hooks, skills, MCP server). Multi-platform (Claude Code, Codex). Merise Agile + TDD + 71 Mantras. ~54% LLM cost savings.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|