syndai 0.1.1 → 0.4.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.
- package/CHANGELOG.md +55 -0
- package/README.md +97 -52
- package/bin/syndai.js +13 -29
- package/package.json +6 -11
- package/src/api.js +188 -56
- package/src/auth.js +245 -0
- package/src/changes.js +350 -0
- package/src/errors.js +148 -0
- package/src/http.js +166 -0
- package/src/main.js +249 -0
- package/src/mcp.js +93 -0
- package/src/openapi.js +145 -0
- package/src/projects.js +168 -0
- package/src/routes.js +65 -0
- package/src/schedules.js +377 -0
- package/src/settings.js +130 -0
- package/src/store.js +137 -0
- package/src/ui.js +156 -0
- package/src/watch.js +299 -0
- package/LICENSE +0 -9
- package/src/claude.js +0 -145
- package/src/codex.js +0 -71
- package/src/connect.js +0 -122
- package/src/proc.js +0 -36
- package/src/prompt.js +0 -43
- package/src/tokens.js +0 -45
package/src/openapi.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The live API description behind `syndai api <operationId>`. The server's public
|
|
2
|
+
// `/openapi.json` is the one source, so a new route is callable the day it deploys. It is
|
|
3
|
+
// cached per host for 24 hours ($XDG_CACHE_HOME/syndai/openapi-<host>.json) and fetched again
|
|
4
|
+
// once when an operationId is not in the cached copy. The server sends no ETag, and a ~27 KB
|
|
5
|
+
// gzipped fetch a day does not justify one.
|
|
6
|
+
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { CliError, EXIT } from "./errors.js";
|
|
9
|
+
import { readJson, writeFileAtomic } from "./store.js";
|
|
10
|
+
import { hostOf } from "./ui.js";
|
|
11
|
+
|
|
12
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
14
|
+
const EXT_PREFIX = /^\/api\/v1\/(external\/coding\/)?/;
|
|
15
|
+
|
|
16
|
+
function cacheFile(ctx) {
|
|
17
|
+
const host = (hostOf(ctx.baseUrl) ?? "api").replace(/[^\w.-]/g, "_");
|
|
18
|
+
return path.join(ctx.paths.cache, `openapi-${host}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The operations, from the cache when it is fresh (or `refresh` is false and it exists). */
|
|
22
|
+
export async function loadOperations(ctx, { refresh = false } = {}) {
|
|
23
|
+
const file = cacheFile(ctx);
|
|
24
|
+
let cached = null;
|
|
25
|
+
try {
|
|
26
|
+
cached = readJson(file, null);
|
|
27
|
+
} catch {
|
|
28
|
+
// A corrupt cache is refetched, never an error.
|
|
29
|
+
}
|
|
30
|
+
if (!refresh && cached?.spec && ctx.io.now() - cached.fetched_at < TTL_MS) {
|
|
31
|
+
return { operations: index(cached.spec), fresh: false };
|
|
32
|
+
}
|
|
33
|
+
const spec = await ctx.client.send("GET", "/openapi.json", { auth: false });
|
|
34
|
+
writeFileAtomic(file, JSON.stringify({ fetched_at: ctx.io.now(), spec }), 0o644);
|
|
35
|
+
return { operations: index(spec), fresh: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One operation by id (exact, or ignoring case, `-` and `_`); refetches once when unknown. */
|
|
39
|
+
export async function findOperation(ctx, id) {
|
|
40
|
+
let { operations, fresh } = await loadOperations(ctx);
|
|
41
|
+
let op = lookup(operations, id);
|
|
42
|
+
if (!op && !fresh) {
|
|
43
|
+
({ operations } = await loadOperations(ctx, { refresh: true }));
|
|
44
|
+
op = lookup(operations, id);
|
|
45
|
+
}
|
|
46
|
+
if (op) return op;
|
|
47
|
+
const near = closest(operations, id);
|
|
48
|
+
throw new CliError(EXIT.USAGE, `No API operation is called "${id}".`, {
|
|
49
|
+
next: near.length ? `did you mean: ${near.join(", ")}? (syndai api lists them all)` : "syndai api",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The operation a concrete METHOD + path calls, or null. */
|
|
54
|
+
export function matchPath(operations, method, concretePath) {
|
|
55
|
+
return (
|
|
56
|
+
operations.find(
|
|
57
|
+
(op) =>
|
|
58
|
+
op.method === method &&
|
|
59
|
+
new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, "\\$&").replace(/\\?\{[^}]+\\?\}/g, "[^/]+")}$`).test(
|
|
60
|
+
concretePath,
|
|
61
|
+
),
|
|
62
|
+
) ?? null
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const norm = (name) => name.toLowerCase().replace(/[-_]/g, "");
|
|
67
|
+
|
|
68
|
+
function lookup(operations, id) {
|
|
69
|
+
return operations.find((op) => op.id === id) ?? operations.find((op) => norm(op.id) === norm(id)) ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function closest(operations, id, count = 3) {
|
|
73
|
+
const wanted = norm(id);
|
|
74
|
+
return operations
|
|
75
|
+
.map((op) => [distance(norm(op.id), wanted), op.id])
|
|
76
|
+
.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]))
|
|
77
|
+
.slice(0, count)
|
|
78
|
+
.map(([, name]) => name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function distance(a, b) {
|
|
82
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
83
|
+
for (let i = 1; i <= a.length; i++) {
|
|
84
|
+
const row = [i];
|
|
85
|
+
for (let j = 1; j <= b.length; j++) {
|
|
86
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
87
|
+
}
|
|
88
|
+
prev = row;
|
|
89
|
+
}
|
|
90
|
+
return prev[b.length];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Flatten an OpenAPI document to `[{id, method, path, group, summary, params, body}]`. */
|
|
94
|
+
export function index(spec) {
|
|
95
|
+
const resolve = (schema) => {
|
|
96
|
+
const ref = schema?.$ref;
|
|
97
|
+
return ref ? (spec.components?.schemas?.[ref.split("/").pop()] ?? {}) : (schema ?? {});
|
|
98
|
+
};
|
|
99
|
+
const operations = [];
|
|
100
|
+
for (const [opPath, item] of Object.entries(spec.paths ?? {})) {
|
|
101
|
+
for (const method of HTTP_METHODS) {
|
|
102
|
+
const op = item[method.toLowerCase()];
|
|
103
|
+
if (!op?.operationId) continue;
|
|
104
|
+
const params = [...(item.parameters ?? []), ...(op.parameters ?? [])].map((p) => ({
|
|
105
|
+
name: p.name,
|
|
106
|
+
in: p.in,
|
|
107
|
+
required: Boolean(p.required),
|
|
108
|
+
}));
|
|
109
|
+
const schemaRef = op.requestBody?.content?.["application/json"]?.schema;
|
|
110
|
+
const schema = resolve(schemaRef);
|
|
111
|
+
const required = new Set(schema.required ?? []);
|
|
112
|
+
const body = schemaRef
|
|
113
|
+
? {
|
|
114
|
+
schema: schemaRef.$ref?.split("/").pop() ?? null,
|
|
115
|
+
fields: Object.entries(schema.properties ?? {}).map(([name, prop]) => ({
|
|
116
|
+
name,
|
|
117
|
+
type: typeName(prop),
|
|
118
|
+
required: required.has(name),
|
|
119
|
+
})),
|
|
120
|
+
}
|
|
121
|
+
: null;
|
|
122
|
+
operations.push({
|
|
123
|
+
id: op.operationId,
|
|
124
|
+
method,
|
|
125
|
+
path: opPath,
|
|
126
|
+
group: opPath.replace(EXT_PREFIX, "").split("/")[0] || "other",
|
|
127
|
+
summary: op.summary ?? "",
|
|
128
|
+
params,
|
|
129
|
+
body,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return operations;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function typeName(schema) {
|
|
137
|
+
const options = schema.oneOf ?? schema.anyOf;
|
|
138
|
+
if (options) return options.map((o) => o.type ?? o.$ref?.split("/").pop() ?? "object").join(" | ");
|
|
139
|
+
return schema.type ?? schema.$ref?.split("/").pop() ?? "object";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `/api/v1/external/coding/runs` -> `/runs`, for compact listings. */
|
|
143
|
+
export function shortPath(opPath) {
|
|
144
|
+
return opPath.startsWith("/api/v1/external/coding/") ? opPath.slice("/api/v1/external/coding".length) : opPath;
|
|
145
|
+
}
|
package/src/projects.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Which project a command acts on. Order (docs: syndai.ai/docs/cli, "Choosing the project"):
|
|
2
|
+
// --app, SYNDAI_APP, the link for this directory or a parent, a git remote that matches a
|
|
3
|
+
// connected repo, the only project, a picker on a terminal. Otherwise exit 2 with the list.
|
|
4
|
+
//
|
|
5
|
+
// A project is a connected repository, joined with its app (live URL, slug) when it has one.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { CliError, EXIT } from "./errors.js";
|
|
10
|
+
import { findLink, saveLink } from "./store.js";
|
|
11
|
+
import { ask, hostOf } from "./ui.js";
|
|
12
|
+
|
|
13
|
+
export async function listProjects(ctx) {
|
|
14
|
+
const customerId = await ctx.customerId();
|
|
15
|
+
const [{ repos }, { items }] = await Promise.all([
|
|
16
|
+
ctx.client.call("listRepos"),
|
|
17
|
+
ctx.client.call("listApps", { params: { customer_id: customerId } }),
|
|
18
|
+
]);
|
|
19
|
+
const appByRepo = new Map(items.map((app) => [app.repository_id, app]));
|
|
20
|
+
return repos.map((repo) => {
|
|
21
|
+
const app = appByRepo.get(repo.id);
|
|
22
|
+
return {
|
|
23
|
+
id: repo.id,
|
|
24
|
+
name: repo.display_name || app?.repository_name || repo.full_name,
|
|
25
|
+
full_name: repo.full_name,
|
|
26
|
+
live_url: app?.url ?? null,
|
|
27
|
+
slug: app?.publication?.slug ?? null,
|
|
28
|
+
repo_urls: [repo.repo_url, repo.clone_url, repo.html_url].filter(Boolean),
|
|
29
|
+
protected_paths: repo.protected_paths ?? [],
|
|
30
|
+
customer_id: customerId,
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function projectLabel(project) {
|
|
36
|
+
const host = project.live_url ? hostOf(project.live_url) : null;
|
|
37
|
+
return host && host !== project.name ? `${project.name} (${host})` : project.name;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The project's live host for "Changing <host>", or its name before it is live. */
|
|
41
|
+
export function projectHost(project) {
|
|
42
|
+
return (project.live_url && hostOf(project.live_url)) || project.name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Projects matching a ref: id, slug, live host, name or repo name (case-insensitive). */
|
|
46
|
+
export function matchRef(projects, ref) {
|
|
47
|
+
const wanted = ref.trim().toLowerCase();
|
|
48
|
+
const wantedHost = hostOf(wanted.includes("://") ? wanted : `https://${wanted}`);
|
|
49
|
+
return projects.filter((p) => {
|
|
50
|
+
const names = [p.id, p.slug, p.name, p.full_name, p.full_name.split("/").pop()];
|
|
51
|
+
if (names.some((n) => n?.toLowerCase() === wanted)) return true;
|
|
52
|
+
return Boolean(p.live_url && wantedHost && hostOf(p.live_url)?.toLowerCase() === wantedHost);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} ctx
|
|
58
|
+
* @param {{app?: string, required?: boolean, picker?: boolean, useLink?: boolean}} options
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveProject(ctx, { app, required = true, picker = true, useLink = true } = {}) {
|
|
61
|
+
const { io, ui, paths } = ctx;
|
|
62
|
+
const projects = await listProjects(ctx);
|
|
63
|
+
const pick = (ref, source) => {
|
|
64
|
+
const hits = matchRef(projects, ref);
|
|
65
|
+
if (hits.length === 1) return hits[0];
|
|
66
|
+
throw noProject(
|
|
67
|
+
projects,
|
|
68
|
+
hits.length
|
|
69
|
+
? `"${ref}" (${source}) matches ${hits.length} projects; use the id.`
|
|
70
|
+
: `No project matches "${ref}" (${source}).`,
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
if (app) return pick(app, "--app");
|
|
74
|
+
if (io.env.SYNDAI_APP) return pick(io.env.SYNDAI_APP, "SYNDAI_APP");
|
|
75
|
+
if (useLink) {
|
|
76
|
+
const link = findLink(paths, io.cwd);
|
|
77
|
+
if (link) {
|
|
78
|
+
const linked = projects.find((p) => p.id === link.project_id);
|
|
79
|
+
if (linked) return linked;
|
|
80
|
+
throw noProject(projects, `The project linked to ${link.dir} is no longer in this workspace.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const remotes = gitRemotes(io.cwd);
|
|
84
|
+
const byRemote = projects.filter((p) => p.repo_urls.some((u) => remotes.has(normalizeRemote(u))));
|
|
85
|
+
if (byRemote.length === 1) return byRemote[0];
|
|
86
|
+
if (projects.length === 1) return projects[0];
|
|
87
|
+
if (picker && ui.interactive && projects.length > 1) return pickInteractively(ctx, projects);
|
|
88
|
+
if (!required) return null;
|
|
89
|
+
throw noProject(
|
|
90
|
+
projects,
|
|
91
|
+
projects.length
|
|
92
|
+
? "Which project? Pass --app or link this directory."
|
|
93
|
+
: "This workspace has no projects yet.",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function noProject(projects, message) {
|
|
98
|
+
const human = projects.length
|
|
99
|
+
? ["Projects:", ...projects.map((p) => ` ${projectLabel(p)} ${p.id}`)].join("\n")
|
|
100
|
+
: "Start one at https://app.syndai.ai";
|
|
101
|
+
return new CliError(EXIT.USAGE, message, {
|
|
102
|
+
next: projects.length ? "syndai link <project>" : null,
|
|
103
|
+
result: {
|
|
104
|
+
data: { projects: projects.map(({ id, name, live_url }) => ({ id, name, live_url })) },
|
|
105
|
+
human,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function pickInteractively(ctx, projects) {
|
|
111
|
+
for (const [i, p] of projects.entries()) ctx.ui.progress(` ${i + 1}. ${projectLabel(p)}`);
|
|
112
|
+
for (;;) {
|
|
113
|
+
const answer = await ask(ctx.io, `Which project? [1-${projects.length}] `);
|
|
114
|
+
const index = Number(answer) - 1;
|
|
115
|
+
if (Number.isInteger(index) && projects[index]) return projects[index];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Normalised remotes of the git checkout at `cwd` (empty outside one). */
|
|
120
|
+
export function gitRemotes(cwd) {
|
|
121
|
+
let out = "";
|
|
122
|
+
try {
|
|
123
|
+
out = execFileSync("git", ["remote", "-v"], {
|
|
124
|
+
cwd,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
127
|
+
timeout: 3000,
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
return new Set();
|
|
131
|
+
}
|
|
132
|
+
return new Set(
|
|
133
|
+
out
|
|
134
|
+
.split("\n")
|
|
135
|
+
.map((line) => line.split(/\s+/)[1])
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.map(normalizeRemote),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `git@host:o/r.git`, `ssh://git@host:22/o/r`, `https://tok@host/o/r.git` -> `host/o/r`. */
|
|
142
|
+
export function normalizeRemote(url) {
|
|
143
|
+
const text = url.trim().toLowerCase();
|
|
144
|
+
const scp = /^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)(.+)$/.exec(text);
|
|
145
|
+
let normal = text;
|
|
146
|
+
if (scp && !text.includes("://")) {
|
|
147
|
+
normal = `${scp[1]}/${scp[2]}`;
|
|
148
|
+
} else {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = new URL(text);
|
|
151
|
+
normal = parsed.hostname + parsed.pathname;
|
|
152
|
+
} catch {
|
|
153
|
+
// Not a URL: compare as typed.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return normal.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function link(ctx, ref) {
|
|
160
|
+
const project = await resolveProject(ctx, { app: ref ?? ctx.flags.app, useLink: false });
|
|
161
|
+
const dir = path.resolve(ctx.io.cwd);
|
|
162
|
+
saveLink(ctx.paths, dir, { project_id: project.id, name: project.name, customer_id: project.customer_id });
|
|
163
|
+
ctx.ui.result(
|
|
164
|
+
{ dir, project_id: project.id, name: project.name },
|
|
165
|
+
`Linked ${dir} to ${projectLabel(project)}.`,
|
|
166
|
+
);
|
|
167
|
+
return EXIT.OK;
|
|
168
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Every REST route the CLI calls, in one table. Code builds paths only through `path()`,
|
|
2
|
+
// so `test/contract.test.js` can prove each one exists, with the same method, in the
|
|
3
|
+
// published OpenAPI (`docs/api/external-coding-v1.openapi.json`). `syndai api` is the one
|
|
4
|
+
// command that bypasses this table: it reads the live OpenAPI instead (openapi.js).
|
|
5
|
+
|
|
6
|
+
const EXT = "/api/v1/external/coding";
|
|
7
|
+
|
|
8
|
+
export const ROUTES = Object.freeze({
|
|
9
|
+
deviceStart: ["POST", "/api/v1/cli/device"],
|
|
10
|
+
deviceToken: ["POST", "/api/v1/cli/device/token"],
|
|
11
|
+
me: ["GET", `${EXT}/me`],
|
|
12
|
+
revokeMe: ["DELETE", `${EXT}/me`],
|
|
13
|
+
listRepos: ["GET", `${EXT}/repos`],
|
|
14
|
+
updateRepo: ["PATCH", `${EXT}/repos/{coding_repository_id}`],
|
|
15
|
+
textEdit: ["POST", `${EXT}/repos/{coding_repository_id}/text-edits`],
|
|
16
|
+
undoTextEdit: ["POST", `${EXT}/repos/{coding_repository_id}/text-edits/{sha}/undo`],
|
|
17
|
+
listApps: ["GET", `${EXT}/customers/{customer_id}/apps`],
|
|
18
|
+
getPublication: ["GET", `${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publication`],
|
|
19
|
+
updatePublication: [
|
|
20
|
+
"PATCH",
|
|
21
|
+
`${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publication`,
|
|
22
|
+
],
|
|
23
|
+
getBugScan: ["GET", `${EXT}/repositories/{repository_id}/bug-scan`],
|
|
24
|
+
setBugScan: ["PUT", `${EXT}/repositories/{repository_id}/bug-scan`],
|
|
25
|
+
getPolicy: ["GET", `${EXT}/policy/defaults`],
|
|
26
|
+
putPolicy: ["PUT", `${EXT}/policy/defaults`],
|
|
27
|
+
imageIngest: ["POST", `${EXT}/runs/image-ingest`],
|
|
28
|
+
createRun: ["POST", `${EXT}/runs`],
|
|
29
|
+
listRuns: ["GET", `${EXT}/runs`],
|
|
30
|
+
getRun: ["GET", `${EXT}/runs/{coding_run_id}`],
|
|
31
|
+
changeState: ["GET", `${EXT}/runs/{run_id}/change`],
|
|
32
|
+
runEvents: ["GET", `${EXT}/runs/{run_id}/events`],
|
|
33
|
+
respond: ["POST", `${EXT}/runs/{run_id}/responses`],
|
|
34
|
+
cancel: ["POST", `${EXT}/runs/{run_id}/cancel`],
|
|
35
|
+
revert: ["POST", `${EXT}/runs/{run_id}/revert`],
|
|
36
|
+
landRetry: ["POST", `${EXT}/runs/{run_id}/land/retry`],
|
|
37
|
+
landClose: ["POST", `${EXT}/runs/{run_id}/land/close`],
|
|
38
|
+
goLive: ["POST", `${EXT}/runs/{run_id}/land/go-live`],
|
|
39
|
+
heldDiff: ["GET", `${EXT}/runs/{run_id}/land/held-diff`],
|
|
40
|
+
listSchedules: ["GET", `${EXT}/schedules`],
|
|
41
|
+
createSchedule: ["POST", `${EXT}/schedules`],
|
|
42
|
+
getSchedule: ["GET", `${EXT}/schedules/{schedule_id}`],
|
|
43
|
+
updateSchedule: ["PATCH", `${EXT}/schedules/{schedule_id}`],
|
|
44
|
+
deleteSchedule: ["DELETE", `${EXT}/schedules/{schedule_id}`],
|
|
45
|
+
pauseSchedule: ["POST", `${EXT}/schedules/{schedule_id}/pause`],
|
|
46
|
+
resumeSchedule: ["POST", `${EXT}/schedules/{schedule_id}/resume`],
|
|
47
|
+
runScheduleNow: ["POST", `${EXT}/schedules/{schedule_id}/run-now`],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** The MCP endpoint `syndai mcp install` points clients at (JSON-RPC, not REST). */
|
|
51
|
+
export const MCP_PATH = "/api/v1/coding/mcp";
|
|
52
|
+
|
|
53
|
+
/** `[method, path]` for a route, with `{name}` placeholders filled and URL-encoded. */
|
|
54
|
+
export function route(name, params = {}) {
|
|
55
|
+
const entry = ROUTES[name];
|
|
56
|
+
if (!entry) throw new Error(`unknown route ${name}`);
|
|
57
|
+
const [method, template] = entry;
|
|
58
|
+
const path = template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
59
|
+
if (params[key] === undefined || params[key] === null) {
|
|
60
|
+
throw new Error(`route ${name} needs ${key}`);
|
|
61
|
+
}
|
|
62
|
+
return encodeURIComponent(String(params[key]));
|
|
63
|
+
});
|
|
64
|
+
return [method, path];
|
|
65
|
+
}
|