@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 CHANGED
@@ -1,91 +1,144 @@
1
1
  # @dayofweek/dcli
2
2
 
3
- CLI for the [Day of Week](https://dayofweek.com) AgTech platform.
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
- Read your organization's data and submit proposals for human review nothing changes until you approve it.
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
- ## Install
7
+ ## Sign in
8
8
 
9
9
  ```bash
10
- npm install -g @dayofweek/dcli
10
+ dcli auth login --scopes brain:read,brain:write
11
+ dcli auth status --json
12
+ dcli doctor --json
11
13
  ```
12
14
 
13
- ## Quick start
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
- # Authenticate
17
- dcli auth set-token <your-token>
20
+ dcli auth logout --json
21
+ ```
18
22
 
19
- # Browse your entities
20
- dcli read entities --json
23
+ ## Shared knowledge
21
24
 
22
- # Submit a proposal
23
- dcli agent propose --op create --table hierarchyEntities \
24
- --title "New Farm" --source "my-agent" \
25
- --parent <parentEntityId> --entity-type Farm \
26
- --file payload.json
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
- # Check proposal status
29
- dcli agent proposals --status pending
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
- ## Agent integration
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
- # Install the Agent Skill for AI agents
36
- dcli skill install
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
- # Works with OpenClaw, Claude Code, Cursor, VS Code Copilot,
39
- # Gemini CLI, Goose, OpenHands, and 25+ others
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
- The skill follows the open [Agent Skills](https://agentskills.io) standard. After `dcli skill install`, any compatible agent on the machine discovers it automatically.
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
- ## Commands
73
+ The legacy entity/proposal Agent Skill remains available through `dcli skill install` without a bundle name.
45
74
 
46
- ### Authentication
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
- ### Reading data
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
- ### Proposals
61
- - `dcli agent propose --op create --table <table> --title <title> [--file payload.json]`
62
- - `dcli agent propose-batch --label <label> --file batch.json`
63
- - `dcli agent proposals [--status pending]`
64
- - `dcli agent show <proposalId>`
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
- ### Agent Skill
67
- - `dcli skill install` — Download skill (requires auth)
68
- - `dcli skill update` Update to latest
69
- - `dcli skill status` Check installed version
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
- ## Configuration
100
+ # Add a markdown note — submitted for human review
101
+ dcli knowledge add --entity <entityId> --title "Variety catalogue" --file note.md
72
102
 
73
- dcli stores its config at `~/.config/dayofweek/dcli.json`.
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
- Environment variables:
76
- - `DCLI_AUTH_TOKEN` Auth token (overrides stored token)
77
- - `DCLI_API_URL` Custom API base URL
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
- ## REST API
114
+ ## Legacy platform commands
80
115
 
81
- dcli talks to the Day of Week platform via a REST API. You can also call it directly:
116
+ Existing read/proposal workflows remain compatible:
82
117
 
83
118
  ```bash
84
- curl -H "Authorization: Bearer dsk_xxx" \
85
- "https://field.dayofweek.com/app/api/dcli/entities?type=Farm"
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
- See the [API schema](https://field.dayofweek.com/app/api/dcli/schema) for all endpoints.
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,6 @@
1
+ export type LoopbackListener = {
2
+ redirectUri: string;
3
+ waitForCallback(timeoutMs?: number): Promise<string>;
4
+ close(): Promise<void>;
5
+ };
6
+ export declare function startLoopbackListener(): Promise<LoopbackListener>;
@@ -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
+ }