@uipath/coder-tool 1.199.0-preview.116

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.
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@uipath/coder-tool",
3
+ "license": "MIT",
4
+ "version": "1.199.0-preview.116",
5
+ "description": "Interactive coding agent (Pi) with UiPath LLM Gateway and BYO provider support.",
6
+ "type": "module",
7
+ "main": "./dist/tool.js",
8
+ "exports": {
9
+ ".": "./dist/tool.js"
10
+ },
11
+ "bin": {
12
+ "coder-tool": "./dist/index.js"
13
+ },
14
+ "dependencies": {
15
+ "@earendil-works/pi-coding-agent": "^0.80.6"
16
+ },
17
+ "publishConfig": {
18
+ "registry": "https://registry.npmjs.org/"
19
+ },
20
+ "gitHead": "c91f4f9738502a4b1ca2849631890fcf59304484"
21
+ }
@@ -0,0 +1,224 @@
1
+ import { Command } from "commander";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
5
+ main: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("../providers/uipath", () => ({
9
+ createUiPathProviderExtension: vi.fn(() => ({
10
+ name: "uipath-llm-gateway",
11
+ factory: vi.fn(),
12
+ })),
13
+ }));
14
+
15
+ let guideExists = true;
16
+ const GUIDE_TEXT = "# UiPath CLI (`uip`) — agent guidance\n(test copy)";
17
+ vi.mock("@uipath/filesystem", () => ({
18
+ getFileSystem: () => ({
19
+ exists: (path: string) =>
20
+ Promise.resolve(guideExists && path.includes("uipath-cli.md")),
21
+ readFile: () => Promise.resolve(GUIDE_TEXT),
22
+ }),
23
+ }));
24
+
25
+ import { main } from "@earendil-works/pi-coding-agent";
26
+ import { OutputFormatter } from "@uipath/common";
27
+ import { registerChatCommand, withAgentGuide } from "./chat";
28
+
29
+ function buildProgram(context?: { exit: (code: number) => void }): Command {
30
+ const program = new Command();
31
+ program.name("coder").exitOverride();
32
+ registerChatCommand(program, context, vi.fn());
33
+ return program;
34
+ }
35
+
36
+ function piArgs(): string[] {
37
+ return (vi.mocked(main).mock.calls[0]?.[0] ?? []) as string[];
38
+ }
39
+
40
+ describe("uip coder", () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ guideExists = true;
44
+ process.exitCode = undefined;
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ });
50
+
51
+ it("launches pi with the UiPath agent guide appended to the system prompt", async () => {
52
+ const program = buildProgram();
53
+
54
+ await program.parseAsync([], { from: "user" });
55
+
56
+ expect(main).toHaveBeenCalledTimes(1);
57
+ const args = piArgs();
58
+ expect(args[0]).toBe("--append-system-prompt");
59
+ // The guide is passed as text, not a file path, so the injection
60
+ // does not depend on Pi's file-or-literal argument resolution.
61
+ expect(args[1]).toBe(GUIDE_TEXT);
62
+ expect(args).toHaveLength(2);
63
+ });
64
+
65
+ it("forwards positional arguments and unknown options to pi", async () => {
66
+ const program = buildProgram();
67
+
68
+ await program.parseAsync(
69
+ ["-p", "--provider", "uipath", "--model", "gpt-4o", "hello"],
70
+ { from: "user" },
71
+ );
72
+
73
+ expect(piArgs().slice(2)).toEqual([
74
+ "-p",
75
+ "--provider",
76
+ "uipath",
77
+ "--model",
78
+ "gpt-4o",
79
+ "hello",
80
+ ]);
81
+ });
82
+
83
+ it("launches pi without the guide when the template is missing", async () => {
84
+ guideExists = false;
85
+ const program = buildProgram();
86
+
87
+ await program.parseAsync(["-p", "hi"], { from: "user" });
88
+
89
+ expect(piArgs()).toEqual(["-p", "hi"]);
90
+ });
91
+
92
+ it("does not inject the guide before pi package-management subcommands", () => {
93
+ expect(withAgentGuide(["install", "some-ext"], "/guide.md")).toEqual([
94
+ "install",
95
+ "some-ext",
96
+ ]);
97
+ expect(withAgentGuide(["-p", "hi"], "/guide.md")).toEqual([
98
+ "--append-system-prompt",
99
+ "/guide.md",
100
+ "-p",
101
+ "hi",
102
+ ]);
103
+ });
104
+
105
+ it("keeps the guide for prompts whose first word matches a subcommand name", () => {
106
+ // Pi routes package commands on args[0] only, so an unquoted prompt
107
+ // like `-p update the readme` is a prompt, not `pi update`.
108
+ expect(
109
+ withAgentGuide(["-p", "update", "the", "readme"], "/guide.md"),
110
+ ).toEqual([
111
+ "--append-system-prompt",
112
+ "/guide.md",
113
+ "-p",
114
+ "update",
115
+ "the",
116
+ "readme",
117
+ ]);
118
+ });
119
+
120
+ it("passes the uipath provider extension to pi", async () => {
121
+ const program = buildProgram();
122
+
123
+ await program.parseAsync([], { from: "user" });
124
+
125
+ const [, options] = vi.mocked(main).mock.calls[0] ?? [];
126
+ const factories =
127
+ (options as { extensionFactories?: unknown[] })
128
+ ?.extensionFactories ?? [];
129
+ expect(factories).toHaveLength(1);
130
+ expect(factories[0]).toMatchObject({ name: "uipath-llm-gateway" });
131
+ });
132
+
133
+ it("turns pi's non-zero process.exit into a failure envelope", async () => {
134
+ const errorSpy = vi
135
+ .spyOn(OutputFormatter, "error")
136
+ .mockImplementation(() => {});
137
+ vi.mocked(main).mockImplementation(async () => {
138
+ // Pi hard-exits on its own error paths instead of throwing.
139
+ process.exit(2);
140
+ });
141
+ const exit = vi.fn();
142
+ const program = buildProgram({ exit });
143
+
144
+ await program.parseAsync(["-p", "hi"], { from: "user" });
145
+
146
+ expect(errorSpy).toHaveBeenCalledWith(
147
+ expect.objectContaining({
148
+ Result: "Failure",
149
+ Message: expect.stringContaining("exited with code 2"),
150
+ }),
151
+ );
152
+ expect(exit).toHaveBeenCalledWith(2);
153
+ });
154
+
155
+ it("handles process.exit from a fire-and-forget shutdown without an unhandled rejection", async () => {
156
+ // Pi's interactive quit (Ctrl+C / Ctrl+D) calls process.exit(0) from
157
+ // a floating promise chain, not from main()'s awaited stack.
158
+ const rejections: unknown[] = [];
159
+ const onRejection = (reason: unknown) => rejections.push(reason);
160
+ process.on("unhandledRejection", onRejection);
161
+ vi.mocked(main).mockImplementation(
162
+ () =>
163
+ new Promise<void>(() => {
164
+ // never resolves — like an interactive session
165
+ setTimeout(() => {
166
+ void Promise.resolve().then(() => process.exit(0));
167
+ }, 0);
168
+ }),
169
+ );
170
+ const exit = vi.fn();
171
+ const program = buildProgram({ exit });
172
+
173
+ await program.parseAsync([], { from: "user" });
174
+ await new Promise((resolve) => setImmediate(resolve));
175
+
176
+ process.off("unhandledRejection", onRejection);
177
+ expect(rejections).toEqual([]);
178
+ expect(exit).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it("treats pi's process.exit(0) as success", async () => {
182
+ const errorSpy = vi
183
+ .spyOn(OutputFormatter, "error")
184
+ .mockImplementation(() => {});
185
+ vi.mocked(main).mockImplementation(async () => {
186
+ process.exit(0);
187
+ });
188
+ const exit = vi.fn();
189
+ const program = buildProgram({ exit });
190
+
191
+ await program.parseAsync(["-p", "hi"], { from: "user" });
192
+
193
+ expect(errorSpy).not.toHaveBeenCalled();
194
+ expect(exit).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it("restores process.exit after pi finishes", async () => {
198
+ const originalExit = process.exit;
199
+ const program = buildProgram();
200
+
201
+ await program.parseAsync([], { from: "user" });
202
+
203
+ expect(process.exit).toBe(originalExit);
204
+ });
205
+
206
+ it("reports a failure envelope and exits 1 when pi fails to start", async () => {
207
+ const errorSpy = vi
208
+ .spyOn(OutputFormatter, "error")
209
+ .mockImplementation(() => {});
210
+ vi.mocked(main).mockRejectedValueOnce(new Error("pi exploded"));
211
+ const exit = vi.fn();
212
+ const program = buildProgram({ exit });
213
+
214
+ await program.parseAsync([], { from: "user" });
215
+
216
+ expect(errorSpy).toHaveBeenCalledWith(
217
+ expect.objectContaining({
218
+ Result: "Failure",
219
+ Message: expect.stringContaining("pi exploded"),
220
+ }),
221
+ );
222
+ expect(exit).toHaveBeenCalledWith(1);
223
+ });
224
+ });
@@ -0,0 +1,156 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import {
3
+ type CommandContext,
4
+ OutputFormatter,
5
+ processContext,
6
+ RESULTS,
7
+ } from "@uipath/common";
8
+ import { getFileSystem } from "@uipath/filesystem";
9
+ import type { Command } from "commander";
10
+ import { createUiPathProviderExtension } from "../providers/uipath";
11
+
12
+ /**
13
+ * Pi package-management subcommands. They must stay the first argv token,
14
+ * and they don't start an agent session, so no system prompt is injected.
15
+ */
16
+ const PI_SUBCOMMANDS = new Set([
17
+ "install",
18
+ "remove",
19
+ "uninstall",
20
+ "update",
21
+ "list",
22
+ "config",
23
+ ]);
24
+
25
+ /**
26
+ * Load the bundled UiPath CLI guide appended to Pi's system prompt.
27
+ * Lives at dist/templates/ in the published package (copied by
28
+ * tools/build-tool.ts) and at the package root when running from source.
29
+ * Returns the guide TEXT (not the path) so the injection does not depend
30
+ * on Pi's file-or-literal resolution of `--append-system-prompt`.
31
+ */
32
+ export async function resolveAgentGuide(): Promise<string | undefined> {
33
+ const fs = getFileSystem();
34
+ const candidates = [
35
+ new URL("./templates/uipath-cli.md", import.meta.url),
36
+ new URL("../../templates/uipath-cli.md", import.meta.url),
37
+ ];
38
+ for (const url of candidates) {
39
+ const path = fileURLToPath(url);
40
+ if (await fs.exists(path)) {
41
+ const text = await fs.readFile(path, "utf-8");
42
+ return text ?? undefined;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /**
49
+ * Prepend `--append-system-prompt <guide>` so the agent knows how to drive
50
+ * the UiPath platform through `uip`. Skipped for Pi's package-management
51
+ * subcommands, which Pi routes on the first argv token only — a prompt like
52
+ * `-p update the readme` must still get the guide.
53
+ */
54
+ export function withAgentGuide(
55
+ args: string[],
56
+ guideText: string | undefined,
57
+ ): string[] {
58
+ if (!guideText) {
59
+ return args;
60
+ }
61
+ const first = args[0];
62
+ if (first !== undefined && PI_SUBCOMMANDS.has(first)) {
63
+ return args;
64
+ }
65
+ return ["--append-system-prompt", guideText, ...args];
66
+ }
67
+
68
+ type HardExit = (code: number) => void;
69
+
70
+ /**
71
+ * Run Pi's CLI entry point with `process.exit` intercepted. Pi hard-exits
72
+ * on both success and failure (deliberately, so misbehaving extensions
73
+ * cannot keep one-shot commands alive); the interception turns that into a
74
+ * return value so failures can be reported through the CLI envelope. The
75
+ * hard exit is re-applied after the command finishes its bookkeeping.
76
+ *
77
+ * The interception resolves a promise instead of throwing: Pi's interactive
78
+ * quit paths (Ctrl+C / Ctrl+D / signal handlers) call `process.exit` from
79
+ * fire-and-forget shutdown chains, where a throw would surface as an
80
+ * unhandled rejection instead of a clean exit.
81
+ */
82
+ async function runPi(args: string[], hardExit?: HardExit): Promise<number> {
83
+ const { main } = await import("@earendil-works/pi-coding-agent");
84
+ const originalExit = process.exit;
85
+ let requestedExitCode: number | undefined;
86
+ let resolveExit: (code: number) => void = () => {};
87
+ const exitRequested = new Promise<number>((resolve) => {
88
+ resolveExit = resolve;
89
+ });
90
+ process.exit = ((code?: number | string | null) => {
91
+ requestedExitCode =
92
+ typeof code === "number" ? code : (process.exitCode ?? 0);
93
+ resolveExit(requestedExitCode);
94
+ return undefined as never;
95
+ }) as typeof process.exit;
96
+ try {
97
+ const mainDone = main(args, {
98
+ extensionFactories: [createUiPathProviderExtension()],
99
+ }).then(() => requestedExitCode ?? 0);
100
+ // If main settles after an exit was already requested, that outcome
101
+ // has nowhere to go — swallow it so it cannot become an unhandled
102
+ // rejection. The raced result below already carries the exit code.
103
+ mainDone.catch(() => {});
104
+ return await Promise.race([mainDone, exitRequested]);
105
+ } finally {
106
+ process.exit = originalExit;
107
+ // Preserve Pi's lifecycle: force the exit once telemetry and output
108
+ // (which run in microtasks before I/O callbacks) have completed.
109
+ const exit: HardExit = hardExit ?? ((code) => originalExit(code));
110
+ setImmediate(() => exit(process.exitCode ?? requestedExitCode ?? 0));
111
+ }
112
+ }
113
+
114
+ /**
115
+ * `uip coder [args...]` — the UiPath coding agent, powered by Pi.
116
+ *
117
+ * All arguments are forwarded verbatim to Pi, so the full Pi CLI surface
118
+ * stays available: no args opens the interactive TUI; `-p "<prompt>"` runs
119
+ * one prompt and exits; `--list-models` shows every provider/model;
120
+ * `--provider uipath --model <id>` pins the UiPath LLM Gateway;
121
+ * `--continue` resumes the previous session. See `uip coder --help`
122
+ * (rendered by Pi) for everything else.
123
+ */
124
+ export function registerChatCommand(
125
+ command: Command,
126
+ context: CommandContext = processContext,
127
+ hardExit?: HardExit,
128
+ ): void {
129
+ command
130
+ .argument(
131
+ "[args...]",
132
+ "arguments forwarded to the Pi coding agent (see 'uip coder --help')",
133
+ )
134
+ .allowUnknownOption()
135
+ .allowExcessArguments(true)
136
+ .helpOption(false)
137
+ .trackedAction(
138
+ context,
139
+ async (_args: string[], _options: unknown, cmd: Command) => {
140
+ const guide = await resolveAgentGuide();
141
+ const exitCode = await runPi(
142
+ withAgentGuide(cmd.args, guide),
143
+ hardExit,
144
+ );
145
+ if (exitCode !== 0) {
146
+ OutputFormatter.error({
147
+ Result: RESULTS.Failure,
148
+ Message: `The coding agent exited with code ${exitCode}.`,
149
+ Instructions:
150
+ "Check the agent output above for the underlying error. Run 'uip coder --help' for usage, or 'uip login' if the uipath provider is missing.",
151
+ });
152
+ context.exit(exitCode);
153
+ }
154
+ },
155
+ );
156
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Standalone entry point for coder-tool
5
+ * This allows the tool to be run independently for testing
6
+ */
7
+
8
+ // Side-effect import: installs Command.prototype.trackedAction on this
9
+ // bundle's commander copy before registerCommands touches it.
10
+ import { setPreviewBuild } from "@uipath/common";
11
+ import { Command } from "commander";
12
+ import { metadata, registerCommands } from "./tool.js";
13
+
14
+ // The standalone bin is a dev/test surface — opt into the preview-gated
15
+ // command unconditionally. The CLI host gates on its own release channel.
16
+ setPreviewBuild(true);
17
+
18
+ const program = new Command();
19
+
20
+ program
21
+ .name(metadata.commandPrefix)
22
+ .description(metadata.description)
23
+ .version(metadata.version);
24
+
25
+ await registerCommands(program);
26
+
27
+ program.parse(process.argv);