@hue-run/sdk 0.21.0 → 0.22.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.
Files changed (74) hide show
  1. package/CLI.md +255 -61
  2. package/README.md +61 -20
  3. package/WORLDS.md +6 -3
  4. package/dist/ai-sdk.d.ts +23 -1
  5. package/dist/ai-sdk.js +60 -2
  6. package/dist/cli/import-formats/adapter.d.ts +80 -0
  7. package/dist/cli/import-formats/adapter.js +1 -0
  8. package/dist/cli/import-formats/agent-events.d.ts +5 -0
  9. package/dist/cli/import-formats/agent-events.js +16 -0
  10. package/dist/cli/import-formats/atif.d.ts +3 -0
  11. package/dist/cli/import-formats/atif.js +15 -0
  12. package/dist/cli/import-formats/braintrust.d.ts +2 -0
  13. package/dist/cli/import-formats/braintrust.js +222 -0
  14. package/dist/cli/import-formats/containers.d.ts +32 -0
  15. package/dist/cli/import-formats/containers.js +399 -0
  16. package/dist/cli/import-formats/datadog.d.ts +2 -0
  17. package/dist/cli/import-formats/datadog.js +264 -0
  18. package/dist/cli/import-formats/helicone.d.ts +2 -0
  19. package/dist/cli/import-formats/helicone.js +161 -0
  20. package/dist/cli/import-formats/identity.d.ts +112 -0
  21. package/dist/cli/import-formats/identity.js +594 -0
  22. package/dist/cli/import-formats/jaeger.d.ts +3 -0
  23. package/dist/cli/import-formats/jaeger.js +266 -0
  24. package/dist/cli/import-formats/langfuse.d.ts +7 -0
  25. package/dist/cli/import-formats/langfuse.js +510 -0
  26. package/dist/cli/import-formats/langsmith.d.ts +2 -0
  27. package/dist/cli/import-formats/langsmith.js +244 -0
  28. package/dist/cli/import-formats/messages.d.ts +28 -0
  29. package/dist/cli/import-formats/messages.js +301 -0
  30. package/dist/cli/import-formats/mlflow.d.ts +2 -0
  31. package/dist/cli/import-formats/mlflow.js +260 -0
  32. package/dist/cli/import-formats/openai-agents.d.ts +2 -0
  33. package/dist/cli/import-formats/openai-agents.js +221 -0
  34. package/dist/cli/import-formats/opik.d.ts +2 -0
  35. package/dist/cli/import-formats/opik.js +193 -0
  36. package/dist/cli/import-formats/otel-family.d.ts +5 -0
  37. package/dist/cli/import-formats/otel-family.js +17 -0
  38. package/dist/cli/import-formats/otel-spans.d.ts +94 -0
  39. package/dist/cli/import-formats/otel-spans.js +262 -0
  40. package/dist/cli/import-formats/otlp-protobuf.d.ts +35 -0
  41. package/dist/cli/import-formats/otlp-protobuf.js +486 -0
  42. package/dist/cli/import-formats/otlp.d.ts +4 -0
  43. package/dist/cli/import-formats/otlp.js +15 -0
  44. package/dist/cli/import-formats/phoenix.d.ts +3 -0
  45. package/dist/cli/import-formats/phoenix.js +256 -0
  46. package/dist/cli/import-formats/raindrop.d.ts +21 -0
  47. package/dist/cli/import-formats/raindrop.js +255 -0
  48. package/dist/cli/import-formats/reader.d.ts +56 -0
  49. package/dist/cli/import-formats/reader.js +364 -0
  50. package/dist/cli/import-formats/registry.d.ts +36 -0
  51. package/dist/cli/import-formats/registry.js +71 -0
  52. package/dist/cli/import-formats/row-platform-fields.d.ts +32 -0
  53. package/dist/cli/import-formats/row-platform-fields.js +76 -0
  54. package/dist/cli/import-formats/rows.d.ts +165 -0
  55. package/dist/cli/import-formats/rows.js +638 -0
  56. package/dist/cli/import-formats/transcripts.d.ts +25 -0
  57. package/dist/cli/import-formats/transcripts.js +498 -0
  58. package/dist/cli/import-formats/weave.d.ts +2 -0
  59. package/dist/cli/import-formats/weave.js +159 -0
  60. package/dist/cli/import-formats/zipkin.d.ts +2 -0
  61. package/dist/cli/import-formats/zipkin.js +219 -0
  62. package/dist/cli/import.d.ts +1 -1
  63. package/dist/cli/import.js +4 -2
  64. package/dist/cli/mcp.d.ts +5 -1
  65. package/dist/cli/mcp.js +35 -23
  66. package/dist/cli/traces-import-convert.d.ts +65 -33
  67. package/dist/cli/traces-import-convert.js +211 -214
  68. package/dist/cli/traces-import-prepare.d.ts +26 -22
  69. package/dist/cli/traces-import-prepare.js +51 -122
  70. package/dist/cli/traces-import.d.ts +4 -4
  71. package/dist/cli/traces-import.js +63 -11
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/package.json +3 -3
@@ -0,0 +1,219 @@
1
+ import { ConversionError, isObject, } from "../traces-import-convert.js";
2
+ import { otelTraceKey, OtelSpanCollector, unsigned } from "./otel-spans.js";
3
+ import { readRecords } from "./rows.js";
4
+ const object = (value) => isObject(value) ? value : undefined;
5
+ const text = (value) => typeof value === "string" && value ? value : undefined;
6
+ const KINDS = { SERVER: 2, CLIENT: 3, PRODUCER: 4, CONSUMER: 5 };
7
+ /** A record with a Zipkin v2 span's hex trace and span ids and none of the fields other formats
8
+ * name a span with (`type`, `startTime`, `spanId`, `traceID`, `span_id`). */
9
+ function hasSpanIds(value) {
10
+ if (!isObject(value))
11
+ return false;
12
+ const { traceId, id } = value;
13
+ if (typeof traceId !== "string" || typeof id !== "string")
14
+ return false;
15
+ if (!/^[0-9a-f]{16}(?:[0-9a-f]{16})?$/i.test(traceId) || !/^[0-9a-f]{16}$/i.test(id))
16
+ return false;
17
+ return !["type", "startTime", "spanId", "traceID", "span_id"].some((key) => key in value);
18
+ }
19
+ /** A Zipkin v2 span on its own: its ids and a field only a span records. */
20
+ const isSpan = (value) => hasSpanIds(value) &&
21
+ (typeof value.timestamp === "number" ||
22
+ typeof value.duration === "number" ||
23
+ isObject(value.localEndpoint) ||
24
+ (typeof value.kind === "string" && value.kind in KINDS) ||
25
+ Array.isArray(value.annotations) ||
26
+ isObject(value.tags));
27
+ /** Tags that describe the process, which the Collector keeps on the resource. */
28
+ const RESOURCE_PREFIXES = [
29
+ "service.",
30
+ "telemetry.",
31
+ "deployment.",
32
+ "host.",
33
+ "os.",
34
+ "process.",
35
+ "container.",
36
+ "k8s.",
37
+ "cloud.",
38
+ "faas.",
39
+ "device.",
40
+ ];
41
+ const attribute = (key, value) => ({
42
+ key,
43
+ value: typeof value === "number" ? { intValue: String(value) } : { stringValue: value },
44
+ });
45
+ /** An endpoint's address and port under `prefix` (`net.host`, `net.peer`). */
46
+ function endpointAttributes(endpoint, prefix) {
47
+ if (!endpoint)
48
+ return [];
49
+ const address = text(endpoint.ipv4) ?? text(endpoint.ipv6);
50
+ const port = endpoint.port;
51
+ return [
52
+ ...(address ? [attribute(`${prefix}.ip`, address)] : []),
53
+ ...(typeof port === "number" && Number.isSafeInteger(port) && port > 0
54
+ ? [attribute(`${prefix}.port`, port)]
55
+ : []),
56
+ ];
57
+ }
58
+ function micros(value, what) {
59
+ if (value === undefined || value === null)
60
+ return undefined;
61
+ const count = unsigned(value);
62
+ if (count === undefined)
63
+ throw new ConversionError(`${what} is not a count of microseconds`);
64
+ return count * 1000n;
65
+ }
66
+ function readSpan(span, sequence) {
67
+ const { traceId, id } = span;
68
+ if (!hasSpanIds(span) || typeof traceId !== "string" || typeof id !== "string")
69
+ throw new ConversionError("a record that is not a Zipkin span");
70
+ const tagsValue = span.tags ?? {};
71
+ const tags = object(tagsValue);
72
+ if (!tags)
73
+ throw new ConversionError("tags is not an object");
74
+ const remaining = new Map();
75
+ for (const [key, value] of Object.entries(tags)) {
76
+ if (typeof value !== "string")
77
+ throw new ConversionError(`tag ${key} is not text`);
78
+ remaining.set(key, value);
79
+ }
80
+ const take = (key) => {
81
+ const value = remaining.get(key);
82
+ remaining.delete(key);
83
+ return value;
84
+ };
85
+ let kind = typeof span.kind === "string" ? (KINDS[span.kind] ?? 0) : 0;
86
+ const kindTag = take("span.kind");
87
+ if (!kind && kindTag === "internal")
88
+ kind = 1;
89
+ let status;
90
+ const code = take("otel.status_code");
91
+ const description = take("otel.status_description");
92
+ if (code !== undefined)
93
+ status = {
94
+ code: code === "OK" ? 1 : code === "ERROR" ? 2 : 0,
95
+ ...(description ? { message: description } : {}),
96
+ };
97
+ const error = take("error");
98
+ if (error !== undefined) {
99
+ const message = error && error !== "true" ? error : status?.message;
100
+ status = { code: 2, ...(message ? { message } : {}) };
101
+ }
102
+ const scopeName = take("otel.scope.name") ?? take("otel.library.name");
103
+ const scopeVersion = take("otel.scope.version") ?? take("otel.library.version");
104
+ const traceState = take("w3c.tracestate");
105
+ const local = object(span.localEndpoint);
106
+ const remote = object(span.remoteEndpoint);
107
+ const resource = [];
108
+ const service = text(local?.serviceName);
109
+ if (service)
110
+ resource.push(attribute("service.name", service));
111
+ const attributes = [];
112
+ for (const [key, value] of remaining)
113
+ (RESOURCE_PREFIXES.some((prefix) => key.startsWith(prefix)) ? resource : attributes).push(attribute(key, value));
114
+ attributes.push(...endpointAttributes(local, "net.host"));
115
+ attributes.push(...endpointAttributes(remote, "net.peer"));
116
+ const peer = text(remote?.serviceName);
117
+ if (peer)
118
+ attributes.push(attribute("peer.service", peer));
119
+ const annotations = span.annotations ?? [];
120
+ if (!Array.isArray(annotations))
121
+ throw new ConversionError("annotations is not a list");
122
+ const start = micros(span.timestamp, "timestamp");
123
+ if (start === 0n)
124
+ throw new ConversionError("timestamp is zero");
125
+ const duration = micros(span.duration, "duration");
126
+ const parentId = text(span.parentId);
127
+ if (parentId !== undefined && !/^[0-9a-f]{1,16}$/i.test(parentId))
128
+ throw new ConversionError("parentId is not a span id");
129
+ return {
130
+ traceId,
131
+ spanKey: id.toLowerCase(),
132
+ ...(parentId ? { parentKey: parentId.toLowerCase() } : {}),
133
+ name: text(span.name) ?? "",
134
+ kind,
135
+ ...(traceState ? { traceState } : {}),
136
+ ...(start !== undefined ? { start } : {}),
137
+ ...(start !== undefined && duration !== undefined ? { end: start + duration } : {}),
138
+ attributes,
139
+ events: annotations.map((item) => {
140
+ const annotation = object(item);
141
+ if (!annotation || typeof annotation.value !== "string")
142
+ throw new ConversionError("an annotation without a value");
143
+ const time = micros(annotation.timestamp, "an annotation timestamp");
144
+ return { name: annotation.value, ...(time ? { time } : {}), attributes: [] };
145
+ }),
146
+ links: [],
147
+ ...(status ? { status } : {}),
148
+ resource,
149
+ ...(scopeName
150
+ ? { scope: { name: scopeName, ...(scopeVersion ? { version: scopeVersion } : {}) } }
151
+ : {}),
152
+ record: span,
153
+ sequence,
154
+ };
155
+ }
156
+ const serviceOf = (span) => {
157
+ const local = object(object(span.record)?.localEndpoint);
158
+ return text(local?.serviceName) ?? "";
159
+ };
160
+ const isShared = (span) => object(span.record)?.shared === true;
161
+ /** Nests each server span that shares its id with its client under the client, and the server's
162
+ * children under the server when they ran on the same service, as Zipkin's UI builds its tree. */
163
+ function nestShared(spans) {
164
+ const unshared = new Set(spans.filter((span) => !isShared(span)).map((span) => span.spanKey));
165
+ const sharedCount = new Map();
166
+ for (const span of spans)
167
+ if (isShared(span))
168
+ sharedCount.set(span.spanKey, (sharedCount.get(span.spanKey) ?? 0) + 1);
169
+ // A shared span is renamed when another span holds its id: its client, or another server.
170
+ const renamed = new Map();
171
+ const keyed = spans.map((span) => {
172
+ if (!isShared(span) || (!unshared.has(span.spanKey) && sharedCount.get(span.spanKey) === 1))
173
+ return span;
174
+ const spanKey = `${span.spanKey}#shared:${serviceOf(span)}`;
175
+ renamed.set(`${span.spanKey}\u0000${serviceOf(span)}`, spanKey);
176
+ return {
177
+ ...span,
178
+ spanKey,
179
+ sourceSpanId: span.spanKey,
180
+ ...(unshared.has(span.spanKey)
181
+ ? { parentKey: span.spanKey }
182
+ : span.parentKey !== undefined
183
+ ? { parentKey: span.parentKey }
184
+ : {}),
185
+ };
186
+ });
187
+ return keyed.map((span) => {
188
+ if (span.parentKey === undefined || span.sourceSpanId !== undefined)
189
+ return span;
190
+ const server = renamed.get(`${span.parentKey}\u0000${serviceOf(span)}`);
191
+ return server ? { ...span, parentKey: server } : span;
192
+ });
193
+ }
194
+ /** The spans a value holds: one span, or a list of spans. */
195
+ const spansOf = (value) => isSpan(value)
196
+ ? [value]
197
+ : Array.isArray(value) && value.length && value.every(isObject) && value.some(isSpan)
198
+ ? value
199
+ : undefined;
200
+ export const zipkin = {
201
+ id: "zipkin",
202
+ manifestFormat: "otlp_json",
203
+ detect: (value) => spansOf(value) !== undefined,
204
+ convert(value, context) {
205
+ const collector = context.collector(() => new OtelSpanCollector("zipkin", nestShared));
206
+ const records = spansOf(value) ?? [];
207
+ // A span's position in its source orders a trace that records no instant at all.
208
+ const base = context.source.line ?? context.source.index ?? 0;
209
+ readRecords(context, records.map((record, index) => ({ record, sequence: base + index })), ({ record }) => {
210
+ const traceId = object(record)?.traceId;
211
+ return typeof traceId === "string" && traceId ? otelTraceKey(traceId) : undefined;
212
+ }, ({ record, sequence }) => {
213
+ const span = object(record);
214
+ if (!span)
215
+ throw new ConversionError("a span that is not an object");
216
+ collector.add(readSpan(span, sequence), context.source.digest);
217
+ });
218
+ },
219
+ };
@@ -26,7 +26,7 @@ export interface ImportCommandIo {
26
26
  * in flight do not hold it until their requests time out. */
27
27
  exit?: (code: number) => void;
28
28
  }
29
- export declare const IMPORT_USAGE = "Usage: hue import langfuse [options]\n\nCopy traces from a Langfuse project into Hue. The import runs on this machine with your own\nLangfuse keys, which are sent only to Langfuse. Each observation becomes a span with the\nlangfuse.* attributes Hue reads; ids are kept, so running the same command again resumes an\nimport without duplicating what was already sent. Langfuse's environment is also sent, as\nrecorded, as hue.import.source_environment; only traces Langfuse recorded in production add to\na user's World.\n\nLangfuse (keys from the environment or --env-path; the secret key never from the command line):\n LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY The Langfuse project's API keys\n LANGFUSE_BASE_URL or LANGFUSE_HOST Langfuse host (default https://cloud.langfuse.com)\n --langfuse-host URL Langfuse host, instead of the variables\n --langfuse-public-key KEY Public key, instead of LANGFUSE_PUBLIC_KEY\n\nHue:\n HUE_API_KEY The Hue project key the traces are sent with (environment, --env-path\n or ./.env.hue); not needed with --dry-run\n --origin URL Hue origin (default HUE_BASE_URL, then https://app.hue.run)\n --service-name NAME service.name of the imported spans (default langfuse)\n\nSelection (newest first):\n --from TIME Traces that started at or after TIME: an ISO 8601 date (midnight UTC)\n or a date and time with a zone, such as 2026-01-01T09:00:00Z\n --to TIME Traces that started before TIME (default: an hour before the command\n starts, so traces still running are left for a later run)\n --limit N At most N traces\n --trace-id ID This Langfuse trace (repeatable); not with --from, --to or --limit\n\nContent:\n --media Download the files Langfuse media references (@@@langfuseMedia:\u2026@@@)\n point to and upload them to Hue; without it, references stay as text\n --no-content Send metadata only: no inputs, outputs, status messages or model\n parameters (content is sent by default, as the SDK records it)\n\nRun:\n --dry-run Read Langfuse and count what would be imported; send nothing to Hue\n --yes Import without asking for confirmation (required without a terminal)\n --env-path PATH Load a dotenv file first (--env-file also works when it exists);\n ./.env.hue loads when HUE_API_KEY is not set. Variables already set win\n -h, --help Show this help\n\nExit codes: 0 every trace was imported, 1 a trace, span or file was not, 2 usage error,\n130 stopped (run the same command again to resume).";
29
+ export declare const IMPORT_USAGE = "Usage: hue import langfuse [options]\n\nCopy traces from a Langfuse project into Hue. The import runs on this machine with your own\nLangfuse keys, which are sent only to Langfuse. Each observation becomes a span with the\nlangfuse.* attributes Hue reads; ids are kept, so running the same command again resumes an\nimport without duplicating what was already sent. Langfuse's environment is also sent, as\nrecorded, as hue.import.source_environment. Ended traces with a resolved user can contribute to\nthat user's World regardless of deployment environment. Traces linked to Hue runs, World instances,\nor run executions never contribute. Hue's evaluation and simulation evidence and deliberately\nexcluded traces also stay excluded.\n\nLangfuse (keys from the environment or --env-path; the secret key never from the command line):\n LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY The Langfuse project's API keys\n LANGFUSE_BASE_URL or LANGFUSE_HOST Langfuse host (default https://cloud.langfuse.com)\n --langfuse-host URL Langfuse host, instead of the variables\n --langfuse-public-key KEY Public key, instead of LANGFUSE_PUBLIC_KEY\n\nHue:\n HUE_API_KEY The Hue project key the traces are sent with (environment, --env-path\n or ./.env.hue); not needed with --dry-run\n --origin URL Hue origin (default HUE_BASE_URL, then https://app.hue.run)\n --service-name NAME service.name of the imported spans (default langfuse)\n\nSelection (newest first):\n --from TIME Traces that started at or after TIME: an ISO 8601 date (midnight UTC)\n or a date and time with a zone, such as 2026-01-01T09:00:00Z\n --to TIME Traces that started before TIME (default: an hour before the command\n starts, so traces still running are left for a later run)\n --limit N At most N traces\n --trace-id ID This Langfuse trace (repeatable); not with --from, --to or --limit\n\nContent:\n --media Download the files Langfuse media references (@@@langfuseMedia:\u2026@@@)\n point to and upload them to Hue; without it, references stay as text\n --no-content Send metadata only: no inputs, outputs, status messages or model\n parameters (content is sent by default, as the SDK records it)\n\nRun:\n --dry-run Read Langfuse and count what would be imported; send nothing to Hue\n --yes Import without asking for confirmation (required without a terminal)\n --env-path PATH Load a dotenv file first (--env-file also works when it exists);\n ./.env.hue loads when HUE_API_KEY is not set. Variables already set win\n -h, --help Show this help\n\nExit codes: 0 every trace was imported, 1 a trace, span or file was not, 2 usage error,\n130 stopped (run the same command again to resume).";
30
30
  /** An instant of the window: an ISO 8601 date (midnight UTC) or a date and time with a zone. */
31
31
  export declare function parseInstant(option: string, value: string): string;
32
32
  /** The Langfuse host as a base URL without a trailing slash, or why it is refused. */
@@ -45,8 +45,10 @@ Copy traces from a Langfuse project into Hue. The import runs on this machine wi
45
45
  Langfuse keys, which are sent only to Langfuse. Each observation becomes a span with the
46
46
  langfuse.* attributes Hue reads; ids are kept, so running the same command again resumes an
47
47
  import without duplicating what was already sent. Langfuse's environment is also sent, as
48
- recorded, as hue.import.source_environment; only traces Langfuse recorded in production add to
49
- a user's World.
48
+ recorded, as hue.import.source_environment. Ended traces with a resolved user can contribute to
49
+ that user's World regardless of deployment environment. Traces linked to Hue runs, World instances,
50
+ or run executions never contribute. Hue's evaluation and simulation evidence and deliberately
51
+ excluded traces also stay excluded.
50
52
 
51
53
  Langfuse (keys from the environment or --env-path; the secret key never from the command line):
52
54
  LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY The Langfuse project's API keys
package/dist/cli/mcp.d.ts CHANGED
@@ -31,7 +31,7 @@ export declare const MCP_TOOLSETS: readonly ["all", "observe", "author", "evalua
31
31
  export declare const TOOLSETS_HEADER = "X-Hue-MCP-Toolsets";
32
32
  /** The header form of `?project=`, used by the sign-in Codex TOML to keep its URL bare. */
33
33
  export declare const PROJECT_HEADER = "X-Hue-MCP-Project";
34
- export declare const MCP_USAGE = "Usage: hue mcp install --client <claude-code|codex|conductor|cursor|vscode|windsurf|gemini>\n [--auth key|oauth] [--read-only] [--project ID-OR-SLUG]\n [--toolsets NAMES] [--url URL]\n [--scope project|user] [--dry-run] [--print]\n\nConfigure a coding agent to use the Hue MCP server. A sign-in configuration holds only the\nURL: the client opens Hue in a browser, where you approve access to the projects of one\norganization. A key configuration references the HUE_MCP_KEY environment variable; a key value\nis never written.\n\nOptions:\n --client NAME Coding agent to configure (required)\n --auth MODE oauth: URL only, sign in with Hue in the client (claude-code, codex and\n conductor; their default)\n key: reference HUE_MCP_KEY (the default for the other clients and with\n --read-only). Without --auth, claude-code and codex keep the choice of\n an existing entry, and use a key for a new one when HUE_MCP_KEY is set\n --read-only Add ?read_only=true to a key configuration's URL so write tools are\n hidden; selects a key without --auth. A sign-in connection has Read and\n write access; use a Read key for read-only\n --project VALUE Pin the connection to one project by id or slug. Its tools omit project_id,\n and the server is named hue-<value> so it can coexist with an unpinned hue\n --toolsets NAMES\n Tools to list, comma-separated: all; the observe, author or evaluate\n profiles; or project, traces, eval_sets, runs, judges, cases, runners,\n environments, intents or docs. Added to the URL as ?toolsets=.\n Defaults: all for claude-code, codex and conductor,\n which search their own tools; observe for cursor. Without a selection\n Hue lists the production reads and search_hue_tools, which reaches\n every other tool\n --url URL Hue MCP endpoint (default https://mcp.hue.run/mcp)\n --scope SCOPE claude-code only: project writes .mcp.json (default); user runs\n `claude mcp add --scope user`\n --dry-run Print the resulting file content or commands without writing or running\n --print Print the configuration snippet only\n -h, --help Show this help\n\nFiles: claude-code .mcp.json, cursor .cursor/mcp.json, vscode .vscode/mcp.json (relative to the\ncurrent directory). codex and gemini use their own CLI when it is on PATH; windsurf prints the\nsnippet for its user configuration file. conductor registers the server for its Claude Code\n(user scope) and Codex agents with their CLIs, printing the command for a CLI not on PATH.\ncodex mcp add signs in at once and waits up to 5 minutes for the browser, so without --auth\nand outside a terminal the command prints it instead of running it.";
34
+ export declare const MCP_USAGE = "Usage: hue mcp install --client <claude-code|codex|conductor|cursor|vscode|windsurf|gemini>\n [--auth key|oauth] [--read-only] [--project ID-OR-SLUG]\n [--toolsets NAMES] [--url URL]\n [--scope project|user] [--dry-run] [--print]\n\nConfigure a coding agent to use the Hue MCP server. A sign-in configuration holds only the\nURL: the client opens Hue in a browser, where you approve access to the projects of one\norganization. A key configuration references the HUE_MCP_KEY environment variable; a key value\nis never written.\n\nOptions:\n --client NAME Coding agent to configure (required)\n --auth MODE oauth: URL only, sign in with Hue in the client (claude-code, codex,\n conductor and cursor; their default)\n key: reference HUE_MCP_KEY (the default for the other clients and with\n --read-only). Without --auth, claude-code, codex and cursor keep the\n choice of an existing entry, and use a key for a new one when\n HUE_MCP_KEY is set\n --read-only Add ?read_only=true to a key configuration's URL so write tools are\n hidden; selects a key without --auth. A sign-in connection has Read and\n write access; use a Read key for read-only\n --project VALUE Pin the connection to one project by id or slug. Its tools omit project_id,\n and the server is named hue-<value> so it can coexist with an unpinned hue\n --toolsets NAMES\n Tools to list, comma-separated: all; the observe, author or evaluate\n profiles; or project, traces, eval_sets, runs, judges, cases, runners,\n environments, intents or docs. Added to the URL as ?toolsets=.\n Defaults: all for claude-code, codex and conductor,\n which search their own tools; observe for cursor. Without a selection\n Hue lists the production reads and search_hue_tools, which reaches\n every other tool\n --url URL Hue MCP endpoint (default https://mcp.hue.run/mcp)\n --scope SCOPE claude-code only: project writes .mcp.json (default); user runs\n `claude mcp add --scope user`\n --dry-run Print the resulting file content or commands without writing or running\n --print Print the configuration snippet only\n -h, --help Show this help\n\nFiles: claude-code .mcp.json, cursor .cursor/mcp.json, vscode .vscode/mcp.json (relative to the\ncurrent directory). codex and gemini use their own CLI when it is on PATH; windsurf prints the\nsnippet for its user configuration file. conductor registers the server for its Claude Code\n(user scope) and Codex agents with their CLIs, printing the command for a CLI not on PATH.\ncodex mcp add signs in at once and waits up to 5 minutes for the browser, so without --auth\nand outside a terminal the command prints it instead of running it.";
35
35
  /**
36
36
  * A URL as a POSIX shell word for printed commands: `?` is a zsh glob and `&` ends a command, so a
37
37
  * URL with a query (such as `?read_only=true`) is single-quoted. Executed commands pass argv.
@@ -103,6 +103,10 @@ export declare function renderMcpSignInSnippets(url: string, serverName?: string
103
103
  url: string;
104
104
  };
105
105
  claudeCodeProjectJson: string;
106
+ cursorServer: {
107
+ url: string;
108
+ };
109
+ cursorJson: string;
106
110
  claudeCodeCli: {
107
111
  args: string[];
108
112
  display: string;
package/dist/cli/mcp.js CHANGED
@@ -37,7 +37,7 @@ const CLIENT_LABELS = {
37
37
  gemini: "Gemini CLI",
38
38
  };
39
39
  /** Clients whose browser sign-in with Hue works against the deployed server. */
40
- const OAUTH_CLIENTS = ["claude-code", "codex", "conductor"];
40
+ const OAUTH_CLIENTS = ["claude-code", "codex", "conductor", "cursor"];
41
41
  /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and its curated profiles. */
42
42
  export const MCP_TOOLSETS = [
43
43
  "all",
@@ -89,11 +89,12 @@ is never written.
89
89
 
90
90
  Options:
91
91
  --client NAME Coding agent to configure (required)
92
- --auth MODE oauth: URL only, sign in with Hue in the client (claude-code, codex and
93
- conductor; their default)
92
+ --auth MODE oauth: URL only, sign in with Hue in the client (claude-code, codex,
93
+ conductor and cursor; their default)
94
94
  key: reference ${ENV_VAR} (the default for the other clients and with
95
- --read-only). Without --auth, claude-code and codex keep the choice of
96
- an existing entry, and use a key for a new one when ${ENV_VAR} is set
95
+ --read-only). Without --auth, claude-code, codex and cursor keep the
96
+ choice of an existing entry, and use a key for a new one when
97
+ ${ENV_VAR} is set
97
98
  --read-only Add ?read_only=true to a key configuration's URL so write tools are
98
99
  hidden; selects a key without --auth. A sign-in connection has Read and
99
100
  write access; use a Read key for read-only
@@ -218,9 +219,12 @@ export function renderMcpSignInSnippets(url, serverName = SERVER_NAME) {
218
219
  project ? `"${PROJECT_HEADER}" = "${project}"` : null,
219
220
  ].filter((value) => value !== null);
220
221
  const claudeCodeServer = { type: "http", url };
222
+ const cursorServer = { url };
221
223
  return {
222
224
  claudeCodeServer,
223
225
  claudeCodeProjectJson: json({ mcpServers: { [serverName]: claudeCodeServer } }),
226
+ cursorServer,
227
+ cursorJson: json({ mcpServers: { [serverName]: cursorServer } }),
224
228
  claudeCodeCli: {
225
229
  args: ["mcp", "add", "--transport", "http", "--scope", "user", serverName, url],
226
230
  display: `claude mcp add --transport http --scope user ${serverName} ${shellWord(url)}`,
@@ -677,11 +681,14 @@ function readClientCli(executable, args, options) {
677
681
  });
678
682
  }
679
683
  const hasAuthorization = (headers) => isRecord(headers) && Object.keys(headers).some((name) => name.toLowerCase() === "authorization");
680
- /** The server's HTTP entry in `./.mcp.json`; an unreadable file is reported by the write. */
681
- async function readClaudeProjectEntry(cwd, serverName) {
684
+ /**
685
+ * The server's HTTP entry under `mcpServers` in a project file (`.mcp.json` for Claude Code,
686
+ * `.cursor/mcp.json` for Cursor); an unreadable file is reported by the write.
687
+ */
688
+ async function readProjectEntry(cwd, file, serverName) {
682
689
  let config;
683
690
  try {
684
- ({ config } = await readJsonConfig(resolve(cwd, ".mcp.json"), ".mcp.json"));
691
+ ({ config } = await readJsonConfig(resolve(cwd, file), file));
685
692
  }
686
693
  catch {
687
694
  return undefined;
@@ -691,7 +698,7 @@ async function readClaudeProjectEntry(cwd, serverName) {
691
698
  if (!isRecord(entry) || typeof entry.url !== "string" || !parseMcpUrl(entry.url))
692
699
  return undefined;
693
700
  const auth = hasAuthorization(entry.headers) ? "key" : "oauth";
694
- return { url: entry.url, auth, source: ".mcp.json" };
701
+ return { url: entry.url, auth, source: file };
695
702
  }
696
703
  /**
697
704
  * The server's entry in Codex's configuration, as `codex mcp get --json` reports it: `found` with
@@ -767,7 +774,7 @@ const notOnPath = (executable, label) => `${executable} is not on PATH. Run this
767
774
  /** `auth: "oauth"` is only planned for {@link OAUTH_CLIENTS}; the caller refuses the others. */
768
775
  function planFor(client, auth, scope, url, serverName) {
769
776
  const snippets = renderMcpSnippets(url, serverName);
770
- // Claude Code and Codex take the same plan shape with or without a key.
777
+ // Claude Code, Codex and Cursor take the same plan shape with or without a key.
771
778
  const shared = auth === "oauth" ? renderMcpSignInSnippets(url, serverName) : snippets;
772
779
  const claudeUser = {
773
780
  executable: "claude",
@@ -810,8 +817,8 @@ function planFor(client, auth, scope, url, serverName) {
810
817
  kind: "file",
811
818
  file: join(".cursor", "mcp.json"),
812
819
  key: "mcpServers",
813
- entry: snippets.cursorServer,
814
- snippet: snippets.cursorJson,
820
+ entry: shared.cursorServer,
821
+ snippet: shared.cursorJson,
815
822
  };
816
823
  case "vscode":
817
824
  return {
@@ -842,8 +849,8 @@ function planFor(client, auth, scope, url, serverName) {
842
849
  /**
843
850
  * The authentication used without `--auth`, and the line saying why. A client that can sign in
844
851
  * does, unless a key is asked for: read-only access (`--read-only`, or `read_only=true` in
845
- * `--url`), which a sign-in connection does not have. For Claude Code and Codex, an existing entry
846
- * keeps its choice, so rerunning the command, to change `--toolsets` for instance, never swaps a
852
+ * `--url`), which a sign-in connection does not have. For Claude Code, Codex and Cursor, an existing
853
+ * entry keeps its choice, so rerunning the command, to change `--toolsets` for instance, never swaps a
847
854
  * working setup; a key keeps its `read_only`. A new entry uses a key when `HUE_MCP_KEY` is set in
848
855
  * `env`; only the variable's presence is checked. Conductor signed in by default before and keeps
849
856
  * doing so: its agents read the login-shell environment Conductor captures, not this shell's. The
@@ -903,10 +910,15 @@ function nextSteps(client, auth, serverName, project, scope, readOnly) {
903
910
  `If Codex did not open Hue in your browser, run: codex mcp login ${serverName}`,
904
911
  `${approve} Then start a new Codex session, which loads ${serverName}.`,
905
912
  ]
906
- : [
907
- `In Conductor, open MCP status from the plug icon or /mcp-status, refresh, and use ${serverName}'s authentication action. ${approve}`,
908
- "Start a new agent session if the Hue tools do not appear. Hue has not yet verified signed-in Conductor end to end; if the tools still do not load, rerun with --auth key.",
909
- ];
913
+ : client === "cursor"
914
+ ? [
915
+ `Turn on ${serverName} in Cursor's MCP settings and use its login action, or run: cursor-agent mcp login ${serverName}`,
916
+ approve,
917
+ ]
918
+ : [
919
+ `In Conductor, open MCP status from the plug icon or /mcp-status, refresh, and use ${serverName}'s authentication action. ${approve}`,
920
+ "Start a new agent session if the Hue tools do not appear. Hue has not yet verified signed-in Conductor end to end; if the tools still do not load, rerun with --auth key.",
921
+ ];
910
922
  else if (client === "vscode")
911
923
  lines = [
912
924
  `${label} prompts for the key (input ${INPUT_ID}) when the server starts; paste the ${ENV_VAR} value that hue login stored in .env.hue.`,
@@ -969,9 +981,7 @@ export async function runMcpCommand(argv, io = {}) {
969
981
  if (explicitAuth !== undefined && explicitAuth !== "key" && explicitAuth !== "oauth")
970
982
  return fail(`--auth must be key or oauth.\n\n${MCP_USAGE}`, 2);
971
983
  if (explicitAuth === "oauth" && !OAUTH_CLIENTS.includes(clientId))
972
- return fail(clientId === "cursor"
973
- ? "--auth oauth is not available for cursor: Hue does not yet accept Cursor's sign-in callback. Use a key (the default), or sign in from claude-code or codex."
974
- : `--auth oauth is available for ${OAUTH_CLIENTS.join(", ")}. Use a key (the default) for ${clientId}.`, 2);
984
+ return fail(`--auth oauth is available for ${OAUTH_CLIENTS.join(", ")}. Use a key (the default) for ${clientId}.`, 2);
975
985
  const scope = parsed.values.scope ?? "project";
976
986
  if (scope !== "project" && scope !== "user")
977
987
  return fail(`--scope must be project or user.\n\n${MCP_USAGE}`, 2);
@@ -1005,11 +1015,13 @@ export async function runMcpCommand(argv, io = {}) {
1005
1015
  ? "read_only in --url"
1006
1016
  : undefined;
1007
1017
  // Without --auth or read_only, an existing entry keeps its choice. A user-scope `claude mcp add`
1008
- // refuses a name it already has, and Conductor keeps its default, so only these two are read.
1018
+ // refuses a name it already has, and Conductor keeps its default, so only these three are read.
1009
1019
  let existing;
1010
1020
  if (explicitAuth === undefined && !parsed.values["read-only"] && !urlReadOnly) {
1011
1021
  if (clientId === "claude-code" && scope === "project")
1012
- existing = await readClaudeProjectEntry(cwd, serverName);
1022
+ existing = await readProjectEntry(cwd, ".mcp.json", serverName);
1023
+ else if (clientId === "cursor")
1024
+ existing = await readProjectEntry(cwd, join(".cursor", "mcp.json"), serverName);
1013
1025
  else if (clientId === "codex") {
1014
1026
  // Without codex on PATH nothing is run, so nothing Codex holds is replaced. With it, an
1015
1027
  // entry that cannot be read may hold either a key or a sign-in, so the command does not
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The conversions behind `hue traces import prepare`: a source file is recognized by its content
3
- * (an OTLP/JSON trace export, a complete Langfuse observation export or a text ATIF export),
4
- * each trace becomes OTLP/JSON entries, one span each, that keep the recorded ids, hierarchy,
5
- * timestamps, content, errors and usage, and the entries are packed into deterministic gzip
6
- * batches. Nothing here depends on where a file sat or what it was called: a renamed or repacked
7
- * bundle converts to byte-identical batches, so the manifest's digests name the same import.
2
+ * The conversions behind `hue traces import prepare` that predate the format registry
3
+ * (`import-formats/registry.ts`, which recognizes each value and hands it to its format): OTLP/JSON
4
+ * spans kept as recorded, complete Langfuse observation exports and text ATIF exports, each trace
5
+ * as OTLP/JSON entries, one span each, that keep the recorded ids, hierarchy, timestamps,
6
+ * content, errors and usage; the shared attribute, large-value and JSON helpers; and the packing
7
+ * of entries into deterministic gzip batches and the manifest. Nothing here depends on where a
8
+ * file sat or what it was called: a renamed or repacked bundle converts to byte-identical
9
+ * batches, so the manifest's digests name the same import.
8
10
  */
9
11
  export declare const traceImportFormats: readonly ["otlp_json", "langfuse_observations", "atif"];
10
12
  export type TraceImportFormat = (typeof traceImportFormats)[number];
@@ -89,15 +91,19 @@ export interface PreparedBlob {
89
91
  }
90
92
  export interface ConvertedTrace {
91
93
  traceId: string;
92
- format: TraceImportFormat;
94
+ /** The id of the format that read the trace (`import-formats/registry.ts`). */
95
+ format: string;
93
96
  entries: ImportEntry[];
94
97
  blobs: PreparedBlob[];
98
+ /** Who the trace ran for, read from its entries as Hue reads them (`import-formats/identity.ts`). */
95
99
  identity?: TraceIdentity;
96
100
  origin: TraceImportOrigin;
97
101
  /** The SHA-256 of every source file that held part of the trace. */
98
102
  sourceDigests: Set<string>;
99
103
  remappedTraceId: boolean;
100
104
  remappedSpans: number;
105
+ /** The rules that supplied instants the source did not record (`import-formats/rows.ts`). */
106
+ inferredTiming?: string[];
101
107
  }
102
108
  /** A trace, span or file that was not converted, and why. */
103
109
  export interface Refusal {
@@ -106,7 +112,21 @@ export interface Refusal {
106
112
  }
107
113
  export declare class ConversionError extends Error {
108
114
  }
115
+ /**
116
+ * A failure that refuses only what was being read: a ConversionError as itself, and a RangeError
117
+ * (a value too large or nested too deep for a reader to walk) as one. Anything else is a defect
118
+ * and is undefined, for the caller to rethrow.
119
+ */
120
+ export declare function conversionFailure(error: unknown): ConversionError | undefined;
121
+ /** How deep a value read from a source may nest: far beyond any export's records, and well
122
+ * inside what the readers and serializers that walk a value can follow. */
123
+ export declare const MAX_SOURCE_NESTING = 512;
124
+ /** Whether a parsed JSON value nests deeper than `max` levels, walked without recursion and
125
+ * holding only the path to the value being looked at. */
126
+ export declare function nestedTooDeep(value: unknown, max?: number): boolean;
109
127
  export declare const sha256Hex: (value: string | Uint8Array) => string;
128
+ export declare const digestHex: (text: string, length: number) => string;
129
+ export declare function isObject(value: unknown): value is Record<string, unknown>;
110
130
  /**
111
131
  * JSON text parsed with every integer too large for a JavaScript number kept as its decimal
112
132
  * text, so an OTLP timestamp written as a number keeps its nanoseconds. `unsafeIntegers` says
@@ -116,29 +136,20 @@ export declare function parseJsonText(text: string): {
116
136
  value: unknown;
117
137
  unsafeIntegers: boolean;
118
138
  };
139
+ /** JSON text a record holds (metadata values, tool arguments, a column a warehouse stores as text)
140
+ * parsed as {@link parseJsonText} parses it; text nested deeper than {@link MAX_SOURCE_NESTING}
141
+ * throws, so the caller keeps it as the text it is. */
142
+ export declare function parseNestedJsonText(text: string): {
143
+ value: unknown;
144
+ unsafeIntegers: boolean;
145
+ };
146
+ /** Langfuse's experiment and evaluator environments (`sdk-experiment`, `langfuse-…`): traffic
147
+ * Langfuse itself marks as evaluation. */
148
+ export declare const langfuseEvaluationEnvironment: (environment: unknown) => boolean;
119
149
  /** Refuses a source value that JavaScript or OTLP cannot retain exactly; never omits it. */
120
150
  export declare function validateSource(value: JsonValue, depth?: number): void;
121
151
  /** JSON.parse otherwise loses duplicate members and can round decimal argument values. */
122
152
  export declare function validateJsonText(raw: string): void;
123
- export type DetectedSource = {
124
- format: "otlp_json";
125
- envelopes: Record<string, unknown>[];
126
- } | {
127
- format: "langfuse_observations";
128
- observations: Record<string, JsonValue>[];
129
- envelope: Record<string, JsonValue>;
130
- unsafeIntegers: boolean;
131
- } | {
132
- format: "atif";
133
- text: string;
134
- document: unknown;
135
- unsafeIntegers: boolean;
136
- } | {
137
- format: "unsupported";
138
- reason: string;
139
- };
140
- /** What a file holds, read from its content alone; never from its name. */
141
- export declare function detectFormat(bytes: Uint8Array): DetectedSource;
142
153
  export declare function anyValue(value: JsonValue): OtlpValue;
143
154
  /** The large values one trace stores apart from its spans, each once by digest. */
144
155
  export declare class BlobSink {
@@ -166,25 +177,41 @@ export declare class AttributeList {
166
177
  json(key: string, value: unknown): void;
167
178
  finish(): OtlpAttribute[];
168
179
  }
169
- /** Gathers the spans of OTLP/JSON exports by trace, across files: an export writes a trace's
170
- * spans in the requests that carried them, which need not be one file. */
180
+ /** The collector key of the formats that keep OTLP spans as recorded (OTLP/JSON, binary OTLP,
181
+ * Jaeger API v3): one {@link OtlpTraceCollector} gathers their spans together. */
182
+ export declare const OTLP_SPANS = "otlp_spans";
183
+ /** Gathers the spans of OTLP/JSON exports by trace, across files and the formats that carry
184
+ * them: an export writes a trace's spans in the requests that carried them, which need not be one
185
+ * file. Each trace is named by the format that read its first span. */
171
186
  export declare class OtlpTraceCollector {
172
187
  private readonly traces;
173
- /** Adds every span of the envelopes; the spans refused, with their reasons. */
174
- add(envelopes: readonly Record<string, unknown>[], sourceDigest: string): Refusal[];
188
+ /** Adds every span of the envelopes `format` read; the spans refused, with their reasons. */
189
+ add(envelopes: readonly Record<string, unknown>[], sourceDigest: string, format: string): Refusal[];
190
+ /** Refuses the trace a span names (its id as recorded), whatever else of it is read; a span
191
+ * that names no trace is refused alone. */
192
+ refuse(traceIdValue: unknown, reason: string): Refusal;
175
193
  private addSpan;
194
+ private draft;
176
195
  /** Every trace gathered so far, spans in time order, and the traces refused whole. */
177
196
  finish(): {
178
197
  traces: ConvertedTrace[];
179
198
  refused: Refusal[];
180
199
  };
181
200
  }
201
+ /** A recorded span's string values over the inline limit stored apart from it, listed beside
202
+ * any values its exporter already listed. */
203
+ export declare function offloadOtlpValues(attributes: OtlpAttribute[], blobs: BlobSink): OtlpAttribute[];
182
204
  export declare function unixNano(value: JsonValue | undefined): string;
205
+ /** JSON text with object keys sorted: equal for equal values whatever their key order. */
206
+ export declare const canonicalJson: (value: JsonValue) => string;
183
207
  /** Gathers Langfuse observations by trace across files and converts each trace as Hue's reader
184
208
  * converts an observation export: one span per observation, the record kept whole. */
185
209
  export declare class LangfuseTraceCollector {
186
210
  private readonly traces;
187
211
  add(observations: readonly Record<string, JsonValue>[], envelope: Record<string, JsonValue>, sourceDigest: string): void;
212
+ /** Refuses a trace by its Langfuse id, whatever else of it is read. */
213
+ refuse(traceId: string, reason: string, sourceDigest: string): void;
214
+ private draft;
188
215
  finish(): {
189
216
  traces: ConvertedTrace[];
190
217
  refused: Refusal[];
@@ -195,6 +222,13 @@ export declare const MAX_ATIF_FILE_BYTES: number;
195
222
  export declare const MAX_ATIF_SPANS = 2000;
196
223
  /** Retains nanoseconds and requires an explicit timezone, valid calendar date and OTLP uint64. */
197
224
  export declare function atifUnixNano(value: string): string;
225
+ /** An ATIF document as read: its exact text, the parsed value and whether the text held an
226
+ * integer JavaScript cannot represent exactly. */
227
+ export interface AtifSource {
228
+ text: string;
229
+ document: unknown;
230
+ unsafeIntegers: boolean;
231
+ }
198
232
  /**
199
233
  * A text ATIF v1.7 or v1.8 export as one trace: an agent span for the run, a model span per
200
234
  * agent step with the conversation so far, a tool span per call under it, and an embedded
@@ -203,9 +237,7 @@ export declare function atifUnixNano(value: string): string;
203
237
  * document stays on the root; each model and tool span keeps its own record. ATIF supplies step
204
238
  * instants, not call durations: model and tool spans are zero-duration point spans.
205
239
  */
206
- export declare function convertAtifExport(source: Extract<DetectedSource, {
207
- format: "atif";
208
- }>, rawBytes: number): ConvertedTrace;
240
+ export declare function convertAtifExport(source: AtifSource, rawBytes: number): ConvertedTrace;
209
241
  export interface PreparedBatch {
210
242
  sha256: string;
211
243
  body: Uint8Array;