@sudosapient/client 0.0.0-stage → 0.1.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/README.md +38 -2
- package/config.js +49 -0
- package/index.js +73 -0
- package/package.json +26 -3
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sudosapient/client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The small fetch client that [`@sudosapient/cli`](../cli) and [`@sudosapient/mcp`](../mcp) use to talk
|
|
4
|
+
to a Sudo Sapient workspace's public API (`/api/v1`), plus the config file they share. It has no
|
|
5
|
+
dependencies and runs on any `fetch` (Node 22 or later). It is a library, not a command; you get it
|
|
6
|
+
with the CLI or the MCP server, or on its own:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @sudosapient/client
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { createClient, ApiError } from '@sudosapient/client';
|
|
14
|
+
|
|
15
|
+
const api = createClient({ url: 'https://sudo.example.com', key: process.env.SUDO_API_KEY, client: 'My script' });
|
|
16
|
+
try {
|
|
17
|
+
const me = await api.get('/me');
|
|
18
|
+
const mine = await api.all('/tasks', { assignee: 'me', status: 'open' });
|
|
19
|
+
await api.post('/tasks', { project: 'WEB', title: 'Fix login bug' });
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e instanceof ApiError) console.error(e.status, e.code, e.message);
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `createClient({ url, key, client, fetch })`: `get`, `post`, `patch`, `request`, and `all` (every
|
|
26
|
+
page of a list endpoint). `client` labels your changes in Activity ("Ada via My script"). Errors
|
|
27
|
+
are `ApiError` with `status`, `code` and `field`.
|
|
28
|
+
- `plainHttpRemote(url)`: true when the URL would send the key unencrypted to another machine.
|
|
29
|
+
- `@sudosapient/client/config`: `loadConfig`, `saveConfig`, `clearConfig` and `configPath` for
|
|
30
|
+
`$XDG_CONFIG_HOME/sudo-sapient/config.json`, the file `sudo-cli login` writes (readable only by
|
|
31
|
+
you; `SUDO_URL` and `SUDO_API_KEY` override it).
|
|
32
|
+
|
|
33
|
+
Endpoints, fields and errors are in the workspace's API reference (`/api/docs`) and in
|
|
34
|
+
[docs/api.md](../../docs/api.md).
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
UNLICENSED, all rights reserved. The package is public on npm so anyone can install and use it, but
|
|
39
|
+
no license to copy, modify or redistribute the code is granted.
|
package/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Where the CLI keeps the workspace URL and API key, shared with the MCP server:
|
|
2
|
+
$XDG_CONFIG_HOME/sudo-sapient/config.json (default ~/.config), readable only by its owner.
|
|
3
|
+
SUDO_URL and SUDO_API_KEY override it. `insecure`: the person allowed plain http to the saved URL
|
|
4
|
+
(`login --insecure`); it doesn't carry over to a SUDO_URL. */
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const configPath = (env = process.env) => path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'sudo-sapient', 'config.json');
|
|
11
|
+
|
|
12
|
+
export function loadConfig(env = process.env) {
|
|
13
|
+
let file = {};
|
|
14
|
+
try {
|
|
15
|
+
file = JSON.parse(fs.readFileSync(configPath(env), 'utf8'));
|
|
16
|
+
} catch {
|
|
17
|
+
/* not logged in yet */
|
|
18
|
+
}
|
|
19
|
+
return { url: env.SUDO_URL || file.url || '', key: env.SUDO_API_KEY || file.key || '', insecure: !env.SUDO_URL && !!file.insecure };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* The key is never readable by anyone else, not even for a moment: the directory is made (or made
|
|
23
|
+
again) owner-only, and the file is written to a new temporary file created 0600, then renamed over
|
|
24
|
+
the old one, so it is never a file that had wider permissions, and a crash can't leave half of it. */
|
|
25
|
+
export function saveConfig(cfg, env = process.env) {
|
|
26
|
+
const file = configPath(env);
|
|
27
|
+
const dir = path.dirname(file);
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
fs.chmodSync(dir, 0o700);
|
|
30
|
+
const tmp = path.join(dir, `.config.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
31
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
32
|
+
try {
|
|
33
|
+
fs.writeFileSync(fd, JSON.stringify(cfg, null, 2) + '\n');
|
|
34
|
+
fs.fsyncSync(fd);
|
|
35
|
+
} finally {
|
|
36
|
+
fs.closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
fs.renameSync(tmp, file);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
fs.rmSync(tmp, { force: true });
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
return file;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearConfig(env = process.env) {
|
|
48
|
+
fs.rmSync(configPath(env), { force: true });
|
|
49
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* A minimal client for the Sudo Sapient public API (/api/v1), shared by the CLI and the MCP server.
|
|
2
|
+
No dependencies: it runs on any fetch (Node 22+, or an in-process `app.request`). */
|
|
3
|
+
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
constructor(status, body) {
|
|
6
|
+
super(body?.error || (status ? `Request failed (${status})` : 'Couldn’t reach Sudo Sapient. Check the URL and your connection.'));
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = body?.code || (status ? 'http_' + status : 'offline');
|
|
9
|
+
this.field = body?.field || null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* `url`: the workspace's base URL (https://sudo.example.com). `client`: a label for activity, e.g.
|
|
14
|
+
"CLI" or "Claude", shown as "Ada via Claude". */
|
|
15
|
+
export function createClient({ url, key, client = 'API', fetch: doFetch = globalThis.fetch }) {
|
|
16
|
+
const base = String(url || '').replace(/\/+$/, '') + '/api/v1';
|
|
17
|
+
async function request(method, path, { query, body, idempotencyKey } = {}) {
|
|
18
|
+
const qs = query ? new URLSearchParams(Object.entries(query).filter(([, v]) => v != null && v !== '')).toString() : '';
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = await doFetch(base + path + (qs ? `?${qs}` : ''), {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
authorization: `Bearer ${key}`,
|
|
25
|
+
// URI-encoded: header values must be Latin-1, and client names needn't be.
|
|
26
|
+
'x-sudo-client': encodeURIComponent(client),
|
|
27
|
+
accept: 'application/json',
|
|
28
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
29
|
+
...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
|
|
30
|
+
},
|
|
31
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ApiError(0, null);
|
|
35
|
+
}
|
|
36
|
+
const data = (res.headers.get('content-type') || '').includes('json') ? await res.json().catch(() => null) : null;
|
|
37
|
+
if (!res.ok) throw new ApiError(res.status, data);
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
request,
|
|
42
|
+
get: (path, query) => request('GET', path, { query }),
|
|
43
|
+
post: (path, body = {}, opts) => request('POST', path, { body, ...opts }),
|
|
44
|
+
patch: (path, body, opts) => request('PATCH', path, { body, ...opts }),
|
|
45
|
+
/* Every page of a list endpoint, up to `max` items. */
|
|
46
|
+
async all(path, query = {}, max = 500) {
|
|
47
|
+
const out = [];
|
|
48
|
+
let cursor;
|
|
49
|
+
do {
|
|
50
|
+
const page = await request('GET', path, { query: { ...query, limit: 100, cursor } });
|
|
51
|
+
out.push(...page.data);
|
|
52
|
+
cursor = page.nextCursor;
|
|
53
|
+
} while (cursor && out.length < max);
|
|
54
|
+
return out.slice(0, max);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Path segment for a task or project reference (an id or key). */
|
|
60
|
+
export const seg = ref => encodeURIComponent(String(ref).trim());
|
|
61
|
+
|
|
62
|
+
/* True when `url` would send the key in the clear to another machine: plain http to anything but
|
|
63
|
+
this one (localhost, 127.x, ::1). */
|
|
64
|
+
export function plainHttpRemote(url) {
|
|
65
|
+
let u;
|
|
66
|
+
try {
|
|
67
|
+
u = new URL(url);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
72
|
+
return u.protocol === 'http:' && !(host === 'localhost' || host.endsWith('.localhost') || host === '::1' || /^127\./.test(host));
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sudosapient/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal fetch client for the Sudo Sapient public API (/api/v1), plus the shared CLI/MCP config.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/sudosapient-labs/project-tracker.git",
|
|
8
|
+
"directory": "packages/client"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/sudosapient-labs/project-tracker/tree/prod/packages/client#readme",
|
|
11
|
+
"bugs": "https://github.com/sudosapient-labs/project-tracker/issues",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./config": "./config.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"config.js",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
}
|
|
6
29
|
}
|