Merge pull request 'fix: make mosaic init idempotent — detect existing config files' (#355) from fix/idempotent-init into main
Reviewed-on: mosaic/mosaic-stack#355
This commit was merged in pull request #355.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/cli",
|
"name": "@mosaic/cli",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -54,12 +54,24 @@ function checkRuntime(cmd: string): void {
|
|||||||
function checkSoul(): void {
|
function checkSoul(): void {
|
||||||
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||||
if (!existsSync(soulPath)) {
|
if (!existsSync(soulPath)) {
|
||||||
console.log('[mosaic] SOUL.md not found. Running mosaic init...');
|
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
|
||||||
|
|
||||||
|
// Prefer the TypeScript wizard (idempotent, detects existing files)
|
||||||
|
try {
|
||||||
|
const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
if (result.status === 0 && existsSync(soulPath)) return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to legacy init
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: legacy bash mosaic-init
|
||||||
const initBin = join(MOSAIC_HOME, 'tools', '_scripts', 'mosaic-init');
|
const initBin = join(MOSAIC_HOME, 'tools', '_scripts', 'mosaic-init');
|
||||||
if (existsSync(initBin)) {
|
if (existsSync(initBin)) {
|
||||||
spawnSync(initBin, [], { stdio: 'inherit' });
|
spawnSync(initBin, [], { stdio: 'inherit' });
|
||||||
} else {
|
} else {
|
||||||
console.error('[mosaic] mosaic-init not found. Run: mosaic wizard');
|
console.error('[mosaic] Setup failed. Run: mosaic wizard');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,4 +65,36 @@ describe('detectInstallStage', () => {
|
|||||||
expect(state.installAction).toBe('keep');
|
expect(state.installAction).toBe('keep');
|
||||||
expect(state.soul.agentName).toBe('TestAgent');
|
expect(state.soul.agentName).toBe('TestAgent');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pre-populates state when reconfiguring', async () => {
|
||||||
|
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
|
||||||
|
writeFileSync(join(tmpDir, 'SOUL.md'), 'You are **Jarvis** in this session.');
|
||||||
|
writeFileSync(join(tmpDir, 'USER.md'), '**Name:** TestUser');
|
||||||
|
|
||||||
|
const p = new HeadlessPrompter({
|
||||||
|
'What would you like to do?': 'reconfigure',
|
||||||
|
});
|
||||||
|
const state = createState(tmpDir);
|
||||||
|
await detectInstallStage(p, state, mockConfig);
|
||||||
|
|
||||||
|
expect(state.installAction).toBe('reconfigure');
|
||||||
|
// Existing values loaded as defaults for reconfiguration
|
||||||
|
expect(state.soul.agentName).toBe('TestAgent');
|
||||||
|
expect(state.user.userName).toBe('TestUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pre-populate state on fresh reset', async () => {
|
||||||
|
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
|
||||||
|
writeFileSync(join(tmpDir, 'SOUL.md'), 'You are **Jarvis** in this session.');
|
||||||
|
|
||||||
|
const p = new HeadlessPrompter({
|
||||||
|
'What would you like to do?': 'reset',
|
||||||
|
});
|
||||||
|
const state = createState(tmpDir);
|
||||||
|
await detectInstallStage(p, state, mockConfig);
|
||||||
|
|
||||||
|
expect(state.installAction).toBe('reset');
|
||||||
|
// Reset should NOT load existing values
|
||||||
|
expect(state.soul.agentName).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ Options:
|
|||||||
--timezone <tz> Your timezone (e.g., "America/Chicago")
|
--timezone <tz> Your timezone (e.g., "America/Chicago")
|
||||||
--non-interactive Fail if any required value is missing (no prompts)
|
--non-interactive Fail if any required value is missing (no prompts)
|
||||||
--soul-only Only generate SOUL.md
|
--soul-only Only generate SOUL.md
|
||||||
|
--force Overwrite existing files without prompting
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
NON_INTERACTIVE=0
|
NON_INTERACTIVE=0
|
||||||
SOUL_ONLY=0
|
SOUL_ONLY=0
|
||||||
|
FORCE=0
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -79,6 +81,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--timezone) TIMEZONE="$2"; shift 2 ;;
|
--timezone) TIMEZONE="$2"; shift 2 ;;
|
||||||
--non-interactive) NON_INTERACTIVE=1; shift ;;
|
--non-interactive) NON_INTERACTIVE=1; shift ;;
|
||||||
--soul-only) SOUL_ONLY=1; shift ;;
|
--soul-only) SOUL_ONLY=1; shift ;;
|
||||||
|
--force) FORCE=1; shift ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
|
*) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
@@ -139,6 +142,134 @@ prompt_multiline() {
|
|||||||
eval "$var_name=\"$value\""
|
eval "$var_name=\"$value\""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Existing file detection ────────────────────────────────────
|
||||||
|
|
||||||
|
detect_existing_config() {
|
||||||
|
local found=0
|
||||||
|
local existing_files=()
|
||||||
|
|
||||||
|
[[ -f "$SOUL_OUTPUT" ]] && { found=1; existing_files+=("SOUL.md"); }
|
||||||
|
[[ -f "$USER_OUTPUT" ]] && { found=1; existing_files+=("USER.md"); }
|
||||||
|
[[ -f "$TOOLS_OUTPUT" ]] && { found=1; existing_files+=("TOOLS.md"); }
|
||||||
|
|
||||||
|
if [[ $found -eq 0 || $FORCE -eq 1 ]]; then
|
||||||
|
return 0 # No existing files or --force: proceed with fresh install
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[mosaic-init] Existing configuration detected:"
|
||||||
|
for f in "${existing_files[@]}"; do
|
||||||
|
echo " ✓ $f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Show current agent name if SOUL.md exists
|
||||||
|
if [[ -f "$SOUL_OUTPUT" ]]; then
|
||||||
|
local current_name
|
||||||
|
current_name=$(grep -oP 'You are \*\*\K[^*]+' "$SOUL_OUTPUT" 2>/dev/null || true)
|
||||||
|
if [[ -n "$current_name" ]]; then
|
||||||
|
echo " Agent: $current_name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $NON_INTERACTIVE -eq 1 ]]; then
|
||||||
|
echo "[mosaic-init] Existing config found. Use --force to overwrite in non-interactive mode."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "What would you like to do?"
|
||||||
|
echo " 1) keep — Keep existing files, skip init (default)"
|
||||||
|
echo " 2) import — Import values from existing files as defaults, then regenerate"
|
||||||
|
echo " 3) overwrite — Start fresh, overwrite all files"
|
||||||
|
printf "Choose [1/2/3]: "
|
||||||
|
read -r choice
|
||||||
|
|
||||||
|
case "${choice:-1}" in
|
||||||
|
1|keep)
|
||||||
|
echo "[mosaic-init] Keeping existing configuration."
|
||||||
|
# Still push to runtime adapters in case framework was updated
|
||||||
|
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
|
||||||
|
echo "[mosaic-init] Updating runtime adapters..."
|
||||||
|
"$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
|
||||||
|
fi
|
||||||
|
echo "[mosaic-init] Done. Launch with: mosaic claude"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
2|import)
|
||||||
|
echo "[mosaic-init] Importing values from existing files as defaults..."
|
||||||
|
import_existing_values
|
||||||
|
;;
|
||||||
|
3|overwrite)
|
||||||
|
echo "[mosaic-init] Starting fresh install..."
|
||||||
|
# Back up existing files
|
||||||
|
local ts
|
||||||
|
ts=$(date +%Y%m%d%H%M%S)
|
||||||
|
for f in "${existing_files[@]}"; do
|
||||||
|
local src="$MOSAIC_HOME/$f"
|
||||||
|
if [[ -f "$src" ]]; then
|
||||||
|
cp "$src" "${src}.bak.${ts}"
|
||||||
|
echo " Backed up $f → ${f}.bak.${ts}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[mosaic-init] Invalid choice. Keeping existing configuration."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
import_existing_values() {
|
||||||
|
# Import SOUL.md values
|
||||||
|
if [[ -f "$SOUL_OUTPUT" ]]; then
|
||||||
|
local content
|
||||||
|
content=$(cat "$SOUL_OUTPUT")
|
||||||
|
|
||||||
|
if [[ -z "$AGENT_NAME" ]]; then
|
||||||
|
AGENT_NAME=$(echo "$content" | grep -oP 'You are \*\*\K[^*]+' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$ROLE_DESCRIPTION" ]]; then
|
||||||
|
ROLE_DESCRIPTION=$(echo "$content" | grep -oP 'Role identity: \K.+' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$STYLE" ]]; then
|
||||||
|
if echo "$content" | grep -q 'Be direct, concise'; then
|
||||||
|
STYLE="direct"
|
||||||
|
elif echo "$content" | grep -q 'Be warm and conversational'; then
|
||||||
|
STYLE="friendly"
|
||||||
|
elif echo "$content" | grep -q 'Use professional, structured'; then
|
||||||
|
STYLE="formal"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Import USER.md values
|
||||||
|
if [[ -f "$USER_OUTPUT" ]]; then
|
||||||
|
local content
|
||||||
|
content=$(cat "$USER_OUTPUT")
|
||||||
|
|
||||||
|
if [[ -z "$USER_NAME" ]]; then
|
||||||
|
USER_NAME=$(echo "$content" | grep -oP '\*\*Name:\*\* \K.+' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$PRONOUNS" ]]; then
|
||||||
|
PRONOUNS=$(echo "$content" | grep -oP '\*\*Pronouns:\*\* \K.+' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$TIMEZONE" ]]; then
|
||||||
|
TIMEZONE=$(echo "$content" | grep -oP '\*\*Timezone:\*\* \K.+' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Import TOOLS.md values
|
||||||
|
if [[ -f "$TOOLS_OUTPUT" ]]; then
|
||||||
|
local content
|
||||||
|
content=$(cat "$TOOLS_OUTPUT")
|
||||||
|
|
||||||
|
if [[ -z "$CREDENTIALS_LOCATION" ]]; then
|
||||||
|
CREDENTIALS_LOCATION=$(echo "$content" | grep -oP '\*\*Location:\*\* \K.+' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_existing_config
|
||||||
|
|
||||||
# ── SOUL.md Generation ────────────────────────────────────────
|
# ── SOUL.md Generation ────────────────────────────────────────
|
||||||
echo "[mosaic-init] Generating SOUL.md — agent identity contract"
|
echo "[mosaic-init] Generating SOUL.md — agent identity contract"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/mosaic",
|
"name": "@mosaic/mosaic",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"description": "Mosaic agent framework — installation wizard and meta package",
|
"description": "Mosaic agent framework — installation wizard and meta package",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export async function detectInstallStage(
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.installAction === 'keep') {
|
if (state.installAction === 'keep' || state.installAction === 'reconfigure') {
|
||||||
|
// Load existing values — for 'keep' they're final, for 'reconfigure'
|
||||||
|
// they become pre-populated defaults so the user can tweak them.
|
||||||
state.soul = await config.readSoul();
|
state.soul = await config.readSoul();
|
||||||
state.user = await config.readUser();
|
state.user = await config.readUser();
|
||||||
state.tools = await config.readTools();
|
state.tools = await config.readTools();
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ export async function soulSetupStage(p: WizardPrompter, state: WizardState): Pro
|
|||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (state.installAction === 'reconfigure') {
|
||||||
|
// Show existing value as default so the user can accept or change it
|
||||||
|
state.soul.agentName = await p.text({
|
||||||
|
message: 'What name should agents use?',
|
||||||
|
placeholder: state.soul.agentName,
|
||||||
|
defaultValue: state.soul.agentName,
|
||||||
|
validate: (v) => {
|
||||||
|
if (v.length === 0) return 'Name cannot be empty';
|
||||||
|
if (v.length > 50) return 'Name must be under 50 characters';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.mode === 'advanced') {
|
if (state.mode === 'advanced') {
|
||||||
@@ -38,7 +50,7 @@ export async function soulSetupStage(p: WizardPrompter, state: WizardState): Pro
|
|||||||
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.soul.communicationStyle) {
|
if (!state.soul.communicationStyle || state.installAction === 'reconfigure') {
|
||||||
state.soul.communicationStyle = await p.select<CommunicationStyle>({
|
state.soul.communicationStyle = await p.select<CommunicationStyle>({
|
||||||
message: 'Communication style',
|
message: 'Communication style',
|
||||||
options: [
|
options: [
|
||||||
@@ -46,7 +58,7 @@ export async function soulSetupStage(p: WizardPrompter, state: WizardState): Pro
|
|||||||
{ value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' },
|
{ value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' },
|
||||||
{ value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' },
|
{ value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' },
|
||||||
],
|
],
|
||||||
initialValue: 'direct',
|
initialValue: state.soul.communicationStyle ?? 'direct',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user