@answerloops/agent-sdk 0.5.1 → 0.6.2
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 +37 -4
- package/dist/cli/index.cjs +264 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +263 -0
- package/dist/index.cjs +31 -18
- package/dist/index.d.cts +45 -11
- package/dist/index.d.ts +45 -11
- package/dist/index.js +31 -18
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
1
|
# @answerloops/agent-sdk
|
|
2
2
|
|
|
3
|
-
Typed Node/browser client for the [answerLoops Agent API](https://answerloops.com/docs/integrations/agent-api) — search the knowledge base, read the FAQ digest, list/create tickets, and generate grounded answers.
|
|
3
|
+
Typed Node/browser client for the [answerLoops Agent API](https://answerloops.com/docs/integrations/agent-api) — search the knowledge base, read the FAQ digest, list/create tickets, and generate grounded answers. Also ships a CLI (`answerloops`) that bootstraps a self-hosted instance or installs the [Claude Code agent skills](https://answerloops.com/docs/integrations/agent-skills).
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Getting started
|
|
6
|
+
|
|
7
|
+
Don't have a running answerLoops instance yet? Bootstrap one:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @answerloops/agent-sdk setup
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Clones the repo if needed, checks Docker + git are present, generates
|
|
14
|
+
`AUTH_SECRET`/`ENCRYPTION_KEY`, starts the published image via Docker
|
|
15
|
+
Compose, and polls `/api/health` until it's actually up. It never invents a
|
|
16
|
+
real credential — if `DATABASE_URL`, `AUTH_URL`, or the Google OAuth pair are
|
|
17
|
+
missing, it tells you exactly what to add to `.env` and exits, rather than
|
|
18
|
+
guessing.
|
|
19
|
+
|
|
20
|
+
Already have a workspace (hosted or self-hosted) and just want an agent
|
|
21
|
+
connected to it? Install the Claude Code skills instead:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @answerloops/agent-sdk skills answerloops-setup answerloops-operate
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Writes both into `.claude/skills/` — install just one by naming it alone.
|
|
28
|
+
|
|
29
|
+
Building against the API directly instead? Install the library:
|
|
6
30
|
|
|
7
31
|
```bash
|
|
8
32
|
npm install @answerloops/agent-sdk
|
|
@@ -24,7 +48,9 @@ const answer = await client.generateAnswer({ question: "How do I reset my API ke
|
|
|
24
48
|
|
|
25
49
|
const ticket = await client.createTicket({ content: "My webhook stopped firing." });
|
|
26
50
|
|
|
27
|
-
const { tickets } = await client.getTickets({ status: "open", limit: 10 });
|
|
51
|
+
const { tickets, next_cursor } = await client.getTickets({ status: "open", limit: 10 });
|
|
52
|
+
// next_cursor is non-null while more tickets remain — pass it back as `cursor` to keep paging:
|
|
53
|
+
// await client.getTickets({ status: "open", limit: 10, cursor: next_cursor });
|
|
28
54
|
```
|
|
29
55
|
|
|
30
56
|
Every key carries least-privilege scopes (`kb:read`, `faq:read`, `tickets:read`, `tickets:write`, `answers:write`) set in **Settings → API Keys**. A call against a scope the key doesn't have throws `AgentApiError` with `status === 403`.
|
|
@@ -40,7 +66,14 @@ try {
|
|
|
40
66
|
await client.generateAnswer({ question: "..." });
|
|
41
67
|
} catch (err) {
|
|
42
68
|
if (err instanceof AgentApiError) {
|
|
43
|
-
console.error(err.status, err.
|
|
69
|
+
console.error(err.status, err.message);
|
|
70
|
+
// On a 429, err.code tells apart the per-minute rate limit from either
|
|
71
|
+
// monthly quota — "rate_limited" (retry shortly) vs.
|
|
72
|
+
// "deflection_limit_reached" / "call_limit_reached" (upgrade, or wait
|
|
73
|
+
// for the next billing cycle; retrying immediately won't help).
|
|
74
|
+
if (err.code === "rate_limited") {
|
|
75
|
+
// back off and retry
|
|
76
|
+
}
|
|
44
77
|
}
|
|
45
78
|
}
|
|
46
79
|
```
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/cli/index.ts
|
|
5
|
+
var import_node_util = require("util");
|
|
6
|
+
|
|
7
|
+
// src/cli/setup.ts
|
|
8
|
+
var import_node_fs4 = require("fs");
|
|
9
|
+
var import_node_path4 = require("path");
|
|
10
|
+
|
|
11
|
+
// src/cli/util.ts
|
|
12
|
+
var import_node_child_process = require("child_process");
|
|
13
|
+
var import_node_fs = require("fs");
|
|
14
|
+
function log(msg) {
|
|
15
|
+
console.log(msg);
|
|
16
|
+
}
|
|
17
|
+
function fail(msg) {
|
|
18
|
+
console.error(`\u2717 ${msg}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
function run(cmd, args, opts = {}) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const child = (0, import_node_child_process.spawn)(cmd, args, {
|
|
24
|
+
cwd: opts.cwd,
|
|
25
|
+
env: opts.env ?? process.env,
|
|
26
|
+
stdio: "inherit"
|
|
27
|
+
});
|
|
28
|
+
child.on("error", reject);
|
|
29
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function commandExists(cmd, args) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const child = (0, import_node_child_process.spawn)(cmd, args, { stdio: "ignore" });
|
|
35
|
+
child.on("error", () => resolve(false));
|
|
36
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function fileExists(path) {
|
|
40
|
+
return (0, import_node_fs.existsSync)(path);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/cli/prereqs.ts
|
|
44
|
+
async function checkPrereqs() {
|
|
45
|
+
const missing = [];
|
|
46
|
+
if (!await commandExists("git", ["--version"])) {
|
|
47
|
+
missing.push("git \u2014 https://git-scm.com/downloads");
|
|
48
|
+
}
|
|
49
|
+
if (!await commandExists("docker", ["--version"])) {
|
|
50
|
+
missing.push("Docker \u2014 https://docs.docker.com/get-docker/");
|
|
51
|
+
} else if (!await commandExists("docker", ["compose", "version"])) {
|
|
52
|
+
missing.push("Docker Compose v2 \u2014 update Docker Desktop or install the compose plugin");
|
|
53
|
+
}
|
|
54
|
+
if (missing.length > 0) {
|
|
55
|
+
for (const m of missing) console.error(`\u2717 ${m}`);
|
|
56
|
+
fail("Missing prerequisites. Install the above, then re-run.");
|
|
57
|
+
}
|
|
58
|
+
log("\u2713 git and Docker (with Compose v2) found");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/cli/repo.ts
|
|
62
|
+
var import_node_path = require("path");
|
|
63
|
+
var REPO_URL = "https://github.com/answerLoops/answerLoops.git";
|
|
64
|
+
var MARKER = "docker-compose.ghcr.yml";
|
|
65
|
+
async function ensureRepo(targetDir) {
|
|
66
|
+
if (fileExists((0, import_node_path.join)(process.cwd(), MARKER))) {
|
|
67
|
+
return process.cwd();
|
|
68
|
+
}
|
|
69
|
+
if (fileExists((0, import_node_path.join)(targetDir, MARKER))) {
|
|
70
|
+
log(`\u2713 using existing checkout at ${targetDir}`);
|
|
71
|
+
return targetDir;
|
|
72
|
+
}
|
|
73
|
+
log(`Cloning ${REPO_URL} into ${targetDir}...`);
|
|
74
|
+
const code = await run("git", ["clone", REPO_URL, targetDir]);
|
|
75
|
+
if (code !== 0) fail(`git clone failed (exit ${code})`);
|
|
76
|
+
return targetDir;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/cli/env.ts
|
|
80
|
+
var import_node_crypto = require("crypto");
|
|
81
|
+
var import_node_fs2 = require("fs");
|
|
82
|
+
var import_node_path2 = require("path");
|
|
83
|
+
var REQUIRED_VARS = ["DATABASE_URL", "AUTH_URL", "AUTH_GOOGLE_ID", "AUTH_GOOGLE_SECRET"];
|
|
84
|
+
var HOW_TO_GET = {
|
|
85
|
+
DATABASE_URL: "a Postgres connection string you run and control, e.g. postgresql://user:pass@host:5432/db",
|
|
86
|
+
AUTH_URL: "the public URL this instance will be reachable at, e.g. http://localhost:3000",
|
|
87
|
+
AUTH_GOOGLE_ID: "a Google OAuth client ID \u2014 console.cloud.google.com, register callback {AUTH_URL}/api/auth/callback/google",
|
|
88
|
+
AUTH_GOOGLE_SECRET: "the matching Google OAuth client secret from the same client"
|
|
89
|
+
};
|
|
90
|
+
function parseEnvFile(path) {
|
|
91
|
+
if (!(0, import_node_fs2.existsSync)(path)) return {};
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const line of (0, import_node_fs2.readFileSync)(path, "utf8").split("\n")) {
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
96
|
+
const eq = trimmed.indexOf("=");
|
|
97
|
+
if (eq === -1) continue;
|
|
98
|
+
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
function genSecret() {
|
|
103
|
+
return (0, import_node_crypto.randomBytes)(32).toString("hex");
|
|
104
|
+
}
|
|
105
|
+
function ensureEnv(repoDir) {
|
|
106
|
+
const envPath = (0, import_node_path2.join)(repoDir, ".env");
|
|
107
|
+
const existing = parseEnvFile(envPath);
|
|
108
|
+
const toAppend = [];
|
|
109
|
+
for (const secretVar of ["AUTH_SECRET", "ENCRYPTION_KEY"]) {
|
|
110
|
+
if (!existing[secretVar] && !process.env[secretVar]) {
|
|
111
|
+
const value = genSecret();
|
|
112
|
+
toAppend.push(`${secretVar}=${value}`);
|
|
113
|
+
log(`\u2713 generated ${secretVar}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (toAppend.length > 0) {
|
|
117
|
+
(0, import_node_fs2.appendFileSync)(envPath, ((0, import_node_fs2.existsSync)(envPath) ? "\n" : "") + toAppend.join("\n") + "\n");
|
|
118
|
+
} else if (!(0, import_node_fs2.existsSync)(envPath)) {
|
|
119
|
+
(0, import_node_fs2.writeFileSync)(envPath, "");
|
|
120
|
+
}
|
|
121
|
+
const missing = REQUIRED_VARS.filter((v) => !existing[v] && !process.env[v]);
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
console.error(`Missing required configuration in ${envPath}:`);
|
|
124
|
+
for (const v of missing) console.error(` ${v} \u2014 ${HOW_TO_GET[v]}`);
|
|
125
|
+
console.error(
|
|
126
|
+
`
|
|
127
|
+
Add these to ${envPath} (or export them before running this command), then re-run.`
|
|
128
|
+
);
|
|
129
|
+
fail("Required environment variables not set.");
|
|
130
|
+
}
|
|
131
|
+
log(`\u2713 ${envPath} has everything required`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/cli/health.ts
|
|
135
|
+
async function waitForHealth(url, timeoutSeconds = 120) {
|
|
136
|
+
const intervalMs = 3e3;
|
|
137
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
138
|
+
log(`Waiting for ${url} to become healthy (timeout ${timeoutSeconds}s)...`);
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(url);
|
|
142
|
+
if (res.ok) {
|
|
143
|
+
log(`\u2713 healthy`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
149
|
+
}
|
|
150
|
+
fail(
|
|
151
|
+
`${url} still unhealthy after ${timeoutSeconds}s. Check logs: docker compose -f docker-compose.ghcr.yml logs app -f`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/cli/skills.ts
|
|
156
|
+
var import_node_fs3 = require("fs");
|
|
157
|
+
var import_node_path3 = require("path");
|
|
158
|
+
var RAW_BASE = "https://raw.githubusercontent.com/answerLoops/answerLoops/main";
|
|
159
|
+
var SKILLS = {
|
|
160
|
+
"answerloops-setup": ["skills/setup/SKILL.md"],
|
|
161
|
+
"answerloops-operate": ["skills/operate/SKILL.md"]
|
|
162
|
+
};
|
|
163
|
+
async function installSkills(names) {
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
const files = SKILLS[name];
|
|
166
|
+
if (!files) fail(`Unknown skill "${name}". Known: ${Object.keys(SKILLS).join(", ")}`);
|
|
167
|
+
const destDir = (0, import_node_path3.join)(".claude", "skills", name);
|
|
168
|
+
(0, import_node_fs3.mkdirSync)(destDir, { recursive: true });
|
|
169
|
+
for (const relPath of files) {
|
|
170
|
+
const url = `${RAW_BASE}/${relPath}`;
|
|
171
|
+
const res = await fetch(url);
|
|
172
|
+
if (!res.ok) fail(`Could not fetch ${url} (${res.status})`);
|
|
173
|
+
const body = await res.text();
|
|
174
|
+
const destPath = (0, import_node_path3.join)(destDir, relPath.split("/").pop());
|
|
175
|
+
(0, import_node_fs3.writeFileSync)(destPath, body);
|
|
176
|
+
}
|
|
177
|
+
log(`\u2713 installed ${name} -> ${destDir}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/cli/setup.ts
|
|
182
|
+
async function runSetup(opts) {
|
|
183
|
+
await checkPrereqs();
|
|
184
|
+
const repoDir = await ensureRepo(opts.targetDir);
|
|
185
|
+
ensureEnv(repoDir);
|
|
186
|
+
log("Starting the stack (docker compose -f docker-compose.ghcr.yml up -d)...");
|
|
187
|
+
const code = await run("docker", ["compose", "-f", "docker-compose.ghcr.yml", "up", "-d"], {
|
|
188
|
+
cwd: repoDir
|
|
189
|
+
});
|
|
190
|
+
if (code !== 0) fail(`docker compose up failed (exit ${code})`);
|
|
191
|
+
const authUrl = readAuthUrl(repoDir) ?? "http://localhost:3000";
|
|
192
|
+
await waitForHealth(`${authUrl.replace(/\/+$/, "")}/api/health`);
|
|
193
|
+
if (opts.withSkills) {
|
|
194
|
+
await installSkills(["answerloops-setup", "answerloops-operate"]);
|
|
195
|
+
}
|
|
196
|
+
log(`
|
|
197
|
+
Your instance is live at ${authUrl}. Sign in with Google and complete onboarding.`);
|
|
198
|
+
}
|
|
199
|
+
function readAuthUrl(repoDir) {
|
|
200
|
+
try {
|
|
201
|
+
const text = (0, import_node_fs4.readFileSync)((0, import_node_path4.join)(repoDir, ".env"), "utf8");
|
|
202
|
+
const line = text.split("\n").find((l) => l.trim().startsWith("AUTH_URL="));
|
|
203
|
+
return line?.slice(line.indexOf("=") + 1).trim();
|
|
204
|
+
} catch {
|
|
205
|
+
return void 0;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/cli/index.ts
|
|
210
|
+
var USAGE = `Usage: answerloops <command> [options]
|
|
211
|
+
|
|
212
|
+
Commands:
|
|
213
|
+
setup Bootstrap a self-hosted answerLoops instance (Docker,
|
|
214
|
+
the published image). Requires Docker + git.
|
|
215
|
+
--dir <path> Directory to clone into if not already in a
|
|
216
|
+
checkout (default: ./answerLoops)
|
|
217
|
+
--with-skills Also install the Claude Code setup + operate
|
|
218
|
+
skills into ./.claude/skills/
|
|
219
|
+
|
|
220
|
+
skills <name...> Install one or more Claude Code skills into
|
|
221
|
+
./.claude/skills/. Names: answerloops-setup,
|
|
222
|
+
answerloops-operate
|
|
223
|
+
|
|
224
|
+
help Show this message
|
|
225
|
+
`;
|
|
226
|
+
async function main() {
|
|
227
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
228
|
+
switch (command) {
|
|
229
|
+
case "setup": {
|
|
230
|
+
const { values } = (0, import_node_util.parseArgs)({
|
|
231
|
+
args: rest,
|
|
232
|
+
options: {
|
|
233
|
+
dir: { type: "string", default: "answerLoops" },
|
|
234
|
+
"with-skills": { type: "boolean", default: false }
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
await runSetup({ targetDir: values.dir, withSkills: values["with-skills"] });
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
case "skills": {
|
|
241
|
+
if (rest.length === 0) {
|
|
242
|
+
console.error("Usage: answerloops skills <name...>");
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
await installSkills(rest);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "help":
|
|
249
|
+
case void 0:
|
|
250
|
+
case "--help":
|
|
251
|
+
case "-h":
|
|
252
|
+
console.log(USAGE);
|
|
253
|
+
break;
|
|
254
|
+
default:
|
|
255
|
+
console.error(`Unknown command "${command}"
|
|
256
|
+
`);
|
|
257
|
+
console.log(USAGE);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
main().catch((err) => {
|
|
262
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
263
|
+
process.exit(1);
|
|
264
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
|
|
6
|
+
// src/cli/setup.ts
|
|
7
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
8
|
+
import { join as join4 } from "path";
|
|
9
|
+
|
|
10
|
+
// src/cli/util.ts
|
|
11
|
+
import { spawn } from "child_process";
|
|
12
|
+
import { existsSync } from "fs";
|
|
13
|
+
function log(msg) {
|
|
14
|
+
console.log(msg);
|
|
15
|
+
}
|
|
16
|
+
function fail(msg) {
|
|
17
|
+
console.error(`\u2717 ${msg}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
function run(cmd, args, opts = {}) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const child = spawn(cmd, args, {
|
|
23
|
+
cwd: opts.cwd,
|
|
24
|
+
env: opts.env ?? process.env,
|
|
25
|
+
stdio: "inherit"
|
|
26
|
+
});
|
|
27
|
+
child.on("error", reject);
|
|
28
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function commandExists(cmd, args) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const child = spawn(cmd, args, { stdio: "ignore" });
|
|
34
|
+
child.on("error", () => resolve(false));
|
|
35
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function fileExists(path) {
|
|
39
|
+
return existsSync(path);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/cli/prereqs.ts
|
|
43
|
+
async function checkPrereqs() {
|
|
44
|
+
const missing = [];
|
|
45
|
+
if (!await commandExists("git", ["--version"])) {
|
|
46
|
+
missing.push("git \u2014 https://git-scm.com/downloads");
|
|
47
|
+
}
|
|
48
|
+
if (!await commandExists("docker", ["--version"])) {
|
|
49
|
+
missing.push("Docker \u2014 https://docs.docker.com/get-docker/");
|
|
50
|
+
} else if (!await commandExists("docker", ["compose", "version"])) {
|
|
51
|
+
missing.push("Docker Compose v2 \u2014 update Docker Desktop or install the compose plugin");
|
|
52
|
+
}
|
|
53
|
+
if (missing.length > 0) {
|
|
54
|
+
for (const m of missing) console.error(`\u2717 ${m}`);
|
|
55
|
+
fail("Missing prerequisites. Install the above, then re-run.");
|
|
56
|
+
}
|
|
57
|
+
log("\u2713 git and Docker (with Compose v2) found");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/cli/repo.ts
|
|
61
|
+
import { join } from "path";
|
|
62
|
+
var REPO_URL = "https://github.com/answerLoops/answerLoops.git";
|
|
63
|
+
var MARKER = "docker-compose.ghcr.yml";
|
|
64
|
+
async function ensureRepo(targetDir) {
|
|
65
|
+
if (fileExists(join(process.cwd(), MARKER))) {
|
|
66
|
+
return process.cwd();
|
|
67
|
+
}
|
|
68
|
+
if (fileExists(join(targetDir, MARKER))) {
|
|
69
|
+
log(`\u2713 using existing checkout at ${targetDir}`);
|
|
70
|
+
return targetDir;
|
|
71
|
+
}
|
|
72
|
+
log(`Cloning ${REPO_URL} into ${targetDir}...`);
|
|
73
|
+
const code = await run("git", ["clone", REPO_URL, targetDir]);
|
|
74
|
+
if (code !== 0) fail(`git clone failed (exit ${code})`);
|
|
75
|
+
return targetDir;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/cli/env.ts
|
|
79
|
+
import { randomBytes } from "crypto";
|
|
80
|
+
import { existsSync as existsSync2, readFileSync, appendFileSync, writeFileSync } from "fs";
|
|
81
|
+
import { join as join2 } from "path";
|
|
82
|
+
var REQUIRED_VARS = ["DATABASE_URL", "AUTH_URL", "AUTH_GOOGLE_ID", "AUTH_GOOGLE_SECRET"];
|
|
83
|
+
var HOW_TO_GET = {
|
|
84
|
+
DATABASE_URL: "a Postgres connection string you run and control, e.g. postgresql://user:pass@host:5432/db",
|
|
85
|
+
AUTH_URL: "the public URL this instance will be reachable at, e.g. http://localhost:3000",
|
|
86
|
+
AUTH_GOOGLE_ID: "a Google OAuth client ID \u2014 console.cloud.google.com, register callback {AUTH_URL}/api/auth/callback/google",
|
|
87
|
+
AUTH_GOOGLE_SECRET: "the matching Google OAuth client secret from the same client"
|
|
88
|
+
};
|
|
89
|
+
function parseEnvFile(path) {
|
|
90
|
+
if (!existsSync2(path)) return {};
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
95
|
+
const eq = trimmed.indexOf("=");
|
|
96
|
+
if (eq === -1) continue;
|
|
97
|
+
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
function genSecret() {
|
|
102
|
+
return randomBytes(32).toString("hex");
|
|
103
|
+
}
|
|
104
|
+
function ensureEnv(repoDir) {
|
|
105
|
+
const envPath = join2(repoDir, ".env");
|
|
106
|
+
const existing = parseEnvFile(envPath);
|
|
107
|
+
const toAppend = [];
|
|
108
|
+
for (const secretVar of ["AUTH_SECRET", "ENCRYPTION_KEY"]) {
|
|
109
|
+
if (!existing[secretVar] && !process.env[secretVar]) {
|
|
110
|
+
const value = genSecret();
|
|
111
|
+
toAppend.push(`${secretVar}=${value}`);
|
|
112
|
+
log(`\u2713 generated ${secretVar}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (toAppend.length > 0) {
|
|
116
|
+
appendFileSync(envPath, (existsSync2(envPath) ? "\n" : "") + toAppend.join("\n") + "\n");
|
|
117
|
+
} else if (!existsSync2(envPath)) {
|
|
118
|
+
writeFileSync(envPath, "");
|
|
119
|
+
}
|
|
120
|
+
const missing = REQUIRED_VARS.filter((v) => !existing[v] && !process.env[v]);
|
|
121
|
+
if (missing.length > 0) {
|
|
122
|
+
console.error(`Missing required configuration in ${envPath}:`);
|
|
123
|
+
for (const v of missing) console.error(` ${v} \u2014 ${HOW_TO_GET[v]}`);
|
|
124
|
+
console.error(
|
|
125
|
+
`
|
|
126
|
+
Add these to ${envPath} (or export them before running this command), then re-run.`
|
|
127
|
+
);
|
|
128
|
+
fail("Required environment variables not set.");
|
|
129
|
+
}
|
|
130
|
+
log(`\u2713 ${envPath} has everything required`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/cli/health.ts
|
|
134
|
+
async function waitForHealth(url, timeoutSeconds = 120) {
|
|
135
|
+
const intervalMs = 3e3;
|
|
136
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
137
|
+
log(`Waiting for ${url} to become healthy (timeout ${timeoutSeconds}s)...`);
|
|
138
|
+
while (Date.now() < deadline) {
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(url);
|
|
141
|
+
if (res.ok) {
|
|
142
|
+
log(`\u2713 healthy`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
}
|
|
147
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
148
|
+
}
|
|
149
|
+
fail(
|
|
150
|
+
`${url} still unhealthy after ${timeoutSeconds}s. Check logs: docker compose -f docker-compose.ghcr.yml logs app -f`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/cli/skills.ts
|
|
155
|
+
import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
156
|
+
import { join as join3 } from "path";
|
|
157
|
+
var RAW_BASE = "https://raw.githubusercontent.com/answerLoops/answerLoops/main";
|
|
158
|
+
var SKILLS = {
|
|
159
|
+
"answerloops-setup": ["skills/setup/SKILL.md"],
|
|
160
|
+
"answerloops-operate": ["skills/operate/SKILL.md"]
|
|
161
|
+
};
|
|
162
|
+
async function installSkills(names) {
|
|
163
|
+
for (const name of names) {
|
|
164
|
+
const files = SKILLS[name];
|
|
165
|
+
if (!files) fail(`Unknown skill "${name}". Known: ${Object.keys(SKILLS).join(", ")}`);
|
|
166
|
+
const destDir = join3(".claude", "skills", name);
|
|
167
|
+
mkdirSync(destDir, { recursive: true });
|
|
168
|
+
for (const relPath of files) {
|
|
169
|
+
const url = `${RAW_BASE}/${relPath}`;
|
|
170
|
+
const res = await fetch(url);
|
|
171
|
+
if (!res.ok) fail(`Could not fetch ${url} (${res.status})`);
|
|
172
|
+
const body = await res.text();
|
|
173
|
+
const destPath = join3(destDir, relPath.split("/").pop());
|
|
174
|
+
writeFileSync2(destPath, body);
|
|
175
|
+
}
|
|
176
|
+
log(`\u2713 installed ${name} -> ${destDir}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/cli/setup.ts
|
|
181
|
+
async function runSetup(opts) {
|
|
182
|
+
await checkPrereqs();
|
|
183
|
+
const repoDir = await ensureRepo(opts.targetDir);
|
|
184
|
+
ensureEnv(repoDir);
|
|
185
|
+
log("Starting the stack (docker compose -f docker-compose.ghcr.yml up -d)...");
|
|
186
|
+
const code = await run("docker", ["compose", "-f", "docker-compose.ghcr.yml", "up", "-d"], {
|
|
187
|
+
cwd: repoDir
|
|
188
|
+
});
|
|
189
|
+
if (code !== 0) fail(`docker compose up failed (exit ${code})`);
|
|
190
|
+
const authUrl = readAuthUrl(repoDir) ?? "http://localhost:3000";
|
|
191
|
+
await waitForHealth(`${authUrl.replace(/\/+$/, "")}/api/health`);
|
|
192
|
+
if (opts.withSkills) {
|
|
193
|
+
await installSkills(["answerloops-setup", "answerloops-operate"]);
|
|
194
|
+
}
|
|
195
|
+
log(`
|
|
196
|
+
Your instance is live at ${authUrl}. Sign in with Google and complete onboarding.`);
|
|
197
|
+
}
|
|
198
|
+
function readAuthUrl(repoDir) {
|
|
199
|
+
try {
|
|
200
|
+
const text = readFileSync2(join4(repoDir, ".env"), "utf8");
|
|
201
|
+
const line = text.split("\n").find((l) => l.trim().startsWith("AUTH_URL="));
|
|
202
|
+
return line?.slice(line.indexOf("=") + 1).trim();
|
|
203
|
+
} catch {
|
|
204
|
+
return void 0;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/cli/index.ts
|
|
209
|
+
var USAGE = `Usage: answerloops <command> [options]
|
|
210
|
+
|
|
211
|
+
Commands:
|
|
212
|
+
setup Bootstrap a self-hosted answerLoops instance (Docker,
|
|
213
|
+
the published image). Requires Docker + git.
|
|
214
|
+
--dir <path> Directory to clone into if not already in a
|
|
215
|
+
checkout (default: ./answerLoops)
|
|
216
|
+
--with-skills Also install the Claude Code setup + operate
|
|
217
|
+
skills into ./.claude/skills/
|
|
218
|
+
|
|
219
|
+
skills <name...> Install one or more Claude Code skills into
|
|
220
|
+
./.claude/skills/. Names: answerloops-setup,
|
|
221
|
+
answerloops-operate
|
|
222
|
+
|
|
223
|
+
help Show this message
|
|
224
|
+
`;
|
|
225
|
+
async function main() {
|
|
226
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
227
|
+
switch (command) {
|
|
228
|
+
case "setup": {
|
|
229
|
+
const { values } = parseArgs({
|
|
230
|
+
args: rest,
|
|
231
|
+
options: {
|
|
232
|
+
dir: { type: "string", default: "answerLoops" },
|
|
233
|
+
"with-skills": { type: "boolean", default: false }
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
await runSetup({ targetDir: values.dir, withSkills: values["with-skills"] });
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case "skills": {
|
|
240
|
+
if (rest.length === 0) {
|
|
241
|
+
console.error("Usage: answerloops skills <name...>");
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
await installSkills(rest);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case "help":
|
|
248
|
+
case void 0:
|
|
249
|
+
case "--help":
|
|
250
|
+
case "-h":
|
|
251
|
+
console.log(USAGE);
|
|
252
|
+
break;
|
|
253
|
+
default:
|
|
254
|
+
console.error(`Unknown command "${command}"
|
|
255
|
+
`);
|
|
256
|
+
console.log(USAGE);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
main().catch((err) => {
|
|
261
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
});
|
package/dist/index.cjs
CHANGED
|
@@ -29,11 +29,14 @@ module.exports = __toCommonJS(index_exports);
|
|
|
29
29
|
var AgentApiError = class extends Error {
|
|
30
30
|
status;
|
|
31
31
|
body;
|
|
32
|
+
/** Only ever set on a 429 — see AgentApiErrorCode. */
|
|
33
|
+
code;
|
|
32
34
|
constructor(status, body, message) {
|
|
33
35
|
super(message);
|
|
34
36
|
this.name = "AgentApiError";
|
|
35
37
|
this.status = status;
|
|
36
38
|
this.body = body;
|
|
39
|
+
this.code = body?.error?.code;
|
|
37
40
|
}
|
|
38
41
|
};
|
|
39
42
|
var DEFAULT_BASE_URL = "https://answerloops.com";
|
|
@@ -49,54 +52,64 @@ var AgentClient = class {
|
|
|
49
52
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
50
53
|
this.fetchImpl = options.fetch ?? fetch;
|
|
51
54
|
}
|
|
52
|
-
/** GET /api/agent/kb/search — requires the `kb:read` scope. */
|
|
55
|
+
/** GET /api/v1/agent/kb/search — requires the `kb:read` scope. */
|
|
53
56
|
searchKb(params) {
|
|
54
57
|
const query = new URLSearchParams({ query: params.query });
|
|
55
58
|
if (params.limit !== void 0) query.set("limit", String(params.limit));
|
|
56
|
-
return this.request("GET", `/api/agent/kb/search?${query}`);
|
|
59
|
+
return this.request("GET", `/api/v1/agent/kb/search?${query}`);
|
|
57
60
|
}
|
|
58
|
-
/** GET /api/agent/faq — requires the `faq:read` scope. */
|
|
61
|
+
/** GET /api/v1/agent/faq — requires the `faq:read` scope. */
|
|
59
62
|
getFaq() {
|
|
60
|
-
return this.request("GET", "/api/agent/faq");
|
|
63
|
+
return this.request("GET", "/api/v1/agent/faq");
|
|
61
64
|
}
|
|
62
|
-
/** GET /api/agent/tickets — requires the `tickets:read` scope. */
|
|
65
|
+
/** GET /api/v1/agent/tickets — requires the `tickets:read` scope. */
|
|
63
66
|
getTickets(params = {}) {
|
|
64
67
|
const query = new URLSearchParams();
|
|
65
68
|
if (params.status) query.set("status", params.status);
|
|
66
69
|
if (params.priority) query.set("priority", params.priority);
|
|
67
70
|
if (params.category) query.set("category", params.category);
|
|
68
71
|
if (params.limit !== void 0) query.set("limit", String(params.limit));
|
|
72
|
+
if (params.cursor !== void 0) query.set("cursor", params.cursor);
|
|
69
73
|
const qs = query.toString();
|
|
70
|
-
return this.request("GET", `/api/agent/tickets${qs ? `?${qs}` : ""}`);
|
|
74
|
+
return this.request("GET", `/api/v1/agent/tickets${qs ? `?${qs}` : ""}`);
|
|
71
75
|
}
|
|
72
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* POST /api/v1/agent/tickets — requires the `tickets:write` scope.
|
|
78
|
+
* `idempotencyKey`, when given, is sent as the standard `Idempotency-Key`
|
|
79
|
+
* header (what most HTTP clients/SDKs attach automatically) rather than a
|
|
80
|
+
* body field — the server accepts either, but the header is what it
|
|
81
|
+
* prefers when both are present.
|
|
82
|
+
*/
|
|
73
83
|
createTicket(params) {
|
|
74
|
-
return this.request(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
return this.request(
|
|
85
|
+
"POST",
|
|
86
|
+
"/api/v1/agent/tickets",
|
|
87
|
+
{ content: params.content, authorName: params.authorName },
|
|
88
|
+
params.idempotencyKey ? { "Idempotency-Key": params.idempotencyKey } : void 0
|
|
89
|
+
);
|
|
79
90
|
}
|
|
80
|
-
/** POST /api/agent/answers — requires the `answers:write` scope. */
|
|
91
|
+
/** POST /api/v1/agent/answers — requires the `answers:write` scope. */
|
|
81
92
|
generateAnswer(params) {
|
|
82
|
-
return this.request("POST", "/api/agent/answers", { question: params.question });
|
|
93
|
+
return this.request("POST", "/api/v1/agent/answers", { question: params.question });
|
|
83
94
|
}
|
|
84
|
-
async request(method, path, body) {
|
|
95
|
+
async request(method, path, body, extraHeaders) {
|
|
85
96
|
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
86
97
|
method,
|
|
87
98
|
headers: {
|
|
88
99
|
Authorization: `Bearer ${this.apiKey}`,
|
|
89
|
-
...body ? { "Content-Type": "application/json" } : {}
|
|
100
|
+
...body ? { "Content-Type": "application/json" } : {},
|
|
101
|
+
...extraHeaders
|
|
90
102
|
},
|
|
91
103
|
body: body ? JSON.stringify(body) : void 0
|
|
92
104
|
});
|
|
93
105
|
const text = await res.text();
|
|
94
106
|
const data = text ? JSON.parse(text) : void 0;
|
|
95
107
|
if (!res.ok) {
|
|
108
|
+
const errorBody = data;
|
|
96
109
|
throw new AgentApiError(
|
|
97
110
|
res.status,
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
errorBody,
|
|
112
|
+
errorBody?.error?.message ?? `Agent API request failed: ${res.status}`
|
|
100
113
|
);
|
|
101
114
|
}
|
|
102
115
|
return data;
|
package/dist/index.d.cts
CHANGED
|
@@ -22,11 +22,15 @@ interface GetTicketsParams {
|
|
|
22
22
|
status?: TicketStatus;
|
|
23
23
|
priority?: TicketPriority;
|
|
24
24
|
category?: TicketCategory;
|
|
25
|
-
/** 1-20, defaults to 10 */
|
|
25
|
+
/** Max results per page, 1-20, defaults to 10 */
|
|
26
26
|
limit?: number;
|
|
27
|
+
/** Opaque value from a previous response's `next_cursor`. Omit for the first page. */
|
|
28
|
+
cursor?: string;
|
|
27
29
|
}
|
|
28
30
|
interface GetTicketsResponse {
|
|
29
31
|
tickets: Ticket[];
|
|
32
|
+
/** Pass as `cursor` on the next call to keep paging; null once there's nothing left. */
|
|
33
|
+
next_cursor: string | null;
|
|
30
34
|
}
|
|
31
35
|
interface CreateTicketParams {
|
|
32
36
|
/** The user's question or issue, verbatim. Max 4000 characters. */
|
|
@@ -36,6 +40,19 @@ interface CreateTicketParams {
|
|
|
36
40
|
/** Retrying with the same key returns the original ticket instead of opening a duplicate. */
|
|
37
41
|
idempotencyKey?: string;
|
|
38
42
|
}
|
|
43
|
+
interface CreateTicketResponse {
|
|
44
|
+
ticket_id: number;
|
|
45
|
+
/** True if this call matched an existing idempotency key instead of opening a new ticket. */
|
|
46
|
+
duplicate: boolean;
|
|
47
|
+
}
|
|
48
|
+
interface FaqResult {
|
|
49
|
+
/** Present only when no FAQ has been generated for the org yet. */
|
|
50
|
+
message?: string;
|
|
51
|
+
week_start?: string;
|
|
52
|
+
week_end?: string;
|
|
53
|
+
ticket_count?: number;
|
|
54
|
+
content?: string;
|
|
55
|
+
}
|
|
39
56
|
interface GenerateAnswerParams {
|
|
40
57
|
/** Max 2000 characters. */
|
|
41
58
|
question: string;
|
|
@@ -52,10 +69,19 @@ interface SearchKbParams {
|
|
|
52
69
|
/** 1-20, defaults to 5 */
|
|
53
70
|
limit?: number;
|
|
54
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* `code` is only ever present on a 429 — it's how a caller tells apart the
|
|
74
|
+
* three independent ceilings this API can hit (rate limit vs. either
|
|
75
|
+
* monthly quota) without pattern-matching `message`. See the `Error` schema
|
|
76
|
+
* in the OpenAPI spec (lib/agent/openapi-spec.ts) for the source of truth.
|
|
77
|
+
*/
|
|
78
|
+
type AgentApiErrorCode = "rate_limited" | "deflection_limit_reached" | "call_limit_reached";
|
|
55
79
|
/** Shape of the `Error` schema returned on 400/401/403/429 responses. */
|
|
56
80
|
interface AgentApiErrorBody {
|
|
57
|
-
error:
|
|
58
|
-
|
|
81
|
+
error: {
|
|
82
|
+
message: string;
|
|
83
|
+
code?: AgentApiErrorCode;
|
|
84
|
+
};
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
interface AgentClientOptions {
|
|
@@ -72,6 +98,8 @@ interface AgentClientOptions {
|
|
|
72
98
|
declare class AgentApiError extends Error {
|
|
73
99
|
readonly status: number;
|
|
74
100
|
readonly body: AgentApiErrorBody | undefined;
|
|
101
|
+
/** Only ever set on a 429 — see AgentApiErrorCode. */
|
|
102
|
+
readonly code: AgentApiErrorCode | undefined;
|
|
75
103
|
constructor(status: number, body: AgentApiErrorBody | undefined, message: string);
|
|
76
104
|
}
|
|
77
105
|
declare class AgentClient {
|
|
@@ -79,17 +107,23 @@ declare class AgentClient {
|
|
|
79
107
|
private readonly baseUrl;
|
|
80
108
|
private readonly fetchImpl;
|
|
81
109
|
constructor(options: AgentClientOptions);
|
|
82
|
-
/** GET /api/agent/kb/search — requires the `kb:read` scope. */
|
|
110
|
+
/** GET /api/v1/agent/kb/search — requires the `kb:read` scope. */
|
|
83
111
|
searchKb(params: SearchKbParams): Promise<KbSearchResponse>;
|
|
84
|
-
/** GET /api/agent/faq — requires the `faq:read` scope. */
|
|
85
|
-
getFaq(): Promise<
|
|
86
|
-
/** GET /api/agent/tickets — requires the `tickets:read` scope. */
|
|
112
|
+
/** GET /api/v1/agent/faq — requires the `faq:read` scope. */
|
|
113
|
+
getFaq(): Promise<FaqResult>;
|
|
114
|
+
/** GET /api/v1/agent/tickets — requires the `tickets:read` scope. */
|
|
87
115
|
getTickets(params?: GetTicketsParams): Promise<GetTicketsResponse>;
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
116
|
+
/**
|
|
117
|
+
* POST /api/v1/agent/tickets — requires the `tickets:write` scope.
|
|
118
|
+
* `idempotencyKey`, when given, is sent as the standard `Idempotency-Key`
|
|
119
|
+
* header (what most HTTP clients/SDKs attach automatically) rather than a
|
|
120
|
+
* body field — the server accepts either, but the header is what it
|
|
121
|
+
* prefers when both are present.
|
|
122
|
+
*/
|
|
123
|
+
createTicket(params: CreateTicketParams): Promise<CreateTicketResponse>;
|
|
124
|
+
/** POST /api/v1/agent/answers — requires the `answers:write` scope. */
|
|
91
125
|
generateAnswer(params: GenerateAnswerParams): Promise<GenerateAnswerResponse>;
|
|
92
126
|
private request;
|
|
93
127
|
}
|
|
94
128
|
|
|
95
|
-
export { AgentApiError, type AgentApiErrorBody, AgentClient, type AgentClientOptions, type CreateTicketParams, type GenerateAnswerParams, type GenerateAnswerResponse, type GetTicketsParams, type GetTicketsResponse, type KbSearchResponse, type KbSearchResult, type SearchKbParams, type Ticket, type TicketCategory, type TicketPriority, type TicketStatus };
|
|
129
|
+
export { AgentApiError, type AgentApiErrorBody, type AgentApiErrorCode, AgentClient, type AgentClientOptions, type CreateTicketParams, type CreateTicketResponse, type FaqResult, type GenerateAnswerParams, type GenerateAnswerResponse, type GetTicketsParams, type GetTicketsResponse, type KbSearchResponse, type KbSearchResult, type SearchKbParams, type Ticket, type TicketCategory, type TicketPriority, type TicketStatus };
|
package/dist/index.d.ts
CHANGED
|
@@ -22,11 +22,15 @@ interface GetTicketsParams {
|
|
|
22
22
|
status?: TicketStatus;
|
|
23
23
|
priority?: TicketPriority;
|
|
24
24
|
category?: TicketCategory;
|
|
25
|
-
/** 1-20, defaults to 10 */
|
|
25
|
+
/** Max results per page, 1-20, defaults to 10 */
|
|
26
26
|
limit?: number;
|
|
27
|
+
/** Opaque value from a previous response's `next_cursor`. Omit for the first page. */
|
|
28
|
+
cursor?: string;
|
|
27
29
|
}
|
|
28
30
|
interface GetTicketsResponse {
|
|
29
31
|
tickets: Ticket[];
|
|
32
|
+
/** Pass as `cursor` on the next call to keep paging; null once there's nothing left. */
|
|
33
|
+
next_cursor: string | null;
|
|
30
34
|
}
|
|
31
35
|
interface CreateTicketParams {
|
|
32
36
|
/** The user's question or issue, verbatim. Max 4000 characters. */
|
|
@@ -36,6 +40,19 @@ interface CreateTicketParams {
|
|
|
36
40
|
/** Retrying with the same key returns the original ticket instead of opening a duplicate. */
|
|
37
41
|
idempotencyKey?: string;
|
|
38
42
|
}
|
|
43
|
+
interface CreateTicketResponse {
|
|
44
|
+
ticket_id: number;
|
|
45
|
+
/** True if this call matched an existing idempotency key instead of opening a new ticket. */
|
|
46
|
+
duplicate: boolean;
|
|
47
|
+
}
|
|
48
|
+
interface FaqResult {
|
|
49
|
+
/** Present only when no FAQ has been generated for the org yet. */
|
|
50
|
+
message?: string;
|
|
51
|
+
week_start?: string;
|
|
52
|
+
week_end?: string;
|
|
53
|
+
ticket_count?: number;
|
|
54
|
+
content?: string;
|
|
55
|
+
}
|
|
39
56
|
interface GenerateAnswerParams {
|
|
40
57
|
/** Max 2000 characters. */
|
|
41
58
|
question: string;
|
|
@@ -52,10 +69,19 @@ interface SearchKbParams {
|
|
|
52
69
|
/** 1-20, defaults to 5 */
|
|
53
70
|
limit?: number;
|
|
54
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* `code` is only ever present on a 429 — it's how a caller tells apart the
|
|
74
|
+
* three independent ceilings this API can hit (rate limit vs. either
|
|
75
|
+
* monthly quota) without pattern-matching `message`. See the `Error` schema
|
|
76
|
+
* in the OpenAPI spec (lib/agent/openapi-spec.ts) for the source of truth.
|
|
77
|
+
*/
|
|
78
|
+
type AgentApiErrorCode = "rate_limited" | "deflection_limit_reached" | "call_limit_reached";
|
|
55
79
|
/** Shape of the `Error` schema returned on 400/401/403/429 responses. */
|
|
56
80
|
interface AgentApiErrorBody {
|
|
57
|
-
error:
|
|
58
|
-
|
|
81
|
+
error: {
|
|
82
|
+
message: string;
|
|
83
|
+
code?: AgentApiErrorCode;
|
|
84
|
+
};
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
interface AgentClientOptions {
|
|
@@ -72,6 +98,8 @@ interface AgentClientOptions {
|
|
|
72
98
|
declare class AgentApiError extends Error {
|
|
73
99
|
readonly status: number;
|
|
74
100
|
readonly body: AgentApiErrorBody | undefined;
|
|
101
|
+
/** Only ever set on a 429 — see AgentApiErrorCode. */
|
|
102
|
+
readonly code: AgentApiErrorCode | undefined;
|
|
75
103
|
constructor(status: number, body: AgentApiErrorBody | undefined, message: string);
|
|
76
104
|
}
|
|
77
105
|
declare class AgentClient {
|
|
@@ -79,17 +107,23 @@ declare class AgentClient {
|
|
|
79
107
|
private readonly baseUrl;
|
|
80
108
|
private readonly fetchImpl;
|
|
81
109
|
constructor(options: AgentClientOptions);
|
|
82
|
-
/** GET /api/agent/kb/search — requires the `kb:read` scope. */
|
|
110
|
+
/** GET /api/v1/agent/kb/search — requires the `kb:read` scope. */
|
|
83
111
|
searchKb(params: SearchKbParams): Promise<KbSearchResponse>;
|
|
84
|
-
/** GET /api/agent/faq — requires the `faq:read` scope. */
|
|
85
|
-
getFaq(): Promise<
|
|
86
|
-
/** GET /api/agent/tickets — requires the `tickets:read` scope. */
|
|
112
|
+
/** GET /api/v1/agent/faq — requires the `faq:read` scope. */
|
|
113
|
+
getFaq(): Promise<FaqResult>;
|
|
114
|
+
/** GET /api/v1/agent/tickets — requires the `tickets:read` scope. */
|
|
87
115
|
getTickets(params?: GetTicketsParams): Promise<GetTicketsResponse>;
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
116
|
+
/**
|
|
117
|
+
* POST /api/v1/agent/tickets — requires the `tickets:write` scope.
|
|
118
|
+
* `idempotencyKey`, when given, is sent as the standard `Idempotency-Key`
|
|
119
|
+
* header (what most HTTP clients/SDKs attach automatically) rather than a
|
|
120
|
+
* body field — the server accepts either, but the header is what it
|
|
121
|
+
* prefers when both are present.
|
|
122
|
+
*/
|
|
123
|
+
createTicket(params: CreateTicketParams): Promise<CreateTicketResponse>;
|
|
124
|
+
/** POST /api/v1/agent/answers — requires the `answers:write` scope. */
|
|
91
125
|
generateAnswer(params: GenerateAnswerParams): Promise<GenerateAnswerResponse>;
|
|
92
126
|
private request;
|
|
93
127
|
}
|
|
94
128
|
|
|
95
|
-
export { AgentApiError, type AgentApiErrorBody, AgentClient, type AgentClientOptions, type CreateTicketParams, type GenerateAnswerParams, type GenerateAnswerResponse, type GetTicketsParams, type GetTicketsResponse, type KbSearchResponse, type KbSearchResult, type SearchKbParams, type Ticket, type TicketCategory, type TicketPriority, type TicketStatus };
|
|
129
|
+
export { AgentApiError, type AgentApiErrorBody, type AgentApiErrorCode, AgentClient, type AgentClientOptions, type CreateTicketParams, type CreateTicketResponse, type FaqResult, type GenerateAnswerParams, type GenerateAnswerResponse, type GetTicketsParams, type GetTicketsResponse, type KbSearchResponse, type KbSearchResult, type SearchKbParams, type Ticket, type TicketCategory, type TicketPriority, type TicketStatus };
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
var AgentApiError = class extends Error {
|
|
3
3
|
status;
|
|
4
4
|
body;
|
|
5
|
+
/** Only ever set on a 429 — see AgentApiErrorCode. */
|
|
6
|
+
code;
|
|
5
7
|
constructor(status, body, message) {
|
|
6
8
|
super(message);
|
|
7
9
|
this.name = "AgentApiError";
|
|
8
10
|
this.status = status;
|
|
9
11
|
this.body = body;
|
|
12
|
+
this.code = body?.error?.code;
|
|
10
13
|
}
|
|
11
14
|
};
|
|
12
15
|
var DEFAULT_BASE_URL = "https://answerloops.com";
|
|
@@ -22,54 +25,64 @@ var AgentClient = class {
|
|
|
22
25
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
23
26
|
this.fetchImpl = options.fetch ?? fetch;
|
|
24
27
|
}
|
|
25
|
-
/** GET /api/agent/kb/search — requires the `kb:read` scope. */
|
|
28
|
+
/** GET /api/v1/agent/kb/search — requires the `kb:read` scope. */
|
|
26
29
|
searchKb(params) {
|
|
27
30
|
const query = new URLSearchParams({ query: params.query });
|
|
28
31
|
if (params.limit !== void 0) query.set("limit", String(params.limit));
|
|
29
|
-
return this.request("GET", `/api/agent/kb/search?${query}`);
|
|
32
|
+
return this.request("GET", `/api/v1/agent/kb/search?${query}`);
|
|
30
33
|
}
|
|
31
|
-
/** GET /api/agent/faq — requires the `faq:read` scope. */
|
|
34
|
+
/** GET /api/v1/agent/faq — requires the `faq:read` scope. */
|
|
32
35
|
getFaq() {
|
|
33
|
-
return this.request("GET", "/api/agent/faq");
|
|
36
|
+
return this.request("GET", "/api/v1/agent/faq");
|
|
34
37
|
}
|
|
35
|
-
/** GET /api/agent/tickets — requires the `tickets:read` scope. */
|
|
38
|
+
/** GET /api/v1/agent/tickets — requires the `tickets:read` scope. */
|
|
36
39
|
getTickets(params = {}) {
|
|
37
40
|
const query = new URLSearchParams();
|
|
38
41
|
if (params.status) query.set("status", params.status);
|
|
39
42
|
if (params.priority) query.set("priority", params.priority);
|
|
40
43
|
if (params.category) query.set("category", params.category);
|
|
41
44
|
if (params.limit !== void 0) query.set("limit", String(params.limit));
|
|
45
|
+
if (params.cursor !== void 0) query.set("cursor", params.cursor);
|
|
42
46
|
const qs = query.toString();
|
|
43
|
-
return this.request("GET", `/api/agent/tickets${qs ? `?${qs}` : ""}`);
|
|
47
|
+
return this.request("GET", `/api/v1/agent/tickets${qs ? `?${qs}` : ""}`);
|
|
44
48
|
}
|
|
45
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* POST /api/v1/agent/tickets — requires the `tickets:write` scope.
|
|
51
|
+
* `idempotencyKey`, when given, is sent as the standard `Idempotency-Key`
|
|
52
|
+
* header (what most HTTP clients/SDKs attach automatically) rather than a
|
|
53
|
+
* body field — the server accepts either, but the header is what it
|
|
54
|
+
* prefers when both are present.
|
|
55
|
+
*/
|
|
46
56
|
createTicket(params) {
|
|
47
|
-
return this.request(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
return this.request(
|
|
58
|
+
"POST",
|
|
59
|
+
"/api/v1/agent/tickets",
|
|
60
|
+
{ content: params.content, authorName: params.authorName },
|
|
61
|
+
params.idempotencyKey ? { "Idempotency-Key": params.idempotencyKey } : void 0
|
|
62
|
+
);
|
|
52
63
|
}
|
|
53
|
-
/** POST /api/agent/answers — requires the `answers:write` scope. */
|
|
64
|
+
/** POST /api/v1/agent/answers — requires the `answers:write` scope. */
|
|
54
65
|
generateAnswer(params) {
|
|
55
|
-
return this.request("POST", "/api/agent/answers", { question: params.question });
|
|
66
|
+
return this.request("POST", "/api/v1/agent/answers", { question: params.question });
|
|
56
67
|
}
|
|
57
|
-
async request(method, path, body) {
|
|
68
|
+
async request(method, path, body, extraHeaders) {
|
|
58
69
|
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
59
70
|
method,
|
|
60
71
|
headers: {
|
|
61
72
|
Authorization: `Bearer ${this.apiKey}`,
|
|
62
|
-
...body ? { "Content-Type": "application/json" } : {}
|
|
73
|
+
...body ? { "Content-Type": "application/json" } : {},
|
|
74
|
+
...extraHeaders
|
|
63
75
|
},
|
|
64
76
|
body: body ? JSON.stringify(body) : void 0
|
|
65
77
|
});
|
|
66
78
|
const text = await res.text();
|
|
67
79
|
const data = text ? JSON.parse(text) : void 0;
|
|
68
80
|
if (!res.ok) {
|
|
81
|
+
const errorBody = data;
|
|
69
82
|
throw new AgentApiError(
|
|
70
83
|
res.status,
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
errorBody,
|
|
85
|
+
errorBody?.error?.message ?? `Agent API request failed: ${res.status}`
|
|
73
86
|
);
|
|
74
87
|
}
|
|
75
88
|
return data;
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@answerloops/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Typed client for the answerLoops Agent API (knowledge base search, FAQ, tickets, grounded answers).",
|
|
3
|
+
"version": "0.6.2",
|
|
4
|
+
"description": "Typed client for the answerLoops Agent API (knowledge base search, FAQ, tickets, grounded answers) and a CLI to self-host answerLoops or install its Claude Code skills.",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.cjs",
|
|
8
8
|
"module": "./dist/index.js",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
|
+
"bin": {
|
|
11
|
+
"answerloops": "./dist/cli/index.js"
|
|
12
|
+
},
|
|
10
13
|
"exports": {
|
|
11
14
|
".": {
|
|
12
15
|
"types": "./dist/index.d.ts",
|
|
@@ -38,7 +41,7 @@
|
|
|
38
41
|
"knowledge-base"
|
|
39
42
|
],
|
|
40
43
|
"scripts": {
|
|
41
|
-
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
44
|
+
"build": "tsup src/index.ts src/cli/index.ts --format esm,cjs --dts --clean && chmod +x dist/cli/index.js",
|
|
42
45
|
"typecheck": "tsc --noEmit"
|
|
43
46
|
},
|
|
44
47
|
"devDependencies": {
|