@pipeshub-ai/mcp 2.2.0 → 2.3.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.
Files changed (107) hide show
  1. package/README.md +2 -0
  2. package/bin/mcp-server.js +648 -175
  3. package/bin/mcp-server.js.map +17 -11
  4. package/bin/pipeshub.js +946 -0
  5. package/bin/pipeshub.js.map +14 -0
  6. package/esm/cli/client.d.ts +54 -0
  7. package/esm/cli/client.d.ts.map +1 -0
  8. package/esm/cli/client.js +230 -0
  9. package/esm/cli/client.js.map +1 -0
  10. package/esm/cli/commands.d.ts +30 -0
  11. package/esm/cli/commands.d.ts.map +1 -0
  12. package/esm/cli/commands.js +321 -0
  13. package/esm/cli/commands.js.map +1 -0
  14. package/esm/cli/config.d.ts +73 -0
  15. package/esm/cli/config.d.ts.map +1 -0
  16. package/esm/cli/config.js +218 -0
  17. package/esm/cli/config.js.map +1 -0
  18. package/esm/cli/init-qm.d.ts +11 -0
  19. package/esm/cli/init-qm.d.ts.map +1 -0
  20. package/esm/cli/init-qm.js +170 -0
  21. package/esm/cli/init-qm.js.map +1 -0
  22. package/esm/cli/pipeshub.d.ts +2 -0
  23. package/esm/cli/pipeshub.d.ts.map +1 -0
  24. package/esm/cli/pipeshub.js +296 -0
  25. package/esm/cli/pipeshub.js.map +1 -0
  26. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts +68 -0
  27. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts.map +1 -0
  28. package/esm/funcs/connectorLookupRecordByIdentifier.js +138 -0
  29. package/esm/funcs/connectorLookupRecordByIdentifier.js.map +1 -0
  30. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts +75 -0
  31. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts.map +1 -0
  32. package/esm/funcs/connectorNavigateKnowledgeGraph.js +152 -0
  33. package/esm/funcs/connectorNavigateKnowledgeGraph.js.map +1 -0
  34. package/esm/lib/base64.d.ts +1 -1
  35. package/esm/lib/base64.d.ts.map +1 -1
  36. package/esm/lib/base64.js +6 -3
  37. package/esm/lib/base64.js.map +1 -1
  38. package/esm/lib/bytes-to-base64.d.ts +16 -0
  39. package/esm/lib/bytes-to-base64.d.ts.map +1 -0
  40. package/esm/lib/bytes-to-base64.js +23 -0
  41. package/esm/lib/bytes-to-base64.js.map +1 -0
  42. package/esm/mcp-server/instructions.d.ts +1 -1
  43. package/esm/mcp-server/instructions.d.ts.map +1 -1
  44. package/esm/mcp-server/instructions.js +30 -0
  45. package/esm/mcp-server/instructions.js.map +1 -1
  46. package/esm/mcp-server/shared.d.ts +2 -2
  47. package/esm/mcp-server/shared.d.ts.map +1 -1
  48. package/esm/mcp-server/shared.js +7 -3
  49. package/esm/mcp-server/shared.js.map +1 -1
  50. package/esm/mcp-server/tools/_agui.d.ts +34 -0
  51. package/esm/mcp-server/tools/_agui.d.ts.map +1 -0
  52. package/esm/mcp-server/tools/_agui.js +91 -0
  53. package/esm/mcp-server/tools/_agui.js.map +1 -0
  54. package/esm/mcp-server/tools/_helpers.d.ts.map +1 -1
  55. package/esm/mcp-server/tools/_helpers.js +13 -6
  56. package/esm/mcp-server/tools/_helpers.js.map +1 -1
  57. package/esm/mcp-server/tools/pipeshubChat.d.ts +0 -3
  58. package/esm/mcp-server/tools/pipeshubChat.d.ts.map +1 -1
  59. package/esm/mcp-server/tools/pipeshubChat.js +75 -98
  60. package/esm/mcp-server/tools/pipeshubChat.js.map +1 -1
  61. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts +118 -1
  62. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts.map +1 -1
  63. package/esm/mcp-server/tools/pipeshubGetRecordContent.js +180 -29
  64. package/esm/mcp-server/tools/pipeshubGetRecordContent.js.map +1 -1
  65. package/esm/mcp-server/tools/pipeshubSearch.d.ts.map +1 -1
  66. package/esm/mcp-server/tools/pipeshubSearch.js +14 -1
  67. package/esm/mcp-server/tools/pipeshubSearch.js.map +1 -1
  68. package/esm/models/availablemodelsresponse.d.ts +1 -1
  69. package/esm/models/conversation.d.ts +1 -1
  70. package/esm/models/lookuprecordbyidentifierop.d.ts +7 -0
  71. package/esm/models/lookuprecordbyidentifierop.d.ts.map +1 -0
  72. package/esm/models/lookuprecordbyidentifierop.js +14 -0
  73. package/esm/models/lookuprecordbyidentifierop.js.map +1 -0
  74. package/esm/models/navigateknowledgegraphop.d.ts +14 -0
  75. package/esm/models/navigateknowledgegraphop.d.ts.map +1 -0
  76. package/esm/models/navigateknowledgegraphop.js +25 -0
  77. package/esm/models/navigateknowledgegraphop.js.map +1 -0
  78. package/esm/models/userteamsresponse.d.ts +1 -1
  79. package/esm/tool-names.js +3 -3
  80. package/esm/tool-names.js.map +1 -1
  81. package/package.json +3 -2
  82. package/qm/README.md +247 -0
  83. package/qm/SECURITY.md +134 -0
  84. package/qm/TROUBLESHOOTING.md +218 -0
  85. package/qm/qm.config.fragment.jsonc +42 -0
  86. package/qm/sandbox/Dockerfile +22 -0
  87. package/qm/sandbox/skills/pipeshub/SKILL.md +69 -0
  88. package/qm/sandbox/tools/pipeshub/tool.json +36 -0
  89. package/src/cli/client.ts +272 -0
  90. package/src/cli/commands.ts +403 -0
  91. package/src/cli/config.ts +229 -0
  92. package/src/cli/init-qm.ts +215 -0
  93. package/src/cli/pipeshub.ts +333 -0
  94. package/src/funcs/connectorLookupRecordByIdentifier.ts +200 -0
  95. package/src/funcs/connectorNavigateKnowledgeGraph.ts +215 -0
  96. package/src/lib/base64.ts +6 -3
  97. package/src/lib/bytes-to-base64.ts +24 -0
  98. package/src/mcp-server/instructions.ts +30 -0
  99. package/src/mcp-server/shared.ts +7 -3
  100. package/src/mcp-server/tools/_agui.ts +138 -0
  101. package/src/mcp-server/tools/_helpers.ts +13 -6
  102. package/src/mcp-server/tools/pipeshubChat.ts +78 -97
  103. package/src/mcp-server/tools/pipeshubGetRecordContent.ts +213 -29
  104. package/src/mcp-server/tools/pipeshubSearch.ts +14 -1
  105. package/src/models/lookuprecordbyidentifierop.ts +26 -0
  106. package/src/models/navigateknowledgegraphop.ts +56 -0
  107. package/src/tool-names.ts +3 -3
@@ -0,0 +1,215 @@
1
+ // `pipeshub init-qm <dir>` — scaffold the QM deployment-layer bundle into an
2
+ // operator's deployment directory.
3
+ //
4
+ // Why this exists rather than "copy the folder from the repo": the bundle and
5
+ // the CLI it describes are one contract. Copying by hand lets an operator end
6
+ // up with a tool.json written for a different version of the binary that is
7
+ // actually installed, and that mismatch is silent — an approval rule naming a
8
+ // subcommand that no longer exists compiles to a pattern matching nothing.
9
+ // Scaffolding from the installed package makes the two versions the same by
10
+ // construction, and stamps the version into the Dockerfile's pin.
11
+
12
+ import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { CliError, EXIT } from "./config.js";
16
+
17
+ /**
18
+ * Walk up from this module to the package root. The CLI runs from two possible
19
+ * layouts — `bin/pipeshub.js` (bundled) and `esm/cli/pipeshub.js` (tsc) — so a
20
+ * fixed number of `..` would be wrong for one of them.
21
+ */
22
+ async function packageRoot(): Promise<string> {
23
+ let dir = dirname(fileURLToPath(import.meta.url));
24
+ for (let i = 0; i < 6; i++) {
25
+ try {
26
+ const raw = await readFile(join(dir, "package.json"), "utf8");
27
+ const pkg = JSON.parse(raw) as { name?: string; version?: string };
28
+ if (pkg.name === "@pipeshub-ai/mcp") return dir;
29
+ } catch {
30
+ // keep walking
31
+ }
32
+ const parent = dirname(dir);
33
+ if (parent === dir) break;
34
+ dir = parent;
35
+ }
36
+ throw new CliError(
37
+ "could not locate the installed package root — reinstall @pipeshub-ai/mcp",
38
+ );
39
+ }
40
+
41
+ async function packageVersion(root: string): Promise<string> {
42
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
43
+ version?: string;
44
+ };
45
+ return pkg.version ?? "latest";
46
+ }
47
+
48
+ async function exists(p: string): Promise<boolean> {
49
+ try {
50
+ await stat(p);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** Copy a directory tree, reporting what was written and what was left alone. */
58
+ async function copyTree(
59
+ from: string,
60
+ to: string,
61
+ force: boolean,
62
+ written: string[],
63
+ skipped: string[],
64
+ ): Promise<void> {
65
+ await mkdir(to, { recursive: true });
66
+ for (const entry of await readdir(from, { withFileTypes: true })) {
67
+ const src = join(from, entry.name);
68
+ const dst = join(to, entry.name);
69
+ if (entry.isDirectory()) {
70
+ await copyTree(src, dst, force, written, skipped);
71
+ continue;
72
+ }
73
+ if (!force && await exists(dst)) {
74
+ skipped.push(dst);
75
+ continue;
76
+ }
77
+ await copyFile(src, dst);
78
+ written.push(dst);
79
+ }
80
+ }
81
+
82
+ const DOCKERFILE_NOTE = (version: string) => `
83
+ # --- PipesHub ---------------------------------------------------------------
84
+ # Installs the \`pipeshub\` binary. Pin the version: the sandbox image and the
85
+ # PipesHub instance's MCP tool surface need to stay compatible, and an unpinned
86
+ # install turns a remote release into a silent change in agent behaviour.
87
+ RUN npm install -g "@pipeshub-ai/mcp@${version}" \\
88
+ && pipeshub --help >/dev/null
89
+ # ----------------------------------------------------------------------------
90
+ `;
91
+
92
+ export interface InitResult {
93
+ written: string[];
94
+ skipped: string[];
95
+ dockerfileAction: "created" | "appended" | "manual";
96
+ version: string;
97
+ configFragment: string;
98
+ }
99
+
100
+ export async function initQm(
101
+ targetDir: string,
102
+ force: boolean,
103
+ ): Promise<InitResult> {
104
+ const root = await packageRoot();
105
+ const bundle = join(root, "qm");
106
+ if (!await exists(join(bundle, "sandbox"))) {
107
+ throw new CliError(
108
+ `the QM bundle is missing from the installed package (looked in ${bundle}). `
109
+ + "This build was packaged without it.",
110
+ );
111
+ }
112
+
113
+ const version = await packageVersion(root);
114
+ const dest = resolve(targetDir);
115
+ const written: string[] = [];
116
+ const skipped: string[] = [];
117
+
118
+ // Tools and skills copy cleanly — they are ours and live in their own
119
+ // subdirectories, so merging with an existing sandbox/ cannot collide with
120
+ // anything the operator wrote.
121
+ for (const sub of ["tools/pipeshub", "skills/pipeshub"]) {
122
+ await copyTree(
123
+ join(bundle, "sandbox", sub),
124
+ join(dest, "sandbox", sub),
125
+ force,
126
+ written,
127
+ skipped,
128
+ );
129
+ }
130
+
131
+ // The Dockerfile is shared with whatever else the operator installs, so it is
132
+ // never overwritten. Create it if absent, append our block if it exists and
133
+ // has no PipesHub block yet, and otherwise leave it entirely alone.
134
+ const dockerfile = join(dest, "sandbox", "Dockerfile");
135
+ let dockerfileAction: InitResult["dockerfileAction"];
136
+ if (!await exists(dockerfile)) {
137
+ await copyFile(join(bundle, "sandbox", "Dockerfile"), dockerfile);
138
+ // Restamp the pin so it matches the version actually installed.
139
+ const body = await readFile(dockerfile, "utf8");
140
+ await writeFile(
141
+ dockerfile,
142
+ body.replace(
143
+ /ARG PIPESHUB_CLI_VERSION=.*/,
144
+ `ARG PIPESHUB_CLI_VERSION=${version}`,
145
+ ),
146
+ "utf8",
147
+ );
148
+ written.push(dockerfile);
149
+ dockerfileAction = "created";
150
+ } else {
151
+ const body = await readFile(dockerfile, "utf8");
152
+ if (body.includes("@pipeshub-ai/mcp")) {
153
+ dockerfileAction = "manual";
154
+ } else {
155
+ await writeFile(dockerfile, body.replace(/\n*$/, "\n") + DOCKERFILE_NOTE(version), "utf8");
156
+ dockerfileAction = "appended";
157
+ }
158
+ }
159
+
160
+ // Do not swallow this. The fragment carries the `sandbox.env` block the
161
+ // operator must merge in, so reporting a successful init without it leaves
162
+ // them with a scaffold that cannot reach PipesHub and no sign of why.
163
+ const fragmentPath = join(bundle, "qm.config.fragment.jsonc");
164
+ let configFragment: string;
165
+ try {
166
+ configFragment = await readFile(fragmentPath, "utf8");
167
+ } catch (e: unknown) {
168
+ throw new CliError(
169
+ `the QM bundle is incomplete — could not read ${fragmentPath} `
170
+ + `(${(e as Error).message}). Reinstall @pipeshub-ai/mcp.`,
171
+ );
172
+ }
173
+
174
+ return { written, skipped, dockerfileAction, version, configFragment };
175
+ }
176
+
177
+ export function renderInitReport(dest: string, r: InitResult): string {
178
+ const lines: string[] = [];
179
+ lines.push(`Scaffolded the PipesHub bundle into ${dest}`);
180
+ lines.push(`CLI version pinned: ${r.version}`);
181
+ lines.push("");
182
+ for (const f of r.written) lines.push(` wrote ${f}`);
183
+ for (const f of r.skipped) lines.push(` kept ${f} (already existed — use --force to replace)`);
184
+ lines.push("");
185
+
186
+ if (r.dockerfileAction === "appended") {
187
+ lines.push("Appended the install block to your existing sandbox/Dockerfile.");
188
+ } else if (r.dockerfileAction === "manual") {
189
+ lines.push(
190
+ "Your sandbox/Dockerfile already mentions @pipeshub-ai/mcp, so it was left"
191
+ + " untouched. Check the pinned version matches " + r.version + ".",
192
+ );
193
+ }
194
+
195
+ lines.push("");
196
+ lines.push("Two things left to do:");
197
+ lines.push("");
198
+ lines.push("1. Set your PipesHub origin in qm.config.jsonc. It must be a PUBLIC");
199
+ lines.push(" HTTPS address — QM sandboxes do not run on your machine, so");
200
+ lines.push(" localhost and LAN addresses are unreachable from them:");
201
+ lines.push("");
202
+ lines.push(' "sandbox": {');
203
+ lines.push(' "env": { "PIPESHUB_BASE_URL": "https://pipeshub.your-company.com" }');
204
+ lines.push(" }");
205
+ lines.push("");
206
+ lines.push(" Do NOT put anyone's token in sandbox.secretEnv — that is org-wide and");
207
+ lines.push(" would hand one person's credential to everybody. Each person adds");
208
+ lines.push(" their own to their own keychain (service: pipeshub, kind: env).");
209
+ lines.push("");
210
+ lines.push("2. Set `egress` in sandbox/tools/pipeshub/tool.json to your hostname,");
211
+ lines.push(" then run: qm check && qm sandbox publish && qm up");
212
+ return lines.join("\n");
213
+ }
214
+
215
+ export const INIT_QM_EXIT = EXIT.OK;
@@ -0,0 +1,333 @@
1
+ // `pipeshub` — a thin CLI over the PipesHub MCP endpoint, built for agent use.
2
+ //
3
+ // Design constraints worth stating at the top, because they are easy to erode:
4
+ //
5
+ // - There is NO way to supply a credential on the command line. No
6
+ // `auth set`, no `auth import`, no `--token`, no `--print-token`. The
7
+ // keychain is the only credential path. A flag that accepts a token would
8
+ // end up in shell history, process listings, and eventually a transcript.
9
+ // - The token is never printed, in any command, in any mode.
10
+ // - Exit codes are contractual: agents branch on them.
11
+
12
+ import {
13
+ CliError,
14
+ EXIT,
15
+ resolveOrigin,
16
+ resolveToken,
17
+ } from "./config.js";
18
+ import { newRequestId } from "./client.js";
19
+ import { initQm, renderInitReport } from "./init-qm.js";
20
+ import {
21
+ ask,
22
+ authStatus,
23
+ connectHelp,
24
+ directoryWhoami,
25
+ get,
26
+ search,
27
+ sources,
28
+ type Ctx,
29
+ type Outcome,
30
+ } from "./commands.js";
31
+
32
+ const USAGE = `pipeshub — query your organization's PipesHub context layer
33
+
34
+ USAGE
35
+ pipeshub <command> [options]
36
+
37
+ COMMANDS
38
+ auth status show connection, user, org, scopes, expiry
39
+ auth connect-help print the keychain setup steps
40
+ init-qm <dir> scaffold the QM deployment-layer bundle (admin)
41
+ sources list searchable sources (ids for --app)
42
+ search <query> locate records
43
+ ask <question> ask a grounded question; returns citations
44
+ get <recordId> fetch a record's content
45
+ directory whoami the identity behind the current token
46
+
47
+ OPTIONS
48
+ --json JSON output (default)
49
+ --text human-readable output where available
50
+ --limit <n> search: max results (default 10)
51
+ --app <id> search: restrict to a source id (repeatable)
52
+ --conversation <id> ask: continue an existing conversation
53
+ --mode <internal|web> ask: retrieval strategy (default internal)
54
+ --as <format> get: server-side conversion, e.g. pdf
55
+ --out <path> get: write to a file instead of stdout
56
+ --max-chars <n> cap snippet/content length (default 2000)
57
+ --insecure-http allow cleartext to a host outside the private allowlist
58
+ --force init-qm: overwrite files that already exist
59
+
60
+ ENVIRONMENT
61
+ PIPESHUB_TOKEN preferred; PIPESHUB_MCP_TOKEN also accepted
62
+ PIPESHUB_BASE_URL origin, e.g. https://pipeshub.example.com
63
+ PIPESHUB_MCP_URL also accepted (its /mcp path is stripped)
64
+
65
+ EXIT CODES
66
+ 0 ok · 2 usage · 3 not authenticated · 4 forbidden · 5 rate limited
67
+ 6 no results (also: an 'ask' answer with no citations)
68
+
69
+ Credentials come from your QM keychain. This CLI cannot accept one as an
70
+ argument, by design. Run 'pipeshub auth connect-help' for setup steps.`;
71
+
72
+ interface Flags {
73
+ json: boolean;
74
+ limit: number;
75
+ apps: string[];
76
+ conversation: string | null;
77
+ mode: string;
78
+ as: string | null;
79
+ out: string | null;
80
+ maxChars: number;
81
+ insecureHttp: boolean;
82
+ force: boolean;
83
+ }
84
+
85
+ function parseFlags(argv: string[]): { positional: string[]; flags: Flags } {
86
+ const flags: Flags = {
87
+ json: true,
88
+ limit: 10,
89
+ apps: [],
90
+ conversation: null,
91
+ mode: "internal",
92
+ as: null,
93
+ out: null,
94
+ maxChars: 2000,
95
+ insecureHttp: false,
96
+ force: false,
97
+ };
98
+ const positional: string[] = [];
99
+
100
+ const needValue = (name: string, value: string | undefined): string => {
101
+ if (value === undefined) {
102
+ throw new CliError(`${name} requires a value`, EXIT.USAGE);
103
+ }
104
+ return value;
105
+ };
106
+
107
+ for (let i = 0; i < argv.length; i++) {
108
+ const a = argv[i];
109
+ if (a === undefined) continue;
110
+ switch (a) {
111
+ case "--json": flags.json = true; break;
112
+ case "--text": flags.json = false; break;
113
+ case "--insecure-http": flags.insecureHttp = true; break;
114
+ case "--force": flags.force = true; break;
115
+ case "--limit": flags.limit = Number(needValue(a, argv[++i])); break;
116
+ case "--app": flags.apps.push(needValue(a, argv[++i])); break;
117
+ case "--conversation": flags.conversation = needValue(a, argv[++i]); break;
118
+ case "--mode": flags.mode = needValue(a, argv[++i]); break;
119
+ case "--as": flags.as = needValue(a, argv[++i]); break;
120
+ case "--out": flags.out = needValue(a, argv[++i]); break;
121
+ case "--max-chars": flags.maxChars = Number(needValue(a, argv[++i])); break;
122
+ default:
123
+ if (a.startsWith("--")) {
124
+ // Fail loudly rather than ignoring a flag the caller believed in.
125
+ throw new CliError(`unknown option: ${a}`, EXIT.USAGE);
126
+ }
127
+ positional.push(a);
128
+ }
129
+ }
130
+ if (!Number.isFinite(flags.limit) || flags.limit <= 0) {
131
+ throw new CliError("--limit must be a positive number", EXIT.USAGE);
132
+ }
133
+ if (!Number.isFinite(flags.maxChars) || flags.maxChars <= 0) {
134
+ throw new CliError("--max-chars must be a positive number", EXIT.USAGE);
135
+ }
136
+ return { positional, flags };
137
+ }
138
+
139
+ /** `--mode internal|web` → the tool's `chatMode` vocabulary. */
140
+ function resolveChatMode(mode: string): string {
141
+ if (mode === "internal" || mode === "internal_search") return "internal_search";
142
+ if (mode === "web" || mode === "web_search") return "web_search";
143
+ throw new CliError(
144
+ `--mode must be "internal" or "web" (got "${mode}"). The other chatMode `
145
+ + "values the API accepts (quick, verification, deep) are only valid for "
146
+ + "agent conversations, which v1 does not expose.",
147
+ EXIT.USAGE,
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Write and wait for the bytes to actually reach the pipe.
153
+ *
154
+ * `process.stdout` is asynchronous when it is a pipe — which is the normal
155
+ * case here, since the caller is an agent capturing output. `process.exit()`
156
+ * does not flush it, so a payload larger than the pipe buffer is silently cut
157
+ * off (measured: a 2,000,015-byte write delivered 1,048,576 bytes) while the
158
+ * exit code still reports success. The consumer then parses truncated JSON.
159
+ *
160
+ * The `write` callback fires once the chunk has been handed to the underlying
161
+ * resource, so exiting after it is safe — and unlike setting `process.exitCode`
162
+ * and returning, it does not wait on undici's keep-alive sockets.
163
+ */
164
+ function writeFlushed(stream: NodeJS.WriteStream, text: string): Promise<void> {
165
+ return new Promise((resolve) => {
166
+ stream.write(text, () => resolve());
167
+ });
168
+ }
169
+
170
+ function emit(outcome: Outcome, json: boolean): Promise<void> {
171
+ if (!json && outcome.text !== undefined) {
172
+ return writeFlushed(process.stdout, outcome.text + "\n");
173
+ }
174
+ return writeFlushed(
175
+ process.stdout,
176
+ JSON.stringify(outcome.payload, null, 2) + "\n",
177
+ );
178
+ }
179
+
180
+ async function run(argv: string[]): Promise<number> {
181
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
182
+ await writeFlushed(process.stdout, USAGE + "\n");
183
+ return EXIT.OK;
184
+ }
185
+
186
+ const { positional, flags } = parseFlags(argv);
187
+ const command = positional[0] ?? "";
188
+ const sub = positional[1] ?? "";
189
+
190
+ // Refuse the credential-mutating shapes explicitly, so the failure explains
191
+ // itself instead of looking like a typo. These names must never work.
192
+ if (command === "auth" && ["set", "import", "login", "add"].includes(sub)) {
193
+ await writeFlushed(
194
+ process.stderr,
195
+ `pipeshub auth ${sub} does not exist, deliberately.\n`
196
+ + "Credentials come from your QM keychain — run "
197
+ + "'pipeshub auth connect-help' for the steps.\n",
198
+ );
199
+ return EXIT.USAGE;
200
+ }
201
+
202
+ const requestId = newRequestId();
203
+ const token = resolveToken();
204
+ const origin = resolveOrigin();
205
+
206
+ // connect-help must work with no credential at all — it is the thing you run
207
+ // precisely when you have none.
208
+ if (command === "auth" && sub === "connect-help") {
209
+ const outcome = connectHelp({
210
+ origin: origin ?? "",
211
+ token: "",
212
+ insecureHttp: flags.insecureHttp,
213
+ requestId,
214
+ json: flags.json,
215
+ maxChars: flags.maxChars,
216
+ });
217
+ await emit(outcome, flags.json);
218
+ return outcome.exit;
219
+ }
220
+
221
+ // Scaffolding is an admin task performed before any credential exists, so it
222
+ // runs before the token and base-URL checks.
223
+ if (command === "init-qm") {
224
+ const dir = positional[1] ?? "";
225
+ if (dir === "") {
226
+ throw new CliError("init-qm requires a target directory", EXIT.USAGE);
227
+ }
228
+ const result = await initQm(dir, flags.force);
229
+ const report = renderInitReport(dir, result);
230
+ if (flags.json) {
231
+ await writeFlushed(process.stdout, JSON.stringify({
232
+ requestId,
233
+ target: dir,
234
+ version: result.version,
235
+ written: result.written,
236
+ skipped: result.skipped,
237
+ dockerfile: result.dockerfileAction,
238
+ }, null, 2) + "\n");
239
+ } else {
240
+ await writeFlushed(process.stdout, report + "\n");
241
+ }
242
+ return EXIT.OK;
243
+ }
244
+
245
+ if (origin === null) {
246
+ // Report the missing credential too when both are absent. Otherwise a
247
+ // person whose actual problem is "I never added my token" is told about an
248
+ // admin-level setting, and goes looking in the wrong place.
249
+ const alsoNoToken = token === null
250
+ ? " Your PipesHub credential is also missing ($PIPESHUB_TOKEN is unset)."
251
+ : "";
252
+ throw new CliError(
253
+ "PIPESHUB_BASE_URL is not set — an admin sets it once for the deployment."
254
+ + alsoNoToken
255
+ + " Run 'pipeshub auth connect-help' for the steps.",
256
+ EXIT.USAGE,
257
+ );
258
+ }
259
+ if (token === null) {
260
+ throw new CliError(
261
+ "No PipesHub credential found ($PIPESHUB_TOKEN is unset). "
262
+ + "Run 'pipeshub auth connect-help' for setup steps.",
263
+ EXIT.UNAUTHENTICATED,
264
+ );
265
+ }
266
+
267
+ const ctx: Ctx = {
268
+ origin,
269
+ token,
270
+ insecureHttp: flags.insecureHttp,
271
+ requestId,
272
+ json: flags.json,
273
+ maxChars: flags.maxChars,
274
+ };
275
+
276
+ let outcome: Outcome;
277
+ switch (command) {
278
+ case "auth":
279
+ if (sub !== "status") {
280
+ throw new CliError(
281
+ `unknown subcommand: auth ${sub || "(none)"}`, EXIT.USAGE,
282
+ );
283
+ }
284
+ outcome = await authStatus(ctx);
285
+ break;
286
+ case "sources":
287
+ outcome = await sources(ctx);
288
+ break;
289
+ case "search": {
290
+ const query = positional.slice(1).join(" ").trim();
291
+ if (query === "") throw new CliError("search requires a query", EXIT.USAGE);
292
+ outcome = await search(ctx, query, flags.limit, flags.apps);
293
+ break;
294
+ }
295
+ case "ask": {
296
+ const query = positional.slice(1).join(" ").trim();
297
+ if (query === "") throw new CliError("ask requires a question", EXIT.USAGE);
298
+ outcome = await ask(ctx, query, flags.conversation, resolveChatMode(flags.mode));
299
+ break;
300
+ }
301
+ case "get": {
302
+ const recordId = positional[1] ?? "";
303
+ if (recordId === "") throw new CliError("get requires a recordId", EXIT.USAGE);
304
+ outcome = await get(ctx, recordId, flags.as, flags.out);
305
+ break;
306
+ }
307
+ case "directory":
308
+ if (sub !== "whoami") {
309
+ throw new CliError(
310
+ "v1 supports 'directory whoami' only. 'groups' needs usergroup:read, "
311
+ + "which is not in the stock MCP_SCOPES and cannot be minted on a "
312
+ + "default instance; 'teams' needs team:read, which the agent "
313
+ + "preset excludes.",
314
+ EXIT.USAGE,
315
+ );
316
+ }
317
+ outcome = await directoryWhoami(ctx);
318
+ break;
319
+ default:
320
+ throw new CliError(`unknown command: ${command}`, EXIT.USAGE);
321
+ }
322
+
323
+ await emit(outcome, flags.json);
324
+ return outcome.exit;
325
+ }
326
+
327
+ const code = await run(process.argv.slice(2)).catch(async (e: unknown) => {
328
+ const err = e as CliError;
329
+ const exit = typeof err.code === "number" ? err.code : EXIT.ERROR;
330
+ await writeFlushed(process.stderr, `pipeshub: ${err.message}\n`);
331
+ return exit;
332
+ });
333
+ process.exit(code);