Skip to content

Commit 6935b29

Browse files
committed
feat(cli): add native quickadd cli handlers
1 parent 8597905 commit 6935b29

File tree

8 files changed

+1170
-231
lines changed

8 files changed

+1170
-231
lines changed

docs/docs/Advanced/CLI.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
title: QuickAdd CLI
3+
---
4+
5+
QuickAdd now registers native Obsidian CLI handlers when your Obsidian version
6+
supports plugin CLI commands.
7+
8+
## Requirements
9+
10+
- Obsidian `1.12.2` or newer (plugin CLI handler API introduced in `1.12.2`)
11+
- QuickAdd enabled in the target vault
12+
13+
## Commands
14+
15+
### `quickadd` / `quickadd:run`
16+
17+
Run a QuickAdd choice from the CLI.
18+
19+
```bash
20+
obsidian vault=dev quickadd choice="Daily log"
21+
obsidian vault=dev quickadd:run id="choice-id"
22+
```
23+
24+
### `quickadd:list`
25+
26+
List all QuickAdd choices (including nested choices inside multis).
27+
28+
```bash
29+
obsidian vault=dev quickadd:list
30+
obsidian vault=dev quickadd:list type=Capture
31+
obsidian vault=dev quickadd:list commands
32+
```
33+
34+
### `quickadd:check`
35+
36+
Check which inputs are still missing before a non-interactive run.
37+
38+
```bash
39+
obsidian vault=dev quickadd:check choice="Daily log"
40+
```
41+
42+
## Passing variables
43+
44+
QuickAdd CLI supports three variable patterns:
45+
46+
1. `value-<name>=...` (URI-compatible)
47+
2. extra `key=value` args
48+
3. `vars=<json-object>` for structured values
49+
50+
Examples:
51+
52+
```bash
53+
obsidian vault=dev quickadd \
54+
choice="Daily log" \
55+
value-project="QuickAdd" \
56+
mood="focused"
57+
58+
obsidian vault=dev quickadd \
59+
choice="Daily log" \
60+
vars='{"project":"QuickAdd","sprint":42}'
61+
```
62+
63+
## Non-interactive behavior
64+
65+
By default, `quickadd` and `quickadd:run` are non-interactive. If QuickAdd
66+
detects missing inputs, it returns a JSON payload with `missing` fields and
67+
`missingFlags` suggestions instead of opening prompts.
68+
69+
Use `ui` to allow interactive prompts:
70+
71+
```bash
72+
obsidian vault=dev quickadd choice="Daily log" ui
73+
```
74+

docs/docs/Advanced/ObsidianUri.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ title: Open QuickAdd from a URI
44

55
QuickAdd choices can be launched from external scripts or apps such as Shortcuts on Mac and iOS, through the use of the `obsidian://quickadd` URI.
66

7+
If you prefer shell scripting, see [QuickAdd CLI](./CLI.md) for native Obsidian
8+
CLI handlers.
9+
710
```
811
obsidian://quickadd?choice=<YOUR_CHOICE_NAME>[&value-VALUE_NAME=...]
912
```
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { CliData, CliFlags } from "obsidian";
3+
import type { IChoiceExecutor } from "../IChoiceExecutor";
4+
import type QuickAdd from "../main";
5+
import { registerQuickAddCliHandlers } from "./registerQuickAddCliHandlers";
6+
import type IChoice from "../types/choices/IChoice";
7+
import type IMultiChoice from "../types/choices/IMultiChoice";
8+
9+
const {
10+
ChoiceExecutorMock,
11+
collectChoiceRequirementsMock,
12+
getUnresolvedRequirementsMock,
13+
} = vi.hoisted(() => ({
14+
ChoiceExecutorMock: vi.fn(),
15+
collectChoiceRequirementsMock: vi.fn(),
16+
getUnresolvedRequirementsMock: vi.fn(),
17+
}));
18+
19+
vi.mock("../choiceExecutor", () => ({
20+
ChoiceExecutor: ChoiceExecutorMock,
21+
}));
22+
23+
vi.mock("../preflight/collectChoiceRequirements", () => ({
24+
collectChoiceRequirements: collectChoiceRequirementsMock,
25+
getUnresolvedRequirements: getUnresolvedRequirementsMock,
26+
}));
27+
28+
interface RegisteredCliHandler {
29+
command: string;
30+
description: string;
31+
flags: CliFlags | null;
32+
handler: (params: CliData) => string | Promise<string>;
33+
}
34+
35+
function flattenChoices(
36+
choices: IChoice[],
37+
): { byName: Map<string, IChoice>; byId: Map<string, IChoice>; } {
38+
const byName = new Map<string, IChoice>();
39+
const byId = new Map<string, IChoice>();
40+
41+
const walk = (list: IChoice[]) => {
42+
for (const choice of list) {
43+
byName.set(choice.name, choice);
44+
byId.set(choice.id, choice);
45+
if (choice.type === "Multi") {
46+
walk((choice as IMultiChoice).choices);
47+
}
48+
}
49+
};
50+
51+
walk(choices);
52+
return { byName, byId };
53+
}
54+
55+
function createPlugin(choices: IChoice[]) {
56+
const handlers: RegisteredCliHandler[] = [];
57+
const { byName, byId } = flattenChoices(choices);
58+
59+
const plugin = {
60+
app: {},
61+
settings: {
62+
choices,
63+
},
64+
getChoiceByName: vi.fn((name: string) => {
65+
const choice = byName.get(name);
66+
if (!choice) throw new Error(`Choice ${name} not found`);
67+
return choice;
68+
}),
69+
getChoiceById: vi.fn((id: string) => {
70+
const choice = byId.get(id);
71+
if (!choice) throw new Error(`Choice ${id} not found`);
72+
return choice;
73+
}),
74+
registerCliHandler: vi.fn(
75+
(
76+
command: string,
77+
description: string,
78+
flags: CliFlags | null,
79+
handler: (params: CliData) => string | Promise<string>,
80+
) => {
81+
handlers.push({ command, description, flags, handler });
82+
},
83+
),
84+
} as unknown as QuickAdd & {
85+
registerCliHandler: (
86+
command: string,
87+
description: string,
88+
flags: CliFlags | null,
89+
handler: (params: CliData) => string | Promise<string>,
90+
) => void;
91+
};
92+
93+
return { plugin, handlers };
94+
}
95+
96+
describe("registerQuickAddCliHandlers", () => {
97+
let executors: IChoiceExecutor[];
98+
99+
const templateChoice: IChoice = {
100+
id: "template-id",
101+
name: "Template Choice",
102+
type: "Template",
103+
command: true,
104+
};
105+
106+
const nestedCaptureChoice: IChoice = {
107+
id: "capture-id",
108+
name: "Capture Choice",
109+
type: "Capture",
110+
command: false,
111+
};
112+
113+
const macroChoice: IChoice = {
114+
id: "macro-id",
115+
name: "Macro Choice",
116+
type: "Macro",
117+
command: true,
118+
};
119+
120+
const multiChoice: IMultiChoice = {
121+
id: "multi-id",
122+
name: "Group",
123+
type: "Multi",
124+
command: false,
125+
collapsed: false,
126+
choices: [nestedCaptureChoice],
127+
placeholder: "Select",
128+
};
129+
130+
beforeEach(() => {
131+
executors = [];
132+
ChoiceExecutorMock.mockReset();
133+
collectChoiceRequirementsMock.mockReset();
134+
getUnresolvedRequirementsMock.mockReset();
135+
136+
ChoiceExecutorMock.mockImplementation(() => {
137+
const executor: IChoiceExecutor = {
138+
execute: vi.fn().mockResolvedValue(undefined),
139+
variables: new Map<string, unknown>(),
140+
consumeAbortSignal: vi.fn().mockReturnValue(null),
141+
};
142+
executors.push(executor);
143+
return executor;
144+
});
145+
146+
collectChoiceRequirementsMock.mockResolvedValue([]);
147+
getUnresolvedRequirementsMock.mockReturnValue([]);
148+
});
149+
150+
it("registers run/list/check handlers when CLI API is available", () => {
151+
const { plugin, handlers } = createPlugin([
152+
templateChoice,
153+
macroChoice,
154+
multiChoice,
155+
]);
156+
157+
const result = registerQuickAddCliHandlers(plugin);
158+
159+
expect(result).toBe(true);
160+
expect(handlers.map((handler) => handler.command)).toEqual([
161+
"quickadd",
162+
"quickadd:run",
163+
"quickadd:list",
164+
"quickadd:check",
165+
]);
166+
});
167+
168+
it("returns false when CLI API is unavailable", () => {
169+
const plugin = {
170+
app: {},
171+
settings: { choices: [] },
172+
} as unknown as QuickAdd;
173+
174+
expect(registerQuickAddCliHandlers(plugin)).toBe(false);
175+
});
176+
177+
it("executes a choice via quickadd:run with parsed variables", async () => {
178+
const { plugin, handlers } = createPlugin([
179+
templateChoice,
180+
macroChoice,
181+
multiChoice,
182+
]);
183+
registerQuickAddCliHandlers(plugin);
184+
const run = handlers.find((handler) => handler.command === "quickadd:run");
185+
expect(run).toBeDefined();
186+
187+
const output = await Promise.resolve(
188+
run!.handler({
189+
choice: templateChoice.name,
190+
"value-project": "QA",
191+
team: "Core",
192+
}),
193+
);
194+
const payload = JSON.parse(String(output));
195+
196+
expect(payload.ok).toBe(true);
197+
expect(executors).toHaveLength(1);
198+
expect(executors[0].execute).toHaveBeenCalledWith(templateChoice);
199+
expect(executors[0].variables.get("project")).toBe("QA");
200+
expect(executors[0].variables.get("team")).toBe("Core");
201+
});
202+
203+
it("returns missing inputs for non-interactive runs", async () => {
204+
const { plugin, handlers } = createPlugin([
205+
templateChoice,
206+
macroChoice,
207+
multiChoice,
208+
]);
209+
registerQuickAddCliHandlers(plugin);
210+
const run = handlers.find((handler) => handler.command === "quickadd:run");
211+
expect(run).toBeDefined();
212+
213+
const missingRequirement = {
214+
id: "title",
215+
label: "Title",
216+
type: "text",
217+
};
218+
collectChoiceRequirementsMock.mockResolvedValue([missingRequirement]);
219+
getUnresolvedRequirementsMock.mockReturnValue([missingRequirement]);
220+
221+
const output = await Promise.resolve(
222+
run!.handler({
223+
choice: templateChoice.name,
224+
}),
225+
);
226+
const payload = JSON.parse(String(output));
227+
228+
expect(payload.ok).toBe(false);
229+
expect(payload.missingInputCount).toBeUndefined();
230+
expect(payload.missingFlags).toContain("value-title=<value>");
231+
expect(executors[0].execute).not.toHaveBeenCalled();
232+
});
233+
234+
it("lists flattened choices and supports command filter", async () => {
235+
const { plugin, handlers } = createPlugin([
236+
templateChoice,
237+
macroChoice,
238+
multiChoice,
239+
]);
240+
registerQuickAddCliHandlers(plugin);
241+
const list = handlers.find((handler) => handler.command === "quickadd:list");
242+
expect(list).toBeDefined();
243+
244+
const output = await Promise.resolve(
245+
list!.handler({
246+
commands: "true",
247+
}),
248+
);
249+
const payload = JSON.parse(String(output));
250+
251+
expect(payload.ok).toBe(true);
252+
expect(payload.count).toBe(2);
253+
expect(payload.choices.map((choice: { id: string; }) => choice.id)).toEqual([
254+
templateChoice.id,
255+
macroChoice.id,
256+
]);
257+
});
258+
259+
it("checks unresolved requirements without executing the choice", async () => {
260+
const { plugin, handlers } = createPlugin([
261+
templateChoice,
262+
macroChoice,
263+
multiChoice,
264+
]);
265+
registerQuickAddCliHandlers(plugin);
266+
const check = handlers.find((handler) => handler.command === "quickadd:check");
267+
expect(check).toBeDefined();
268+
269+
const requirement = {
270+
id: "project",
271+
label: "Project",
272+
type: "text",
273+
};
274+
collectChoiceRequirementsMock.mockResolvedValue([requirement]);
275+
getUnresolvedRequirementsMock.mockReturnValue([requirement]);
276+
277+
const output = await Promise.resolve(
278+
check!.handler({
279+
choice: templateChoice.name,
280+
}),
281+
);
282+
const payload = JSON.parse(String(output));
283+
284+
expect(payload.ok).toBe(false);
285+
expect(payload.requiredInputCount).toBe(1);
286+
expect(payload.missingInputCount).toBe(1);
287+
expect(executors[0].execute).not.toHaveBeenCalled();
288+
});
289+
});

0 commit comments

Comments
 (0)