@love-moon/conductor-cli 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * conductor search — whole-history message search (the web GlobalSearch box).
5
+ *
6
+ * Usage:
7
+ * conductor search <query...> [--limit N]
8
+ *
9
+ * Route: GET /api/search?q=<query>&limit=<n>
10
+ * Response: { query, backend: "fts" | "like", hits: [{ taskId, taskTitle,
11
+ * messageId, role, snippet, createdAt }] }
12
+ *
13
+ * Human output groups hits by task (same as the web UI); `--json` prints the
14
+ * raw response. Global flags: --json, --dry-run (accepted for consistency; a
15
+ * search is read-only so it always runs), --config-file.
16
+ */
17
+
18
+ import path from "node:path";
19
+ import process from "node:process";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ import yargs from "yargs/yargs";
23
+ import { hideBin } from "yargs/helpers";
24
+
25
+ import { EXIT, exitCodeForError, pad, printJson, printPretty, reportError } from "../src/entity-helpers.js";
26
+ import { apiPath, argsError, buildHttp } from "../src/backend-http.js";
27
+
28
+ const isMainModule = (() => {
29
+ const currentFile = fileURLToPath(import.meta.url);
30
+ const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
31
+ return entryFile === currentFile;
32
+ })();
33
+
34
+ /** Same default page size as the web search box. */
35
+ const DEFAULT_LIMIT = 30;
36
+
37
+ function oneLine(text) {
38
+ return String(text ?? "").replace(/\s+/g, " ").trim();
39
+ }
40
+
41
+ function formatTimestamp(value) {
42
+ const date = new Date(value);
43
+ if (Number.isNaN(date.getTime())) return "";
44
+ return date.toISOString().slice(0, 16).replace("T", " ");
45
+ }
46
+
47
+ export function groupHitsByTask(hits) {
48
+ const order = [];
49
+ const byTask = new Map();
50
+ for (const hit of hits) {
51
+ let group = byTask.get(hit.taskId);
52
+ if (!group) {
53
+ group = { taskId: hit.taskId, taskTitle: hit.taskTitle, hits: [] };
54
+ byTask.set(hit.taskId, group);
55
+ order.push(hit.taskId);
56
+ }
57
+ group.hits.push(hit);
58
+ }
59
+ return order.map((taskId) => byTask.get(taskId));
60
+ }
61
+
62
+ async function handleSearch(argv, deps) {
63
+ const query = [].concat(argv.query ?? []).map(String).join(" ").trim();
64
+ if (!query) throw argsError("Search query must not be empty");
65
+ if (argv.limit !== undefined && (!Number.isFinite(argv.limit) || argv.limit <= 0)) {
66
+ throw argsError("--limit must be a positive number");
67
+ }
68
+ const http = await buildHttp(deps);
69
+ const result = await http.get(apiPath("search"), {
70
+ query: { q: query, limit: argv.limit ?? DEFAULT_LIMIT },
71
+ });
72
+ if (argv.json) {
73
+ printJson(deps.stdout, result);
74
+ return EXIT.OK;
75
+ }
76
+ const hits = Array.isArray(result?.hits) ? result.hits : [];
77
+ if (hits.length === 0) {
78
+ printPretty(deps.stdout, `No messages match "${query}".`);
79
+ return EXIT.OK;
80
+ }
81
+ const groups = groupHitsByTask(hits);
82
+ printPretty(
83
+ deps.stdout,
84
+ `${hits.length} hit${hits.length === 1 ? "" : "s"} in ${groups.length} task${groups.length === 1 ? "" : "s"}`
85
+ + (result?.backend ? ` (backend: ${result.backend})` : ""),
86
+ );
87
+ for (const group of groups) {
88
+ printPretty(deps.stdout, "");
89
+ printPretty(deps.stdout, `${group.taskTitle || "(untitled task)"} [${group.taskId}]`);
90
+ for (const hit of group.hits) {
91
+ printPretty(
92
+ deps.stdout,
93
+ ` ${pad(formatTimestamp(hit.createdAt), 16)} ${pad(hit.role ?? "", 9)} ${oneLine(hit.snippet)}`,
94
+ );
95
+ }
96
+ }
97
+ return EXIT.OK;
98
+ }
99
+
100
+ export async function main(argvInput = hideBin(process.argv), deps = {}) {
101
+ const stdout = deps.stdout || process.stdout;
102
+ const stderr = deps.stderr || process.stderr;
103
+ const env = deps.env || process.env;
104
+ const cwd = deps.cwd || process.cwd();
105
+ const consoleErr = { error: (msg) => stderr.write(`${msg}\n`) };
106
+ const handlerDeps = { ...deps, stdout, stderr, env, cwd };
107
+
108
+ let exitCode = EXIT.OK;
109
+ try {
110
+ await yargs(argvInput)
111
+ .scriptName("conductor search")
112
+ .strict()
113
+ .help()
114
+ .option("json", { type: "boolean", default: false })
115
+ .option("dry-run", { type: "boolean", default: false, describe: "Accepted for consistency; search is read-only" })
116
+ .option("config-file", { type: "string", describe: "Path to Conductor config file" })
117
+ .command(
118
+ "$0 <query..>",
119
+ "Search message history across all of your tasks",
120
+ (cmd) => cmd
121
+ .positional("query", { type: "string", array: true, describe: "Search terms" })
122
+ .option("limit", { type: "number", describe: `Maximum hits (default ${DEFAULT_LIMIT})` })
123
+ .example("$0 flaky test", "Find messages mentioning 'flaky test'")
124
+ .example("$0 migration --limit 5 --json", "Raw JSON, 5 hits"),
125
+ async (argv) => {
126
+ exitCode = await handleSearch(argv, { ...handlerDeps, configFile: argv.configFile });
127
+ },
128
+ )
129
+ .fail((msg, err) => {
130
+ if (err) {
131
+ throw err;
132
+ }
133
+ stderr.write(`${msg}\n`);
134
+ exitCode = EXIT.ARGS;
135
+ })
136
+ .parseAsync();
137
+ } catch (err) {
138
+ exitCode = reportError(consoleErr, err);
139
+ }
140
+ return exitCode;
141
+ }
142
+
143
+ if (isMainModule) {
144
+ main().then((code) => {
145
+ if (code !== 0) process.exit(code);
146
+ }).catch((err) => {
147
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
148
+ process.exit(exitCodeForError(err));
149
+ });
150
+ }