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
5 changes: 5 additions & 0 deletions .changeset/clear-store-help-aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@donadiosolutions/lcm": patch
---

Honor help before required CLI arguments and accept both repeatable store tag spellings.
2 changes: 2 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ This repo is a TypeScript SQLite daemon that persists Agent session memories acr
- New HTTP routes must have corresponding tests in `test/daemon/routes/`.
- Tests should cover: happy path, missing required fields (400), and resource-not-found (404).
- Flag PRs adding routes without tests.
- Exported production helpers require direct non-mocked tests; do not rely on coverage through a consumer that mocks the defining module.
- Never delete legacy parsing fallbacks or defensive handling for non-`Error` thrown values merely to satisfy coverage. Cover those branches with deterministic failure injection while preserving compatibility behavior.
- Copy-paste shell examples must contain executable argument tokens. Keep Commander comma declaration syntax such as `--tag, --tags <tag>` in option declarations or help text, never in an invocation where `--tag,` would be passed literally.
- Hook command or protocol changes must be searched and aligned across user docs, bundled hook READMEs and skill checklists, installer command registrations, and E2E tests.
- Test-only numeric capacity and limit seams must reject non-positive, non-integer, and non-finite values before mutating shared state.
- Tests that enable fake timers must restore real timers from `afterEach` or a
Expand Down
98 changes: 37 additions & 61 deletions bin/lcm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,16 +423,20 @@ function resolveCustomHelpRequest(cliArgv: string[]): CustomHelpRequest | undefi
const args = cliArgv.slice(2);
if (args.length === 0) return {};
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) return {};
if (!args.includes("-h") && !args.includes("--help")) return undefined;

const [command] = args;
return args.length >= 3 && ["daemon", "config", "connectors"].includes(command)
? { command }
: undefined;
const terminator = args.indexOf("--");
const optionArgs = terminator === -1 ? args : args.slice(0, terminator);
if (!optionArgs.includes("-h") && !optionArgs.includes("--help")) return undefined;

const [command] = optionArgs;
if (command === "help") {
const topic = optionArgs[1];
return topic === undefined || topic === "-h" || topic === "--help"
? {}
: { command: topic };
}
return { command };
}



export function registerMemoryCommands(program: Command): void {
program
.command("search <query>")
Expand All @@ -443,11 +447,6 @@ export function registerMemoryCommands(program: Command): void {
.helpOption(false)
.option("-h, --help", "Show help")
.action(async (query: string, opts) => {
if (opts.help) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("search"); exit(0);
}

const layers = normalizeStringList(opts.layer);
const tags = normalizeStringList(opts.tag) ?? [];
ensureAllowedValues(layers, ["episodic", "promoted"], "--layer");
Expand All @@ -472,11 +471,6 @@ export function registerMemoryCommands(program: Command): void {
.helpOption(false)
.option("-h, --help", "Show help")
.action(async (query: string, opts) => {
if (opts.help) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("grep"); exit(0);
}

const mode = ensureAllowedValue(opts.mode, ["full_text", "regex"], "--mode");
const scope = ensureAllowedValue(opts.scope, ["messages", "summaries", "both"], "--scope");

Expand Down Expand Up @@ -531,7 +525,12 @@ export function registerMemoryCommands(program: Command): void {
program
.command("store <text>")
.description("Store a durable memory entry for the current project")
.option("--tag <tag>", "Attach a tag to the stored memory (repeatable)", collectRepeatedOption, [])
.option(
"--tag, --tags <tag>",
"Attach a tag to the stored memory (repeatable)",
collectRepeatedOption,
[],
)
.helpOption(false)
.option("-h, --help", "Show help")
.action(async (text: string, opts) => {
Expand All @@ -544,7 +543,7 @@ export function registerMemoryCommands(program: Command): void {
const result = await client.post("/store", {
cwd: process.cwd(),
text,
tags: normalizeStringList(opts.tag) ?? [],
tags: normalizeStringList(opts.tags) ?? [],
metadata: {},
});
printJson(result);
Expand Down Expand Up @@ -1055,13 +1054,7 @@ export function registerPostgreSqlCommand(program: Command): void {
const postgresCmd = new Command("postgres")
.description("Provision and maintain PostgreSQL storage");
postgresCmd.helpOption(false).option("-h, --help", "Show help");
const helpRequested = (opts: PostgreSqlOptions): boolean =>
opts.help === true || postgresCmd.opts<PostgreSqlOptions>().help === true;
postgresCmd.action(async (opts: PostgreSqlOptions) => {
if (helpRequested(opts)) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("postgres"); exit(0);
}
postgresCmd.action(async () => {
console.error("Usage: lcm postgres migrate [--json]");
exit(1);
});
Expand All @@ -1073,10 +1066,6 @@ export function registerPostgreSqlCommand(program: Command): void {
.helpOption(false)
.option("-h, --help", "Show help")
.action(async (opts: PostgreSqlOptions) => {
if (helpRequested(opts)) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("postgres"); exit(0);
}
try {
const { provisionPostgreSql } = await import(
"../src/storage/postgresql/provisioning.js"
Expand Down Expand Up @@ -1242,6 +1231,19 @@ export async function runCli(
cliArgv: string[] = process.argv,
preflightSeams?: ForegroundDaemonPreflightSeams,
): Promise<void> {
const customHelp = resolveCustomHelpRequest(cliArgv);
if (customHelp && cliArgv.slice(2).length > 0) {
const cliHelp = await import("../src/cli-help.js");
const hasCommandHelp = Object.prototype.hasOwnProperty.call(cliHelp, "hasCommandHelp")
? (cliHelp as { hasCommandHelp?: unknown }).hasCommandHelp
: undefined;
if (customHelp.command === undefined
|| (typeof hasCommandHelp === "function" && hasCommandHelp(customHelp.command))) {
cliHelp.printHelp(customHelp.command);
exit(0);
}
}

const internalDaemonTestIdentity = resolveInternalDaemonTestIdentity(cliArgv);
const migrate = preflightSeams?.migrate ?? migrateLegacyHomeIfNeeded;
if (isForegroundDaemonStartArgv(cliArgv)) {
Expand Down Expand Up @@ -1733,10 +1735,6 @@ export async function runCli(
.helpOption(false)
.option("-h, --help", "Show help")
.action(async (opts) => {
if (opts.help) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("install"); exit(0);
}
const dryRun: boolean = opts.dryRun ?? false;
const { install } = await import("../installer/install.js");
if (dryRun) {
Expand All @@ -1757,10 +1755,6 @@ export async function runCli(
.helpOption(false)
.option("-h, --help", "Show help")
.action(async (opts) => {
if (opts.help) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("uninstall"); exit(0);
}
const dryRun: boolean = opts.dryRun ?? false;
const { uninstall } = await import("../installer/uninstall.js");
if (dryRun) {
Expand Down Expand Up @@ -1951,11 +1945,7 @@ export async function runCli(
// ─── events ────────────────────────────────────────────────────────────────
const eventsCmd = new Command("events").description("Manage passive-learning sidecar events");
eventsCmd.helpOption(false).option("-h, --help", "Show help");
eventsCmd.action(async (opts) => {
if (opts.help || cliArgv.includes("-h") || cliArgv.includes("--help")) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("events"); exit(0);
}
eventsCmd.action(async () => {
console.error(
"Usage: lcm events <promote|status|validate|quarantine|replay> [options]",
);
Expand All @@ -1970,11 +1960,6 @@ export async function runCli(
.helpOption(false)
.option("-h, --help", "Show help")
.action(async (opts) => {
if (opts.help || cliArgv.includes("-h") || cliArgv.includes("--help")) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("events"); exit(0);
}

const all: boolean = opts.all ?? false;
const jsonFlag: boolean = opts.json ?? false;
const client = await createDaemonClientOrExit();
Expand Down Expand Up @@ -2256,11 +2241,7 @@ export async function runCli(
// ─── connectors ────────────────────────────────────────────────────────────
const connectorsCmd = new Command("connectors").description("Manage connectors for coding agents");
connectorsCmd.helpOption(false).option("-h, --help", "Show help");
connectorsCmd.action(async (opts) => {
if (opts.help) {
const { printHelp } = await import("../src/cli-help.js");
printHelp("connectors"); exit(0);
}
connectorsCmd.action(async () => {
console.error("Usage: lcm connectors <list|install|remove|doctor> [options]");
exit(1);
});
Expand Down Expand Up @@ -2847,16 +2828,11 @@ export async function runCli(
})();
});

// Resolve unsafe nested help from argv before parsing. Commander does not
// reliably expose a child help option to these nested actions, and those
// actions may read or mutate state before help can be rendered.
const customHelp = resolveCustomHelpRequest(cliArgv);
if (customHelp) {
if (cliArgv.slice(2).length === 0) {
const { printHelp } = await import("../src/cli-help.js");
printHelp(customHelp.command);
printHelp(customHelp?.command);
exit(0);
}

await program.parseAsync(cliArgv);
await unknownCommandCompletion;
}
Expand Down
15 changes: 14 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,20 @@ lcm connectors install --help

Nested help is resolved before command execution. A help request therefore
never starts the daemon, changes a machine or project identity, installs or removes
a connector, or performs another command action.
a connector, or performs another command action. For known commands, this
preflight happens before required-argument validation, so `lcm store --help`
and other incomplete command forms still show the relevant help page.

The store command accepts one tag per occurrence using either long spelling;
the aliases can be mixed and retain command-line order:

```bash
lcm store "Use ensureDaemon before background promote" --tag type:solution --tags scope:lcm
```

In `lcm store`, `--tag` and `--tags` are repeatable single-tag aliases. This is
different from `lcm export --tags`, which remains a comma-separated filter,
for example `lcm export --tags decision,architecture`.

An unknown command writes an error and the complete command list to the
terminal, completes both outputs, and then exits with status 1.
Expand Down
11 changes: 8 additions & 3 deletions src/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,15 @@ const HELP: Record<string, CommandHelp> = {

store: {
summary: "Store a durable memory entry for the current project.",
usage: "lcm store <text> [--tag <tag>]",
usage: "lcm store <text> [--tag, --tags <tag>]",
options: [
["--tag <tag>", "Attach a tag to the stored memory (repeatable)"],
["--tag, --tags <tag>", "Attach a tag to the stored memory (repeatable; aliases may be mixed)"],
],
examples: [
["lcm store \"Auth uses JWT with 24h expiry\"", "Store a plain-text memory"],
["lcm store \"Use ensureDaemon before background promote\" --tag type:solution --tag scope:lcm", "Store a tagged memory"],
["lcm store \"Use ensureDaemon before background promote\" --tag type:solution --tags scope:lcm", "Store a tagged memory; aliases preserve occurrence order"],
],
notes: "Each --tag or --tags occurrence attaches one tag; the spellings may be mixed and preserve command-line order. This differs from export --tags, which accepts a comma-separated filter. For known commands, --help is resolved before required arguments and command actions.",
},

compact: {
Expand Down Expand Up @@ -555,6 +556,10 @@ const GROUPS = [
},
];

export function hasCommandHelp(command: string): boolean {
return Object.hasOwn(HELP, command);
}

function pad(str: string, width: number): string {
return str + " ".repeat(width - str.length);
}
Expand Down
4 changes: 3 additions & 1 deletion src/connectors/templates/sections/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
- `lcm grep "pattern" --mode regex` — Regex search across messages and summaries
- `lcm describe <nodeId>` — Inspect metadata for a specific memory node
- `lcm expand <nodeId> --depth N` — Expand a summary node into lower-level detail
- `lcm store "content"` — Persist knowledge to promoted memory
- `lcm store "content" --tag type:solution` — Persist tagged knowledge to promoted memory
- Store tags: `--tag <tag>` and `--tags <tag>` are repeatable aliases that may be mixed in command-line order
- `lcm store "content" --tag type:solution --tags scope:lcm` — Store one ordered pair of tags using both spellings
- `lcm doctor` — Run diagnostics
- `lcm diagnose` — Scan recent sessions for hook and MCP issues
- `lcm import` — Import default agent session transcripts into memory
Expand Down
Loading
Loading