diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 069b40a..eee9339 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -884,6 +884,24 @@ npx thermoworks doneness [meat] [--json] - `--json` - Output machine-readable JSON. +### `thermoworks placement [meat] [--json]` + +Show meat and pit probe placement guidance + +**Usage** + +```bash +npx thermoworks placement [meat] [--json] +``` + +**Arguments** + +- `meat` - Meat name or alias. + +**Options** + +- `--json` - Output machine-readable JSON. + ### `thermoworks safe [--channel N] [--temp T] [--protein P] [--held N] [--json]` Show food-safety pasteurization progress diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index a838204..3ce25cc 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -42,6 +42,7 @@ import { mcpStart } from "./commands/mcp.js"; import { metrics } from "./commands/metrics.js"; import { notifications } from "./commands/notifications.js"; import { open } from "./commands/open.js"; +import { placement } from "./commands/placement.js"; import { plan } from "./commands/plan.js"; import { replay } from "./commands/replay.js"; import { safe } from "./commands/safe.js"; @@ -718,6 +719,15 @@ export const commandDefinitions: readonly CommandDefinition[] = [ supportsJson: true, handler: ({ args, options }: CommandContext) => doneness(args[1], options), }, + { + name: "placement", + summary: "Show meat and pit probe placement guidance", + usage: "placement [meat] [--json]", + usageLines: ["placement [meat] Show meat and pit probe placement guidance"], + arguments: [{ name: "meat", description: "Meat name or alias." }], + supportsJson: true, + handler: ({ args, options }: CommandContext) => placement(args.slice(1), options), + }, { name: "safe", summary: "Show food-safety pasteurization progress", diff --git a/packages/cli/src/commands/placement.ts b/packages/cli/src/commands/placement.ts new file mode 100644 index 0000000..851733c --- /dev/null +++ b/packages/cli/src/commands/placement.ts @@ -0,0 +1,64 @@ +import { getProbePlacements, type ProbePlacement, resolveProbePlacement } from "thermoworks-sdk"; + +import { type OutputOptions, outputJson } from "../output.js"; + +/** Format the built-in probe placements as a compact table. */ +export function formatPlacementTable(placements: ProbePlacement[]): string { + const header = { meat: "Meat", probe: "Meat probe" }; + const rows = placements.map((placement) => ({ + meat: placement.meat, + probe: placement.meatProbe, + })); + const width = Math.max(header.meat.length, ...rows.map((row) => row.meat.length)); + const lines = ["Probe placement guide:\n", ` ${header.meat.padEnd(width)} ${header.probe}`]; + for (const row of rows) lines.push(` ${row.meat.padEnd(width)} ${row.probe}`); + return `${lines.join("\n")}\n`; +} + +/** Format one cut's probe placement details. */ +export function formatPlacementDetail(placement: ProbePlacement): string { + const lines = [ + placement.meat, + ` Meat probe: ${placement.meatProbe}`, + ` Pit probe: ${placement.pitProbe}`, + ` Avoid: ${placement.avoid.join(", ")}`, + ]; + for (const note of placement.notes) lines.push(` Note: ${note}`); + return `${lines.join("\n")}\n`; +} + +/** + * Show probe placement guidance for common cuts. Uses offline SDK reference data, + * so it needs no ThermoWorks Cloud login or network access. + */ +export async function placement( + args: string[], + options: OutputOptions = { json: false }, +): Promise { + const meat = args.join(" ").trim(); + + if (meat.length > 0) { + if (meat.startsWith("--")) { + console.error(`Unknown option: ${meat}`); + process.exit(1); + } + const result = resolveProbePlacement(meat); + if (!result) { + console.error(`Unknown meat: "${meat}". Run "thermoworks placement" to see built-in cuts.`); + process.exit(1); + } + if (options.json) { + outputJson(result); + return; + } + process.stdout.write(formatPlacementDetail(result)); + return; + } + + const placements = getProbePlacements(); + if (options.json) { + outputJson(placements); + return; + } + process.stdout.write(formatPlacementTable(placements)); +} diff --git a/packages/cli/tests/placement.test.ts b/packages/cli/tests/placement.test.ts new file mode 100644 index 0000000..d2a863f --- /dev/null +++ b/packages/cli/tests/placement.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + formatPlacementDetail, + formatPlacementTable, + placement, +} from "../src/commands/placement.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let stdoutWriteSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +const brisket = { + meat: "Brisket", + meatProbe: "Center of the flat", + pitProbe: "At grate level", + avoid: ["fat seam", "bone"], + notes: ["Use a second probe in the point."], +}; + +describe("formatPlacementTable", () => { + it("renders a placement row for each cut", () => { + const out = formatPlacementTable([brisket]); + expect(out).toContain("Probe placement guide"); + expect(out).toContain("Brisket"); + expect(out).toContain("Center of the flat"); + }); +}); + +describe("formatPlacementDetail", () => { + it("renders a labeled block for one cut", () => { + const out = formatPlacementDetail(brisket); + expect(out).toContain("Meat probe:"); + expect(out).toContain("Pit probe:"); + expect(out).toContain("Avoid:"); + expect(out).toContain("Use a second probe"); + }); +}); + +describe("placement command", () => { + it("prints a table of every built-in cut", async () => { + await placement([]); + const written = stdoutWriteSpy.mock.calls[0]?.[0] as string; + expect(written).toContain("Probe placement guide"); + expect(written).toContain("Brisket"); + expect(written).toContain("Pork Butt"); + }); + + it("prints details for a single resolved cut", async () => { + await placement(["brisket"]); + const written = stdoutWriteSpy.mock.calls[0]?.[0] as string; + expect(written).toContain("Brisket"); + expect(written).toContain("Meat probe:"); + }); + + it("resolves multi-word aliases", async () => { + await placement(["pulled", "pork"]); + const written = stdoutWriteSpy.mock.calls[0]?.[0] as string; + expect(written).toContain("Pork Butt"); + }); + + it("exits with an error for an unknown meat", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exit"); + }); + await expect(placement(["unobtainium"])).rejects.toThrow("exit"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown meat")); + exitSpy.mockRestore(); + }); + + it("emits a JSON array for the full list", async () => { + await placement([], { json: true }); + const written = logSpy.mock.calls.map((c) => c[0]).join("\n"); + const parsed = JSON.parse(written); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].meat).toBe("Brisket"); + expect(parsed[0]).toHaveProperty("meatProbe"); + }); + + it("emits a JSON object for a single cut", async () => { + await placement(["brisket"], { json: true }); + const written = logSpy.mock.calls.map((c) => c[0]).join("\n"); + const parsed = JSON.parse(written); + expect(parsed.meat).toBe("Brisket"); + expect(parsed.meatProbe).toContain("flat"); + }); +}); diff --git a/packages/cli/tests/registry.test.ts b/packages/cli/tests/registry.test.ts index 9f81057..ace8832 100644 --- a/packages/cli/tests/registry.test.ts +++ b/packages/cli/tests/registry.test.ts @@ -41,6 +41,7 @@ const expectedCommands = [ "graph", "guide", "doneness", + "placement", "safe", "carryover", "cooldown", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 457e27d..e83e05d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -51,6 +51,8 @@ export { getPasteurizationTable, requiredHoldMinutes, } from "./pasteurization.js"; +export type { ProbePlacement } from "./placement.js"; +export { getProbePlacements, resolveProbePlacement } from "./placement.js"; export type { CookPlan, CookPlanItem, diff --git a/packages/sdk/src/placement.ts b/packages/sdk/src/placement.ts new file mode 100644 index 0000000..8675639 --- /dev/null +++ b/packages/sdk/src/placement.ts @@ -0,0 +1,105 @@ +import { getMeatProfiles, resolveMeatProfile } from "./plan.js"; + +/** Probe placement guidance for one built-in cut. */ +export interface ProbePlacement { + /** Canonical meat profile name. */ + readonly meat: string; + /** Where to place the food probe. */ + readonly meatProbe: string; + /** Where to place the pit or ambient probe. */ + readonly pitProbe: string; + /** Common placement mistakes to avoid. */ + readonly avoid: readonly string[]; + /** Extra cook-specific placement notes. */ + readonly notes: readonly string[]; +} + +const PLACEMENTS: Record> = { + Brisket: { + meatProbe: "Center of the flat, from the side, with the tip in the thickest lean section.", + pitProbe: "Clipped to the grate near the brisket, shielded from direct radiant heat.", + avoid: ["Fat seams", "the point", "touching the grate", "the firebox side of an offset smoker"], + notes: ["Use a second probe in the point if you want to track both muscles."], + }, + "Pork Butt": { + meatProbe: "Deep in the money muscle or thickest center, angled away from the blade bone.", + pitProbe: "At grate level beside the butt, not above the dome thermometer.", + avoid: ["Blade bone", "large fat pockets", "the outer bark"], + notes: ["Probe tenderness in several spots near the end because shoulders finish unevenly."], + }, + "Pork Ribs": { + meatProbe: "Between bones in the thickest center rack section if using a probe.", + pitProbe: "At grate level beside the rack, with the clip clear of sauce or foil.", + avoid: ["Touching bone", "thin end bones", "pinching the cable under a lid"], + notes: ["Ribs are still best judged by bend, pullback, and probe tenderness."], + }, + "Baby Back Ribs": { + meatProbe: "Between bones in the thickest center of the rack, parallel to the bones.", + pitProbe: "At grate level beside the rack and away from direct flame.", + avoid: ["Bone contact", "the narrow tail end", "placing the pit probe above a water pan edge"], + notes: ["Use temperature as a trend. Tenderness matters more than a single number."], + }, + "Whole Chicken": { + meatProbe: "Deep in the breast from the side, tip centered and not touching the cavity.", + pitProbe: "At grate level beside the bird, away from dripping flare-ups.", + avoid: ["Bone", "the cavity", "skin-only shallow placement"], + notes: ["Spot-check the thigh separately. It should finish hotter than the breast."], + }, + "Chicken Wings": { + meatProbe: "Use the largest drumette or flat if monitoring, with the tip centered in meat.", + pitProbe: "At grate level near the wing pile or basket.", + avoid: ["Bone", "skin folds", "tiny pieces that over-read quickly"], + notes: ["Wings are small, so spot checks are often more reliable than a leave-in probe."], + }, + Turkey: { + meatProbe: "Deep in the breast from the side, tip centered and clear of the rib cage.", + pitProbe: "At grate level beside the turkey, not high in the dome.", + avoid: ["Rib bones", "the cavity", "stuffing", "touching a roasting rack"], + notes: ["Track the breast and spot-check the thigh so dark meat has time to finish."], + }, + "Tri-Tip": { + meatProbe: "Into the thickest center from the side, following the grain line.", + pitProbe: "At grate level beside the roast, away from the sear zone.", + avoid: ["The tapered tip", "surface fat", "direct radiant heat during a reverse sear"], + notes: ["Move or remove the probe before a hard sear if cable heat is a risk."], + }, + Salmon: { + meatProbe: "Into the thickest fillet section from the side, shallow enough to stay centered.", + pitProbe: "At grate level beside the fillet or plank.", + avoid: ["Thin tail sections", "touching the plank", "pushing through the fillet"], + notes: ["Salmon changes quickly near the end. Pair the probe with visual flake checks."], + }, + "Beef Short Ribs": { + meatProbe: "Centered in the thickest meat between bones, inserted from the side.", + pitProbe: "At grate level beside the ribs, clear of the bone side and water pan edge.", + avoid: ["Bone", "fat pockets", "the membrane side"], + notes: ["Probe each rib for tenderness because thickness varies across the plate."], + }, +}; + +function copyPlacement(meat: string, placement: Omit): ProbePlacement { + return { + meat, + meatProbe: placement.meatProbe, + pitProbe: placement.pitProbe, + avoid: [...placement.avoid], + notes: [...placement.notes], + }; +} + +/** Return probe placement guidance for every built-in meat profile. */ +export function getProbePlacements(): ProbePlacement[] { + return getMeatProfiles().map((profile) => { + const placement = PLACEMENTS[profile.name]; + if (!placement) throw new Error(`Missing probe placement for ${profile.name}`); + return copyPlacement(profile.name, placement); + }); +} + +/** Resolve a meat name or alias to probe placement guidance. */ +export function resolveProbePlacement(meat: string): ProbePlacement | null { + const profile = resolveMeatProfile(meat); + if (!profile) return null; + const placement = PLACEMENTS[profile.name]; + return placement ? copyPlacement(profile.name, placement) : null; +} diff --git a/packages/sdk/tests/placement.test.ts b/packages/sdk/tests/placement.test.ts new file mode 100644 index 0000000..faec25e --- /dev/null +++ b/packages/sdk/tests/placement.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { getProbePlacements, resolveProbePlacement } from "../src/placement.js"; +import { getMeatProfiles } from "../src/plan.js"; + +describe("getProbePlacements", () => { + it("covers every built-in meat profile", () => { + const placements = getProbePlacements(); + expect(placements.map((p) => p.meat)).toEqual(getMeatProfiles().map((p) => p.name)); + expect(placements.find((p) => p.meat === "Brisket")?.meatProbe).toContain("flat"); + }); + + it("returns copies so callers cannot mutate the source", () => { + const first = getProbePlacements(); + (first[0]!.avoid as string[]).push("hacked"); + expect(getProbePlacements()[0]?.avoid).not.toContain("hacked"); + }); +}); + +describe("resolveProbePlacement", () => { + it("resolves canonical names and aliases", () => { + expect(resolveProbePlacement("BRISKET")?.meat).toBe("Brisket"); + expect(resolveProbePlacement("pulled pork")?.meat).toBe("Pork Butt"); + }); + + it("returns null for unknown meats", () => { + expect(resolveProbePlacement("unobtainium")).toBeNull(); + }); +});