@lotics/cli 0.116.0 → 0.123.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/AGENTS.md +4 -0
- package/README.md +13 -0
- package/dist/src/cli.js +772 -383
- package/dist/src/client.d.ts +16 -1
- package/dist/src/client.js +30 -2
- package/dist/src/invocation.d.ts +51 -0
- package/dist/src/invocation.js +142 -0
- package/package.json +1 -1
package/dist/src/client.d.ts
CHANGED
|
@@ -150,6 +150,14 @@ export declare class LoticsClient {
|
|
|
150
150
|
readonly baseUrl: string;
|
|
151
151
|
constructor(options: LoticsClientOptions);
|
|
152
152
|
private throwResponseError;
|
|
153
|
+
/**
|
|
154
|
+
* The backend's `log()` middleware registers `user-agent` and
|
|
155
|
+
* `x-posthog-session-id` onto the per-request Logger, so they ride EVERY log
|
|
156
|
+
* line that request emits — the validation 400, the tool error, the timing.
|
|
157
|
+
* Sending them is therefore the whole of the correlation work: it turns an
|
|
158
|
+
* anonymous API-key request into "`app workflow set`, from cli 0.117.0, the
|
|
159
|
+
* fourth command of this session".
|
|
160
|
+
*/
|
|
153
161
|
private buildHeaders;
|
|
154
162
|
private request;
|
|
155
163
|
whoami(): Promise<{
|
|
@@ -855,6 +863,10 @@ export declare class LoticsClient {
|
|
|
855
863
|
outputs?: Record<string, unknown>;
|
|
856
864
|
name?: string;
|
|
857
865
|
description?: string;
|
|
866
|
+
/** The `body_sha` this push was built on. Makes the write conditional: the
|
|
867
|
+
* server refuses it when the live body has moved since, rather than
|
|
868
|
+
* letting a stale copy overwrite an edit its author never saw. */
|
|
869
|
+
expected_body_sha?: string;
|
|
858
870
|
}): Promise<ToolExecuteResult>;
|
|
859
871
|
/**
|
|
860
872
|
* Bind (create or replace) an app query by alias via the `set_app_query` tool
|
|
@@ -868,7 +880,9 @@ export declare class LoticsClient {
|
|
|
868
880
|
ast: unknown;
|
|
869
881
|
params?: Record<string, unknown>;
|
|
870
882
|
description?: string;
|
|
871
|
-
}
|
|
883
|
+
},
|
|
884
|
+
/** The fingerprint this edit was based on — makes the write conditional. */
|
|
885
|
+
expected_sha?: string): Promise<ToolExecuteResult>;
|
|
872
886
|
/**
|
|
873
887
|
* Bind (create or replace) an app agent by alias via the `set_app_agent` tool
|
|
874
888
|
* — the deploy-free authoring path for `apps.agents`, parallel to
|
|
@@ -1036,6 +1050,7 @@ export declare class LoticsClient {
|
|
|
1036
1050
|
* (empty array when none declared).
|
|
1037
1051
|
*/
|
|
1038
1052
|
workflow_aliases?: string[];
|
|
1053
|
+
agent_aliases?: string[];
|
|
1039
1054
|
query_aliases?: string[];
|
|
1040
1055
|
}): Promise<{
|
|
1041
1056
|
version_id: string;
|
package/dist/src/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { transportErrorMessage } from "@lotics/shared/transport_error";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { getInvocation } from "./invocation.js";
|
|
4
5
|
function findAvailableFilename(dir, filename, reserved) {
|
|
5
6
|
// `reserved` tracks absolute paths claimed by in-flight downloads in the same
|
|
6
7
|
// batch — required for parallel callers because the file may not be on disk
|
|
@@ -75,9 +76,21 @@ export class LoticsClient {
|
|
|
75
76
|
}
|
|
76
77
|
throw new Error(`${response.status}: ${message}`);
|
|
77
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* The backend's `log()` middleware registers `user-agent` and
|
|
81
|
+
* `x-posthog-session-id` onto the per-request Logger, so they ride EVERY log
|
|
82
|
+
* line that request emits — the validation 400, the tool error, the timing.
|
|
83
|
+
* Sending them is therefore the whole of the correlation work: it turns an
|
|
84
|
+
* anonymous API-key request into "`app workflow set`, from cli 0.117.0, the
|
|
85
|
+
* fourth command of this session".
|
|
86
|
+
*/
|
|
78
87
|
buildHeaders() {
|
|
88
|
+
const invocation = getInvocation();
|
|
79
89
|
const headers = {
|
|
80
90
|
"Authorization": `Bearer ${this.apiKey}`,
|
|
91
|
+
// Unversioned when the SDK is used as a library — there is no CLI process
|
|
92
|
+
// whose version to name, and a wrong one is worse than none.
|
|
93
|
+
"user-agent": invocation?.userAgent ?? "lotics-cli",
|
|
81
94
|
};
|
|
82
95
|
if (this.workspaceId) {
|
|
83
96
|
headers["x-workspace-id"] = this.workspaceId;
|
|
@@ -85,6 +98,12 @@ export class LoticsClient {
|
|
|
85
98
|
if (this.viewAsMemberId) {
|
|
86
99
|
headers["x-view-as-member-id"] = this.viewAsMemberId;
|
|
87
100
|
}
|
|
101
|
+
if (invocation) {
|
|
102
|
+
headers["x-lotics-cli-command"] = invocation.command;
|
|
103
|
+
if (invocation.session !== null) {
|
|
104
|
+
headers["x-posthog-session-id"] = invocation.session;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
88
107
|
return headers;
|
|
89
108
|
}
|
|
90
109
|
async request(method, path, body) {
|
|
@@ -547,6 +566,7 @@ export class LoticsClient {
|
|
|
547
566
|
...(body.outputs ? { outputs: body.outputs } : {}),
|
|
548
567
|
...(body.name ? { name: body.name } : {}),
|
|
549
568
|
...(body.description ? { description: body.description } : {}),
|
|
569
|
+
...(body.expected_body_sha ? { expected_body_sha: body.expected_body_sha } : {}),
|
|
550
570
|
});
|
|
551
571
|
}
|
|
552
572
|
/**
|
|
@@ -557,8 +577,15 @@ export class LoticsClient {
|
|
|
557
577
|
* deploy validates the manifest. Note: `apps.queries` is manifest-authoritative,
|
|
558
578
|
* so the next `lotics app deploy` overwrites this from the manifest.
|
|
559
579
|
*/
|
|
560
|
-
async setAppQuery(app_id, alias, declaration
|
|
561
|
-
|
|
580
|
+
async setAppQuery(app_id, alias, declaration,
|
|
581
|
+
/** The fingerprint this edit was based on — makes the write conditional. */
|
|
582
|
+
expected_sha) {
|
|
583
|
+
return this.execute("set_app_query", {
|
|
584
|
+
app_id,
|
|
585
|
+
alias,
|
|
586
|
+
declaration,
|
|
587
|
+
...(expected_sha ? { expected_sha } : {}),
|
|
588
|
+
});
|
|
562
589
|
}
|
|
563
590
|
/**
|
|
564
591
|
* Bind (create or replace) an app agent by alias via the `set_app_agent` tool
|
|
@@ -723,6 +750,7 @@ export class LoticsClient {
|
|
|
723
750
|
// server records what the served version calls — the remove_app_workflow
|
|
724
751
|
// guard reads this back. These are the manifest KEYS only, never bindings.
|
|
725
752
|
formData.append("workflow_aliases", JSON.stringify(args.workflow_aliases ?? []));
|
|
753
|
+
formData.append("agent_aliases", JSON.stringify(args.agent_aliases ?? []));
|
|
726
754
|
formData.append("query_aliases", JSON.stringify(args.query_aliases ?? []));
|
|
727
755
|
const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(args.app_id)}/versions`;
|
|
728
756
|
const response = await fetch(url, {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Enabled by an explicit `LOTICS_TELEMETRY=1`. Anything else, including unset, is off. */
|
|
2
|
+
export declare function telemetryEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* The verb path of an invocation — `app.workflow.set`, `run.query_records` —
|
|
5
|
+
* from the raw argv positionals.
|
|
6
|
+
*
|
|
7
|
+
* Positionals stop at the first token that is a flag, a JSON blob, an `@file`,
|
|
8
|
+
* or an identifier: the point of the header is to name the COMMAND, and a table
|
|
9
|
+
* id or a record payload past that point is customer data that has no business
|
|
10
|
+
* in a request header. `run` keeps one extra token because the tool name IS the
|
|
11
|
+
* verb there — `run` alone would collapse ~90 distinct operations into one label.
|
|
12
|
+
*/
|
|
13
|
+
export declare function commandPath(argv: readonly string[]): string;
|
|
14
|
+
/** `lotics-cli/0.117.0 node/v22.1.0 linux` — enough to correlate a failure with a stale CLI. */
|
|
15
|
+
export declare function userAgent(version: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* The command + session of the running CLI process, set once by `cli.ts` before
|
|
18
|
+
* dispatch and read by `LoticsClient.buildHeaders`.
|
|
19
|
+
*
|
|
20
|
+
* Process-scoped ambient state, which is a cost — but the alternative is
|
|
21
|
+
* threading two strings through the ~20 places a client is constructed, and the
|
|
22
|
+
* facts are genuinely process-wide (there is one command per invocation). It is
|
|
23
|
+
* write-once at startup and read-only after, so nothing downstream has to track
|
|
24
|
+
* when it changes.
|
|
25
|
+
*
|
|
26
|
+
* Unset is the LIBRARY case: `@lotics/cli` also exports `LoticsClient`, where
|
|
27
|
+
* `process.argv` belongs to someone else's program and would name a command that
|
|
28
|
+
* was never run. Absent beats wrong, so the header is simply omitted.
|
|
29
|
+
*/
|
|
30
|
+
type Invocation = {
|
|
31
|
+
command: string;
|
|
32
|
+
session: string | null;
|
|
33
|
+
userAgent: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* `version` is passed in rather than imported: `version.ts` resolves
|
|
37
|
+
* `package.json` relative to its own module URL, which only holds from `dist/`,
|
|
38
|
+
* so importing it into `client.ts` would make the library entry unloadable from
|
|
39
|
+
* source. The bin already reads it correctly; the client just relays it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function setInvocation(command: string, session: string | null, version: string): void;
|
|
42
|
+
export declare function getInvocation(): Invocation | null;
|
|
43
|
+
/** Back to the library state (no CLI process). Exists for tests. */
|
|
44
|
+
export declare function resetInvocation(): void;
|
|
45
|
+
export declare function sessionStorePath(): string;
|
|
46
|
+
/**
|
|
47
|
+
* The current session id, or null when telemetry is off. `now` is injected so the
|
|
48
|
+
* idle-gap boundary is testable without a clock.
|
|
49
|
+
*/
|
|
50
|
+
export declare function sessionId(now?: number, env?: NodeJS.ProcessEnv): string | null;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this process was asked to do, and which run of a sitting it belongs to —
|
|
3
|
+
* the two facts that make a server log line attributable to a CLI invocation.
|
|
4
|
+
*
|
|
5
|
+
* Today a CLI request is indistinguishable from any other API-key request: the
|
|
6
|
+
* backend's `log()` middleware registers `user_agent` and `$session_id` onto
|
|
7
|
+
* every log line of a request, and the CLI sent neither. So a 400 in PostHog
|
|
8
|
+
* names an endpoint and nothing about the command that produced it, and nothing
|
|
9
|
+
* links it to the twelve invocations that preceded it — which is the whole
|
|
10
|
+
* question when an agent is stuck in a retry loop.
|
|
11
|
+
*
|
|
12
|
+
* Split by sensitivity, deliberately:
|
|
13
|
+
* - the VERSION and the COMMAND describe the request that is already being
|
|
14
|
+
* made (the endpoint mostly implies the verb anyway) and always ride;
|
|
15
|
+
* - the SESSION id is a cross-request correlator, so it rides only under
|
|
16
|
+
* `LOTICS_TELEMETRY=1`. Opt-out would be the industry default; this package
|
|
17
|
+
* publishes to npm and runs on customers' machines, so it is opt-in.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
/** Enabled by an explicit `LOTICS_TELEMETRY=1`. Anything else, including unset, is off. */
|
|
23
|
+
export function telemetryEnabled(env = process.env) {
|
|
24
|
+
return env.LOTICS_TELEMETRY === "1";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The verb path of an invocation — `app.workflow.set`, `run.query_records` —
|
|
28
|
+
* from the raw argv positionals.
|
|
29
|
+
*
|
|
30
|
+
* Positionals stop at the first token that is a flag, a JSON blob, an `@file`,
|
|
31
|
+
* or an identifier: the point of the header is to name the COMMAND, and a table
|
|
32
|
+
* id or a record payload past that point is customer data that has no business
|
|
33
|
+
* in a request header. `run` keeps one extra token because the tool name IS the
|
|
34
|
+
* verb there — `run` alone would collapse ~90 distinct operations into one label.
|
|
35
|
+
*/
|
|
36
|
+
export function commandPath(argv) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
for (const raw of argv) {
|
|
39
|
+
if (parts.length >= 3)
|
|
40
|
+
break;
|
|
41
|
+
if (raw.startsWith("-") || raw.startsWith("@") || raw.startsWith("{"))
|
|
42
|
+
break;
|
|
43
|
+
if (raw.includes("/") || raw.includes("."))
|
|
44
|
+
break;
|
|
45
|
+
if (!/^[a-z][a-z0-9_-]*$/i.test(raw))
|
|
46
|
+
break;
|
|
47
|
+
if (isResourceId(raw))
|
|
48
|
+
break;
|
|
49
|
+
parts.push(raw);
|
|
50
|
+
}
|
|
51
|
+
return parts.length > 0 ? parts.join(".") : "unknown";
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A Lotics resource id (`app_Gw6rs95ZYKQs`, `tbl_1KxO3W08g75o`) as opposed to a
|
|
55
|
+
* snake_case tool name (`query_records`, `grep_knowledge`).
|
|
56
|
+
*
|
|
57
|
+
* Both are `<word>_<word>`, so prefix-and-underscore alone is not the tell — it
|
|
58
|
+
* rejected half the tool registry. The discriminator is the SUFFIX: an id's is a
|
|
59
|
+
* long base62 blob, which always carries an uppercase letter or a digit; a tool
|
|
60
|
+
* name's second word is an English word in lowercase. Requiring both (a short
|
|
61
|
+
* prefix with a long mixed-case suffix, and no second underscore) leaves
|
|
62
|
+
* `set_app_workflow` and `aggregate_records` on the verb side where they belong.
|
|
63
|
+
*/
|
|
64
|
+
function isResourceId(token) {
|
|
65
|
+
const match = /^[a-z]{2,5}_([A-Za-z0-9]{8,})$/.exec(token);
|
|
66
|
+
return match !== null && /[A-Z0-9]/.test(match[1]);
|
|
67
|
+
}
|
|
68
|
+
/** `lotics-cli/0.117.0 node/v22.1.0 linux` — enough to correlate a failure with a stale CLI. */
|
|
69
|
+
export function userAgent(version) {
|
|
70
|
+
return `lotics-cli/${version} node/${process.version} ${os.platform()}`;
|
|
71
|
+
}
|
|
72
|
+
let invocation = null;
|
|
73
|
+
/**
|
|
74
|
+
* `version` is passed in rather than imported: `version.ts` resolves
|
|
75
|
+
* `package.json` relative to its own module URL, which only holds from `dist/`,
|
|
76
|
+
* so importing it into `client.ts` would make the library entry unloadable from
|
|
77
|
+
* source. The bin already reads it correctly; the client just relays it.
|
|
78
|
+
*/
|
|
79
|
+
export function setInvocation(command, session, version) {
|
|
80
|
+
invocation = { command, session, userAgent: userAgent(version) };
|
|
81
|
+
}
|
|
82
|
+
export function getInvocation() {
|
|
83
|
+
return invocation;
|
|
84
|
+
}
|
|
85
|
+
/** Back to the library state (no CLI process). Exists for tests. */
|
|
86
|
+
export function resetInvocation() {
|
|
87
|
+
invocation = null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A sitting of the CLI, as one id shared by every invocation in it.
|
|
91
|
+
*
|
|
92
|
+
* Under an agent harness that already has a session concept, adopt it — the ids
|
|
93
|
+
* then line up with the harness's own transcript, which is the difference between
|
|
94
|
+
* "these 40 commands are related" and "these 40 commands ARE that conversation".
|
|
95
|
+
* Otherwise sessionize the way web analytics does: reuse the stored id while
|
|
96
|
+
* invocations keep arriving, mint a new one after an idle gap.
|
|
97
|
+
*/
|
|
98
|
+
const IDLE_GAP_MS = 30 * 60 * 1000;
|
|
99
|
+
export function sessionStorePath() {
|
|
100
|
+
return path.join(os.homedir(), ".lotics", "session.json");
|
|
101
|
+
}
|
|
102
|
+
function readSession(file) {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
105
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
106
|
+
return null;
|
|
107
|
+
const { id, last_seen } = parsed;
|
|
108
|
+
if (typeof id !== "string" || typeof last_seen !== "number")
|
|
109
|
+
return null;
|
|
110
|
+
return { id, last_seen };
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Absent, unreadable, or corrupt — all mean "no session to continue", and a
|
|
114
|
+
// telemetry id is never worth failing a command over.
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The current session id, or null when telemetry is off. `now` is injected so the
|
|
120
|
+
* idle-gap boundary is testable without a clock.
|
|
121
|
+
*/
|
|
122
|
+
export function sessionId(now = Date.now(), env = process.env) {
|
|
123
|
+
if (!telemetryEnabled(env))
|
|
124
|
+
return null;
|
|
125
|
+
const harnessSession = env.CLAUDE_CODE_SESSION_ID;
|
|
126
|
+
if (harnessSession !== undefined && harnessSession !== "")
|
|
127
|
+
return harnessSession;
|
|
128
|
+
const file = sessionStorePath();
|
|
129
|
+
const stored = readSession(file);
|
|
130
|
+
const id = stored !== null && now - stored.last_seen < IDLE_GAP_MS
|
|
131
|
+
? stored.id
|
|
132
|
+
: `cli_${now.toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
133
|
+
try {
|
|
134
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
135
|
+
fs.writeFileSync(file, JSON.stringify({ id, last_seen: now }), { mode: 0o600 });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// A read-only home still gets a usable id for this process; only the
|
|
139
|
+
// continuity across invocations is lost.
|
|
140
|
+
}
|
|
141
|
+
return id;
|
|
142
|
+
}
|