Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CONTRIBUTING.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ Chaque preset stable sépare :
Les schémas communs se trouvent dans `profiles/schema/v1/`. La piste BLE reste
séparée sous `ble/`.

## Documenter les API complexes

Les API exportées aux frontières matérielles et d'intégrité des données exigent
un JSDoc adjacent. Documenter les entrées, sorties, erreurs stables, effets de
bord et règles de préservation ; ne pas répéter l'implémentation ligne par ligne.
Les frontières protégées couvrent actuellement le framing et les sessions HID,
les charges d'éclairage, les transformations AppSense/profile, la navigation des
emplacements de session et le workflow de profil du GUI.

`tests/api-docs.test.mjs` importe ces modules frontières et refuse les callables
et constantes exportés sans contrat formel. Mettre à jour la liste explicite des
modules du test lorsqu'une nouvelle frontière est introduite.

## Préparer l'environnement

Prérequis : Node.js 18 ou version ultérieure. Les dépendances de validation
Expand Down
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ Every stable preset separates:
The common schemas live in `profiles/schema/v1/`. The BLE track stays separate
under `ble/`.

## Documenting complex APIs

Exported APIs at hardware and data-integrity boundaries require adjacent JSDoc.
Document inputs, outputs, stable errors, side effects and preservation rules;
do not repeat the implementation line by line. The guarded boundaries currently
cover HID framing and sessions, lighting payloads, AppSense/profile transforms,
session-slot navigation and the GUI profile workflow.

`tests/api-docs.test.mjs` imports these boundary modules and rejects an exported
callable or constant without a formal contract. Update the test's explicit
module list when a new boundary module is introduced.

## Preparing the environment

Prerequisite: Node.js 18 or newer. The validation dependencies are pinned by
Expand Down
40 changes: 40 additions & 0 deletions prototype/src/profile-workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import {
import { parseOptionalNonNegativeInteger } from "./configurator-state.js";
import { LOCALES } from "./i18n/index.js";

/**
* Normalizes an imported profile for the configurator. A missing Claude layer
* is synthesized from a compatible template; every other inspection failure is
* preserved for the caller to present.
*
* @param {object} parsed Parsed Work Louder Input profile export.
* @returns {{source: object, info: object, layerCreated: {templateName: string}|null, derived: {mapping: object, assigned: number}}}
* Prepared immutable workflow input and its derived mapping.
* @throws {Error} When the profile is unsafe or cannot supply a layer template.
*/
export function prepareImportedProfile(parsed) {
let source = parsed;
let layerCreated = null;
Expand All @@ -30,6 +40,14 @@ export function prepareImportedProfile(parsed) {
};
}

/**
* Computes a browser-compatible SHA-256 digest without making hashing a hard
* requirement. Unavailable or rejected Web Crypto returns an empty string.
*
* @param {string} text UTF-8 text to hash.
* @param {Crypto} [crypto] Injectable Web Crypto implementation.
* @returns {Promise<string>} Lowercase hexadecimal digest, or `""` on failure.
*/
Comment on lines +43 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 '\bsha256Hex\s*\(' prototype --glob '*.js' --glob '*.mjs'

Repository: thannous/claude-codex-micro

Length of output: 2471


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== profile-workflow sha256Hex implementation =="
sed -n '35,65p' prototype/src/profile-workflow.js

echo
echo "== sha256Hex call sites with crypto argument =="
rg -n -C 3 '\bsha256Hex\s*\(\s*[^,\n]+,\s*([^,\n]+)' prototype/src prototype/tests --glob '*.js' --glob '*.mjs'

Repository: thannous/claude-codex-micro

Length of output: 2835


Normalize the crypto parameter type to match supported inputs.

sha256Hex is called with null and a minimal { subtle: { digest } } object. Update the JSDoc type to reflect nullable and minimal injectable inputs, or narrow the runtime contract and change the tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prototype/src/profile-workflow.js` around lines 43 - 50, Update the sha256Hex
JSDoc parameter declaration to match its supported inputs: allow null and the
minimal injectable object exposing subtle.digest, while preserving the existing
failure behavior for unavailable or rejected Web Crypto.

export async function sha256Hex(text, crypto = globalThis.window?.crypto) {
try {
const digest = await crypto?.subtle?.digest(
Expand All @@ -45,6 +63,18 @@ export async function sha256Hex(text, crypto = globalThis.window?.crypto) {
}
}

/**
* Builds the reviewed downloadable profile and binds its exact serialized JSON
* to a SHA-256 fingerprint when Web Crypto is available.
*
* @param {object} source Prepared official profile export.
* @param {object} mapping Canonical configurator mapping.
* @param {{claude: string, base: string}} appSenseIds User-entered local ids.
* @param {Crypto} [crypto] Injectable Web Crypto implementation.
* @returns {Promise<{json: string, sha: string, report: object}>} Download data
* and the transformer's preservation report.
* @throws {Error} When profile generation violates a safety invariant.
*/
export async function createProfileReview(source, mapping, appSenseIds, crypto) {
const { profile, report } = buildInputProfile(source, mapping, {
requireAppSense: false,
Expand All @@ -55,6 +85,16 @@ export async function createProfileReview(source, mapping, appSenseIds, crypto)
return { json, sha: await sha256Hex(json, crypto), report };
}

/**
* Converts parser and domain failures into a stable, localized UI error shape.
* Unknown `Error` instances retain their message; non-errors use the generic
* invalid-file translation.
*
* @param {unknown} error Failure raised while loading or transforming a profile.
* @param {(key: string) => string} t Locale translator.
* @returns {{message: string, code: string|null}} User-facing message and
* optional stable domain code.
*/
export function describeProfileError(error, t) {
if (error instanceof SyntaxError) {
return { message: t("errors.invalidJson"), code: null };
Expand Down
87 changes: 77 additions & 10 deletions scripts/lib/hid-device.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,39 @@ import {
} from "./hid-frame.mjs";
import { METHODS } from "./hid-lighting.mjs";

/** USB vendor id reported by the Codex Micro vendor interface. */
export const VENDOR_ID = 0x303a;

/** USB product id reported by the Codex Micro vendor interface. */
export const PRODUCT_ID = 0x8360;

/** Vendor usage page carrying the JSON-RPC transport. */
export const VENDOR_USAGE_PAGE = 0xff00;

const CALL_TIMEOUT_MS = 10000;

/** Minimum delay between RPC calls required for reliable firmware handling. */
export const CALL_SPACING_MS = 50;

/** Error carrying a stable transport code suitable for CLI and GUI handling. */
export class DeviceError extends Error {
/**
* @param {string} code Stable machine-readable failure code.
* @param {string} [message] Human-readable detail; defaults to the code.
*/
constructor(code, message) {
super(message ?? code);
this.name = "DeviceError";
this.code = code;
}
}

// Lazy import: node-hid is a native optionalDependency. The error message has to
// say what to do, not only that it is missing.
/**
* Loads the optional native HID dependency only when hardware access is used.
*
* @returns {Promise<typeof import("node-hid")>} Loaded `node-hid` module.
* @throws {DeviceError} With `HID_UNAVAILABLE` when the dependency cannot load.
*/
export async function loadHid() {
try {
return await import("node-hid");
Expand All @@ -52,8 +68,13 @@ export async function loadHid() {
}
}

// Lists the Codex Micro vendor interfaces. The keyboard exposes several HID
// collections; only usage page 0xFF00 carries the RPC channel.
/**
* Tests whether a `node-hid` descriptor is the Codex Micro vendor collection.
* The keyboard collection is deliberately excluded even when VID/PID match.
*
* @param {object|null|undefined} device HID descriptor returned by `node-hid`.
* @returns {boolean} Whether the descriptor carries the vendor RPC channel.
*/
export function isCodexVendorInterface(device) {
return (
device?.vendorId === VENDOR_ID &&
Expand All @@ -62,6 +83,12 @@ export function isCodexVendorInterface(device) {
);
}

/**
* Enumerates only Codex Micro vendor RPC interfaces.
*
* @returns {Promise<object[]>} Matching `node-hid` device descriptors.
* @throws {DeviceError} When the optional HID dependency is unavailable.
*/
export async function listInterfaces() {
const hid = await loadHid();
return hid.devices().filter(isCodexVendorInterface);
Expand All @@ -75,9 +102,11 @@ async function openHandle(path) {
return hid.HIDAsync.open(path);
}

// RPC session: paced sequential queue, responses correlated by id, notifications
// dispatched, foreign writes detected. One session = one request in flight, the
// way the firmware expects it.
/**
* Paced RPC session over one HID handle. Calls are serialized, responses are
* correlated by id, notifications are dispatched, and foreign lighting writes
* are surfaced to the optional coexistence callback.
*/
export class DeviceSession {
#handle;
#assembler = createLineAssembler();
Expand All @@ -91,6 +120,13 @@ export class DeviceSession {
#closed = false;
#lastCallStartedAt = 0;

/**
* @param {{on: Function, write: Function, close: Function}} handle Open HID handle.
* @param {object} [options] Session callbacks.
* @param {(method: string, response: unknown) => void|Promise<void>} [options.onForeignWrite]
* Called for orphan responses to known lighting methods.
* @param {(line: string) => void} [options.onDebugLine] Called for firmware debug lines.
*/
Comment on lines +123 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle rejected onForeignWrite promises or remove Promise<void> from the contract.

The constructor and DeviceSession.open contracts accept an asynchronous callback. #resolve() invokes this callback at Line [278] without awaiting or catching the returned promise. A rejected callback promise becomes unhandled. Either handle the promise and define its failure behavior, or restrict both JSDoc types to void.

Also applies to: 142-151

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/lib/hid-device.mjs` around lines 123 - 129, Update the onForeignWrite
contract in the handle/session JSDoc declarations and the `#resolve`() callback
invocation consistently: either await and catch rejected promises with defined
failure behavior, or remove Promise<void> from both callback types and keep them
synchronous. Ensure the constructor and DeviceSession.open declarations match
the behavior implemented by `#resolve`().

constructor(handle, { onForeignWrite, onDebugLine } = {}) {
this.#handle = handle;
this.#onForeignWrite = onForeignWrite ?? null;
Expand All @@ -103,6 +139,16 @@ export class DeviceSession {
});
}

/**
* Opens the requested interface, or the first matching Codex Micro interface.
*
* @param {object} [options] Device selection and callbacks.
* @param {string} [options.path] Exact `node-hid` path to open.
* @param {(method: string, response: unknown) => void|Promise<void>} [options.onForeignWrite]
* @param {(line: string) => void} [options.onDebugLine]
* @returns {Promise<DeviceSession>} Ready, event-wired session.
* @throws {DeviceError} With `DEVICE_NOT_FOUND` when no interface is available.
*/
static async open({ path, ...options } = {}) {
const target = path ?? (await listInterfaces()).at(0)?.path;
if (!target) {
Expand All @@ -114,16 +160,31 @@ export class DeviceSession {
return new DeviceSession(await openHandle(target), options);
}

/**
* Registers one handler for a firmware notification method.
* A newer handler for the same method replaces the previous one.
*
* @param {string} method Notification method name.
* @param {(params: unknown) => void} handler Notification consumer.
* @returns {() => void} Idempotent unsubscriber for this exact handler.
*/
onNotification(method, handler) {
this.#notifyHandlers.set(method, handler);
return () => {
if (this.#notifyHandlers.get(method) === handler) this.#notifyHandlers.delete(method);
};
}

// Queues a call and waits for its response. Tasks run one at a time with
// CALL_SPACING_MS of spacing, since the firmware handles commands in a
// trickle.
/**
* Queues one RPC call and resolves only its correlated response. Calls run
* sequentially with {@link CALL_SPACING_MS} between start times.
*
* @param {string} method Firmware method name.
* @param {unknown} [params] Method parameters; `null` when omitted.
* @param {number} [id] Explicit request id, primarily for deterministic tests.
* @returns {Promise<object>} Parsed firmware response envelope.
* @throws {DeviceError} Via rejection on timeout, write, RPC, or disconnect failure.
*/
Comment on lines +178 to +187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve request-validation errors in DeviceSession.call().

buildRequest() in scripts/lib/hid-frame.mjs rejects empty methods and identifiers outside [0, 999). #run() catches those errors in the same block as HID write failures and wraps them as WRITE_FAILED at Lines [228]-[237]. Callers cannot distinguish invalid input from a transport failure. Validate before queueing or preserve a dedicated validation error, then document the behavior here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/lib/hid-device.mjs` around lines 178 - 187, Preserve validation
errors from buildRequest in DeviceSession.call instead of wrapping them as
WRITE_FAILED in `#run`. Validate the method and request id before queueing, or
identify and rethrow the dedicated validation error separately from HID write
failures; document this behavior in the call method’s JSDoc.

call(method, params = null, id = createRpcId()) {
return new Promise((resolve, reject) => {
this.#queue.push({ method, params, id, resolve, reject });
Expand Down Expand Up @@ -227,6 +288,12 @@ export class DeviceSession {
for (const task of this.#queue.splice(0)) task.reject(error);
}

/**
* Rejects queued and in-flight calls, then closes the underlying HID handle.
* Calling it after the HID stack has already closed is safe.
*
* @returns {Promise<void>} Resolves after the close attempt completes.
*/
async close() {
this.#closed = true;
this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Session closed."));
Expand Down
Loading
Loading