mirror of
https://github.com/anthropics/claude-code.git
synced 2026-10-02 05:25:05 +08:00
telemetry: complete rows gathered through $, sent in batches, serving built-in plugins only (#95618)
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ source, published as it is built into the binary.
|
||||
| --- | --- | --- |
|
||||
| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
|
||||
| [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in |
|
||||
| [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a plugin can record an event as a first-party analytics row; sends nothing wherever Claude Code's analytics are off. | Built in |
|
||||
| [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a built-in plugin can record an event as a first-party analytics row, sent in batches; refuses installed plugins; sends nothing wherever Claude Code's analytics are off. | Built in |
|
||||
| [`agents-md`](agents-md) | `AGENTS.md` as project instructions, by one option: loaded where the project has no `CLAUDE.md` of its own (`claude-md-or-agents-md`, the default) or beside it (`claude-md-and-agents-md`), placed and framed exactly as the engine places `CLAUDE.md`, nested ones on a `Read`; or the project's and the person's instruction files dropped and the organization's kept (`managed-only`); or `CLAUDE.md` alone, as the engine reads it (`claude-md`). | Built in |
|
||||
|
||||
Each folder is a complete plugin: `.claude-plugin/plugin.json`, a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "telemetry",
|
||||
"version": "0.1.0",
|
||||
"description": "Plugin analytics: adds $.telemetry in the engine.create fold, so a plugin logs an event or marks a feature's use as one first-party row per call, sent with the session's own credential.",
|
||||
"description": "Plugin analytics: adds $.telemetry in the engine.create fold, so a plugin logs an event or marks a feature's use as a first-party row, sent in batches with the session's own credential.",
|
||||
"author": {
|
||||
"name": "Anthropic"
|
||||
},
|
||||
|
||||
+58
-29
@@ -1,32 +1,55 @@
|
||||
# telemetry
|
||||
|
||||
Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry` to
|
||||
the engine interface every plugin above it is handed, built over the
|
||||
`$.session` and `$.http` nouns beneath. `$.telemetry.log({ event, props })`
|
||||
sends one event as one first-party row, `tengu_plugin_<event>`;
|
||||
`$.telemetry.mark({ feature, kind, reason?, props? })` marks one use of a
|
||||
feature as the CLI's own feature events do, `tengu_feature_<kind>` with a
|
||||
`feature_name` and the mark's properties beside it. Each call is one POST to
|
||||
the event-logging ingest with the session's own credential
|
||||
(`$.session.authorize()`, resolved at each call), one attempt, nothing
|
||||
batched; a session with no first-party credential, or an ingest that
|
||||
refuses, rejects the caller's promise.
|
||||
the engine interface every plugin above it is handed, built over the nouns
|
||||
beneath, and a hook on its own two events serves the plugins built into
|
||||
Claude Code alone: a call from a plugin a person installed or an
|
||||
administrator listed is refused with a reason (the host stamps every call
|
||||
with the plugin that raised it, `next.origin`, and the gate reads its tier).
|
||||
`$.telemetry.log({ event, props })` queues one event as one first-party
|
||||
row, `tengu_plugin_<event>`; `$.telemetry.mark({ feature, kind, reason?,
|
||||
props? })` marks one use of a feature as the CLI's own feature events do,
|
||||
`tengu_feature_<kind>` with a `feature_name` and the mark's properties
|
||||
beside it. Both resolve once the row is queued. Rows go out in batches: one
|
||||
POST to the event-logging ingest with the session's own credential
|
||||
(`$.session.authorize()`, resolved for each batch) a few seconds after the
|
||||
first row was queued, at once when a hundred wait, and when the session
|
||||
ends; a batch the ingest refuses with a server error, a timeout or a rate
|
||||
limit is tried once more. A session with no first-party credential, or an
|
||||
ingest that still refuses, drops the batch; each outcome is one line in the
|
||||
debug log.
|
||||
|
||||
Each row carries what the CLI's own rows carry, gathered through `$` once
|
||||
a session: an event id, the install's device id and the signed-in account's
|
||||
ids from the CLI's global config, the session's id, model, client type,
|
||||
entrypoint and interactivity, and an `env` block (platform and
|
||||
architecture from one `uname` probe, terminal, shell, package managers and
|
||||
runtimes, CI and GitHub Actions, the remote container, the deployment, the
|
||||
Linux distribution and kernel, WSL, the working directory's version
|
||||
control), with the repository's remote hash beside the row's properties.
|
||||
What the engine alone knows (its version and build time, its runtime's
|
||||
version, the process's memory, the request's betas, the subscription tier,
|
||||
the calling agent) is not on `$`, and those columns stay empty.
|
||||
|
||||
It sends nothing wherever the CLI's own analytics are off: under
|
||||
`DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
|
||||
`DO_NOT_TRACK`, on any third-party provider (Bedrock, Vertex, Foundry and
|
||||
kin), and on a deployment with its own OAuth URL. Each is read through
|
||||
`$.env` at every call, rows go one after another, and the credential is
|
||||
authorized afresh right before each POST, so a session that has since moved
|
||||
to a third-party provider or a cloud gateway sends nothing more. The row's
|
||||
`user_type` is `ant` when `USER_TYPE` says so, else `external`.
|
||||
`DO_NOT_TRACK`, in a test run, on any third-party provider (Bedrock,
|
||||
Vertex, Foundry and kin) the host does not manage, on a cloud gateway
|
||||
(the environment's switch or the managed policy's login pins), and on a
|
||||
deployment with its own OAuth URL. Each is read through `$.env` and
|
||||
`$.settings` before every batch, so a session that has since moved to a
|
||||
third-party provider or a gateway sends nothing more; when the switches
|
||||
cannot be read, nothing is sent either. The row's `user_type` is `ant`
|
||||
when `USER_TYPE` says so, else `external`.
|
||||
|
||||
Nothing free-form reaches a row. An event name and every property key is a
|
||||
snake_case token; a value is a finite number, a boolean, or a Choice (a
|
||||
string named together with the list it is chosen from), under `log` and
|
||||
`mark` alike; `mark` takes `ok`, `sad` or `bad`, with a `reason` required on
|
||||
the last two and refused on the first. An entry that breaks a rule is
|
||||
refused before anything is sent.
|
||||
refused before anything is queued. Of the environment, a variable whose
|
||||
value is a secret, or names a person or a host, is read for whether it is
|
||||
set and nothing more; a shell is its basename from a closed list.
|
||||
|
||||
`hooks/register.ts` is the module; `types/index.d.ts` is the noun's contract,
|
||||
the one declaration of `$.telemetry` that this mod's hooks, a mod calling the
|
||||
@@ -35,20 +58,26 @@ noun and a test answering it all read.
|
||||
## What it hooks
|
||||
|
||||
`engine.create`: `{ ...await next(e), telemetry }`, so the noun is added and
|
||||
nothing beneath is replaced.
|
||||
nothing beneath is replaced. `telemetry.*`, the gate: a caller in the
|
||||
built-in tier (or the engine) goes on, any other is refused, and a gate
|
||||
that throws refuses too. `session.start`, to learn whether a person is at
|
||||
the prompt; `session.end`, to send what still waits.
|
||||
|
||||
## What it calls on `$`
|
||||
|
||||
`session.authorize`, `session.id`, `session.model`, `http.fetch`, `env.get`
|
||||
(the switches above and `USER_TYPE`, by literal name), each on the
|
||||
interface the fold handed it.
|
||||
`session.authorize`, `session.id`, `session.model`, `session.surfaces`,
|
||||
`session.cwd`, `session.repo`, `settings.read`, `env.get` (the switches and
|
||||
the describing variables, by literal name), `fs.read`, `fs.list`,
|
||||
`fs.exists`, `process.run` (one `sh -c` of `uname` and `command -v`),
|
||||
`clock.after`, `clock.sleep`, `http.fetch` and `ui.log` (to the debug log),
|
||||
each on the interface the fold handed it.
|
||||
|
||||
## Where it runs
|
||||
## Where it runs, whom it serves
|
||||
|
||||
This plugin is seated by the CLI itself, on internal builds whose own
|
||||
analytics are on, and nowhere else: `session.authorize` exists only there,
|
||||
and the rows it writes join tables only the CLI's own events reach. It is
|
||||
not meant to be installed or loaded with `--plugin-dir`; the folder has a
|
||||
manifest so it reads like every other plugin, not so it can stand alone. A
|
||||
plugin that calls `$.telemetry` where this one is absent finds no such noun
|
||||
and should treat that as "no analytics here".
|
||||
This plugin is seated by the CLI itself, on every build whose own analytics
|
||||
are on, and nowhere else; it serves the plugins bundled with the CLI and
|
||||
refuses every other caller. It is not meant to be installed or loaded with
|
||||
`--plugin-dir`; the folder has a manifest so it reads like every other
|
||||
plugin, not so it can stand alone. A built-in that calls `$.telemetry`
|
||||
where this one is absent finds no such noun and should treat that as "no
|
||||
analytics here".
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* The most rows one batch carries: a queue this full goes out at once
|
||||
* rather than on the timer.
|
||||
*/
|
||||
export const BATCH_ROWS = 100
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './batch-rows.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* How long a queued row waits for company before its batch goes out, in
|
||||
* milliseconds; a session that ends sooner sends what waits then.
|
||||
*/
|
||||
export const BATCH_WINDOW_MS = 5000
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './batch-window-ms.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './batch-rows'
|
||||
export * from './batch-window-ms'
|
||||
export * from './is-retriable'
|
||||
export * from './message-of'
|
||||
export * from './pending-row'
|
||||
export * from './retry-delay-ms'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './is-retriable.js'
|
||||
export * from './status-request-timeout'
|
||||
export * from './status-server-error'
|
||||
export * from './status-too-many-requests'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,15 @@
|
||||
import { STATUS_REQUEST_TIMEOUT } from './status-request-timeout'
|
||||
import { STATUS_SERVER_ERROR } from './status-server-error'
|
||||
import { STATUS_TOO_MANY_REQUESTS } from './status-too-many-requests'
|
||||
|
||||
/**
|
||||
* Whether an answer from the ingest is worth the one retry: a server error,
|
||||
* a timeout or a rate limit; anything else it refused stays refused.
|
||||
*
|
||||
* @param status the HTTP status the ingest answered
|
||||
* @returns true for 5xx, 408 and 429
|
||||
*/
|
||||
export const isRetriable = (status: number) =>
|
||||
status >= STATUS_SERVER_ERROR ||
|
||||
status === STATUS_REQUEST_TIMEOUT ||
|
||||
status === STATUS_TOO_MANY_REQUESTS
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './status-request-timeout.js'
|
||||
|
||||
export * as default from '.'
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* HTTP 408: the ingest gave up waiting for the request; worth the one retry.
|
||||
*/
|
||||
export const STATUS_REQUEST_TIMEOUT = 408
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './status-server-error.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* HTTP 500, the first server-side status: it and everything above is the
|
||||
* ingest's own failure and worth the one retry.
|
||||
*/
|
||||
export const STATUS_SERVER_ERROR = 500
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './status-too-many-requests.js'
|
||||
|
||||
export * as default from '.'
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* HTTP 429: the ingest is shedding load; worth the one retry after a pause.
|
||||
*/
|
||||
export const STATUS_TOO_MANY_REQUESTS = 429
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './message-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* What a batch's failure says, for the refusal each of its rows rejects
|
||||
* with: the error's own message, or the value as text.
|
||||
*
|
||||
* @param error what sending the batch threw
|
||||
* @returns the message
|
||||
*/
|
||||
export const messageOf = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './pending-row.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,11 @@
|
||||
import type Entries from '../../entries'
|
||||
|
||||
/**
|
||||
* One row waiting for its batch: the checked fields, and the id and time
|
||||
* it was logged with.
|
||||
*/
|
||||
export type PendingRow = {
|
||||
fields: Entries.Fields
|
||||
eventId: string
|
||||
loggedAt: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './retry-delay-ms.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* How long the one retry waits after a POST that failed or was answered with
|
||||
* a status worth retrying, in milliseconds.
|
||||
*/
|
||||
export const RETRY_DELAY_MS = 500
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Where the CLI's global config file is, read off the environment; read to
|
||||
* find the file, never sent.
|
||||
*
|
||||
* The config directory when one is named, else the home directory (the
|
||||
* profile directory on Windows).
|
||||
*/
|
||||
export type ConfigLocation = {
|
||||
readonly configDir: string | undefined
|
||||
readonly home: string | undefined
|
||||
readonly userProfile: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './config-location.js'
|
||||
|
||||
export * as default from '.'
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The client types the CLI names by entrypoint alone: the SDKs, the VS Code
|
||||
* and desktop hosts, and the local agent.
|
||||
*/
|
||||
export const CLIENT_TYPE_BY_ENTRYPOINT: Readonly<Record<string, string>> = {
|
||||
'sdk-ts': 'sdk-typescript',
|
||||
'sdk-py': 'sdk-python',
|
||||
'sdk-cli': 'sdk-cli',
|
||||
'claude-vscode': 'claude-vscode',
|
||||
'local-agent': 'local-agent',
|
||||
'claude-desktop': 'claude-desktop',
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './client-type-by-entrypoint.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Facts } from '../../facts'
|
||||
import IsAnalyticsOff from '../../is-analytics-off'
|
||||
import { CLIENT_TYPE_BY_ENTRYPOINT } from './client-type-by-entrypoint'
|
||||
|
||||
/**
|
||||
* The row's `client_type` as the CLI decides it at start: a GitHub Action,
|
||||
* an SDK or host by entrypoint, `remote` with an ingress token, else `cli`.
|
||||
*
|
||||
* @param facts the variables as read once for the session
|
||||
* @returns the client type
|
||||
*/
|
||||
export function clientTypeOf(facts: Facts) {
|
||||
const isRemote =
|
||||
facts.entrypoint === 'remote' ||
|
||||
facts.hasSessionAccessToken ||
|
||||
facts.hasSessionIngressTokenFile ||
|
||||
facts.hasWebsocketAuthFileDescriptor ||
|
||||
IsAnalyticsOff.isEnvTruthy(facts.claudeCodeRemote)
|
||||
|
||||
const isGithubAction = IsAnalyticsOff.isEnvTruthy(facts.githubActions)
|
||||
|
||||
return isGithubAction
|
||||
? 'github-action'
|
||||
: (CLIENT_TYPE_BY_ENTRYPOINT[facts.entrypoint ?? ''] ??
|
||||
(isRemote ? 'remote' : 'cli'))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './client-type-by-entrypoint'
|
||||
export * from './client-type-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { TelemetryDeps } from '../telemetry-deps'
|
||||
import type { Context } from './context'
|
||||
import { environmentFieldsOf } from './environment-fields-of'
|
||||
import { probeOf } from './probe-of'
|
||||
import { sessionFieldsOf } from './session-fields-of'
|
||||
|
||||
/**
|
||||
* Gathers and shapes what holds for the whole session, once: who the rows
|
||||
* are from, the session's fields, its `env` block and the remote hash.
|
||||
*
|
||||
* Nothing in it is a secret or a path: every field is the closed value the
|
||||
* CLI's own column holds.
|
||||
*
|
||||
* @param deps the calls on the nouns beneath
|
||||
* @param isInteractive whether a person is at the prompt
|
||||
* @returns the session's context for every batch after
|
||||
*/
|
||||
export async function contextOf(
|
||||
deps: TelemetryDeps,
|
||||
isInteractive: boolean,
|
||||
): Promise<Context> {
|
||||
const probe = await probeOf(deps, isInteractive)
|
||||
|
||||
return {
|
||||
identity: probe.identity,
|
||||
session: sessionFieldsOf(probe.facts),
|
||||
environment: environmentFieldsOf(probe),
|
||||
remoteHash: probe.remoteHash,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { EnvironmentFields } from '../environment-fields'
|
||||
import type { Identity } from '../identity'
|
||||
import type { SessionFields } from '../session-fields'
|
||||
|
||||
/**
|
||||
* Everything a row carries that holds for the whole session, gathered once
|
||||
* before the first batch.
|
||||
*
|
||||
* Who it is from, the session's constant fields, its `env` block, and the
|
||||
* repository's remote hash for the metadata.
|
||||
*/
|
||||
export type Context = {
|
||||
readonly identity: Identity
|
||||
readonly session: SessionFields
|
||||
readonly environment: EnvironmentFields
|
||||
readonly remoteHash: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './context.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The row's `entrypoint` field: the variable as the CLI or its host set it
|
||||
* when spelled like one (a short lowercase token), else `other`.
|
||||
*
|
||||
* @param variable CLAUDE_CODE_ENTRYPOINT as read; undefined stays so
|
||||
* @returns the field, or undefined
|
||||
*/
|
||||
export function entrypointOf(variable: string | undefined) {
|
||||
const isSpelled =
|
||||
variable === undefined || /^[a-z][a-z0-9_-]{0,39}$/.test(variable)
|
||||
|
||||
return isSpelled ? variable : 'other'
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './entrypoint-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The row's `github_action_ref`: what follows `claude-code-action/` in the
|
||||
* action's path, as the CLI reads it; undefined when the path names none.
|
||||
*
|
||||
* @param actionPath GITHUB_ACTION_PATH as read
|
||||
* @returns the ref, or undefined
|
||||
*/
|
||||
export const actionRefOf = (actionPath: string | undefined) =>
|
||||
actionPath?.includes('claude-code-action/')
|
||||
? actionPath.split('claude-code-action/')[1]
|
||||
: undefined
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './action-ref-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,60 @@
|
||||
import Deployment from '../../deployment'
|
||||
import IsAnalyticsOff from '../../is-analytics-off'
|
||||
import type { EnvironmentFields } from '../environment-fields'
|
||||
import type { Probe } from '../probe'
|
||||
import { shellOf } from '../shell-of'
|
||||
import { terminalOf } from '../terminal-of'
|
||||
import { actionRefOf } from './action-ref-of'
|
||||
import { githubActionsFieldsOf } from './github-actions-fields-of'
|
||||
import { tagsOf } from './tags-of'
|
||||
|
||||
/**
|
||||
* The rows' `env` block from what was probed, valued as the CLI's own is;
|
||||
* the GitHub Actions fields only inside a workflow, the Linux ones on Linux.
|
||||
*
|
||||
* @param probe what was gathered for the session
|
||||
* @returns the block
|
||||
*/
|
||||
export function environmentFieldsOf(probe: Probe): EnvironmentFields {
|
||||
const { facts, machine, platform } = probe
|
||||
const isGithubAction = IsAnalyticsOff.isEnvTruthy(facts.githubActions)
|
||||
const isLinux = machine.platformRaw === 'linux'
|
||||
|
||||
return {
|
||||
platform,
|
||||
platformRaw: machine.platformRaw,
|
||||
arch: machine.arch,
|
||||
terminal: terminalOf(facts, platform, probe.isInteractive),
|
||||
shell: shellOf(facts.shellPath),
|
||||
packageManagers: machine.packageManagers,
|
||||
runtimes: machine.runtimes,
|
||||
isCi: IsAnalyticsOff.isEnvTruthy(facts.ci),
|
||||
isClaubbit: IsAnalyticsOff.isEnvTruthy(facts.claubbit),
|
||||
isGithubAction,
|
||||
isClaudeCodeAction: IsAnalyticsOff.isEnvTruthy(facts.claudeCodeAction),
|
||||
isClaudeCodeRemote: IsAnalyticsOff.isEnvTruthy(facts.claudeCodeRemote),
|
||||
isLocalAgentMode: facts.entrypoint === 'local-agent',
|
||||
isConductor: facts.bundleIdentifier === 'com.conductor.app',
|
||||
deploymentEnvironment: Deployment.deploymentOf(probe.signals, platform),
|
||||
remoteEnvironmentType: facts.remoteEnvironmentType,
|
||||
claudeCodeContainerId: facts.containerId,
|
||||
claudeCodeRemoteSessionId: facts.remoteSessionId,
|
||||
tags: tagsOf(facts.tags),
|
||||
githubEventName: isGithubAction ? facts.githubEventName : undefined,
|
||||
githubActionsRunnerEnvironment: isGithubAction
|
||||
? facts.runnerEnvironment
|
||||
: undefined,
|
||||
githubActionsRunnerOs: isGithubAction ? facts.runnerOs : undefined,
|
||||
githubActionRef: isGithubAction
|
||||
? actionRefOf(facts.githubActionPath)
|
||||
: undefined,
|
||||
githubActionsMetadata: isGithubAction
|
||||
? githubActionsFieldsOf(facts)
|
||||
: undefined,
|
||||
wslVersion: probe.wslVersion,
|
||||
linuxDistroId: probe.distro.id,
|
||||
linuxDistroVersion: probe.distro.version,
|
||||
linuxKernel: isLinux ? machine.kernel : undefined,
|
||||
vcs: probe.vcs,
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { Facts } from '../../../facts'
|
||||
import type EnvironmentFields from '../../environment-fields'
|
||||
|
||||
/**
|
||||
* The GitHub Actions ids for the row's `env` block; undefined when the
|
||||
* workflow names none of the three.
|
||||
*
|
||||
* @param facts the variables as read once for the session
|
||||
* @returns the ids, or undefined
|
||||
*/
|
||||
export function githubActionsFieldsOf(
|
||||
facts: Facts,
|
||||
): EnvironmentFields.GithubActionsFields | undefined {
|
||||
const hasAny =
|
||||
facts.githubActorId !== undefined ||
|
||||
facts.githubRepositoryId !== undefined ||
|
||||
facts.githubRepositoryOwnerId !== undefined
|
||||
|
||||
return hasAny
|
||||
? {
|
||||
actorId: facts.githubActorId,
|
||||
repositoryId: facts.githubRepositoryId,
|
||||
repositoryOwnerId: facts.githubRepositoryOwnerId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './github-actions-fields-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './action-ref-of'
|
||||
export * from './environment-fields-of.js'
|
||||
export * from './github-actions-fields-of'
|
||||
export * from './tags-of'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './tags-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The row's `tags` field: the variable split on commas and trimmed, or
|
||||
* undefined when it names none.
|
||||
*
|
||||
* @param variable CLAUDE_CODE_TAGS as read
|
||||
* @returns the field, or undefined
|
||||
*/
|
||||
export function tagsOf(variable: string | undefined) {
|
||||
const listed = (variable ?? '')
|
||||
.split(',')
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return listed.length > 0 ? listed : undefined
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { GithubActionsFields } from './github-actions-fields'
|
||||
|
||||
/**
|
||||
* The `env` block every row of the session carries, as the CLI's own rows
|
||||
* carry it, before its keys are spelled for the wire (Entries.wireOf).
|
||||
*
|
||||
* The machine, the terminal and shell, CI and GitHub Actions, the remote
|
||||
* container, the deployment. `isClaudeAiAuth` follows the credential each
|
||||
* batch is sent with and joins these then; an absent field is undefined.
|
||||
*/
|
||||
export type EnvironmentFields = {
|
||||
readonly platform: string
|
||||
readonly platformRaw: string
|
||||
readonly arch: string
|
||||
readonly terminal: string
|
||||
readonly shell: string
|
||||
readonly packageManagers: string
|
||||
readonly runtimes: string
|
||||
readonly isCi: boolean
|
||||
readonly isClaubbit: boolean
|
||||
readonly isGithubAction: boolean
|
||||
readonly isClaudeCodeAction: boolean
|
||||
readonly isClaudeCodeRemote: boolean
|
||||
readonly isLocalAgentMode: boolean
|
||||
readonly isConductor: boolean
|
||||
readonly deploymentEnvironment: string
|
||||
readonly remoteEnvironmentType: string | undefined
|
||||
readonly claudeCodeContainerId: string | undefined
|
||||
readonly claudeCodeRemoteSessionId: string | undefined
|
||||
readonly tags: readonly string[] | undefined
|
||||
readonly githubEventName: string | undefined
|
||||
readonly githubActionsRunnerEnvironment: string | undefined
|
||||
readonly githubActionsRunnerOs: string | undefined
|
||||
readonly githubActionRef: string | undefined
|
||||
readonly githubActionsMetadata: GithubActionsFields | undefined
|
||||
readonly wslVersion: string | undefined
|
||||
readonly linuxDistroId: string | undefined
|
||||
readonly linuxDistroVersion: string | undefined
|
||||
readonly linuxKernel: string | undefined
|
||||
readonly vcs: string | undefined
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The GitHub Actions ids a row's `env` block carries when the session runs
|
||||
* in a workflow: the actor, the repository and its owner, by number.
|
||||
*/
|
||||
export type GithubActionsFields = {
|
||||
readonly actorId: string | undefined
|
||||
readonly repositoryId: string | undefined
|
||||
readonly repositoryOwnerId: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './github-actions-fields.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,4 @@
|
||||
export type * from './environment-fields.js'
|
||||
export * from './github-actions-fields'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ConfigLocation } from '../../../config-location'
|
||||
|
||||
/**
|
||||
* Where the CLI keeps its global config: `.claude.json` under the config
|
||||
* directory when one is named, else under the home or profile directory.
|
||||
*
|
||||
* @param location the three variables as read
|
||||
* @returns the file's path, or undefined when no directory is named
|
||||
*/
|
||||
export function globalConfigPath(location: ConfigLocation): string | undefined {
|
||||
const directory = location.configDir || location.home || location.userProfile
|
||||
|
||||
const separator =
|
||||
directory?.includes('\\') && !directory.includes('/') ? '\\' : '/'
|
||||
|
||||
return directory
|
||||
? `${directory.replace(/[\\/]+$/, '')}${separator}.claude.json`
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './global-config-path.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ConfigLocation } from '../../config-location'
|
||||
import Entries from '../../entries'
|
||||
import { globalConfigPath } from './global-config-path'
|
||||
import type { Identity } from './identity'
|
||||
import { plausibleUuid } from './plausible-uuid'
|
||||
|
||||
/**
|
||||
* Reads the identity the CLI's global config records: the device id it
|
||||
* minted for this install, and the account it is signed in to.
|
||||
*
|
||||
* A config that is missing, unreadable or not JSON yields an identity with
|
||||
* nothing in it; the address is kept on an internal build alone.
|
||||
*
|
||||
* @param read reads a file as text
|
||||
* @param location where the config is
|
||||
* @param isInternal whether this is an internal build
|
||||
* @returns the identity, each field undefined unless the config has it
|
||||
*/
|
||||
export async function identityOf(
|
||||
read: (path: string) => Promise<string>,
|
||||
location: ConfigLocation,
|
||||
isInternal: boolean,
|
||||
): Promise<Identity> {
|
||||
const path = globalConfigPath(location)
|
||||
|
||||
let config: unknown
|
||||
|
||||
try {
|
||||
config = path === undefined ? undefined : JSON.parse(await read(path))
|
||||
} catch {
|
||||
config = undefined
|
||||
}
|
||||
|
||||
const record = Entries.isRecord(config) ? config : {}
|
||||
|
||||
const account = Entries.isRecord(record.oauthAccount)
|
||||
? record.oauthAccount
|
||||
: {}
|
||||
|
||||
const deviceId = record.userID
|
||||
const email = account.emailAddress
|
||||
|
||||
const isDeviceId =
|
||||
typeof deviceId === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(deviceId)
|
||||
|
||||
const isEmailKept = isInternal && typeof email === 'string' && email !== ''
|
||||
|
||||
return {
|
||||
deviceId: isDeviceId ? deviceId : undefined,
|
||||
accountUuid: plausibleUuid(account.accountUuid),
|
||||
organizationUuid: plausibleUuid(account.organizationUuid),
|
||||
email: isEmailKept ? email : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Who the rows are from, as the CLI's global config records it: the
|
||||
* install's device id, and the signed-in account's ids and address.
|
||||
*
|
||||
* Each is undefined when the config has none or cannot be read; the address
|
||||
* rides only on an internal build's rows, as the CLI's own does.
|
||||
*/
|
||||
export type Identity = {
|
||||
readonly deviceId: string | undefined
|
||||
readonly accountUuid: string | undefined
|
||||
readonly organizationUuid: string | undefined
|
||||
readonly email: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './identity.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './global-config-path'
|
||||
export * from './identity'
|
||||
export * from './identity-of.js'
|
||||
export * from './plausible-uuid'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './plausible-uuid.js'
|
||||
export * from './uuid-length-floor'
|
||||
export * from './uuid-length-limit'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,18 @@
|
||||
import { UUID_LENGTH_FLOOR } from './uuid-length-floor'
|
||||
import { UUID_LENGTH_LIMIT } from './uuid-length-limit'
|
||||
|
||||
/**
|
||||
* A value the config holds as an account or organization id, when it is a
|
||||
* string long enough to be one; a number or a trivial string is dropped.
|
||||
*
|
||||
* @param value what the config holds under the key
|
||||
* @returns the id, or undefined
|
||||
*/
|
||||
export function plausibleUuid(value: unknown): string | undefined {
|
||||
const isPlausible =
|
||||
typeof value === 'string' &&
|
||||
value.length >= UUID_LENGTH_FLOOR &&
|
||||
value.length <= UUID_LENGTH_LIMIT
|
||||
|
||||
return isPlausible ? value : undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './uuid-length-floor.js'
|
||||
|
||||
export * as default from '.'
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* The shortest string taken for an account or organization id, the CLI's
|
||||
* own floor: long enough to sweep trivial junk, no format assumed.
|
||||
*/
|
||||
export const UUID_LENGTH_FLOOR = 8
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './uuid-length-limit.js'
|
||||
|
||||
export * as default from '.'
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* The longest string taken for an account or organization id or a device
|
||||
* id; anything longer is not an id and is dropped.
|
||||
*/
|
||||
export const UUID_LENGTH_LIMIT = 128
|
||||
@@ -0,0 +1,21 @@
|
||||
export * from './client-type-of'
|
||||
export * from './context'
|
||||
export * from './context-of.js'
|
||||
export * from './entrypoint-of'
|
||||
export * from './environment-fields'
|
||||
export * from './environment-fields-of'
|
||||
export * from './identity'
|
||||
export * from './linux-distro-of'
|
||||
export * from './machine'
|
||||
export * from './probe'
|
||||
export * from './probe-of'
|
||||
export * from './remote-hash-of'
|
||||
export * from './session-fields'
|
||||
export * from './session-fields-of'
|
||||
export * from './shell-of'
|
||||
export * from './terminal-of'
|
||||
export * from './vcs-of'
|
||||
export * from './version-of'
|
||||
export * from './wsl-version-of'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './linux-distro'
|
||||
export * from './linux-distro-of.js'
|
||||
export * from './os-release-value'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { LinuxDistro } from './linux-distro'
|
||||
import { osReleaseValue } from './os-release-value'
|
||||
|
||||
/**
|
||||
* Reads the distribution's id and version off `/etc/os-release`, as the
|
||||
* CLI's own rows do; a file that is missing or unreadable yields neither.
|
||||
*
|
||||
* @param readOsRelease reads `/etc/os-release` as text
|
||||
* @returns the distribution
|
||||
*/
|
||||
export async function linuxDistroOf(
|
||||
readOsRelease: () => Promise<string>,
|
||||
): Promise<LinuxDistro> {
|
||||
let content: string
|
||||
|
||||
try {
|
||||
content = await readOsRelease()
|
||||
} catch {
|
||||
content = ''
|
||||
}
|
||||
|
||||
return {
|
||||
id: osReleaseValue(content, 'ID'),
|
||||
version: osReleaseValue(content, 'VERSION_ID'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './linux-distro.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* What `/etc/os-release` says of a Linux machine for the row: the
|
||||
* distribution's `ID` and `VERSION_ID`, undefined when absent or unread.
|
||||
*/
|
||||
export type LinuxDistro = {
|
||||
readonly id: string | undefined
|
||||
readonly version: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './os-release-value.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* One key's value in an os-release file, unquoted; undefined when the file
|
||||
* has no such line.
|
||||
*
|
||||
* @param content the file's text
|
||||
* @param key the key
|
||||
* @returns the value, or undefined
|
||||
*/
|
||||
export const osReleaseValue = (content: string, key: 'ID' | 'VERSION_ID') =>
|
||||
content
|
||||
.split('\n')
|
||||
.map(line => /^(ID|VERSION_ID)=(.*)$/.exec(line))
|
||||
.find(match => match?.[1] === key)?.[2]
|
||||
?.replace(/^"|"$/g, '')
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* The architecture as the CLI's runtime names it, from what `uname -m` or
|
||||
* Windows' PROCESSOR_ARCHITECTURE says: `x64`, `arm64`, `ia32`, else as is.
|
||||
*
|
||||
* @param machine the architecture as the system spells it
|
||||
* @returns the runtime's spelling
|
||||
*/
|
||||
export function archOf(machine: string) {
|
||||
const spelled = machine.trim().toLowerCase()
|
||||
|
||||
const named: readonly (readonly [boolean, string])[] = [
|
||||
[spelled === 'x86_64' || spelled === 'amd64', 'x64'],
|
||||
[spelled === 'aarch64' || spelled === 'arm64', 'arm64'],
|
||||
[spelled === 'x86' || /^i[3-6]86$/.test(spelled), 'ia32'],
|
||||
]
|
||||
|
||||
return named.find(([isIt]) => isIt)?.[1] ?? (spelled || 'unknown')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './arch-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './arch-of'
|
||||
export * from './machine'
|
||||
export * from './machine-of.js'
|
||||
export * from './platform-of'
|
||||
export * from './probe-script'
|
||||
export * from './probe-timeout-ms'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ProcessRunResult } from 'claude-code'
|
||||
|
||||
import type { Facts } from '../../facts'
|
||||
import { archOf } from './arch-of'
|
||||
import type { Machine } from './machine'
|
||||
import { PROBE_SCRIPT } from './probe-script'
|
||||
|
||||
/**
|
||||
* Probes the machine once: on Windows (the `OS` variable says so) from the
|
||||
* environment alone, elsewhere with one `sh` run of PROBE_SCRIPT.
|
||||
*
|
||||
* A probe that fails or exits non-zero leaves the system unnamed
|
||||
* (`unknown`) and the lists empty; nothing is retried.
|
||||
*
|
||||
* @param run runs one program to its end
|
||||
* @param facts the variables as read, for the Windows answer
|
||||
* @returns the machine as probed
|
||||
*/
|
||||
export async function machineOf(
|
||||
run: (argv: readonly string[]) => Promise<ProcessRunResult>,
|
||||
facts: Facts,
|
||||
): Promise<Machine> {
|
||||
if (facts.os === 'Windows_NT') {
|
||||
return {
|
||||
platformRaw: 'win32',
|
||||
arch: archOf(facts.processorArchitecture ?? ''),
|
||||
kernel: undefined,
|
||||
packageManagers: '',
|
||||
runtimes: '',
|
||||
}
|
||||
}
|
||||
|
||||
let result: ProcessRunResult | undefined
|
||||
|
||||
try {
|
||||
result = await run(['sh', '-c', PROBE_SCRIPT])
|
||||
} catch {
|
||||
result = undefined
|
||||
}
|
||||
|
||||
const [system = '', kernel = '', machine = '', managers = '', runtimes = ''] =
|
||||
(result?.exitCode === 0 ? result.stdout.split('\n') : []).map(line =>
|
||||
line.trim(),
|
||||
)
|
||||
|
||||
return {
|
||||
platformRaw: system.toLowerCase() || 'unknown',
|
||||
arch: archOf(machine),
|
||||
kernel: kernel || undefined,
|
||||
packageManagers: managers.replace(/,$/, ''),
|
||||
runtimes: runtimes.replace(/,$/, ''),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './machine.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* What one probe of the machine answers for the rows' `env` block: the
|
||||
* system's own name, the architecture, the kernel, and what is on the PATH.
|
||||
*
|
||||
* `platformRaw` is the system's name lowercased (`darwin`, `linux`,
|
||||
* `freebsd`, `win32`); the package managers and runtimes are comma-joined,
|
||||
* an empty string for none.
|
||||
*/
|
||||
export type Machine = {
|
||||
readonly platformRaw: string
|
||||
readonly arch: string
|
||||
readonly kernel: string | undefined
|
||||
readonly packageManagers: string
|
||||
readonly runtimes: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './platform-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The row's `platform` as the CLI's own rows have it: the host platform the
|
||||
* environment names when it is one of the three, else by the system's name.
|
||||
*
|
||||
* @param hostPlatform CLAUDE_CODE_HOST_PLATFORM, when set
|
||||
* @param platformRaw the system's own name lowercased
|
||||
* @returns `win32`, `darwin`, or `linux` for anything else
|
||||
*/
|
||||
export function platformOf(
|
||||
hostPlatform: string | undefined,
|
||||
platformRaw: string,
|
||||
) {
|
||||
const named = ['win32', 'darwin', 'linux'].find(one => one === hostPlatform)
|
||||
const isOwnName = platformRaw === 'win32' || platformRaw === 'darwin'
|
||||
|
||||
return named ?? (isOwnName ? platformRaw : 'linux')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './probe-script.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The one shell script the plugin runs, once a session, anywhere but
|
||||
* Windows; five lines come back.
|
||||
*
|
||||
* `uname -s`, `uname -r`, `uname -m`, then the package managers found of
|
||||
* npm, yarn and pnpm comma-joined, then the runtimes found of bun, deno and
|
||||
* node.
|
||||
*/
|
||||
export const PROBE_SCRIPT = [
|
||||
'uname -s',
|
||||
'uname -r',
|
||||
'uname -m',
|
||||
'for c in npm yarn pnpm; do command -v "$c" >/dev/null 2>&1 && ' +
|
||||
'printf "%s," "$c"; done; echo',
|
||||
'for c in bun deno node; do command -v "$c" >/dev/null 2>&1 && ' +
|
||||
'printf "%s," "$c"; done; echo',
|
||||
].join('\n')
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './probe-timeout-ms.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* How long the one machine probe may run before the host stops it, in
|
||||
* milliseconds; a stopped probe leaves the machine unnamed.
|
||||
*/
|
||||
export const PROBE_TIMEOUT_MS = 5000
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './linux-files'
|
||||
export * from './linux-files-of'
|
||||
export * from './probe-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './linux-files-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { TelemetryDeps } from '../../../telemetry-deps'
|
||||
import { linuxDistroOf } from '../../linux-distro-of'
|
||||
import { wslVersionOf } from '../../wsl-version-of'
|
||||
import type { LinuxFiles } from '../linux-files'
|
||||
|
||||
/**
|
||||
* Reads the four Linux files the rows draw on, on Linux alone; elsewhere,
|
||||
* and for each file that cannot be read, the answer is empty.
|
||||
*
|
||||
* @param deps the calls on the nouns beneath
|
||||
* @param isLinux whether the machine named itself Linux
|
||||
* @returns what the files say
|
||||
*/
|
||||
export async function linuxFilesOf(
|
||||
deps: TelemetryDeps,
|
||||
isLinux: boolean,
|
||||
): Promise<LinuxFiles> {
|
||||
const [distro, wslVersion, isEc2, isDocker] = await Promise.all([
|
||||
isLinux
|
||||
? linuxDistroOf(() => deps.read('/etc/os-release'))
|
||||
: { id: undefined, version: undefined },
|
||||
isLinux ? wslVersionOf(() => deps.read('/proc/version')) : undefined,
|
||||
isLinux
|
||||
? deps.read('/sys/hypervisor/uuid').then(
|
||||
uuid => uuid.startsWith('ec2'),
|
||||
() => false,
|
||||
)
|
||||
: false,
|
||||
isLinux ? deps.exists('/.dockerenv').catch(() => false) : false,
|
||||
])
|
||||
|
||||
return {
|
||||
distro,
|
||||
wslVersion,
|
||||
fileSignals: [
|
||||
['aws-ec2', isEc2],
|
||||
['docker', isDocker],
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './linux-files.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,12 @@
|
||||
import type Deployment from '../../../deployment'
|
||||
import type { LinuxDistro } from '../../linux-distro-of'
|
||||
|
||||
/**
|
||||
* What the Linux files say for the rows: the distribution, the WSL release,
|
||||
* and the two deployments whose signal is a file (EC2, Docker).
|
||||
*/
|
||||
export type LinuxFiles = {
|
||||
readonly distro: LinuxDistro
|
||||
readonly wslVersion: string | undefined
|
||||
readonly fileSignals: readonly Deployment.DeploymentSignal[]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { TelemetryDeps } from '../../telemetry-deps'
|
||||
import Identity from '../identity'
|
||||
import Machine from '../machine'
|
||||
import type { Probe } from '../probe'
|
||||
import { remoteHashOf } from '../remote-hash-of'
|
||||
import { vcsOf } from '../vcs-of'
|
||||
import { linuxFilesOf } from './linux-files-of'
|
||||
|
||||
/**
|
||||
* Gathers what holds for the whole session through the nouns beneath: the
|
||||
* variables, the global config, one machine probe, the directory, the remote.
|
||||
*
|
||||
* Each read that fails leaves its part empty; the Linux files are read on
|
||||
* Linux alone.
|
||||
*
|
||||
* @param deps the calls on the nouns beneath
|
||||
* @param isInteractive whether a person is at the prompt
|
||||
* @returns what was gathered, for context-of to shape
|
||||
*/
|
||||
export async function probeOf(
|
||||
deps: TelemetryDeps,
|
||||
isInteractive: boolean,
|
||||
): Promise<Probe> {
|
||||
const [facts, signals, location, environment, cwd, repo] = await Promise.all([
|
||||
deps.facts(),
|
||||
deps.deployment(),
|
||||
deps.configLocation(),
|
||||
deps.environment(),
|
||||
deps.cwd().catch(() => undefined),
|
||||
deps.repo().catch(() => null),
|
||||
])
|
||||
|
||||
const [identity, machine, vcs, remoteHash] = await Promise.all([
|
||||
Identity.identityOf(deps.read, location, environment.userType === 'ant'),
|
||||
Machine.machineOf(deps.run, facts),
|
||||
cwd === undefined ? undefined : vcsOf(deps.list, cwd, facts.hasP4Port),
|
||||
remoteHashOf(repo?.remote),
|
||||
])
|
||||
|
||||
const files = await linuxFilesOf(deps, machine.platformRaw === 'linux')
|
||||
|
||||
return {
|
||||
facts,
|
||||
signals: [...signals, ...files.fileSignals],
|
||||
identity,
|
||||
machine,
|
||||
platform: Machine.platformOf(facts.hostPlatform, machine.platformRaw),
|
||||
isInteractive,
|
||||
distro: files.distro,
|
||||
wslVersion: files.wslVersion,
|
||||
vcs,
|
||||
remoteHash,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './probe.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,22 @@
|
||||
import type Deployment from '../../deployment'
|
||||
import type { Facts } from '../../facts'
|
||||
import type { Identity } from '../identity'
|
||||
import type { LinuxDistro } from '../linux-distro-of'
|
||||
import type { Machine } from '../machine'
|
||||
|
||||
/**
|
||||
* Everything gathered for the session's context before it is shaped: the
|
||||
* variables, the identity, the machine, the Linux files and the directory.
|
||||
*/
|
||||
export type Probe = {
|
||||
readonly facts: Facts
|
||||
readonly signals: readonly Deployment.DeploymentSignal[]
|
||||
readonly identity: Identity
|
||||
readonly machine: Machine
|
||||
readonly platform: string
|
||||
readonly isInteractive: boolean
|
||||
readonly distro: LinuxDistro
|
||||
readonly wslVersion: string | undefined
|
||||
readonly vcs: string | undefined
|
||||
readonly remoteHash: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HEX_RADIX } from './hex-radix'
|
||||
|
||||
/**
|
||||
* A buffer's bytes as lowercase hex, two digits each.
|
||||
*
|
||||
* @param buffer the bytes
|
||||
* @returns the hex text
|
||||
*/
|
||||
export const hexOf = (buffer: ArrayBuffer) =>
|
||||
[...new Uint8Array(buffer)]
|
||||
.map(byte => byte.toString(HEX_RADIX).padStart(2, '0'))
|
||||
.join('')
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Sixteen: the radix a byte is written in as two hex digits.
|
||||
*/
|
||||
export const HEX_RADIX = 16
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './hex-radix.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './hex-of.js'
|
||||
export * from './hex-radix'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './hex-of'
|
||||
export * from './normalized-remote-of'
|
||||
export * from './remote-hash-length'
|
||||
export * from './remote-hash-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './normalized-remote-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* A git remote URL as the CLI normalizes it before hashing: `host/path`
|
||||
* lowercased, the user and a trailing `.git` dropped.
|
||||
*
|
||||
* The scp form (`git@host:owner/repo`) and the URL form (`https://` or
|
||||
* `ssh://`) alike; undefined for anything else.
|
||||
*
|
||||
* @param remote the remote URL as `$.session.repo()` answers it
|
||||
* @returns the normalized remote, or undefined
|
||||
*/
|
||||
export function normalizedRemoteOf(remote: string): string | undefined {
|
||||
const trimmed = remote.trim()
|
||||
|
||||
const [, host, path] =
|
||||
/^git@([^:/@]+):(.+?)(?:\.git)?$/.exec(trimmed) ??
|
||||
/^(?:https?|ssh):\/\/(?:[^@/?#]*@)?([^/?#@]+)\/(.+?)(?:\.git)?$/.exec(
|
||||
trimmed,
|
||||
) ??
|
||||
[]
|
||||
|
||||
const isRecognized = host !== undefined && path !== undefined
|
||||
|
||||
return isRecognized ? `${host}/${path}`.toLowerCase() : undefined
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './remote-hash-length.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* How many hex digits of the remote's SHA-256 a row carries as `rh`: the
|
||||
* CLI's own sixteen, so the plugin's rows join where the CLI's do.
|
||||
*/
|
||||
export const REMOTE_HASH_LENGTH = 16
|
||||
@@ -0,0 +1,24 @@
|
||||
import { hexOf } from './hex-of'
|
||||
import { normalizedRemoteOf } from './normalized-remote-of'
|
||||
import { REMOTE_HASH_LENGTH } from './remote-hash-length'
|
||||
|
||||
/**
|
||||
* The rows' `rh`: the first hex digits of the SHA-256 of the session's
|
||||
* normalized origin remote, the key the CLI's own rows join a repository by.
|
||||
*
|
||||
* @param remote the origin remote as `$.session.repo()` answers it
|
||||
* @returns the hash, or undefined outside a recognized remote
|
||||
*/
|
||||
export async function remoteHashOf(
|
||||
remote: string | null | undefined,
|
||||
): Promise<string | undefined> {
|
||||
const normalized = remote ? normalizedRemoteOf(remote) : undefined
|
||||
|
||||
if (normalized === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return hexOf(
|
||||
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized)),
|
||||
).slice(0, REMOTE_HASH_LENGTH)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './session-fields-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Facts } from '../../facts'
|
||||
import { clientTypeOf } from '../client-type-of'
|
||||
import { entrypointOf } from '../entrypoint-of'
|
||||
import type { SessionFields } from '../session-fields'
|
||||
import { versionOf } from '../version-of'
|
||||
|
||||
/**
|
||||
* The session's constant row fields from the variables as read: the client,
|
||||
* the entrypoint, the SDK's version and a benchmark run's ids.
|
||||
*
|
||||
* @param facts the variables as read once for the session
|
||||
* @returns the fields
|
||||
*/
|
||||
export const sessionFieldsOf = (facts: Facts): SessionFields => ({
|
||||
clientType: clientTypeOf(facts),
|
||||
entrypoint: entrypointOf(facts.entrypoint),
|
||||
agentSdkVersion: versionOf(facts.agentSdkVersion),
|
||||
sweBenchRunId: facts.sweBenchRunId || undefined,
|
||||
sweBenchInstanceId: facts.sweBenchInstanceId || undefined,
|
||||
sweBenchTaskId: facts.sweBenchTaskId || undefined,
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './session-fields.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The row fields that are the session's for its whole life, before their
|
||||
* keys are spelled for the wire.
|
||||
*
|
||||
* The client, the entrypoint, the SDK and a benchmark run's ids, each
|
||||
* undefined when the environment names none.
|
||||
*/
|
||||
export type SessionFields = {
|
||||
readonly clientType: string
|
||||
readonly entrypoint: string | undefined
|
||||
readonly agentSdkVersion: string | undefined
|
||||
readonly sweBenchRunId: string | undefined
|
||||
readonly sweBenchInstanceId: string | undefined
|
||||
readonly sweBenchTaskId: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './known-shells'
|
||||
export * from './shell-of.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './known-shells.js'
|
||||
|
||||
export * as default from '.'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user