Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
390 changes: 177 additions & 213 deletions README.md

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions packages/dmoss-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ moss "prompt" run a one-shot prompt
moss auth login optional: link a D-Robotics developer community account
moss auth status show community login and provider/model/key source
moss setup configure your own provider/model/API key
moss doctor health-check config, auth, workspace, board, and MCP (non-zero exit on failure)
moss resume --last continue the most recent saved session (fork --last branches a copy)
moss mcp add <name> <cmd> [args...] register an MCP server without editing JSON (mcp list / mcp remove)
moss config --help show configuration commands
moss --help show the focused CLI help
moss --help --all show the complete CLI reference
Expand All @@ -249,8 +252,12 @@ Inside Moss:
/goal show or manage the active goal runner
/compact compress older conversation history into a summary
/attach attach an image or text file to the next prompt
/connect connect an RDK board for this session
/sessions list saved conversations you can resume
/connect connect an RDK board and enter board mode (/disconnect to leave)
/sessions list saved conversations (use /resume to switch into one)
/resume switch this session to a saved conversation ([key|--last])
/mcp show configured MCP servers, status, and tool counts
/doctor health-check model, egress, board, MCP, and config in this session
/yolo grant full power for this session — no per-call approval (/yolo off reverts)
/diff show git working-tree changes
/auth login optional: link a D-Robotics developer community account
/help show focused command help
Expand Down
45 changes: 38 additions & 7 deletions packages/dmoss-agent/src/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { execSync } from 'node:child_process';
import os from 'node:os';
import { resolveCliAgentRuntimeOptions } from './cli/agent-runtime.js';
import { createCliToolApprovalHook, resolveCliSafetyMode } from './cli/approval.js';
import { loadCliConfigFile, loadEnvFromAncestors, resolveCliConfig, resolveConfigDir, safeProcessCwd } from './cli/config.js';
import { CliConfigWriteError, loadCliConfigFile, loadEnvFromAncestors, resolveCliConfig, resolveConfigDir, safeProcessCwd } from './cli/config.js';
import { parseCliArgs } from './cli/args.js';
import { renderCliDoctor } from './cli/doctor.js';
import { renderCliDoctor, cliDoctorHasFailure } from './cli/doctor.js';
import { displayHelp, displayVersion } from './cli/help.js';
import { createConfiguredGuardrailHooks } from './cli/guardrails.js';
import { createConfiguredHookCallbacks } from './cli/hooks.js';
Expand Down Expand Up @@ -195,6 +195,17 @@ if (parsedArgs.help && parsedArgs.command === 'config') {
if (parsedArgs.help) displayHelp(c, { all: parsedArgs.helpAll });
if (parsedArgs.version) displayVersion(c);

// A mistyped subcommand (`moss confgi`) must NOT silently become a billable chat
// one-shot. Fail fast with a suggestion; the user can still force a prompt via
// `moss chat "<text>"`. Only bare single-token typos reach here (see parseCliArgs).
if (parsedArgs.unknownCommand) {
const { token, suggestion } = parsedArgs.unknownCommand;
console.error(`moss: unknown command '${token}'`);
console.error(`Did you mean '${suggestion}'? Run \`moss --help\` for usage.`);
console.error(`To send it to the agent as a prompt instead: moss chat "${token}"`);
process.exit(1);
}

async function setupMesh(agent: DmossAgent, deviceConfig: DeviceSshConfig | null) {
const meshPort = parseInt(process.env.DMOSS_MESH_PORT || '9090', 10);
const meshId = process.env.DMOSS_MESH_ID || `dmoss-${Date.now()}`;
Expand Down Expand Up @@ -324,7 +335,11 @@ async function main() {
// Model settings are config-only (decision 2026-06). Say so once when a
// leftover provider env var is present, instead of silently ignoring it —
// doctor shows the same list as a structured `env ignored` line.
if (resolvedConfig.ignoredModelEnvVars.length > 0 && parsedArgs.command !== 'doctor') {
// Gate on the resolved CLI log level so `--quiet` / `DMOSS_LOG_LEVEL=warn`
// silence this notice; doctor's `env ignored` line stays the source of truth.
const cliLogLevel = resolveCliLogLevel();
const noticesVisible = cliLogLevel === 'debug' || cliLogLevel === 'info';
if (resolvedConfig.ignoredModelEnvVars.length > 0 && parsedArgs.command !== 'doctor' && noticesVisible) {
console.error(
`[config] ignoring model env var(s): ${resolvedConfig.ignoredModelEnvVars.join(', ')} — ` +
'model settings come only from moss config (moss setup / moss config set)',
Expand All @@ -338,15 +353,17 @@ async function main() {
const runtimeDir = workspacePathMigration.paths.runtimeDir;

if (parsedArgs.command === 'doctor') {
console.error(await renderCliDoctor({
const report = await renderCliDoctor({
config: resolvedConfig,
configDir: resolveConfigDir(),
runtimeDir,
currentVersion: getPackageVersion(),
safetyMode,
detailMode: resolveCliDetailMode(argv),
}));
return;
});
console.error(report);
// Exit non-zero on any `fail` line so doctor works as an automation health gate.
process.exit(cliDoctorHasFailure(report) ? 1 : 0);
}
if (parsedArgs.command === 'update') {
const code = await runCliUpdate({
Expand Down Expand Up @@ -403,6 +420,11 @@ async function main() {
useLast: parsedArgs.sessionLast || continueLatest,
forkSource: parsedArgs.forkSource,
});
if (session.error) {
console.error(`[session] ${session.error}`);
console.error('[session] List saved sessions with `moss sessions`, or start a new one with `moss`.');
process.exit(1);
}
if (session.notice) console.error(`[session] ${session.notice}`);
const memoryManager = new MemoryManager(workspacePathMigration.paths.memoryDir);
const skillLearner = new SkillLearner({ skillsDir: workspacePathMigration.paths.skillsDir });
Expand Down Expand Up @@ -559,4 +581,13 @@ async function main() {
}
}

main().catch((err) => { console.error('Fatal:', err); process.exit(1); });
main().catch((err) => {
// Config-write failures already carry a clean, actionable one-liner — show it
// alone instead of a raw Node stack from writeFileSync.
if (err instanceof CliConfigWriteError) {
console.error(`moss: ${err.message}`);
process.exit(1);
}
console.error('Fatal:', err);
process.exit(1);
});
31 changes: 28 additions & 3 deletions packages/dmoss-agent/src/cli/approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,17 @@ function isWorkspaceTrustEligible(sideEffect: ToolSideEffectClass): boolean {
return sideEffect === 'local_write';
}

/**
* Whether answering "a" (Always) may blanket-trust this tool for the rest of
* the session. device_mutation is excluded: those are idempotent:false physical
* board operations (reboot, restart, rm on the device), so trusting the whole
* tool by name after one approval would silently auto-approve every later
* device command. They re-prompt every time; "a" only approves the current call.
*/
function isSessionTrustEligible(sideEffect: ToolSideEffectClass): boolean {
return sideEffect !== 'device_mutation';
}

function previewInput(input: Record<string, unknown>): string {
const raw = sanitizeSecrets(JSON.stringify(input, null, 2));
return raw.length > 1200 ? `${raw.slice(0, 1200)}\n... [truncated ${raw.length} chars]` : raw;
Expand Down Expand Up @@ -401,8 +412,11 @@ function approvalScopeSummary(preview: CliToolApprovalPreview, input: Record<str
}
}

function approvalAlwaysSummary(preview: CliToolApprovalPreview): string {
function approvalAlwaysSummary(preview: CliToolApprovalPreview): string | undefined {
if (isWorkspaceTrustEligible(preview.sideEffect)) return 'trust this workspace for the session';
// Device mutations never blanket-trust — don't advertise an "always" option
// the hook won't honor.
if (!isSessionTrustEligible(preview.sideEffect)) return undefined;
return 'allow this scope for the session';
}

Expand All @@ -415,13 +429,16 @@ export function renderCliApprovalPrompt(
// Decision-time detail: ± diff for file edits, action plan for device
// mutations — so the user can decide without expanding anything.
const detail = buildApprovalDetailLines(preview.toolName, preview.sideEffect, input, detailCtx);
const always = approvalAlwaysSummary(preview);
const lines = [
'',
`Moss wants to ${approvalActionSummary(preview, input)}`,
target ? ` ${target}` : '',
...detail,
`Scope: ${approvalScopeSummary(preview, input)}`,
`Allow once, ${approvalAlwaysSummary(preview)}, or deny. [y/a/N] `,
always
? `Allow once, ${always}, or deny. [y/a/N] `
: 'Allow once, or deny (device mutations always re-prompt). [y/N] ',
].filter((line) => line !== '');
return lines.join('\n');
}
Expand Down Expand Up @@ -592,6 +609,12 @@ export function createCliToolApprovalHook(
// was unusable for any mutating tool). read-only still blocks all mutation at
// isAllowedInMode above; the dangerous-command floor and deniedTools still apply.
if (!process.stdin.isTTY) {
// Headless auto-approval is a real decision with no human in the loop:
// leave a one-line audit trail on stderr so `-p` runs are observable.
// (deniedTools / read-only / isCommandDangerous already gated above.)
console.error(
`[approval] headless auto-approve: ${tool.name} (${preview.sideEffect}) under ${liveMode} — no TTY to prompt`,
);
return { approved: true };
}

Expand All @@ -603,9 +626,11 @@ export function createCliToolApprovalHook(
if (answer === 'a' || answer === 'always') {
if (isWorkspaceTrustEligible(preview.sideEffect)) {
sessionTrustedWorkspaces.add(workspaceRoot);
} else {
} else if (isSessionTrustEligible(preview.sideEffect)) {
sessionTrustedTools.add(tool.name);
}
// device_mutation: "a" approves this call only — never blanket-trust the
// tool, so the next device command still prompts.
return { approved: true };
}
if (answer === 'y' || answer === 'yes') {
Expand Down
98 changes: 84 additions & 14 deletions packages/dmoss-agent/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export interface ParsedCliArgs {
print: boolean;
outputFormat: 'text' | 'json' | 'stream-json';
maxTurns?: number;
/**
* Set when a bare single-token invocation looks like a mistyped subcommand
* (e.g. `moss confgi`). The caller must surface "unknown command, did you
* mean …?" and exit non-zero instead of starting a billable chat one-shot.
*/
unknownCommand?: { token: string; suggestion: string };
rawArgv: string[];
}

Expand Down Expand Up @@ -137,20 +143,60 @@ function normalizeDetail(value: string): ParsedCliArgs['detailMode'] {
throw new Error(`Unsupported detail mode "${value}"`);
}

const KNOWN_COMMANDS: readonly CliCommand[] = [
'setup',
'auth',
'config',
'doctor',
'update',
'resume',
'fork',
'mcp',
];

function asCommand(value: string | undefined): CliCommand | null {
if (
value === 'setup' ||
value === 'auth' ||
value === 'config' ||
value === 'doctor' ||
value === 'update' ||
value === 'resume' ||
value === 'fork' ||
value === 'mcp'
) {
return value;
return value && (KNOWN_COMMANDS as readonly string[]).includes(value) ? (value as CliCommand) : null;
}

function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
if (m === 0) return n;
if (n === 0) return m;
let prev = Array.from({ length: n + 1 }, (_, i) => i);
let curr = new Array<number>(n + 1);
for (let i = 1; i <= m; i++) {
curr[0] = i;
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
[prev, curr] = [curr, prev];
}
return null;
return prev[n];
}

/**
* Closest known subcommand within edit distance 2, or null. Used to turn a
* mistyped `moss confgi` into a "did you mean 'config'?" error instead of a
* silent billable chat one-shot. Deliberately conservative: an exact command
* match is handled earlier, and legitimate one-word prompts (`moss hi`) sit far
* outside distance 2 from every command so they keep flowing to chat.
* @public
*/
export function closestKnownCommand(token: string): string | null {
const candidate = token.toLowerCase().trim();
if (!candidate || (KNOWN_COMMANDS as readonly string[]).includes(candidate)) return null;
let best: string | null = null;
let bestDistance = Infinity;
for (const command of KNOWN_COMMANDS) {
const distance = levenshtein(candidate, command);
if (distance < bestDistance) {
bestDistance = distance;
best = command;
}
}
return bestDistance <= 2 ? best : null;
}

function flagConsumesNext(arg: string): boolean {
Expand Down Expand Up @@ -322,11 +368,19 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
if (arg === '--ask-for-approval' || arg.startsWith('--ask-for-approval=')) {
const parsed = readValue(argv, i, arg);
const raw = parsed.value.toLowerCase().trim();
if (raw === 'never') {
const approval = normalizeApprovalPolicyConfig(raw);
const safety = normalizeSafetyMode(raw);
if (!approval && !safety) {
// Silently dropping unknown values let `--ask-for-approval yolo` look
// accepted while changing nothing; reject so the user sees the typo.
throw new Error(
`--ask-for-approval must be never|prompt|on-request|read-only|workspace-write|full-access, got "${parsed.value}"`,
);
}
if (approval === 'never') {
approvalPolicy = 'never';
configOverrides.approvalPolicy = 'never';
}
const safety = normalizeSafetyMode(raw);
if (safety) safetyModeOverride = safety;
i = parsed.nextIndex;
continue;
Expand Down Expand Up @@ -371,6 +425,21 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
}
}

// Catch a mistyped subcommand BEFORE it becomes a billable chat one-shot.
// Only a bare single-token invocation (`moss confgi`) with no flags qualifies;
// multi-word prose prompts and flag-bearing invocations are never intercepted.
let unknownCommand: ParsedCliArgs['unknownCommand'];
if (
command === 'chat' &&
commandArgs.length === 0 &&
promptParts.length === 1 &&
!argv.includes('--') &&
!argv.some((token) => token.startsWith('-'))
) {
const suggestion = closestKnownCommand(promptParts[0]);
if (suggestion) unknownCommand = { token: promptParts[0], suggestion };
}

return {
command,
commandArgs,
Expand All @@ -390,6 +459,7 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
print,
outputFormat,
maxTurns,
unknownCommand,
rawArgv: argv,
};
}
35 changes: 30 additions & 5 deletions packages/dmoss-agent/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,23 @@ export class CliConfigFileError extends Error {
}
}

/**
* Raised when persisting a config file fails (EACCES on a read-only dir, ENOSPC,
* a root-owned config.json after `sudo npm i -g`, …). Carries a one-line,
* stack-free message so the CLI surfaces `cannot write config to <path>: <reason>`
* instead of dumping a raw `writeFileSync` Node stack at the user.
* @public
*/
export class CliConfigWriteError extends Error {
readonly configPath: string;

constructor(configPath: string, reason: string) {
super(`cannot write config to ${configPath}: ${reason}`);
this.name = 'CliConfigWriteError';
this.configPath = configPath;
}
}

export type CliConfigProfile = 'cautious' | 'balanced' | 'autonomous';
export type CliSafetyModeConfig = 'read-only' | 'workspace-write' | 'full-access';
export type ConfigApprovalPolicy = 'prompt' | 'never';
Expand Down Expand Up @@ -430,11 +447,19 @@ export function loadCliConfigFile(
}

export function saveConfigFileAtPath(config: ConfigFile, configPath: string): void {
fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, {
encoding: 'utf-8',
mode: 0o600,
});
try {
fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, {
encoding: 'utf-8',
mode: 0o600,
});
} catch (err) {
// A write failure (EACCES/EPERM/ENOSPC/EROFS) must read as a clean,
// actionable line — never a raw Node stack trace through the top-level
// `Fatal:` handler.
const reason = err instanceof Error ? err.message : String(err);
throw new CliConfigWriteError(configPath, reason);
}
try {
fs.chmodSync(configPath, 0o600);
} catch {
Expand Down
11 changes: 11 additions & 0 deletions packages/dmoss-agent/src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ function fail(label: string, detail: string): string {
return ` fail ${label}: ${detail}`;
}

/**
* True when a rendered doctor report contains at least one `fail` line. The
* caller exits non-zero on failure so `moss doctor` is usable as a CI/automation
* health gate (it previously always exited 0, masking unwritable workspaces,
* missing API keys, and broken MCP config).
* @public
*/
export function cliDoctorHasFailure(report: string): boolean {
return report.split('\n').some((line) => line.startsWith(' fail '));
}

export function renderNodeDoctorLine(version: string = process.version): string {
return nodeVersionProblem(version)
? fail('node', `${version}; requires >=${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}.0`)
Expand Down
Loading
Loading