Skip to content
Open
66 changes: 57 additions & 9 deletions docs/src/content/docs/docs/Advanced/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ obsidian vault=dev quickadd:run-template \
- `path=` is the template file (vault-relative). A leading slash is allowed and a missing `.md` extension is added, matching how Template choices resolve paths. If no file resolves there, the command returns `{"ok":false}` up front.
- The new note's name comes from `{{VALUE}}` - pass it as `value-value=...`. A non-interactive run with an empty or missing name returns `missingFlags` instead of creating an unnamed note. The note is created in Obsidian's "Default location for new notes".
- The picker (interactive command) only lists templates inside your configured template folder(s); `path=` here is explicit, so any vault file resolves.
- Like `quickadd:run`, name collisions on the target note still prompt interactively (the file-exists choice is not a pre-collected input).
- Like `quickadd:run`, name collisions on the target note still prompt (the file-exists choice is not a pre-collected input). Under `quickadd:interactive` that prompt is forwarded to you like any other.

_Introduced in QuickAdd 2.14.0._

Expand Down Expand Up @@ -125,6 +125,42 @@ In a [scheduled job](/docs/Advanced/TriggerQuickAddFromOutsideObsidian/#run-quic
only add `ui` when the job runs while you are logged in and able to answer the
prompts.

## Knowing whether anything actually landed {#verified-and-effect}
Comment thread
chhoumann marked this conversation as resolved.

`ok:true` means the choice ran without aborting. It does **not** mean your vault
changed. Two more keys answer the questions an automation actually asks:

| Key | Question it answers | Values |
| --- | --- | --- |
| `verified` | Did QuickAdd confirm what the engine did? | `true` on the outcome path (`verify` on a Template/Capture choice), `false` when it could not look |
| `effect` | What did the run do to the vault? | `created`, `changed`, `unchanged`, `unknown` |

```bash
obsidian vault=dev quickadd:run choice="Inbox" value-value=" " verify=true
# -> {"ok":true,"choice":{…},"file":"Inbox.md","verified":true,"effect":"unchanged","durationMs":6}
```

That run is working exactly as designed: the capture's payload was empty, so
QuickAdd deliberately left `Inbox.md` alone rather than writing a blank line, and
said so in a notice. A Template set to **Do nothing** when the file already exists
reports the same. If you are counting captures, writing an idempotency marker, or
deciding whether to retry, key off `effect`, not `ok`.

`effect` is present on every **success** payload (`ok:true`), and `unknown` is stated
rather than omitted - a missing key reads as `false` in both `jq` and JavaScript, which
would turn "QuickAdd did not look" into "nothing happened". A failed or cancelled run
carries `error` instead and no `effect`, because there is no outcome to describe.
`verified:false` still means only *"not confirmed - go look"*; it never means
*"confirmed that nothing changed"*.

The `obsidian://quickadd` [x-callback](/docs/Advanced/TriggerQuickAddFromOutsideObsidian/)
success callback carries the same `effect` value.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
:::note[Available in the next release]
`effect` is new. `capabilities` in the `quickadd:interactive` handshake contains
`outcome-effect` on a build that has it.
:::

## Answer run-time prompts from outside: `quickadd:interactive` {#interactive-runs-quickaddinteractive}

Some choices prompt at *run time* for inputs that can't be gathered up front -
Expand All @@ -136,7 +172,7 @@ the prompts opening in Obsidian.

```bash
obsidian vault=dev quickadd:interactive choice="Import from Readwise"
# -> {"ok":true,"host":"127.0.0.1","port":51789,"sessionId":"…","token":"…","capabilities":["abort"]}
# -> {"ok":true,"host":"127.0.0.1","port":51789,"sessionId":"…","token":"…","capabilities":["abort","outcome-effect"]}
```

The command returns connection details immediately and runs the choice in the
Expand All @@ -146,11 +182,26 @@ background. Attach to the session and drive it:
- `POST http://127.0.0.1:<port>/reply?session=<id>&token=<token>` with body `{"requestId":…,"value":…}` to answer, or `{"requestId":…,"cancelled":true}` to cancel (which ends the run - except on an `info` panel, see below).
- `POST http://127.0.0.1:<port>/abort?session=<id>&token=<token>` - end the run. Answers `{"ok":true,"interrupted":<n>}`, where `n` is how many pending prompts it rejected; `409` if the run had already finished (benign - poll for the terminal event); `404` for an unknown session or token, or for any method other than `POST`.

Prompts a Template or Capture run opens itself - the "file already exists" chooser,
the folder picker, the note-discovery picker, the heading picker, the capture-target
picker - are forwarded like any other. (The AI assistant's tool-confirmation dialog is
the one that is not: run such a choice at the desktop, or set tool confirmation to
"never".)

:::note[Available in the next release]
Forwarding the run's own pickers is new. Before it, a Template or Capture run opened
them in Obsidian and `/abort` could not reach them.
::: They arrive as `suggester` prompts, and because the engine controls the
list, a reply that is not one of the offered `value` tokens is refused rather than
acted on (unless the prompt sets `allowCustomInput`, as the folder and discovery
pickers do so you can create something new).

Prompt `type`s and the `value` you reply with: `suggester`/`input`/`date` →
string, `confirm` → boolean, `checkbox` → string array, `info` →
acknowledgement, `form` → an object mapping each field's `id` to its string
value (date fields use the `@date:ISO` format). The run's outcome arrives as the
`done`/`error` poll event.
`done`/`error` poll event: `done` carries the same `verified` and `effect` keys
described under [Knowing whether anything actually landed](#verified-and-effect).

### Cancelling, and ending a run

Expand All @@ -171,12 +222,9 @@ whether it stopped anything.

:::caution[What `/abort` cannot reach]
`/abort` interrupts prompts that were routed **to you**. A run that is mid-work
between prompts keeps going, and a Template or Capture run still opens some prompts in
Obsidian itself - the "file already exists" chooser, the folder picker, the
capture-target picker - which do not travel over this bridge
([#1614](https://github.com/chhoumann/quickadd/issues/1614)). So `"interrupted":0`
means nothing was waiting on you, and the run may still succeed and commit its side
effects.
between prompts keeps going, so `"interrupted":0` means nothing was waiting on you and
the run may still finish and commit its side effects. Keep polling for the terminal
event either way.
:::

:::note[Available in the next release]
Expand Down
13 changes: 13 additions & 0 deletions src/ai/tools/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { IChoiceExecutor } from "../../IChoiceExecutor";
import { settingsStore } from "../../settingsStore";
import { CompleteFormatter } from "../../formatters/completeFormatter";
import { makeNoticeHandler } from "../makeNoticeHandler";
import { ChoiceAbortError } from "src/errors/ChoiceAbortError";
import { MacroAbortError } from "../../errors/MacroAbortError";
import { resolveModelInputOrThrow } from "../aiHelpers";
import type { AIProvider, Model } from "../Provider";
Expand Down Expand Up @@ -373,6 +374,18 @@ export class Agent {
if (!needs) return true;
if (this.approveAllThisRun && !perToolFloor) return true;

// Non-interactive run (CLI without `ui`): nobody can approve this, and the
// modal would park forever - a plain `quickadd:run` of a Macro whose script
// calls `quickAddApi.ai.agent` with a non-readOnly tool simply never returned
// (`needsGlobalConfirmation` defaults to "destructive", so most tools ask).
// Refuse with the text that says how to make the choice runnable headlessly.
if (this.choiceExecutor.interactive === false) {
throw new ChoiceAbortError(
`'${call.name}' needs approval before it runs, but this run is non-interactive. ` +
`Set the AI assistant's tool-confirmation to "never", mark the tool readOnly, or re-run with the ui flag.`,
);
}

const outcome = await AIToolConfirmModal.Prompt(
this.app,
call.name,
Expand Down
11 changes: 10 additions & 1 deletion src/cli/registerQuickAddCliHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ describe("registerQuickAddCliHandlers", () => {
executeWithOutcome: vi.fn().mockResolvedValue({
status: "success",
file: { path: "Created Note.md" },
effect: "created",
}),
variables: new Map<string, unknown>(),
consumeAbortSignal: vi.fn().mockReturnValue(null),
Expand Down Expand Up @@ -250,6 +251,11 @@ describe("registerQuickAddCliHandlers", () => {
expect(verified.ok).toBe(true);
expect(verified.verified).toBe(true);
expect(verified.file).toBe("Created Note.md");
// `verified` says QuickAdd confirmed the run; `effect` says what it did to the
// vault. They are separate keys on purpose - a correctly-behaving no-op is
// `verified:true` with `effect:"unchanged"`, and overloading `verified` would
// have made it look retryable forever (#1615).
expect(verified.effect).toBe("created");
expect(executors[0].executeWithOutcome).toHaveBeenCalledWith(
templateChoice,
);
Expand All @@ -269,6 +275,9 @@ describe("registerQuickAddCliHandlers", () => {
expect(legacy.ok).toBe(true);
expect(legacy.verified).toBe(false);
expect(legacy.file).toBeUndefined();
// Stated, not omitted: an absent key reads as `false` to both `jq` and JS,
// which would turn "we did not look" into "nothing happened".
expect(legacy.effect).toBe("unknown");
expect(executors[1].execute).toHaveBeenCalledWith(templateChoice);
});

Expand Down Expand Up @@ -419,7 +428,7 @@ describe("registerQuickAddCliHandlers", () => {
// run (#1605). An unknown path and an unauthed session answer the same 404
// shape, so without this a client could only detect the build by string-matching
// an error body.
expect(response.capabilities).toEqual(["abort"]);
expect(response.capabilities).toEqual(["abort", "outcome-effect"]);
// The run is fire-and-forget; let its microtasks settle.
await new Promise((resolve) => setTimeout(resolve, 0));

Expand Down
29 changes: 24 additions & 5 deletions src/cli/registerQuickAddCliHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const RUN_FLAGS: CliFlags = {
},
verify: {
description:
"Report the verified outcome for Template/Capture choices (file path on success, honest failure when the engine swallows an error)",
"Report the verified outcome for Template/Capture choices (file path and effect on success, honest failure when the engine swallows an error)",
},
};

Expand Down Expand Up @@ -433,6 +433,12 @@ async function runResolvedChoice(
// The outcome path confirms the engine actually completed (a file
// was created / capture written), so this success is verified.
verified: true,
// What the run did to the vault. `verified` answers "did QuickAdd
// confirm the run?"; `effect` answers "did anything land?" - the
// question an automation counting captures or writing an idempotency
// marker actually asks. A correctly-behaving no-op is
// `verified:true, effect:"unchanged"` (#1615).
effect: outcome.effect,
durationMs,
});
}
Expand Down Expand Up @@ -493,6 +499,9 @@ async function runResolvedChoice(
// logic off `ok` can tell it apart from the verified outcome path (and use
// quickadd:check up front, or quickadd:run-template for a verified create).
verified: false,
// Stated, never left absent: a missing key reads as `false` to both `jq`
// and JS, which would turn "we did not look" into "nothing happened".
effect: "unknown",
durationMs,
});
} catch (error) {
Expand Down Expand Up @@ -784,6 +793,7 @@ async function interactiveHandler(
choice: describeChoice(choice),
file: outcome.file?.path,
verified: true,
effect: outcome.effect,
},
});
return;
Expand Down Expand Up @@ -816,7 +826,16 @@ async function interactiveHandler(
}
interactivePromptServer.finish(sessionId, {
kind: "done",
result: { ok: true, choice: describeChoice(choice) },
// The legacy void-execute tail (Macro, and anything without the
// outcome path). It used to emit neither flag, so a client could not
// tell this frame apart from the verified one above; both are now
// stated explicitly (#1615).
result: {
ok: true,
choice: describeChoice(choice),
verified: false,
effect: "unknown",
},
});
} catch (error) {
interactivePromptServer.finish(sessionId, {
Expand All @@ -839,7 +858,7 @@ async function interactiveHandler(
// panel instead of ending the run (#1605). Without it a client could only
// tell the two behaviours apart by string-matching a 404 body, since an
// unknown path and an unauthed session answer the same shape.
capabilities: ["abort"],
capabilities: ["abort", "outcome-effect"],
});
} catch (error) {
return serialize({
Expand Down Expand Up @@ -908,14 +927,14 @@ export function registerQuickAddCliHandlers(plugin: QuickAdd): boolean {

register(
CLI_COMMANDS.runDefault,
"Run a QuickAdd choice (ok:true reports the choice ran without aborting; check the verified flag to know if a file was created)",
"Run a QuickAdd choice (ok:true reports the choice ran without aborting; check verified to know QuickAdd confirmed the run, and effect to know whether the vault changed: created/changed/unchanged/unknown)",
RUN_FLAGS,
(params: CliData) =>
runChoiceHandler(plugin, params, CLI_COMMANDS.runDefault),
);
register(
CLI_COMMANDS.run,
"Run a QuickAdd choice (ok:true reports the choice ran without aborting; check the verified flag to know if a file was created)",
"Run a QuickAdd choice (ok:true reports the choice ran without aborting; check verified to know QuickAdd confirmed the run, and effect to know whether the vault changed: created/changed/unchanged/unknown)",
RUN_FLAGS,
(params: CliData) => runChoiceHandler(plugin, params, CLI_COMMANDS.run),
);
Expand Down
Loading