import fs from 'node:fs'; import path from 'node:path'; import * as readline from 'node:readline'; import { stdin as input, stderr as output, stdout as standardOutput } from 'node:process'; import { buildApiV1Url, isHttpUrl, stripEndpointSuffix } from '../provider/api-v1-url.js'; import { auditResolvedCliConfig, isBroadTrustedToolPattern, loadCliConfigFile, loadConfigFile, normalizeApprovalPolicyConfig, normalizeConfigProfile, normalizeSafetyModeConfig, parseConfigBoolean, parseProviderPreset, parseTrustedTools, PROVIDER_PRESETS, resolveCliConfig, resolveConfigDir, resolveConfigPath, resolveProjectConfigPath, saveConfigFile, saveConfigFileAtPath, type CliConfigOverrides, type CliProviderPreset, type ConfigFile, type ResolvedCliConfig, } from './config.js'; import { clearMossCommunityAuthSession, formatCommunityAuthStatus, getMossCommunityAuthStatus, } from './community-auth.js'; import { loadModelChoicesForRuntime } from './model-catalog.js'; import { errorMessage } from '../errors.js'; export async function probeSetupReachability( config: Partial, options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {} ): Promise { let result; try { result = await loadModelChoicesForRuntime(config, config.model ?? '', { timeoutMs: options.timeoutMs ?? 2500, fetchImpl: options.fetchImpl, }); } catch { return 'Saved, but could not reach the gateway with this key — check baseUrl/key, then re-run `moss setup`.'; } if (result.source === 'live') { return `Configured and reachable — ${result.choices.length} model(s) available from the gateway.`; } if (result.warning) { return 'Saved, but could not reach the gateway with this key — check baseUrl/key, then re-run `moss setup`.'; } return `Key saved (${result.providerLabel} — skipping live reachability check).`; } function print(line = ''): void { output.write(`${line}\n`); } function question(prompt: string): Promise { const rl = readline.createInterface({ input, output }); return new Promise((resolve) => { rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim()); }); }); } function questionWith(rl: readline.Interface, prompt: string): Promise { return new Promise((resolve) => { rl.question(prompt, (answer) => resolve(answer.trim())); }); } function hiddenQuestion(prompt: string): Promise { if (!input.isTTY) return question(prompt); return new Promise((resolve) => { readline.emitKeypressEvents(input); const wasRaw = input.isRaw; input.setRawMode(true); input.resume(); output.write(prompt); let value = ''; function cleanup() { input.off('keypress', onKeypress); input.setRawMode(wasRaw); output.write('\n'); resolve(value.trim()); } function onKeypress(str: string, key: readline.Key) { if (key.ctrl && key.name === 'c') { output.write('\n'); process.exit(130); } if (key.name === 'return' || key.name === 'enter') { cleanup(); return; } if (key.name === 'backspace') { value = value.slice(0, -1); return; } if (!key.ctrl && !key.meta && str) { value += str; } } input.on('keypress', onKeypress); }); } function providerFromChoice(choice: string): CliProviderPreset { const normalized = choice.trim().toLowerCase(); if (normalized === '1' || normalized === 'deepseek' || normalized === 'ds') return 'deepseek'; if (normalized === '2' || normalized === 'qwen' || normalized === 'aliyun') return 'qwen'; if (normalized === '3' || normalized === 'openai') return 'openai'; if (normalized === '4' || normalized === 'anthropic' || normalized === 'claude') return 'anthropic'; if (normalized === '5' || normalized === 'compatible' || normalized === 'openai-compatible') return 'openai-compatible'; return 'deepseek'; } function sanitizeBaseUrl(value: string): string { const trimmed = value.trim(); try { const url = new URL(trimmed); url.username = ''; url.password = ''; url.search = ''; url.hash = ''; return stripEndpointSuffix(url.toString()); } catch { return stripEndpointSuffix(trimmed); } } const MODEL_SIGNATURES: Record = { deepseek: { prefixes: ['deepseek-'], names: ['deepseek-v4-flash', 'deepseek-v4-pro'], }, qwen: { prefixes: ['qwen-', 'qwen3', 'qvq-', 'qwq-'], names: ['qwen3.6-plus', 'qwen3.7-max', 'qwen3.6-flash', 'qwen-plus', 'qwen-max', 'qwen-turbo'], }, openai: { prefixes: ['gpt-', 'o1-', 'o3-', 'o4-', 'davinci-'], names: [ 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o1', 'o1-mini', 'o3-mini', 'o4-mini', ], }, anthropic: { prefixes: ['claude-'], names: [ 'claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', ], }, 'openai-compatible': { prefixes: [], names: [] }, }; function guessModelProvider(model: string): CliProviderPreset | null { const lower = model.toLowerCase().trim(); for (const [provider, sig] of Object.entries(MODEL_SIGNATURES)) { if (provider === 'openai-compatible') continue; if (sig.prefixes.some((p) => lower.startsWith(p))) return provider as CliProviderPreset; if (sig.names.some((n) => lower === n)) return provider as CliProviderPreset; } return null; } function withoutSecret(value: string): string { try { const url = new URL(value); url.username = ''; url.password = ''; url.search = ''; url.hash = ''; return url.toString().replace(/\/$/, ''); } catch { return value || '(not configured)'; } } function guardrailSummary(resolved: ReturnType): string { const inputCount = resolved.guardrails.input.blockPatterns.length + resolved.guardrails.input.redactPatterns.length; const outputCount = resolved.guardrails.output.blockPatterns.length + resolved.guardrails.output.redactPatterns.length; if (inputCount === 0 && outputCount === 0) return `none (${resolved.guardrailsSource})`; return `input ${inputCount}, output ${outputCount} (${resolved.guardrailsSource})`; } function configAuditSummary(resolved: ReturnType): string { const warnings = auditResolvedCliConfig(resolved); if (warnings.length === 0) return 'none'; return warnings.map((warning) => `${warning.code}: ${warning.message}`).join('; '); } function serializeResolvedConfig( resolved: ReturnType ): Record { return { schema: 'moss_cli_config.v1', profile: resolved.profile, profileSource: resolved.profileSource, provider: resolved.provider, providerSource: resolved.providerSource, model: resolved.model, modelSource: resolved.modelSource, baseUrl: withoutSecret(resolved.baseUrl), baseUrlSource: resolved.baseUrlSource, apiKeyConfigured: Boolean(resolved.apiKey), apiKeySource: resolved.apiKeySource, ignoredModelEnvVars: [...resolved.ignoredModelEnvVars], workspace: resolved.workspace, workspaceSource: resolved.workspaceSource, safetyMode: resolved.safetyMode, safetyModeSource: resolved.safetyModeSource, approvalPolicy: resolved.approvalPolicy, approvalPolicySource: resolved.approvalPolicySource, trustedTools: [...resolved.trustedTools], trustedToolsSource: resolved.trustedToolsSource, deniedTools: [...resolved.deniedTools], deniedToolsSource: resolved.deniedToolsSource, promptCacheEnabled: resolved.promptCacheEnabled, promptCacheSource: resolved.promptCacheSource, promptCacheDebug: resolved.promptCacheDebug, promptCacheDebugSource: resolved.promptCacheDebugSource, guardrails: { input: { blockPatterns: [...resolved.guardrails.input.blockPatterns], redactPatterns: [...resolved.guardrails.input.redactPatterns], }, output: { blockPatterns: [...resolved.guardrails.output.blockPatterns], redactPatterns: [...resolved.guardrails.output.redactPatterns], }, }, guardrailsSource: resolved.guardrailsSource, maxAgentTurns: resolved.maxAgentTurns, maxAgentTurnsSource: resolved.maxAgentTurnsSource, contextTokens: resolved.contextTokens, contextTokensSource: resolved.contextTokensSource, compactionSettings: { ...resolved.compactionSettings }, compactionSettingsSource: resolved.compactionSettingsSource, mcpEnabled: resolved.mcpEnabled, mcpEnabledSource: resolved.mcpEnabledSource, mcpConfigPath: resolved.mcpConfigPath, mcpConfigPathSource: resolved.mcpConfigPathSource, configWarnings: auditResolvedCliConfig(resolved), configPath: resolved.configPath, projectConfigPath: resolved.projectConfigPath ?? null, }; } function serializeConfigValidation( resolved: ReturnType, options: { strict: boolean; extraWarnings?: ReturnType } ): Record { const warnings = options.extraWarnings ?? auditResolvedCliConfig(resolved); return { schema: 'moss_cli_config_validation.v1', ok: !options.strict || warnings.length === 0, strict: options.strict, warningCount: warnings.length, configWarnings: warnings, configPath: resolved.configPath, projectConfigPath: resolved.projectConfigPath ?? null, }; } function parseConfigPositiveInteger( value: string, key: string ): { ok: true; value: number } | { ok: false; error: string } { const parsed = Number(value.trim()); if (!Number.isInteger(parsed) || parsed <= 0) { return { ok: false, error: `Supported ${key} value: positive integer` }; } return { ok: true, value: parsed }; } function parseConfigPatternList(value: string, key: string): string[] { const patterns = value .split(',') .map((pattern) => pattern.trim()) .filter(Boolean); const unique = [...new Set(patterns)]; for (const pattern of unique) { if (pattern.length > 500) { throw new Error(`Unsupported ${key} pattern: values must be 500 characters or less`); } let regex: RegExp; try { regex = new RegExp(pattern, 'g'); } catch (err) { const message = errorMessage(err); throw new Error(`Invalid ${key} pattern "${pattern}": ${message}`); } if (regex.test('')) { throw new Error(`Invalid ${key} pattern "${pattern}": pattern must not match empty text`); } } return unique; } function setGuardrailPatternList(config: ConfigFile, key: string, value: string): boolean { if ( key !== 'guardrails.input.blockPatterns' && key !== 'guardrails.input.redactPatterns' && key !== 'guardrails.output.blockPatterns' && key !== 'guardrails.output.redactPatterns' ) { return false; } const [, direction, listKey] = key.split('.') as [ 'guardrails', 'input' | 'output', 'blockPatterns' | 'redactPatterns', ]; config.guardrails = { ...config.guardrails, [direction]: { ...config.guardrails?.[direction], [listKey]: parseConfigPatternList(value, key), }, }; return true; } export function renderAuthStatus( config?: ConfigFile, env: NodeJS.ProcessEnv = process.env, startDir = process.cwd(), overrides: CliConfigOverrides = {}, heading = '[auth]' ): string { const loaded = config === undefined ? loadCliConfigFile(env, process.argv.slice(2), startDir) : undefined; const resolved = resolveCliConfig(env, config ?? loaded?.config, overrides, loaded); const communityStatus = getMossCommunityAuthStatus({ env }); return [ heading, ` community: ${formatCommunityAuthStatus(communityStatus)}`, ` provider: ${resolved.provider} (${resolved.providerSource})`, ` profile: ${resolved.profile} (${resolved.profileSource})`, ` model: ${resolved.model ? `${resolved.model} (${resolved.modelSource})` : `(${resolved.modelSource})`}`, ` baseUrl: ${withoutSecret(resolved.baseUrl)} (${resolved.baseUrlSource}) → chat completions: ${withoutSecret(buildApiV1Url(resolved.baseUrl, 'chat/completions'))}`, ` apiKey: ${resolved.apiKey ? `configured (${resolved.apiKeySource === 'built-in' ? 'built-in, shared gateway key' : `${resolved.apiKeySource}, ${resolved.apiKeyEncrypted ? 'encrypted' : 'plain text'}`})` : "missing -- run 'moss setup' to configure"}`, ` safetyMode: ${resolved.safetyMode} (${resolved.safetyModeSource})`, ` approvalPolicy: ${resolved.approvalPolicy} (${resolved.approvalPolicySource})`, ` trustedTools: ${resolved.trustedTools.length ? resolved.trustedTools.join(', ') : 'none'} (${resolved.trustedToolsSource})`, ` deniedTools: ${resolved.deniedTools.length ? resolved.deniedTools.join(', ') : 'none'} (${resolved.deniedToolsSource})`, ` promptCache: ${resolved.promptCacheEnabled ? 'enabled' : 'disabled'} (${resolved.promptCacheSource})`, ` promptCacheDebug: ${resolved.promptCacheDebug ? 'enabled' : 'disabled'} (${resolved.promptCacheDebugSource})`, ` guardrails: ${guardrailSummary(resolved)}`, ` maxAgentTurns: ${resolved.maxAgentTurns} (${resolved.maxAgentTurnsSource})`, ` contextTokens: ${resolved.contextTokens} (${resolved.contextTokensSource})`, ` compaction: reserve ${resolved.compactionSettings.reserveTokens}, keepRecent ${resolved.compactionSettings.keepRecentTokens} (${resolved.compactionSettingsSource})`, ` mcp: ${resolved.mcpEnabled ? 'enabled' : 'disabled'} (${resolved.mcpEnabledSource})`, ` mcpConfig: ${resolved.mcpConfigPath} (${resolved.mcpConfigPathSource})`, ` configWarnings: ${configAuditSummary(resolved)}`, ` config: ${resolved.configPath}`, ` projectConfig: ${resolved.projectConfigPath || 'none'}${resolved.projectConfigPath ? ' — project config overrides user config for this workspace' : ''}`, ].join('\n'); } export function renderConfigJson( config?: ConfigFile, env: NodeJS.ProcessEnv = process.env, startDir = process.cwd(), overrides: CliConfigOverrides = {} ): string { const loaded = config === undefined ? loadCliConfigFile(env, process.argv.slice(2), startDir) : undefined; const resolved = resolveCliConfig(env, config ?? loaded?.config, overrides, loaded); return JSON.stringify(serializeResolvedConfig(resolved), null, 2); } export function renderConfigUsage(): string { return [ 'Usage:', ' moss config', ' moss config init [--project] [--force]', ' moss config show', ' moss config show --json', ' moss config validate [--strict] [--json]', ' moss config set # model', ' moss config set # operational', ' moss config set = [=...] # batch', ' moss config set --project = [=...]', ' moss config set --project ', ' moss config unset ', ' moss config unset --project ', '', 'Config file:', ' Moss reads .moss/config.json from the current workspace as project defaults', ' moss --config-file /path/to/config.json config show', ' set MOSS_CONFIG_FILE=/path/to/config.json to use an explicit config file', '', 'Examples:', ' moss config init --project', ' moss config validate --strict', ' moss config set profile autonomous', ' moss config set provider openai-compatible', ' moss config set model ', ' moss config set baseUrl https://your-gateway.example # API root, not /v1 or /chat/completions', ' moss setup # stores the API key (hidden prompt, safer than command line)', ' moss config set --project safetyMode workspace-write', ' moss config set approvalPolicy prompt', ' moss config set trustedTools exec,filesystem__*', ' moss config set deniedTools device_*,write_file', ' moss config set mcp.enabled true', ' moss config set mcp.configPath .moss/mcp.json', ' moss config set guardrails.input.redactPatterns SECRET=[^\\\\s]+', ' moss config set agent.maxTurns 96', ' moss config set agent.contextTokens 200000', ' moss config set agent.compaction.reserveTokens 20000', ].join('\n'); } export function runConfigShow( startDir = process.cwd(), options: { json?: boolean; overrides?: CliConfigOverrides } = {} ): void { const overrides = options.overrides ?? {}; if (options.json) { standardOutput.write(`${renderConfigJson(undefined, process.env, startDir, overrides)}\n`); return; } standardOutput.write( `${renderAuthStatus(undefined, process.env, startDir, overrides, '[config]')}\n` ); } export function runConfigValidate(args: string[] = [], startDir = process.cwd()): void { let json = false; let strict = false; for (const arg of args) { if (arg === '--json') json = true; else if (arg === '--strict') strict = true; else { print(renderConfigUsage()); process.exitCode = 1; return; } } const loaded = loadCliConfigFile(process.env, process.argv.slice(2), startDir); const resolved = resolveCliConfig(process.env, loaded.config, {}, loaded); const warnings = [...auditResolvedCliConfig(resolved)]; if (!resolved.usingBundledDefault && !resolved.model) { warnings.push({ code: 'model.missing', severity: 'warn', source: 'default', message: `no model configured for provider "${resolved.provider}"; run \`moss config set model=\` or \`moss setup\``, }); } if (!resolved.usingBundledDefault && !resolved.apiKey) { warnings.push({ code: 'model.missing_api_key', severity: 'warn', source: 'default', message: `no API key configured for provider "${resolved.provider}"; moss will fail at runtime — run \`moss setup\` to add one`, }); } if (strict && warnings.length > 0) process.exitCode = 1; if (json) { standardOutput.write( `${JSON.stringify(serializeConfigValidation(resolved, { strict, extraWarnings: warnings }), null, 2)}\n` ); return; } print(`[config] valid: ${resolved.configPath}`); if (resolved.projectConfigPath) print(`[config] project config: ${resolved.projectConfigPath}`); if (warnings.length === 0) { print('[config] warnings: none'); return; } for (const warning of warnings) { print(`[config] warning ${warning.code}: ${warning.message}`); } if (strict) print('[config] strict validation failed because warnings are present.'); } export async function runSetupWizard(): Promise { const current = loadConfigFile(); print('Moss model setup'); print(''); print('Choose provider:'); print(' 1. DeepSeek (recommended)'); print(' 2. Aliyun / Qwen'); print(' 3. OpenAI'); print(' 4. Anthropic'); print(' 5. OpenAI-compatible'); const pipedAnswers = input.isTTY ? null : fs.readFileSync(0, 'utf-8').split(/\r?\n/); let answerIndex = 0; const nextPipedAnswer = () => (pipedAnswers ? (pipedAnswers[answerIndex++] ?? '').trim() : ''); const rl = input.isTTY ? readline.createInterface({ input, output }) : null; const providerAnswer = rl ? await questionWith(rl, 'Provider [1]: ') : nextPipedAnswer(); const provider = providerFromChoice(providerAnswer || '1'); const preset = PROVIDER_PRESETS[provider]; const defaultModel = current.model || preset.defaultModel; const defaultBaseUrl = current.baseUrl || preset.defaultBaseUrl; if (provider === 'openai-compatible') { const baseUrlPrompt = defaultBaseUrl ? `Gateway URL [${defaultBaseUrl}]: ` : 'Gateway URL: '; const baseUrlAnswer = rl ? await questionWith(rl, baseUrlPrompt) : nextPipedAnswer(); const baseUrlInput = baseUrlAnswer || defaultBaseUrl; if (!isHttpUrl(baseUrlInput)) { rl?.close(); print(`Setup cancelled: base URL must be a full http(s) URL, got: ${baseUrlInput}`); process.exitCode = 1; return; } const baseUrl = sanitizeBaseUrl(baseUrlInput); if (baseUrl !== baseUrlInput.trim().replace(/\/+$/, '')) { print(''); print( `Note: base URL normalized to "${baseUrl}" (endpoint paths, query strings, and credentials stripped).` ); } if (input.isTTY) rl?.close(); const apiKey = input.isTTY ? await hiddenQuestion('API key (hidden): ') : nextPipedAnswer(); if (!apiKey) { print('Setup cancelled: API key is required.'); process.exitCode = 1; return; } let model = defaultModel; let skipPostProbe = false; if (input.isTTY) { print(''); print('Checking available models on your gateway…'); const liveModels = await (async () => { try { const res = await fetch(buildApiV1Url(baseUrl, 'models'), { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(5000), }); if (!res.ok) return []; const json = (await res.json()) as { data?: { id?: string; name?: string }[] }; return (json?.data ?? []) .flatMap((item) => { const id = item?.id ?? item?.name ?? ''; return typeof id === 'string' && id.trim() ? [id.trim()] : []; }) .slice(0, 30); } catch { return []; } })(); const rl2 = readline.createInterface({ input, output }); if (liveModels.length > 0) { skipPostProbe = true; print(`Found ${liveModels.length} model(s):`); liveModels.slice(0, 15).forEach((m, i) => print(` ${i + 1}. ${m}`)); const defaultChoice = defaultModel || liveModels[0]!; const ans = (await questionWith(rl2, `Choose model [${defaultChoice}]: `)).trim(); if (/^\d+$/.test(ans)) { model = liveModels[parseInt(ans, 10) - 1] ?? defaultChoice; } else { model = ans || defaultChoice; } } else { print('Note: could not reach /v1/models — enter your model name manually.'); const ans = ( await questionWith(rl2, `Model name${defaultModel ? ` [${defaultModel}]` : ''}: `) ).trim(); model = ans || defaultModel; } rl2.close(); } else { const ans = nextPipedAnswer(); model = ans || defaultModel; } const next: ConfigFile = { ...current, provider, baseUrl, apiKey, promptCache: current.promptCache ?? { enabled: true, debug: false }, ...(model ? { model } : {}), }; saveConfigFile(next); print(''); print(`Saved configuration to ${resolveConfigPath()}`); print(`Provider: ${preset.displayName}`); print( model ? `Model: ${model}` : "Model: (not set — start Moss and run /model to pick from your gateway's available models)" ); print(`Base URL: ${withoutSecret(baseUrl)}`); if (!skipPostProbe && input.isTTY) { print(''); print('Checking the gateway…'); print(await probeSetupReachability({ provider, model, baseUrl, apiKey })); } print(''); print('Security note: the API key is stored encrypted in the config file (file mode 600).'); print('Avoid sharing or committing this file. Run `moss auth logout` to remove the key.'); print(''); print( 'Try `moss "explain this project and how to run it"` or run `moss` for interactive mode.' ); return; } const fastPath = Boolean(rl); let model: string; let baseUrlInput: string; if (fastPath) { model = defaultModel; baseUrlInput = defaultBaseUrl; print( `Using ${preset.displayName} defaults — model ${defaultModel}, base URL ${defaultBaseUrl}.` ); print('(Change later with `moss config set model ` or `moss config set baseUrl `.)'); } else { const modelAnswer = rl ? await questionWith(rl, `Model [${defaultModel}]: `) : nextPipedAnswer(); model = modelAnswer || defaultModel; const baseUrlAnswer = rl ? await questionWith(rl, `Base URL [${defaultBaseUrl}]: `) : nextPipedAnswer(); baseUrlInput = baseUrlAnswer || defaultBaseUrl; } if (!isHttpUrl(baseUrlInput)) { rl?.close(); print(`Setup cancelled: base URL must be a full http(s) URL, got: ${baseUrlInput}`); process.exitCode = 1; return; } const baseUrl = sanitizeBaseUrl(baseUrlInput); const wasNormalized = baseUrl !== baseUrlInput.trim().replace(/\/+$/, ''); if (wasNormalized) { print(''); print(`Note: the base URL was normalized from "${baseUrlInput.trim()}" to "${baseUrl}".`); print( 'Endpoint paths (/v1/chat/completions, /v1), query strings (?foo=bar), and credentials were stripped.' ); print('Moss appends /v1/chat/completions itself — the saved value above is your API root.'); } let apiKey: string; if (input.isTTY) { rl?.close(); apiKey = await hiddenQuestion('API key (hidden): '); } else { apiKey = nextPipedAnswer(); } if (!apiKey) { print('Setup cancelled: API key is required.'); process.exitCode = 1; return; } const next: ConfigFile = { ...current, provider, model, baseUrl, apiKey, promptCache: current.promptCache ?? { enabled: true, debug: false }, }; saveConfigFile(next); print(''); print(`Saved configuration to ${resolveConfigPath()}`); print(`Provider: ${preset.displayName}`); print(`Model: ${model}`); print(`Base URL: ${withoutSecret(baseUrl)}`); if (input.isTTY) { print(''); print('Checking the gateway…'); print(await probeSetupReachability({ provider, model, baseUrl, apiKey })); } print(''); print('Security note: the API key is stored encrypted in the config file (file mode 600).'); print('Avoid sharing or committing this file. Run `moss auth logout` to remove the key.'); print(''); print('Try `moss "explain this project and how to run it"` or run `moss` for interactive mode.'); } export async function runAuthLogout(): Promise { const removedCommunitySession = clearMossCommunityAuthSession(); if (removedCommunitySession) { print('[auth] D-Robotics community session removed.'); } const current = loadConfigFile(); if (!current.apiKey) { if (!removedCommunitySession) print('[auth] No API key or D-Robotics community session is stored.'); return; } const answer = await question('Remove stored API key from Moss config? [y/N] '); if (!/^y(es)?$/i.test(answer)) { print('[auth] Cancelled.'); return; } const next = { ...current }; delete next.apiKey; saveConfigFile(next); print('[auth] Stored API key removed. Model and baseUrl were preserved.'); } function resolveConfigEditTarget( args: string[], startDir: string ): { args: string[]; configPath: string; scope: 'user' | 'project' } { if (args[0] !== '--project') { return { args, configPath: resolveConfigPath(), scope: 'user' }; } const root = path.resolve(startDir); return { args: args.slice(1), configPath: resolveProjectConfigPath(root) ?? path.join(root, '.moss', 'config.json'), scope: 'project', }; } function resolveConfigInitTarget( args: string[], startDir: string ): { configPath: string; scope: 'user' | 'project'; force: boolean } | null { let scope: 'user' | 'project' = 'user'; let force = false; for (const arg of args) { if (arg === '--project') { scope = 'project'; } else if (arg === '--force') { force = true; } else { print(renderConfigUsage()); process.exitCode = 1; return null; } } const root = path.resolve(startDir); return { scope, force, configPath: scope === 'project' ? (resolveProjectConfigPath(root) ?? path.join(root, '.moss', 'config.json')) : resolveConfigPath(), }; } function buildUserConfigTemplate(): ConfigFile { const resolved = resolveCliConfig(process.env, {}); return removeEmptyNestedConfig({ profile: resolved.profile, provider: resolved.provider, model: resolved.model, baseUrl: resolved.baseUrl, workspace: resolved.workspaceSource === 'cwd' ? undefined : resolved.workspace, safetyMode: resolved.safetyMode, approvalPolicy: resolved.approvalPolicy, trustedTools: [...resolved.trustedTools], deniedTools: [...resolved.deniedTools], promptCache: { enabled: resolved.promptCacheEnabled, debug: resolved.promptCacheDebug, }, mcp: { enabled: resolved.mcpEnabled, configPath: resolved.mcpConfigPath, }, agent: { maxTurns: resolved.maxAgentTurns, contextTokens: resolved.contextTokens, compaction: { ...resolved.compactionSettings }, }, _examples: { customModel: { provider: 'openai-compatible', baseUrl: 'https://your-gateway.example', model: 'your-model-name', apiKey: 'paste-your-api-key', }, }, }); } function buildProjectConfigTemplate(): ConfigFile { const resolved = resolveCliConfig(process.env, {}); return removeEmptyNestedConfig({ profile: resolved.profile, safetyMode: resolved.safetyMode, approvalPolicy: resolved.approvalPolicy, trustedTools: [...resolved.trustedTools], deniedTools: [...resolved.deniedTools], promptCache: { enabled: resolved.promptCacheEnabled, debug: resolved.promptCacheDebug, }, mcp: { enabled: resolved.mcpEnabled, configPath: '.moss/mcp.json', }, agent: { maxTurns: resolved.maxAgentTurns, contextTokens: resolved.contextTokens, compaction: { ...resolved.compactionSettings }, }, _examples: { customModel: { _comment: 'set these via moss config set --project provider|model|baseUrl ', _apiKey: 'use moss setup for the key (hidden prompt); apiKey set via config file is encrypted at rest', }, }, }); } function supportedConfigKeys(): string { return 'Supported keys — model: provider, model, baseUrl, apiKey; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, mcp.enabled, mcp.configPath, agent.maxTurns, agent.contextTokens, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens'; } function removeEmptyNestedConfig(config: ConfigFile): ConfigFile { const next = { ...config }; if ( next.promptCache && typeof next.promptCache === 'object' && Object.keys(next.promptCache).length === 0 ) { delete next.promptCache; } if (next.agent?.compaction && Object.keys(next.agent.compaction).length === 0) { next.agent = { ...next.agent }; delete next.agent.compaction; } if (next.agent && Object.keys(next.agent).length === 0) { delete next.agent; } if (next.mcp && Object.keys(next.mcp).length === 0) { delete next.mcp; } if (next.guardrails) { const guardrails = { ...next.guardrails }; if (guardrails.input && Object.keys(guardrails.input).length === 0) delete guardrails.input; if (guardrails.output && Object.keys(guardrails.output).length === 0) delete guardrails.output; if (Object.keys(guardrails).length === 0) delete next.guardrails; else next.guardrails = guardrails; } return next; } export function runConfigInit(args: string[], startDir = process.cwd()): void { const target = resolveConfigInitTarget(args, startDir); if (!target) return; if (fs.existsSync(target.configPath) && !target.force) { print(`[config] ${target.configPath} already exists. Use --force to overwrite.`); process.exitCode = 1; return; } const template = target.scope === 'project' ? buildProjectConfigTemplate() : buildUserConfigTemplate(); saveConfigFileAtPath(template, target.configPath); const scope = target.scope === 'project' ? 'project ' : ''; print(`[config] ${scope}config initialized in ${target.configPath}`); } function applyConfigSetPair( next: ConfigFile, current: ConfigFile, key: string, value: string ): { ok: boolean; messages: string[] } { const messages: string[] = []; if (key === 'profile') { const profile = normalizeConfigProfile(value); if (!profile) { return { ok: false, messages: ['Supported profile values: cautious, balanced, autonomous'] }; } next.profile = profile; } else if (key === 'provider') { const provider = parseProviderPreset(value); if (!provider) { return { ok: false, messages: [ `Unknown provider: ${value}`, 'Supported provider values: deepseek, qwen, openai, anthropic, openai-compatible', 'Run `moss config --help` for supported keys and usage.', ], }; } next.provider = provider; const existingModel = ((next.model ?? '') as string).toLowerCase().trim(); if (existingModel && provider !== 'openai-compatible') { const guessed = guessModelProvider(existingModel); if (guessed && guessed !== provider) { messages.push( `[config] Warning: model "${existingModel}" looks like a ${PROVIDER_PRESETS[guessed].displayName} model, but provider is ${PROVIDER_PRESETS[provider].displayName}. Mismatch?` ); } } } else if (key === 'model') { next.model = value; const resolvedProvider = next.provider ?? current.provider; if (resolvedProvider && resolvedProvider !== 'openai-compatible') { const guessed = guessModelProvider(value); if (guessed && guessed !== resolvedProvider) { messages.push( `[config] Warning: model "${value}" looks like a ${PROVIDER_PRESETS[guessed].displayName} model, but provider is ${PROVIDER_PRESETS[resolvedProvider as CliProviderPreset].displayName}. Mismatch?` ); } } } else if (key === 'apiKey') { next.apiKey = value; } else if (key === 'baseUrl') { if (!isHttpUrl(value)) { return { ok: false, messages: [ `Invalid baseUrl: ${value.trim()}`, 'baseUrl must be a full http(s) URL, e.g. https://your-gateway.example (API root, no /v1)', ], }; } const sanitized = sanitizeBaseUrl(value); const wasNormalized = sanitized !== value.trim().replace(/\/+$/, ''); if (wasNormalized) { messages.push(`[config] baseUrl normalized to API root: ${sanitized}`); messages.push( '[config] (Moss appends /v1/chat/completions itself — endpoint paths, query strings, and credentials are stripped.)' ); } next.baseUrl = sanitized; } else if (key === 'workspace') { next.workspace = path.resolve(value); } else if (key === 'safetyMode') { const mode = normalizeSafetyModeConfig(value); if (!mode) { return { ok: false, messages: ['Supported safetyMode values: read-only, workspace-write, full-access'], }; } next.safetyMode = mode; } else if (key === 'approvalPolicy') { const policy = normalizeApprovalPolicyConfig(value); if (!policy) { return { ok: false, messages: ['Supported approvalPolicy values: prompt, never'] }; } next.approvalPolicy = policy; } else if (key === 'trustedTools') { try { const parsedTrusted = parseTrustedTools(value) ?? []; next.trustedTools = parsedTrusted; const broad = parsedTrusted.filter(isBroadTrustedToolPattern); if (broad.length > 0) { messages.push( `[config] WARNING: broad trusted pattern(s) ${broad.join(', ')} auto-approve every mutating tool the safety mode allows; prefer exact tool names or narrow server__tool globs.` ); } } catch (err) { return { ok: false, messages: [errorMessage(err)] }; } } else if (key === 'deniedTools') { try { next.deniedTools = parseTrustedTools(value) ?? []; } catch (err) { return { ok: false, messages: [errorMessage(err)] }; } } else if (key === 'promptCache') { const enabled = parseConfigBoolean(value); if (enabled === null) { return { ok: false, messages: ['Supported promptCache values: true/false (yes/no, on/off, 1/0 also accepted)'], }; } const previous = typeof next.promptCache === 'object' && next.promptCache !== null ? next.promptCache : {}; next.promptCache = { ...previous, enabled }; } else if (key === 'promptCacheDebug') { const debug = parseConfigBoolean(value); if (debug === null) { return { ok: false, messages: [ 'Supported promptCacheDebug values: true/false (yes/no, on/off, 1/0 also accepted)', ], }; } const previous = typeof next.promptCache === 'object' && next.promptCache !== null ? next.promptCache : { enabled: typeof next.promptCache === 'boolean' ? next.promptCache : true }; next.promptCache = { ...previous, debug }; } else if (key.startsWith('guardrails.')) { try { if (!setGuardrailPatternList(next, key, value)) { return { ok: false, messages: [ supportedConfigKeys(), 'Run `moss config --help` for supported keys and usage.', ], }; } } catch (err) { return { ok: false, messages: [errorMessage(err)] }; } } else if (key === 'mcp.enabled') { const enabled = parseConfigBoolean(value); if (enabled === null) { return { ok: false, messages: ['Supported mcp.enabled values: true/false (yes/no, on/off, 1/0 also accepted)'], }; } next.mcp = { ...next.mcp, enabled }; } else if (key === 'mcp.configPath') { next.mcp = { ...next.mcp, configPath: value }; } else if (key === 'agent.maxTurns' || key === 'agent.contextTokens') { const parsed = parseConfigPositiveInteger(value, key); if (!parsed.ok) { return { ok: false, messages: [parsed.error] }; } next.agent = { ...next.agent }; if (key === 'agent.maxTurns') next.agent.maxTurns = parsed.value; else next.agent.contextTokens = parsed.value; } else if ( key === 'agent.compaction.reserveTokens' || key === 'agent.compaction.keepRecentTokens' ) { const parsed = parseConfigPositiveInteger(value, key); if (!parsed.ok) { return { ok: false, messages: [parsed.error] }; } next.agent = { ...next.agent, compaction: { ...next.agent?.compaction, }, }; if (key === 'agent.compaction.reserveTokens') { next.agent.compaction = { ...next.agent.compaction, reserveTokens: parsed.value }; } else { next.agent.compaction = { ...next.agent.compaction, keepRecentTokens: parsed.value }; } } else { return { ok: false, messages: [supportedConfigKeys(), 'Run `moss config --help` for supported keys and usage.'], }; } return { ok: true, messages }; } export function runConfigSet(args: string[], startDir = process.cwd()): void { const target = resolveConfigEditTarget(args, startDir); args = target.args; const isBatch = args.length > 0 && args[0].includes('='); let pairs: { key: string; value: string }[]; if (isBatch) { pairs = []; for (const arg of args) { const eqIdx = arg.indexOf('='); if (eqIdx === -1) { print(`Batch config set: each argument must be key=value, got "${arg}"`); process.exitCode = 1; return; } pairs.push({ key: arg.slice(0, eqIdx), value: arg.slice(eqIdx + 1) }); } } else { const [key, ...rest] = args; const value = rest.join(' ').trim(); if (!key) { print(renderConfigUsage()); process.exitCode = 1; return; } if (!value) { print( `config ${key}: value must not be empty. Run \`moss config --help\` for supported keys and usage.` ); process.exitCode = 1; return; } if (rest.length > 1) { print( `config set: "${key}" takes a single value (got ${rest.length}). Quote it if it contains spaces: moss config set ${key} "${value}".` ); process.exitCode = 1; return; } pairs = [{ key, value }]; } const current = loadConfigFile(target.configPath); const next = { ...current }; const allMessages: string[] = []; let apiKeySet = false; for (const { key, value } of pairs) { if (!value) { print( `config ${key}: value must not be empty. Run \`moss config --help\` for supported keys and usage.` ); process.exitCode = 1; return; } const result = applyConfigSetPair(next, current, key, value); if (!result.ok) { for (const msg of result.messages) print(msg); if (isBatch) print('[config] nothing saved — fix the error above and retry the batch.'); process.exitCode = 1; return; } allMessages.push(...result.messages); if (key === 'apiKey') apiKeySet = true; } saveConfigFileAtPath(next, target.configPath); const scope = target.scope === 'project' ? 'project ' : ''; if (isBatch) { const keyList = pairs.map((p) => p.key).join(', '); print(`[config] ${scope}updated ${pairs.length} key(s) in ${target.configPath}: ${keyList}`); } else { print(`[config] ${scope}${pairs[0].key} updated in ${target.configPath}`); } for (const msg of allMessages) print(msg); if (pairs.some((p) => p.key === 'baseUrl')) { print(`[config] baseUrl saved: ${next.baseUrl}`); } if (apiKeySet) { print(`[config] API key saved (encrypted) at ${target.configPath}.`); print( '[config] NOTE: the key was sent via command line and may be in your shell history; for a hidden prompt, use `moss setup` next time.' ); } } export function runConfigUnset(args: string[], startDir = process.cwd()): void { const target = resolveConfigEditTarget(args, startDir); args = target.args; const [key, ...rest] = args; if (!key || rest.length > 0) { print(renderConfigUsage()); process.exitCode = 1; return; } const current = loadConfigFile(target.configPath); let next: ConfigFile = { ...current }; if (key === 'profile') delete next.profile; else if (key === 'provider') delete next.provider; else if (key === 'model') delete next.model; else if (key === 'baseUrl') delete next.baseUrl; else if (key === 'apiKey') delete next.apiKey; else if (key === 'workspace') delete next.workspace; else if (key === 'safetyMode') delete next.safetyMode; else if (key === 'approvalPolicy') delete next.approvalPolicy; else if (key === 'trustedTools') delete next.trustedTools; else if (key === 'deniedTools') delete next.deniedTools; else if (key === 'promptCache') { if (typeof current.promptCache === 'object' && current.promptCache !== null) { next.promptCache = { ...current.promptCache }; delete next.promptCache.enabled; } else { delete next.promptCache; } } else if (key === 'promptCacheDebug') { if (typeof current.promptCache === 'object' && current.promptCache !== null) { next.promptCache = { ...current.promptCache }; delete next.promptCache.debug; } } else if (key === 'guardrails.input.blockPatterns') { next.guardrails = { ...current.guardrails, input: { ...current.guardrails?.input } }; delete next.guardrails.input?.blockPatterns; } else if (key === 'guardrails.input.redactPatterns') { next.guardrails = { ...current.guardrails, input: { ...current.guardrails?.input } }; delete next.guardrails.input?.redactPatterns; } else if (key === 'guardrails.output.blockPatterns') { next.guardrails = { ...current.guardrails, output: { ...current.guardrails?.output } }; delete next.guardrails.output?.blockPatterns; } else if (key === 'guardrails.output.redactPatterns') { next.guardrails = { ...current.guardrails, output: { ...current.guardrails?.output } }; delete next.guardrails.output?.redactPatterns; } else if (key === 'mcp.enabled') { next.mcp = { ...current.mcp }; delete next.mcp.enabled; } else if (key === 'mcp.configPath') { next.mcp = { ...current.mcp }; delete next.mcp.configPath; } else if (key === 'agent.maxTurns') { next.agent = { ...current.agent }; delete next.agent.maxTurns; } else if (key === 'agent.contextTokens') { next.agent = { ...current.agent }; delete next.agent.contextTokens; } else if (key === 'agent.compaction.reserveTokens') { next.agent = { ...current.agent, compaction: { ...current.agent?.compaction } }; delete next.agent.compaction?.reserveTokens; } else if (key === 'agent.compaction.keepRecentTokens') { next.agent = { ...current.agent, compaction: { ...current.agent?.compaction } }; delete next.agent.compaction?.keepRecentTokens; } else { print(supportedConfigKeys()); print('Run `moss config --help` for supported keys and usage.'); process.exitCode = 1; return; } const changed = JSON.stringify(next) !== JSON.stringify(current); next = removeEmptyNestedConfig(next); saveConfigFileAtPath(next, target.configPath); const scope = target.scope === 'project' ? 'project ' : ''; if (changed) { print(`[config] ${scope}${key} removed from ${target.configPath}`); } else { print(`[config] ${scope}${key}: not set (nothing to remove)`); } } export function printMissingConfigGuidance( interactive: boolean, options: { bundledDefaultSuppressedBy?: string } = {} ): void { print('Moss needs a model configuration before it can run.'); if (options.bundledDefaultSuppressedBy) { print(''); print( `Note: the built-in model gateway is available but disabled because ${options.bundledDefaultSuppressedBy} already sets model settings.` ); print( 'Remove them (moss config unset provider|model|baseUrl) or complete them with an API key.' ); } print(''); print('Fast path:'); print(' moss setup'); print(''); print('Script path (no TTY — model settings are read from config files, never env vars):'); print(' moss config set provider deepseek'); print(' moss config set model deepseek-v4-flash'); print(' # for the API key, use moss setup (hidden prompt) or write it into a JSON config file:'); print( ' # WARNING: moss config set apiKey leaves the key in your shell history — prefer moss setup.' ); print(' moss --config-file /path/to/config.json # {"provider":"deepseek","apiKey":"..."}'); print(''); if (interactive) { print('You can run setup now, then start `moss` again.'); } else { print('Run moss setup to configure a model, then retry your one-shot command.'); } } export async function offerSetupForInteractiveMissingConfig( options: { bundledDefaultSuppressedBy?: string } = {} ): Promise { printMissingConfigGuidance(true, options); const answer = await question('Start setup now? [Y/n] '); if (!answer || /^y(es)?$/i.test(answer)) { await runSetupWizard(); } else { print('Setup skipped. Run `moss setup` when you are ready.'); process.exitCode = 1; } } const ONE_SHOT_ONBOARDING_MARKER = '.moss_onboarding_shown'; function oneShotOnboardingMarkerPath(env: NodeJS.ProcessEnv = process.env): string { return path.join(resolveConfigDir(env), ONE_SHOT_ONBOARDING_MARKER); } export function hasShownOneShotOnboardingHint(env: NodeJS.ProcessEnv = process.env): boolean { try { return fs.existsSync(oneShotOnboardingMarkerPath(env)); } catch { return false; } } export function markOneShotOnboardingShown(env: NodeJS.ProcessEnv = process.env): void { try { const dir = resolveConfigDir(env); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.writeFileSync(path.join(dir, ONE_SHOT_ONBOARDING_MARKER), '', { encoding: 'utf-8', mode: 0o600, }); } catch { } } export function renderOneShotOnboardingHint(): string { return [ '[moss] No model configured yet.', ' Run `moss setup` to configure one, or tell me: "help me add a model configuration."', ' (This hint appears only once.)', ].join('\n'); }