mods: a noun's types live in its own types folder and dependents read them from there

This commit is contained in:
poteat
2026-09-12 11:57:54 -07:00
parent 748e0c4a44
commit b95b0150f8
32 changed files with 204 additions and 248 deletions
+30 -1
View File
@@ -76,7 +76,36 @@ crosses it. `$.ui.press({ plugin, key })` presses a `Button` the test
rendered, as a click in the terminal does.
`tsc -p mods/tsconfig.json` typechecks every mod's hooks and tests against
`types/`.
`types/` and each mod's own `types/` contract.
## Composing mods: noun contracts
A mod that adds a noun to `$` in the `engine.create` fold owns that noun's
types, and keeps them in one place: its `types/index.d.ts`, an ambient file
with no imports that merges into `claude-code`, declaring the noun on
`EngineInterface` and exporting the types it is made of, each named for the
noun (`telemetry/types/index.d.ts` declares `$.telemetry` and exports
`Telemetry`, `TelemetryLogEntry`, `TelemetryMarkEntry` and the rest).
- The contract is the only declaration of the noun. The mod's own hooks
import its types from `claude-code` (`import type { Telemetry } from
'claude-code'`), and the value its `engine.create` hook returns is checked
against `EngineInterface['telemetry']`, so the implementation cannot drift
from what callers read.
- A mod that calls another's noun reads the same file and never copies it:
`mods/tsconfig.json` includes `*/types/**/*.d.ts`, so `$.telemetry.log(…)`
in `diff` types against `telemetry`'s contract as it stands.
- A test of a mod that calls another's noun seats a provider for it, an inline
plugin whose `engine.create` hook adds the noun, and answers the calls the
way it answers the engine's: `on('telemetry.log', ($, e) => ({ value:
undefined }))` runs above the provider's own method, its `e` typed by the
contract. With no provider loaded the `$` build refuses the hook, naming the
noun nobody provides.
A plugin outside this repository that depends on a mod's noun points its
tsconfig `include` at that mod's `types/` folder for now; once the engine
writes the contracts of the plugins a session has installed, `/plugin-types`
will put them beside `claude-code.d.ts` and the include goes away.
Early access: hooks modules load only where function hooks are enabled, and
the API these mods are written against may change between releases without
-57
View File
@@ -1,57 +0,0 @@
/**
* The `$.telemetry` noun as this plugin calls it, declared for a build of
* this folder on its own; no module imports it.
*
* The telemetry plugin adds the noun in the engine.create fold on internal
* builds and nowhere else: where no plugin provides it the calls throw and
* record/ drops the row. The engine's repository leaves this file out.
*
* @entry
*/
declare module 'claude-code' {
/**
* How one use of a feature went: as hoped, degraded, or failed outright.
*/
type DiffTelemetryMarkKind = 'ok' | 'sad' | 'bad'
/**
* A string property: the value and the list it is chosen from.
*/
type DiffTelemetryChoice = { value: string; of: readonly string[] }
/**
* One property value a row may carry.
*/
type DiffTelemetryProp = number | boolean | DiffTelemetryChoice
/**
* What `$.telemetry.log` takes: the event's name after the prefix, and
* its properties by snake_case key.
*/
type DiffTelemetryLogEntry = {
event: string
props?: Readonly<Record<string, DiffTelemetryProp>>
}
/**
* What `$.telemetry.mark` takes: the feature, how it went, why when not
* ok, and the properties the row carries beside them by snake_case key.
*/
type DiffTelemetryMarkEntry = {
feature: string
kind: DiffTelemetryMarkKind
reason?: string
props?: Readonly<Record<string, DiffTelemetryProp>>
}
interface EngineInterface {
/**
* A plugin's analytics, one first-party row per call; present only
* where the telemetry plugin is seated.
*/
telemetry: {
log: (entry: DiffTelemetryLogEntry) => Promise<void>
mark: (entry: DiffTelemetryMarkEntry) => Promise<void>
}
}
}
+2 -1
View File
@@ -4,5 +4,6 @@
"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.",
"author": {
"name": "Anthropic"
}
},
"types": "types/index.d.ts"
}
+3 -2
View File
@@ -28,8 +28,9 @@ string named together with the list it is chosen from), under `log` and
the last two and refused on the first. An entry that breaks a rule is
refused before anything is sent.
`hooks/register.ts` is the module; `hooks/telemetry-types/` is the noun's
type as a caller sees it.
`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
noun and a test answering it all read.
## What it hooks
@@ -1,6 +1,6 @@
import type TelemetryTypes from '../../telemetry-types'
import { checkedValue } from '../checked-value'
import { isRecord } from '../is-record'
import type { Method } from '../method'
import { PROP_LIMIT } from '../prop-limit'
import { refusal } from '../refusal'
import { TOKEN } from '../token'
@@ -15,7 +15,7 @@ import { TOKEN } from '../token'
*/
export function checkedProps(
props: unknown,
method: TelemetryTypes.Method,
method: Method,
): Record<string, string | number | boolean> {
if (!isRecord(props)) {
throw refusal('props: an object of properties by key', method)
@@ -1,7 +1,7 @@
import type TelemetryTypes from '../../telemetry-types'
import { CHOICE_TOKEN } from '../choice-token'
import { CHOICES_LIMIT } from '../choices-limit'
import { isRecord } from '../is-record'
import type { Method } from '../method'
import { refusal } from '../refusal'
/**
@@ -19,7 +19,7 @@ import { refusal } from '../refusal'
export function checkedValue(
key: string,
value: unknown,
method: TelemetryTypes.Method = 'log',
method: Method = 'log',
): string | number | boolean {
if (typeof value === 'boolean') {
return value
+1
View File
@@ -16,6 +16,7 @@ export * from './is-record'
export * from './mark'
export * from './mark-fields-of'
export * from './mark-kinds'
export * from './method'
export * from './prop-limit'
export * from './refusal'
export * from './row-session'
@@ -1,4 +1,5 @@
import type TelemetryTypes from '../../telemetry-types'
import type { TelemetryMarkKind } from 'claude-code'
import { MARK_KINDS } from '../mark-kinds'
/**
@@ -7,5 +8,5 @@ import { MARK_KINDS } from '../mark-kinds'
* @param value what the caller passed as `kind`
* @returns whether value names one of the three mark kinds
*/
export const isMarkKind = (value: unknown): value is TelemetryTypes.MarkKind =>
export const isMarkKind = (value: unknown): value is TelemetryMarkKind =>
MARK_KINDS.some(kind => kind === value)
@@ -1,10 +1,6 @@
import type TelemetryTypes from '../../telemetry-types'
import type { TelemetryMarkKind } from 'claude-code'
/**
* The three kinds a mark may be, in the order the feature events name them.
*/
export const MARK_KINDS: readonly TelemetryTypes.MarkKind[] = [
'ok',
'sad',
'bad',
]
export const MARK_KINDS: readonly TelemetryMarkKind[] = ['ok', 'sad', 'bad']
+3 -2
View File
@@ -1,4 +1,5 @@
import type TelemetryTypes from '../../telemetry-types'
import type { TelemetryMarkKind } from 'claude-code'
import type { Fields } from '../fields'
/**
@@ -6,7 +7,7 @@ import type { Fields } from '../fields'
* and its properties as they go into the row.
*/
export type Mark = {
kind: TelemetryTypes.MarkKind
kind: TelemetryMarkKind
feature: string
reason?: string
props: Fields['props']
@@ -0,0 +1,3 @@
export type * from './method.js'
export * as default from '.'
@@ -1,4 +1,4 @@
import type TelemetryTypes from '../../telemetry-types'
import type { Method } from '../method'
/**
* The error a refused entry rejects with, naming the method and what was wrong.
@@ -11,7 +11,5 @@ import type TelemetryTypes from '../../telemetry-types'
* @returns the error to reject the call with, naming the method and what was
* wrong
*/
export const refusal = (
what: string,
method: TelemetryTypes.Method = 'log',
): Error => new Error(`$.telemetry.${method}: ${what}`)
export const refusal = (what: string, method: Method = 'log'): Error =>
new Error(`$.telemetry.${method}: ${what}`)
-1
View File
@@ -4,6 +4,5 @@ export * from './is-analytics-off'
export * from './register.js'
export * from './telemetry-deps'
export * from './telemetry-of'
export * from './telemetry-types'
export * as default from '.'
+25 -28
View File
@@ -1,4 +1,4 @@
import type { On } from 'claude-code'
import type { EngineInterface, On } from 'claude-code'
import { telemetryOf } from './telemetry-of'
@@ -15,33 +15,30 @@ export function register(on: On) {
on('engine.create', async ($, e, next) => {
const beneath = await next(e)
return {
...beneath,
telemetry: telemetryOf({
authorize: () => beneath.session.authorize(),
id: () => beneath.session.id(),
model: () => beneath.session.model(),
environment: async () => ({
userType: await beneath.env.get('USER_TYPE'),
disableTelemetry: await beneath.env.get('DISABLE_TELEMETRY'),
disableNonessentialTraffic: await beneath.env.get(
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
),
doNotTrack: await beneath.env.get('DO_NOT_TRACK'),
customOauthUrl: await beneath.env.get('CLAUDE_CODE_CUSTOM_OAUTH_URL'),
useBedrock: await beneath.env.get('CLAUDE_CODE_USE_BEDROCK'),
useVertex: await beneath.env.get('CLAUDE_CODE_USE_VERTEX'),
useFoundry: await beneath.env.get('CLAUDE_CODE_USE_FOUNDRY'),
useAnthropicAws: await beneath.env.get(
'CLAUDE_CODE_USE_ANTHROPIC_AWS',
),
useAnthropicGoogleCloud: await beneath.env.get(
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
),
useMantle: await beneath.env.get('CLAUDE_CODE_USE_MANTLE'),
}),
fetch: (url, init) => beneath.http.fetch(url, init),
const telemetry: EngineInterface['telemetry'] = telemetryOf({
authorize: () => beneath.session.authorize(),
id: () => beneath.session.id(),
model: () => beneath.session.model(),
environment: async () => ({
userType: await beneath.env.get('USER_TYPE'),
disableTelemetry: await beneath.env.get('DISABLE_TELEMETRY'),
disableNonessentialTraffic: await beneath.env.get(
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
),
doNotTrack: await beneath.env.get('DO_NOT_TRACK'),
customOauthUrl: await beneath.env.get('CLAUDE_CODE_CUSTOM_OAUTH_URL'),
useBedrock: await beneath.env.get('CLAUDE_CODE_USE_BEDROCK'),
useVertex: await beneath.env.get('CLAUDE_CODE_USE_VERTEX'),
useFoundry: await beneath.env.get('CLAUDE_CODE_USE_FOUNDRY'),
useAnthropicAws: await beneath.env.get('CLAUDE_CODE_USE_ANTHROPIC_AWS'),
useAnthropicGoogleCloud: await beneath.env.get(
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
),
useMantle: await beneath.env.get('CLAUDE_CODE_USE_MANTLE'),
}),
}
fetch: (url, init) => beneath.http.fetch(url, init),
})
return { ...beneath, telemetry }
})
}
@@ -1,7 +1,8 @@
import type { Telemetry } from 'claude-code'
import Entries from '../entries'
import { isAnalyticsOff } from '../is-analytics-off'
import type { TelemetryDeps } from '../telemetry-deps'
import type TelemetryTypes from '../telemetry-types'
/**
* Builds `$.telemetry`: `log` and `mark` check the entry, read the environment
@@ -14,12 +15,12 @@ import type TelemetryTypes from '../telemetry-types'
* @param deps the calls on the nouns beneath
* @returns the `$.telemetry` interface, `log` and `mark`
*/
export function telemetryOf(deps: TelemetryDeps): TelemetryTypes.Telemetry {
export function telemetryOf(deps: TelemetryDeps): Telemetry {
let queue: Promise<unknown> = Promise.resolve()
async function post(
fields: Entries.Fields,
method: TelemetryTypes.Method,
method: Entries.Method,
): Promise<void> {
const environment = await deps.environment()
@@ -59,7 +60,7 @@ export function telemetryOf(deps: TelemetryDeps): TelemetryTypes.Telemetry {
function queued(
fields: Entries.Fields,
method: TelemetryTypes.Method,
method: Entries.Method,
): Promise<void> {
const turn = queue.then(() => post(fields, method))
queue = turn.catch(() => undefined)
@@ -1,9 +0,0 @@
/**
* A string property: the value and the list it is chosen from, declared
* beside it, so no free text reaches the row.
*
* Every member of `of` is a lowercase token of letters, digits, `_` and
* `-`, which may start with a digit, at most CHOICES_LIMIT of them; `value`
* is one of them.
*/
export type Choice = { value: string; of: readonly string[] }
@@ -1,3 +0,0 @@
export type * from './choice.js'
export * as default from '.'
@@ -1,9 +0,0 @@
export * from './choice'
export * from './log-entry'
export * from './mark-entry'
export * from './mark-kind'
export type * from './method.js'
export * from './prop'
export type * from './telemetry.js'
export * as default from '.'
@@ -1,3 +0,0 @@
export type * from './log-entry.js'
export * as default from '.'
@@ -1,10 +0,0 @@
import type { Prop } from '../prop'
/**
* What `$.telemetry.log` takes: the event's name after the prefix, and its
* properties by snake_case key.
*/
export type LogEntry = {
event: string
props?: Readonly<Record<string, Prop>>
}
@@ -1,3 +0,0 @@
export type * from './mark-entry.js'
export * as default from '.'
@@ -1,13 +0,0 @@
import type { MarkKind } from '../mark-kind'
import type { Prop } from '../prop'
/**
* What `$.telemetry.mark` takes: the feature, how it went, why when not
* ok, and the properties the row carries beside them by snake_case key.
*/
export type MarkEntry = {
feature: string
kind: MarkKind
reason?: string
props?: Readonly<Record<string, Prop>>
}
@@ -1,3 +0,0 @@
export type * from './mark-kind.js'
export * as default from '.'
@@ -1,8 +0,0 @@
/**
* How a feature went, as the CLI's own feature events count it.
*
* `ok`: used, the person got what they asked. `sad`: degraded, a fallback
* or a partial, the person still got something. `bad`: failed, the person
* got nothing.
*/
export type MarkKind = 'ok' | 'sad' | 'bad'
@@ -1,3 +0,0 @@
export type * from './prop.js'
export * as default from '.'
@@ -1,7 +0,0 @@
import type { Choice } from '../choice'
/**
* A property's value: a finite number, a boolean, or a Choice; never free
* text.
*/
export type Prop = number | boolean | Choice
@@ -1,57 +0,0 @@
import type { LogEntry } from './log-entry'
import type { MarkEntry } from './mark-entry'
/**
* A plugin's analytics, sent one event at a time through `$.telemetry`.
*
* Internal builds alone: the telemetry built-in adds the noun in the
* engine.create fold, so a plugin on an external build, or one where the
* built-in is off, finds no `$.telemetry`.
*/
export type Telemetry = {
/**
* Sends one event, `tengu_plugin_<event>`, as one first-party row;
* resolves once the ingest accepted it.
*
* The calling built-in names itself in `event`; one already named `tengu_…`
* is sent as named. A value is a finite number, a boolean or a Choice; free
* text is refused. One input, as every op on `$` takes.
*
* @param entry the event's name, a snake_case token, and its properties by
* snake_case key
* @example
* await $.telemetry.log({
* event: "suggest_learning_survey_answered",
* props: {
* answer: 2,
* page: { value: "ready", of: ["ready", "later"] },
* },
* })
*/
log: (entry: LogEntry) => Promise<void>
/**
* Marks one use of a feature as the CLI's own feature events do, one
* `tengu_feature_<kind>` row; resolves once the ingest accepted it.
*
* The row carries `feature_name`, `error_code` on sad or bad (`reason`,
* required there and refused on ok) and the entry's `props`, checked as
* `log`'s are; it joins the product-wide feature surface, so no prefix.
*
* @param entry the feature, how it went, why when not ok, and the row's
* properties by snake_case key
* @example
* await $.telemetry.mark({ feature: "learn_page", kind: "ok" })
* await $.telemetry.mark({
* feature: "learn_page",
* kind: "ok",
* props: { page: { value: "later", of: ["ready", "later"] } },
* })
* await $.telemetry.mark({
* feature: "learn_page",
* kind: "sad",
* reason: "blocked",
* })
*/
mark: (entry: MarkEntry) => Promise<void>
}
+2 -4
View File
@@ -1,6 +1,4 @@
import type { CommandRunInput } from 'claude-code'
import type { LogEntry } from '../../hooks/telemetry-types'
import type { CommandRunInput, TelemetryLogEntry } from 'claude-code'
/**
* The command that has the recording plugin log an entry, typed as the
@@ -9,7 +7,7 @@ import type { LogEntry } from '../../hooks/telemetry-types'
* @param entry what to log
* @returns `/record <entry>`
*/
export const record = (entry: LogEntry): CommandRunInput => ({
export const record = (entry: TelemetryLogEntry): CommandRunInput => ({
command: 'record',
args: JSON.stringify(entry),
origin: { kind: 'composer' },
+2 -2
View File
@@ -1,11 +1,11 @@
import type { LogEntry } from '../../hooks/telemetry-types'
import type { TelemetryLogEntry } from 'claude-code'
/**
* A survey answered, as a plugin logs it.
*
* @returns the entry, fresh each call
*/
export const surveyAnswer = (): LogEntry => ({
export const surveyAnswer = (): TelemetryLogEntry => ({
event: 'survey_answered',
props: { answer: 2, seen: true },
})
+115
View File
@@ -0,0 +1,115 @@
/**
* The `$.telemetry` noun as every caller sees it: the one contract for the
* noun, merged into `claude-code` beside the engine's own declarations.
*
* The telemetry mod adds the noun in the `engine.create` fold and checks its
* return against `EngineInterface['telemetry']`; a mod that calls it, and a
* test that answers it with `on('telemetry.log', …)`, read the same types by
* including this folder in their tsconfig. Nothing here is imported: the file
* is ambient, so it merges wherever it is included.
*/
declare module 'claude-code' {
interface EngineInterface {
/**
* A plugin's analytics, one first-party row per call; present only where
* the telemetry mod is seated (internal builds), absent everywhere else.
*/
telemetry: Telemetry
}
/**
* A plugin's analytics, sent one event at a time through `$.telemetry`.
*
* Internal builds alone: the telemetry mod adds the noun in the
* `engine.create` fold, so a plugin on an external build, or one where the
* mod is off, finds no `$.telemetry` and its call throws.
*/
export type Telemetry = {
/**
* Sends one event, `tengu_plugin_<event>`, as one first-party row;
* resolves once the ingest accepted it.
*
* The calling mod names itself in `event`; one already named `tengu_…` is
* sent as named. A value is a finite number, a boolean or a
* TelemetryChoice; free text is refused. One input, as every op on `$`
* takes.
*
* @param entry the event's name, a snake_case token, and its properties by
* snake_case key
* @example
* await $.telemetry.log({
* event: "suggest_learning_survey_answered",
* props: {
* answer: 2,
* page: { value: "ready", of: ["ready", "later"] },
* },
* })
*/
log: (entry: TelemetryLogEntry) => Promise<void>
/**
* Marks one use of a feature as the CLI's own feature events do, one
* `tengu_feature_<kind>` row; resolves once the ingest accepted it.
*
* The row carries `feature_name`, `error_code` on sad or bad (`reason`,
* required there and refused on ok) and the entry's `props`, checked as
* `log`'s are; it joins the product-wide feature surface, so no prefix.
*
* @param entry the feature, how it went, why when not ok, and the row's
* properties by snake_case key
* @example
* await $.telemetry.mark({ feature: "learn_page", kind: "ok" })
* await $.telemetry.mark({
* feature: "learn_page",
* kind: "sad",
* reason: "blocked",
* })
*/
mark: (entry: TelemetryMarkEntry) => Promise<void>
}
/**
* What `$.telemetry.log` takes: the event's name after the prefix, and its
* properties by snake_case key.
*/
export type TelemetryLogEntry = {
event: string
props?: Readonly<Record<string, TelemetryProp>>
}
/**
* What `$.telemetry.mark` takes: the feature, how it went, why when not
* ok, and the properties the row carries beside them by snake_case key.
*/
export type TelemetryMarkEntry = {
feature: string
kind: TelemetryMarkKind
reason?: string
props?: Readonly<Record<string, TelemetryProp>>
}
/**
* How a feature went, as the CLI's own feature events count it.
*
* `ok`: used, the person got what they asked. `sad`: degraded, a fallback
* or a partial, the person still got something. `bad`: failed, the person
* got nothing.
*/
export type TelemetryMarkKind = 'ok' | 'sad' | 'bad'
/**
* A property's value: a finite number, a boolean, or a TelemetryChoice;
* never free text.
*/
export type TelemetryProp = number | boolean | TelemetryChoice
/**
* A string property: the value and the list it is chosen from, declared
* beside it, so no free text reaches the row.
*
* Every member of `of` is a lowercase token of letters, digits, `_` and
* `-`, which may start with a digit, at most 32 of them; `value` is one of
* them.
*/
export type TelemetryChoice = { value: string; of: readonly string[] }
}
+1 -1
View File
@@ -13,5 +13,5 @@
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
},
"include": ["types", "*/hooks", "*/tests"]
"include": ["types", "*/types/**/*.d.ts", "*/hooks", "*/tests"]
}