@dayofweek/dcli 1.3.0 → 1.5.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 +107 -54
- package/dist/auth/login.d.ts +18 -0
- package/dist/auth/login.js +47 -0
- package/dist/auth/loopback.d.ts +6 -0
- package/dist/auth/loopback.js +59 -0
- package/dist/auth/pkce.d.ts +14 -0
- package/dist/auth/pkce.js +87 -0
- package/dist/bin/dcli.js +576 -58
- package/dist/client.d.ts +205 -2
- package/dist/client.js +280 -44
- package/dist/config.d.ts +4 -1
- package/dist/config.js +25 -7
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +113 -0
- package/dist/skills.d.ts +16 -0
- package/dist/skills.js +77 -0
- package/dist/uri.d.ts +9 -0
- package/dist/uri.js +72 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -1,91 +1,144 @@
|
|
|
1
1
|
# @dayofweek/dcli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`dcli` is the Day of Week command-line client for personal and shared knowledge. It gives Agent Skills a narrow, auditable interface to shared notes and original sources without putting credentials or shared content inside your personal wiki.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The Day of Week desktop app is the recommended installer. A signed standalone binary is also available for technical users; npm is required only when developing `dcli` itself.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Sign in
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
|
|
10
|
+
dcli auth login --scopes brain:read,brain:write
|
|
11
|
+
dcli auth status --json
|
|
12
|
+
dcli doctor --json
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
Login uses a short-lived, single-use browser authorization code with PKCE. The device credential is stored in macOS Keychain, a Linux secret-service provider, Windows' protected credential file, or a permission-restricted file fallback. `dcli` does not write a new plaintext token to its JSON config.
|
|
16
|
+
|
|
17
|
+
You can remove the device credential at any time:
|
|
14
18
|
|
|
15
19
|
```bash
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
dcli auth logout --json
|
|
21
|
+
```
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
dcli read entities --json
|
|
23
|
+
## Shared knowledge
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
```bash
|
|
26
|
+
# Discover spaces that this device may access
|
|
27
|
+
dcli brain list --json
|
|
28
|
+
|
|
29
|
+
# Search one space
|
|
30
|
+
dcli brain search "soil health" --area <areaId> --json
|
|
31
|
+
|
|
32
|
+
# Read a canonical Day of Week URI
|
|
33
|
+
dcli brain get 'dayofweek://brain/<areaId>/note/<noteId>' --json
|
|
27
34
|
|
|
28
|
-
#
|
|
29
|
-
dcli
|
|
35
|
+
# Explicitly share a local Markdown note
|
|
36
|
+
dcli brain share --area <areaId> --title "Soil plan" \
|
|
37
|
+
--file wiki/soil-plan.md --intent interactive --json
|
|
38
|
+
|
|
39
|
+
# Optimistic update; a stale version exits with the conflict exit code
|
|
40
|
+
dcli brain update 'dayofweek://brain/<areaId>/note/<noteId>' \
|
|
41
|
+
--file wiki/soil-plan.md --if-version 3 --json
|
|
30
42
|
```
|
|
31
43
|
|
|
32
|
-
|
|
44
|
+
Autonomous note creation is saved as a draft. Interactive creation is saved as approved. An organization role, Studio role, or administrator role never grants shared-space access on its own: access requires an active device scope and an active membership in the exact destination space.
|
|
45
|
+
|
|
46
|
+
### Original files
|
|
47
|
+
|
|
48
|
+
Original files are deliberately separate from note text:
|
|
33
49
|
|
|
34
50
|
```bash
|
|
35
|
-
|
|
36
|
-
dcli
|
|
51
|
+
dcli brain source upload --area <areaId> --file research.pdf --mime application/pdf --json
|
|
52
|
+
dcli brain source get 'dayofweek://brain/<areaId>/source/<sourceId>' --json
|
|
53
|
+
dcli brain source download 'dayofweek://brain/<areaId>/source/<sourceId>' \
|
|
54
|
+
--output ./downloads/research.pdf --json
|
|
55
|
+
```
|
|
37
56
|
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
Downloads require an explicit output path, verify SHA-256 while streaming, fsync the temporary file, and install it atomically. Existing destinations are not replaced unless `--overwrite` is supplied. Recorded meetings also require `--meeting --consent-ack`; that flag is an explicit attestation that participants were informed and consented.
|
|
58
|
+
|
|
59
|
+
## Agent Skills
|
|
60
|
+
|
|
61
|
+
Named bundles are deterministic and available to authenticated devices:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
dcli skill list --json
|
|
65
|
+
dcli skill install personal-llm-wiki --dir .agents/skills/personal-llm-wiki --json
|
|
66
|
+
dcli skill install dayofweek-brain --dir .agents/skills/dayofweek-brain --json
|
|
67
|
+
dcli skill status dayofweek-brain --dir .agents/skills/dayofweek-brain --json
|
|
68
|
+
dcli skill update dayofweek-brain --dir .agents/skills/dayofweek-brain --json
|
|
40
69
|
```
|
|
41
70
|
|
|
42
|
-
|
|
71
|
+
Updates overwrite only files whose installed hash still matches the managed manifest. Locally edited managed files are preserved and the new server version is written beside them as `<name>.new`.
|
|
43
72
|
|
|
44
|
-
|
|
73
|
+
The legacy entity/proposal Agent Skill remains available through `dcli skill install` without a bundle name.
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
- `dcli auth login` — Authenticate via browser
|
|
48
|
-
- `dcli auth set-token <token>` — Save a token locally
|
|
49
|
-
- `dcli auth status` — Check token health
|
|
50
|
-
- `dcli auth devices` — List your agent tokens
|
|
51
|
-
- `dcli auth create-token <name>` — Create an agent token
|
|
52
|
-
- `dcli auth revoke <id>` — Revoke a token
|
|
75
|
+
## URI contract
|
|
53
76
|
|
|
54
|
-
|
|
55
|
-
- `dcli read entities [--type Farm] [--parent <id>] [--limit 50]`
|
|
56
|
-
- `dcli read entity <entityId>`
|
|
57
|
-
- `dcli read produce [--entity <id>]`
|
|
58
|
-
- `dcli read contacts [--entity <id>]`
|
|
77
|
+
`dcli` accepts only exact canonical resource forms:
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
79
|
+
```text
|
|
80
|
+
dayofweek://brain/<areaId>/note/<noteId>
|
|
81
|
+
dayofweek://brain/<areaId>/source/<sourceId>
|
|
82
|
+
https://field.dayofweek.com/brain/<areaId>/note/<noteId>
|
|
83
|
+
https://field.dayofweek.com/brain/<areaId>/source/<sourceId>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Unknown hosts, user-info, ports, query strings, fragments, encoded path separators, malformed identifiers, and oversized input are rejected before any API request.
|
|
87
|
+
|
|
88
|
+
## Entity knowledge
|
|
89
|
+
|
|
90
|
+
Knowledge documents attached to an entity — read them, add to them, mirror them
|
|
91
|
+
out into another knowledge base.
|
|
65
92
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
```bash
|
|
94
|
+
# Read
|
|
95
|
+
dcli knowledge list --entity <entityId> --json
|
|
96
|
+
dcli knowledge list --entity <entityId> --full --json # whole content, not excerpts
|
|
97
|
+
dcli knowledge get <documentId> --json
|
|
98
|
+
dcli knowledge search "cold chain" --entity <entityId> --json
|
|
70
99
|
|
|
71
|
-
|
|
100
|
+
# Add a markdown note — submitted for human review
|
|
101
|
+
dcli knowledge add --entity <entityId> --title "Variety catalogue" --file note.md
|
|
72
102
|
|
|
73
|
-
|
|
103
|
+
# Mirror an entity's sources to disk as markdown with provenance front-matter
|
|
104
|
+
dcli knowledge export --entity <entityId> --out ./knowledge
|
|
105
|
+
```
|
|
74
106
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
107
|
+
`add` submits a proposal by default: a person approves it before it lands. If
|
|
108
|
+
your token has admin rights you can pass `--direct` to skip review, and
|
|
109
|
+
`dcli knowledge attach --file article.pdf` to upload a binary source. Both write
|
|
110
|
+
immediately, so treat them as something you do when a person has asked for that
|
|
111
|
+
specific document — not as the normal path. The server rejects them for
|
|
112
|
+
non-admin tokens.
|
|
78
113
|
|
|
79
|
-
##
|
|
114
|
+
## Legacy platform commands
|
|
80
115
|
|
|
81
|
-
|
|
116
|
+
Existing read/proposal workflows remain compatible:
|
|
82
117
|
|
|
83
118
|
```bash
|
|
84
|
-
|
|
85
|
-
|
|
119
|
+
dcli read entities --json
|
|
120
|
+
dcli agent propose --op create --table hierarchyEntities \
|
|
121
|
+
--title "New Farm" --source "my-agent" \
|
|
122
|
+
--parent <parentEntityId> --entity-type Farm --file payload.json
|
|
123
|
+
dcli agent proposals --status pending --json
|
|
86
124
|
```
|
|
87
125
|
|
|
88
|
-
|
|
126
|
+
## Configuration and development
|
|
127
|
+
|
|
128
|
+
Non-secret preferences are stored at `~/.config/dayofweek/dcli.json`.
|
|
129
|
+
|
|
130
|
+
- `DCLI_API_URL` overrides the API base URL.
|
|
131
|
+
- `DCLI_AUTH_TOKEN` and `--token` remain temporary compatibility overrides; they are not persisted by the current login flow.
|
|
132
|
+
|
|
133
|
+
Development commands:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm install
|
|
137
|
+
npm test
|
|
138
|
+
npm run build
|
|
139
|
+
npm run standalone:build
|
|
140
|
+
npm run standalone:smoke
|
|
141
|
+
```
|
|
89
142
|
|
|
90
143
|
## License
|
|
91
144
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type LoopbackListener } from "./loopback.js";
|
|
2
|
+
type LoginDependencies = {
|
|
3
|
+
startListener: () => Promise<LoopbackListener>;
|
|
4
|
+
openBrowser: (url: string) => Promise<unknown>;
|
|
5
|
+
fetch: typeof globalThis.fetch;
|
|
6
|
+
};
|
|
7
|
+
export declare function browserLogin(options: {
|
|
8
|
+
apiUrl: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
deviceName?: string;
|
|
11
|
+
sourceApp?: "dcli" | "dayofweek-desktop";
|
|
12
|
+
dependencies?: Partial<LoginDependencies>;
|
|
13
|
+
}): Promise<{
|
|
14
|
+
secret: string;
|
|
15
|
+
scopes: string[];
|
|
16
|
+
bootstrap: unknown;
|
|
17
|
+
}>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { createAuthorizationRequest, verifyAuthorizationCallback } from "./pkce.js";
|
|
3
|
+
import { startLoopbackListener } from "./loopback.js";
|
|
4
|
+
export async function browserLogin(options) {
|
|
5
|
+
const dependencies = {
|
|
6
|
+
startListener: startLoopbackListener,
|
|
7
|
+
openBrowser: async (url) => (await import("open")).default(url),
|
|
8
|
+
fetch: globalThis.fetch,
|
|
9
|
+
...options.dependencies,
|
|
10
|
+
};
|
|
11
|
+
const listener = await dependencies.startListener();
|
|
12
|
+
try {
|
|
13
|
+
const appBase = options.apiUrl.replace(/\/api\/dcli\/?$/, "");
|
|
14
|
+
const request = await createAuthorizationRequest({
|
|
15
|
+
authorizeUrl: `${appBase}/dcli/auth`,
|
|
16
|
+
redirectUri: listener.redirectUri,
|
|
17
|
+
deviceName: options.deviceName ?? hostname(),
|
|
18
|
+
scopes: options.scopes,
|
|
19
|
+
});
|
|
20
|
+
const authorizeUrl = new URL(request.url);
|
|
21
|
+
authorizeUrl.searchParams.set("source_app", options.sourceApp ?? "dcli");
|
|
22
|
+
await dependencies.openBrowser(authorizeUrl.toString());
|
|
23
|
+
const callbackUrl = await listener.waitForCallback();
|
|
24
|
+
const { code } = verifyAuthorizationCallback(callbackUrl, request.state);
|
|
25
|
+
const exchange = await dependencies.fetch(`${options.apiUrl}/auth/exchange`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ code, verifier: request.verifier, redirectUri: listener.redirectUri }),
|
|
29
|
+
});
|
|
30
|
+
if (!exchange.ok)
|
|
31
|
+
throw new Error("Authorization code exchange failed");
|
|
32
|
+
const credential = await exchange.json();
|
|
33
|
+
const bootstrapResponse = await dependencies.fetch(`${options.apiUrl}/brain/bootstrap`, {
|
|
34
|
+
headers: { Authorization: `Bearer ${credential.secret}` },
|
|
35
|
+
});
|
|
36
|
+
if (!bootstrapResponse.ok)
|
|
37
|
+
throw new Error("Day of Week bootstrap check failed");
|
|
38
|
+
return {
|
|
39
|
+
secret: credential.secret,
|
|
40
|
+
scopes: credential.scopes,
|
|
41
|
+
bootstrap: await bootstrapResponse.json(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await listener.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
export async function startLoopbackListener() {
|
|
3
|
+
let callbackResolve;
|
|
4
|
+
let callbackReject;
|
|
5
|
+
const callback = new Promise((resolve, reject) => {
|
|
6
|
+
callbackResolve = resolve;
|
|
7
|
+
callbackReject = reject;
|
|
8
|
+
});
|
|
9
|
+
let settled = false;
|
|
10
|
+
const server = createServer((request, response) => {
|
|
11
|
+
const address = server.address();
|
|
12
|
+
const port = address && typeof address === "object" ? address.port : 0;
|
|
13
|
+
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
|
|
14
|
+
response.statusCode = url.pathname === "/callback" ? 200 : 404;
|
|
15
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
16
|
+
response.setHeader("Cache-Control", "no-store");
|
|
17
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
18
|
+
response.end(url.pathname === "/callback" ? "Day of Week authorization received. You can close this window." : "Not found");
|
|
19
|
+
if (!settled && url.pathname === "/callback") {
|
|
20
|
+
settled = true;
|
|
21
|
+
callbackResolve?.(url.toString());
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
server.on("error", (error) => {
|
|
25
|
+
if (!settled) {
|
|
26
|
+
settled = true;
|
|
27
|
+
callbackReject?.(error);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
await new Promise((resolve, reject) => {
|
|
31
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
32
|
+
server.once("error", reject);
|
|
33
|
+
});
|
|
34
|
+
const address = server.address();
|
|
35
|
+
if (!address || typeof address === "string")
|
|
36
|
+
throw new Error("Could not bind loopback listener");
|
|
37
|
+
const redirectUri = `http://127.0.0.1:${address.port}/callback`;
|
|
38
|
+
return {
|
|
39
|
+
redirectUri,
|
|
40
|
+
async waitForCallback(timeoutMs = 120_000) {
|
|
41
|
+
let timer;
|
|
42
|
+
try {
|
|
43
|
+
return await Promise.race([
|
|
44
|
+
callback,
|
|
45
|
+
new Promise((_, reject) => {
|
|
46
|
+
timer = setTimeout(() => reject(new Error("Authorization callback timed out")), timeoutMs);
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
if (timer)
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
async close() {
|
|
56
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function validateLoopbackRedirect(input: string): URL;
|
|
2
|
+
export declare function createAuthorizationRequest(options: {
|
|
3
|
+
authorizeUrl: string;
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
deviceName: string;
|
|
6
|
+
scopes: string[];
|
|
7
|
+
}): Promise<{
|
|
8
|
+
url: string;
|
|
9
|
+
state: string;
|
|
10
|
+
verifier: string;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function verifyAuthorizationCallback(callbackUrl: string, expectedState: string): {
|
|
13
|
+
code: string;
|
|
14
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const ALLOWED_SCOPES = new Set(["brain:read", "brain:write", "brain:manage"]);
|
|
3
|
+
function base64url(value) {
|
|
4
|
+
return Buffer.from(value).toString("base64url");
|
|
5
|
+
}
|
|
6
|
+
function safeEqual(left, right) {
|
|
7
|
+
const a = Buffer.from(left);
|
|
8
|
+
const b = Buffer.from(right);
|
|
9
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
10
|
+
}
|
|
11
|
+
export function validateLoopbackRedirect(input) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(input);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error("Invalid loopback redirect URI");
|
|
18
|
+
}
|
|
19
|
+
if (url.protocol !== "http:" ||
|
|
20
|
+
url.hostname !== "127.0.0.1" ||
|
|
21
|
+
!url.port ||
|
|
22
|
+
url.pathname !== "/callback" ||
|
|
23
|
+
url.username ||
|
|
24
|
+
url.password ||
|
|
25
|
+
url.search ||
|
|
26
|
+
url.hash) {
|
|
27
|
+
throw new Error("Redirect must be an exact http://127.0.0.1:<port>/callback URI");
|
|
28
|
+
}
|
|
29
|
+
const port = Number(url.port);
|
|
30
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
31
|
+
throw new Error("Invalid loopback callback port");
|
|
32
|
+
}
|
|
33
|
+
return url;
|
|
34
|
+
}
|
|
35
|
+
export async function createAuthorizationRequest(options) {
|
|
36
|
+
const redirectUri = validateLoopbackRedirect(options.redirectUri).toString();
|
|
37
|
+
const scopes = [...new Set(options.scopes)];
|
|
38
|
+
if (scopes.length === 0 || scopes.some((scope) => !ALLOWED_SCOPES.has(scope))) {
|
|
39
|
+
throw new Error("Invalid requested brain scopes");
|
|
40
|
+
}
|
|
41
|
+
const deviceName = options.deviceName.trim();
|
|
42
|
+
if (!deviceName || deviceName.length > 100)
|
|
43
|
+
throw new Error("Invalid device name");
|
|
44
|
+
const state = base64url(randomBytes(32));
|
|
45
|
+
const verifier = base64url(randomBytes(48));
|
|
46
|
+
const challenge = createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
47
|
+
const url = new URL(options.authorizeUrl);
|
|
48
|
+
if (url.protocol !== "https:" && url.hostname !== "127.0.0.1") {
|
|
49
|
+
throw new Error("Authorization URL must use HTTPS");
|
|
50
|
+
}
|
|
51
|
+
url.search = new URLSearchParams({
|
|
52
|
+
redirect_uri: redirectUri,
|
|
53
|
+
state,
|
|
54
|
+
code_challenge: challenge,
|
|
55
|
+
code_challenge_method: "S256",
|
|
56
|
+
device_name: deviceName,
|
|
57
|
+
scope: scopes.join(" "),
|
|
58
|
+
}).toString();
|
|
59
|
+
return { url: url.toString(), state, verifier };
|
|
60
|
+
}
|
|
61
|
+
export function verifyAuthorizationCallback(callbackUrl, expectedState) {
|
|
62
|
+
const url = new URL(callbackUrl);
|
|
63
|
+
if (url.protocol !== "http:" ||
|
|
64
|
+
url.hostname !== "127.0.0.1" ||
|
|
65
|
+
url.pathname !== "/callback" ||
|
|
66
|
+
url.username ||
|
|
67
|
+
url.password ||
|
|
68
|
+
url.hash) {
|
|
69
|
+
throw new Error("Invalid authorization callback");
|
|
70
|
+
}
|
|
71
|
+
const keys = [...url.searchParams.keys()];
|
|
72
|
+
if (keys.length !== 2 ||
|
|
73
|
+
new Set(keys).size !== 2 ||
|
|
74
|
+
!keys.includes("code") ||
|
|
75
|
+
!keys.includes("state") ||
|
|
76
|
+
url.searchParams.getAll("code").length !== 1 ||
|
|
77
|
+
url.searchParams.getAll("state").length !== 1) {
|
|
78
|
+
throw new Error("Authorization callback must contain only code and state");
|
|
79
|
+
}
|
|
80
|
+
const code = url.searchParams.get("code") ?? "";
|
|
81
|
+
const state = url.searchParams.get("state") ?? "";
|
|
82
|
+
if (!/^[A-Za-z0-9_-]{3,256}$/.test(code))
|
|
83
|
+
throw new Error("Invalid authorization code");
|
|
84
|
+
if (!safeEqual(state, expectedState))
|
|
85
|
+
throw new Error("Authorization state mismatch");
|
|
86
|
+
return { code };
|
|
87
|
+
}
|