@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/README.md +104 -0
- package/dist/index.js +10107 -0
- package/dist/templates/uipath-cli.md +38 -0
- package/dist/tool.js +29799 -0
- package/package.json +21 -0
- package/src/commands/chat.spec.ts +224 -0
- package/src/commands/chat.ts +156 -0
- package/src/index.ts +27 -0
- package/src/providers/uipath.spec.ts +296 -0
- package/src/providers/uipath.ts +195 -0
- package/src/tool.spec.ts +38 -0
- package/src/tool.ts +20 -0
- package/templates/uipath-cli.md +38 -0
- package/tests/coding-agent.e2e.test.ts +17 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +1 -0
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);
|