Sign In

@ttctl/cli

Package Overview
Dependencies
Maintainers
1
Versions
24
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ttctl/cli - npm Package Compare versions

Comparing version
0.1.0-rc.16
to
0.1.0-rc.17
+34
dist/commands/engagements/payments.d.ts
import { engagements } from "@ttctl/core";
import type { OutputFormat } from "../../lib/output.js";
/**
* Action handler for `ttctl engagements payments list <job-id>`. Lists
* the payments under an engagement, addressed by its JOB id (the wire
* op `GetEngagementPayments` takes `$jobId` — see core
* `engagements.payments.list`).
*
* Pagination is ADR-007 row 4 (limit + forward cursor): `--limit` caps
* the page; `--after <id>` is a forward cursor that IS a payment id. The
* JSON / YAML envelope carries `pageInfo` with `hasNextPage` (and
* `perPage` when `--limit` is set) — cursor pagination has no page
* numbers, so `currentPage` / `totalPages` are intentionally omitted.
* The pretty footer shows the total count and the next-page cursor.
*/
export interface EngagementsPaymentsListOptions {
limit?: number;
after?: string;
output: OutputFormat;
}
export declare function runEngagementsPaymentsList(jobId: string, opts: EngagementsPaymentsListOptions): Promise<void>;
/**
* Cursor-style footer: "N shown · M total" plus the `--after` hint when
* another page is available. Pure — directly unit-testable.
*/
export declare function formatPaymentsFooter(page: engagements.EngagementPaymentsPage): string;
/**
* Render the payments list as a `cli-table3` table sized to the terminal
* width. Columns: id (the cursor for `--after`), number, status, amount,
* due, paid. Money values are decimal strings emitted verbatim — no
* locale formatting, no rounding (parse with a decimal library, never
* `parseFloat`).
*/
export declare function formatPaymentsTable(items: engagements.EngagementPayment[], terminalWidth?: number): string;
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Oleksii PELYKH
import Table from "cli-table3";
import { engagements } from "@ttctl/core";
import { wrapListEnvelope } from "../../lib/envelopes.js";
import { emitResult } from "../../lib/output.js";
import { formatDate } from "./list.js";
import { handleEngagementsError, loadAuthTokenOrExit } from "./shared.js";
export async function runEngagementsPaymentsList(jobId, opts) {
const token = await loadAuthTokenOrExit("engagements payments list", opts.output);
const listOpts = {};
if (opts.limit !== undefined)
listOpts.limit = opts.limit;
if (opts.after !== undefined)
listOpts.after = opts.after;
let page;
try {
page = await engagements.payments.list(token, jobId, listOpts);
}
catch (err) {
handleEngagementsError("engagements payments list", err, opts.output);
}
const pageInfo = { hasNextPage: page.nextCursor !== null };
if (page.limit !== null)
pageInfo.perPage = page.limit;
emitResult(wrapListEnvelope(page.items, pageInfo), opts.output, {
pretty: () => renderPaymentsPretty(page),
table: () => renderPaymentsPretty(page),
empty: { command: "engagements.payments.list" },
});
}
/**
* Render the payments table plus the cursor footer. The footer is
* appended only when `totalCount > 0` — empty pages route through the
* empty-state CTA before this renderer fires.
*/
function renderPaymentsPretty(page) {
const table = formatPaymentsTable(page.items);
if (page.totalCount <= 0)
return table;
return `${table}\n${formatPaymentsFooter(page)}`;
}
/**
* Cursor-style footer: "N shown · M total" plus the `--after` hint when
* another page is available. Pure — directly unit-testable.
*/
export function formatPaymentsFooter(page) {
const base = `${page.items.length.toString()} shown · ${page.totalCount.toString()} total`;
return page.nextCursor !== null ? `${base} · more: --after ${page.nextCursor}` : base;
}
/**
* Render the payments list as a `cli-table3` table sized to the terminal
* width. Columns: id (the cursor for `--after`), number, status, amount,
* due, paid. Money values are decimal strings emitted verbatim — no
* locale formatting, no rounding (parse with a decimal library, never
* `parseFloat`).
*/
export function formatPaymentsTable(items, terminalWidth = process.stdout.columns || 100) {
const head = ["id", "number", "status", "amount", "due", "paid"];
if (items.length === 0) {
return new Table({ head }).toString();
}
const idWidth = 22;
const numberWidth = 10;
const statusWidth = 12;
const amountWidth = 14;
const dueWidth = 12;
// 6 columns × 2 padding + 7 borders ≈ 19
const paidWidth = Math.max(12, terminalWidth - idWidth - numberWidth - statusWidth - amountWidth - dueWidth - 19);
const table = new Table({
head,
colWidths: [idWidth, numberWidth, statusWidth, amountWidth, dueWidth, paidWidth],
colAligns: ["left", "right", "left", "right", "left", "left"],
wordWrap: true,
});
for (const p of items) {
table.push([p.id, p.number.toString(), p.status, p.amount, formatDate(p.dueDate), formatDate(p.paidAt)]);
}
return table.toString();
}
import { jobs } from "@ttctl/core";
import type { OutputFormat } from "../../lib/output.js";
/**
* Options for `ttctl jobs dashboard` — the talent's "my activity"
* list (`viewer.jobActivityList`). Pagination only, mirroring
* `jobs recommended`.
*/
export interface JobsDashboardOptions {
page?: number;
perPage?: number;
output: OutputFormat;
}
/**
* Action handler for `ttctl jobs dashboard`. Lists dashboard activity
* items (engagements / applications / pending actions) in the same list
* envelope + pretty footer as the other paginated jobs leaves.
*/
export declare function runJobsDashboard(opts: JobsDashboardOptions): Promise<void>;
/**
* Action handler for `ttctl jobs dashboard-count <status-group>`. The
* wire op requires a status group (e.g. `ACTIVE_ENGAGEMENT`), so the
* group is a required positional. Emits `{ statusGroup, count }` on
* `json` / `yaml`; pretty renders a one-line summary.
*/
export declare function runJobsDashboardCount(statusGroup: string, output: OutputFormat): Promise<void>;
/**
* Render the dashboard activity list as a table. The `job id` column is
* the actionable id (feed it to `jobs show`); the activity-level `id`
* lives in the JSON envelope only.
*
* Pure — directly unit-testable.
*/
export declare function formatDashboardTable(items: jobs.DashboardJobItem[], terminalWidth?: number): string;
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Oleksii PELYKH
import Table from "cli-table3";
import { jobs } from "@ttctl/core";
import { wrapListEnvelope } from "../../lib/envelopes.js";
import { emitResult } from "../../lib/output.js";
import { buildJobsPageInfo, formatDate, formatPageFooter, handleJobsError, loadAuthTokenOrExit } from "./shared.js";
/**
* Action handler for `ttctl jobs dashboard`. Lists dashboard activity
* items (engagements / applications / pending actions) in the same list
* envelope + pretty footer as the other paginated jobs leaves.
*/
export async function runJobsDashboard(opts) {
const token = await loadAuthTokenOrExit("jobs dashboard", opts.output);
const listOpts = {};
if (opts.page !== undefined)
listOpts.page = opts.page;
if (opts.perPage !== undefined)
listOpts.perPage = opts.perPage;
let page;
try {
page = await jobs.getJobsForDashboard(token, listOpts);
}
catch (err) {
handleJobsError("jobs dashboard", err, opts.output);
}
const pageInfo = buildJobsPageInfo(page);
emitResult(wrapListEnvelope(page.items, pageInfo), opts.output, {
pretty: (data) => renderDashboardPretty(data.items, page),
table: (data) => renderDashboardPretty(data.items, page),
empty: { command: "jobs.dashboard" },
});
}
/**
* Action handler for `ttctl jobs dashboard-count <status-group>`. The
* wire op requires a status group (e.g. `ACTIVE_ENGAGEMENT`), so the
* group is a required positional. Emits `{ statusGroup, count }` on
* `json` / `yaml`; pretty renders a one-line summary.
*/
export async function runJobsDashboardCount(statusGroup, output) {
const token = await loadAuthTokenOrExit("jobs dashboard-count", output);
let count;
try {
count = await jobs.getJobsCountForDashboard(token, statusGroup);
}
catch (err) {
handleJobsError("jobs dashboard-count", err, output);
}
emitResult({ statusGroup, count }, output, {
pretty: (data) => `Dashboard jobs (${data.statusGroup}): ${data.count.toString()}`,
});
}
/**
* Render the dashboard activity list as a table. The `job id` column is
* the actionable id (feed it to `jobs show`); the activity-level `id`
* lives in the JSON envelope only.
*
* Pure — directly unit-testable.
*/
export function formatDashboardTable(items, terminalWidth = process.stdout.columns || 100) {
const head = ["job id", "title", "client", "status", "group", "updated"];
if (items.length === 0)
return new Table({ head }).toString();
const idWidth = 22;
const clientWidth = 22;
const statusWidth = 16;
const groupWidth = 20;
const updatedWidth = 12;
const remaining = Math.max(20, terminalWidth - idWidth - clientWidth - statusWidth - groupWidth - updatedWidth - 20);
const table = new Table({
head,
colWidths: [idWidth, Math.max(20, remaining), clientWidth, statusWidth, groupWidth, updatedWidth],
wordWrap: true,
});
for (const it of items) {
table.push([
it.job.id,
it.job.title ?? "(untitled)",
it.job.client?.fullName ?? "",
it.status?.verbose ?? it.status?.value ?? "",
it.statusGroup ?? "",
formatDate(it.lastUpdatedAt),
]);
}
return table.toString();
}
function renderDashboardPretty(items, page) {
const table = formatDashboardTable(items);
if (page.totalCount <= 0)
return table;
return `${table}\n${formatPageFooter(page.page, page.perPage, page.totalCount)}`;
}
import { jobs } from "@ttctl/core";
import type { OutputFormat } from "../../lib/output.js";
/**
* Action handler for `ttctl jobs match-quality <id>`. Fetches the
* platform's per-criterion match-quality breakdown for the talent×job pair
* and emits via the cross-CLI output helper.
*
* `json` / `yaml` emit the full `{ metrics }` projection. Pretty renders one
* line per criterion with its `statusV2` status, required / availability-
* request flags, and the human-facing description / explanation.
*/
export declare function runJobsMatchQuality(id: string, output: OutputFormat): Promise<void>;
/**
* Render the match-quality breakdown as a sectioned multi-line block. Pure —
* directly unit-testable. The header surfaces the criterion count so an empty
* breakdown reads as "Toptal returned no criteria" rather than a dropped
* section.
*/
export declare function formatMatchQuality(jobId: string, quality: jobs.JobMatchQuality): string;
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Oleksii PELYKH
import { jobs } from "@ttctl/core";
import { emitResult } from "../../lib/output.js";
import { handleJobsError, loadAuthTokenOrExit } from "./shared.js";
/**
* Action handler for `ttctl jobs match-quality <id>`. Fetches the
* platform's per-criterion match-quality breakdown for the talent×job pair
* and emits via the cross-CLI output helper.
*
* `json` / `yaml` emit the full `{ metrics }` projection. Pretty renders one
* line per criterion with its `statusV2` status, required / availability-
* request flags, and the human-facing description / explanation.
*/
export async function runJobsMatchQuality(id, output) {
const token = await loadAuthTokenOrExit("jobs match-quality", output);
let result;
try {
result = await jobs.matchQuality(token, id);
}
catch (err) {
handleJobsError("jobs match-quality", err, output);
}
emitResult(result, output, {
pretty: (data) => formatMatchQuality(id, data),
});
}
/**
* Render the match-quality breakdown as a sectioned multi-line block. Pure —
* directly unit-testable. The header surfaces the criterion count so an empty
* breakdown reads as "Toptal returned no criteria" rather than a dropped
* section.
*/
export function formatMatchQuality(jobId, quality) {
const count = quality.metrics.length;
const lines = [
`Match quality for job ${jobId} (${count.toString()} ${count === 1 ? "criterion" : "criteria"})`,
];
for (const m of quality.metrics) {
lines.push(...formatMetric(m));
}
return lines.join("\n");
}
function formatMetric(m) {
const label = m.name ?? m.slug ?? "(unnamed)";
const status = m.statusV2 !== null && m.statusV2 !== "" ? ` [${m.statusV2}]` : "";
const flagParts = [];
if (m.isRequired === true)
flagParts.push("required");
if (m.forAvailabilityRequest === true)
flagParts.push("availability-request");
const flags = flagParts.length > 0 ? ` (${flagParts.join(", ")})` : "";
const lines = [` • ${label}${status}${flags}`];
if (m.description !== null && m.description !== "")
lines.push(` ${m.description}`);
if (m.explanation !== null && m.explanation !== "")
lines.push(` ${m.explanation}`);
return lines;
}
import { jobs } from "@ttctl/core";
import type { OutputFormat } from "../../lib/output.js";
/**
* Action handler for `ttctl jobs rate-insight <id>`. Fetches the platform's
* per-job rate-intelligence panel for the talent×job pair and emits via the
* cross-CLI output helper.
*
* `json` / `yaml` emit the full `JobRateInsight` projection (or `null` when the
* platform surfaces no insight). Pretty renders the discriminated band — the
* estimated revenue plus, for an uncompetitive job, the recommended /
* recent-application rate band.
*/
export declare function runJobsRateInsight(id: string, output: OutputFormat): Promise<void>;
/**
* Render the rate insight as a short multi-line block. Pure — directly
* unit-testable. `null` (no insight surfaced for the job) reads as an explicit
* line rather than an empty render. Rate values are emitted verbatim (they are
* BigDecimal strings — no locale formatting, no rounding).
*/
export declare function formatRateInsight(jobId: string, insight: jobs.JobRateInsight | null): string;
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Oleksii PELYKH
import { jobs } from "@ttctl/core";
import { emitResult } from "../../lib/output.js";
import { handleJobsError, loadAuthTokenOrExit } from "./shared.js";
/**
* Action handler for `ttctl jobs rate-insight <id>`. Fetches the platform's
* per-job rate-intelligence panel for the talent×job pair and emits via the
* cross-CLI output helper.
*
* `json` / `yaml` emit the full `JobRateInsight` projection (or `null` when the
* platform surfaces no insight). Pretty renders the discriminated band — the
* estimated revenue plus, for an uncompetitive job, the recommended /
* recent-application rate band.
*/
export async function runJobsRateInsight(id, output) {
const token = await loadAuthTokenOrExit("jobs rate-insight", output);
let result;
try {
result = await jobs.rateInsight(token, id);
}
catch (err) {
handleJobsError("jobs rate-insight", err, output);
}
emitResult(result, output, {
pretty: (data) => formatRateInsight(id, data),
});
}
/**
* Render the rate insight as a short multi-line block. Pure — directly
* unit-testable. `null` (no insight surfaced for the job) reads as an explicit
* line rather than an empty render. Rate values are emitted verbatim (they are
* BigDecimal strings — no locale formatting, no rounding).
*/
export function formatRateInsight(jobId, insight) {
if (insight === null) {
return `No rate insight available for job ${jobId}.`;
}
const lines = [`Rate insight for job ${jobId} — ${insight.kind ?? "unknown"}`];
if (insight.estimatedRevenue !== null && insight.estimatedRevenue !== "") {
lines.push(` Estimated revenue: ${insight.estimatedRevenue}`);
}
if (insight.recommendedRate !== null && insight.recommendedRate !== "") {
lines.push(` Recommended rate: ${insight.recommendedRate}`);
}
if (insight.recentApplicationRate !== null && insight.recentApplicationRate !== "") {
lines.push(` Recent application rate: ${insight.recentApplicationRate}`);
}
if (insight.estimatedRevenueExplanation !== null && insight.estimatedRevenueExplanation !== "") {
lines.push(` ${insight.estimatedRevenueExplanation}`);
}
if (insight.longTermDisclaimer !== null && insight.longTermDisclaimer !== "") {
lines.push(` ${insight.longTermDisclaimer}`);
}
return lines.join("\n");
}
import { Command } from "commander";
import { me } from "@ttctl/core";
import type { OutputFormat } from "../../lib/output.js";
/**
* Build the `ttctl me` command tree. Viewer-scoped reads that aren't
* profile-content or engagement domain. One sub-domain today:
*
* | Leaf | Description |
* |-----------------------------------------------------|----------------------------|
* | `actions list [--before <c>] [--after <c>] [--limit N]` | Viewer performed-actions audit log |
*
* **Pagination** is ADR-007 (ttctl) row 5 — bare bidirectional cursor:
* `--before` / `--after` take opaque cursor tokens (the wire's `String`
* cursors, surfaced verbatim — no client-side translation), `--limit`
* caps the page size. These name the wire args 1:1 (surface-honesty).
*/
export declare function buildMeCommand(): Command;
/**
* Action handler for `ttctl me actions list`. Returns the actions in
* the v1.0 list envelope on `--json` / `--yaml`; renders a `cli-table3`
* table on `--output=pretty`. An empty list is a legitimate return.
*/
export declare function runMeActionsList(options: {
before?: string;
after?: string;
limit?: number;
output: OutputFormat;
}): Promise<void>;
/**
* Render the actions list as a `cli-table3` table. Columns: occurred,
* category, description. The description column shows the raw `template`
* (its `variables` substitutions surface in `--json` / `--yaml`) — TTCtl
* does not invent a substitution syntax it hasn't verified on the wire.
*/
export declare function formatActionsTable(items: me.PerformedAction[], terminalWidth?: number): string;
/** Trim an ISO 8601 timestamp to `YYYY-MM-DD HH:MM`; pass through non-ISO input. */
export declare function formatTimestamp(value: string | null): string;
/**
* Thin wrapper around the shared CLI error router closed over
* `me.MeError`. The router applies the envelope ABI branching uniformly.
*/
export declare function handleMeError(commandLabel: string, err: unknown, format?: OutputFormat): never;
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Oleksii PELYKH
import Table from "cli-table3";
import { Command, Option } from "commander";
import { me } from "@ttctl/core";
import { wrapListEnvelope } from "../../lib/envelopes.js";
import { handleDomainError } from "../../lib/error-routing.js";
import { emitResult, OUTPUT_FORMATS } from "../../lib/output.js";
import { parsePaginationFlag } from "../../lib/pagination.js";
import { loadAuthTokenOrExit } from "../profile/shared.js";
/**
* Build the `ttctl me` command tree. Viewer-scoped reads that aren't
* profile-content or engagement domain. One sub-domain today:
*
* | Leaf | Description |
* |-----------------------------------------------------|----------------------------|
* | `actions list [--before <c>] [--after <c>] [--limit N]` | Viewer performed-actions audit log |
*
* **Pagination** is ADR-007 (ttctl) row 5 — bare bidirectional cursor:
* `--before` / `--after` take opaque cursor tokens (the wire's `String`
* cursors, surfaced verbatim — no client-side translation), `--limit`
* caps the page size. These name the wire args 1:1 (surface-honesty).
*/
export function buildMeCommand() {
const cmd = new Command("me").description("Viewer-scoped reads (your own audit log, etc.).");
const actions = cmd.command("actions").description("Your performed-actions audit log (read-only).");
actions
.command("list")
.description("List your performed actions — the per-role audit log (status changes, applications submitted, etc.).\n\n" +
"Pagination (ADR-007 row 5, bidirectional cursor): --before / --after take opaque cursor tokens; --limit caps the page size.")
.addOption(new Option("--before <cursor>", "opaque cursor — return actions before this point"))
.addOption(new Option("--after <cursor>", "opaque cursor — return actions after this point"))
.addOption(new Option("--limit <number>", "max actions to return").argParser((raw) => parsePaginationFlag("--limit", raw)))
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (options) => {
await runMeActionsList(options);
});
return cmd;
}
/**
* Action handler for `ttctl me actions list`. Returns the actions in
* the v1.0 list envelope on `--json` / `--yaml`; renders a `cli-table3`
* table on `--output=pretty`. An empty list is a legitimate return.
*/
export async function runMeActionsList(options) {
const token = await loadAuthTokenOrExit("me actions list", options.output);
const opts = {};
if (options.before !== undefined)
opts.before = options.before;
if (options.after !== undefined)
opts.after = options.after;
if (options.limit !== undefined)
opts.limit = options.limit;
let items;
try {
items = await me.actions.list(token, opts);
}
catch (err) {
handleMeError("me actions list", err, options.output);
}
emitResult(wrapListEnvelope(items), options.output, {
pretty: (data) => formatActionsTable(data.items),
table: (data) => formatActionsTable(data.items),
empty: { command: "me.actions.list" },
});
}
/**
* Render the actions list as a `cli-table3` table. Columns: occurred,
* category, description. The description column shows the raw `template`
* (its `variables` substitutions surface in `--json` / `--yaml`) — TTCtl
* does not invent a substitution syntax it hasn't verified on the wire.
*/
export function formatActionsTable(items, terminalWidth = process.stdout.columns || 100) {
if (items.length === 0) {
return new Table({ head: ["occurred", "category", "description"] }).toString();
}
const occurredWidth = 22;
const categoryWidth = 24;
// 3 columns × 2 padding + 4 borders ≈ 10
const descWidth = Math.max(20, terminalWidth - occurredWidth - categoryWidth - 10);
const table = new Table({
head: ["occurred", "category", "description"],
colWidths: [occurredWidth, categoryWidth, descWidth],
colAligns: ["left", "left", "left"],
wordWrap: true,
});
for (const a of items) {
table.push([formatTimestamp(a.occurredAt), a.category ?? "—", a.description?.template ?? "—"]);
}
return table.toString();
}
/** Trim an ISO 8601 timestamp to `YYYY-MM-DD HH:MM`; pass through non-ISO input. */
export function formatTimestamp(value) {
if (value === null || value === "")
return "—";
const match = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})/.exec(value);
return match ? `${match[1]} ${match[2]}` : value;
}
/**
* Thin wrapper around the shared CLI error router closed over
* `me.MeError`. The router applies the envelope ABI branching uniformly.
*/
export function handleMeError(commandLabel, err, format = "pretty") {
handleDomainError(commandLabel, err, me.MeError, format);
}
import { payments } from "@ttctl/core";
import type { OutputFormat } from "../../lib/output.js";
/**
* Action handler for `ttctl payments show-many <id...>`.
* Batch-fetches several payouts in one wire round-trip via
* `payments.showMany` (`PaymentsByIDs`), emitting the found payouts in
* input order. Ids that resolve to no payout are reported (pretty: a
* trailing "Not found" line; json / yaml consumers diff the returned
* `id`s against their input).
*/
export declare function runPaymentsShowMany(ids: string[], output: OutputFormat): Promise<void>;
/**
* Render several payout detail views as one pretty block — each payout's
* {@link formatPayoutDetail} output separated by a horizontal rule, with a
* trailing "Not found" line listing any requested ids the API did not
* return. Pure — directly unit-testable.
*/
export declare function formatPayoutDetails(items: payments.Payout[], missing: string[]): string;
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Oleksii PELYKH
import { payments } from "@ttctl/core";
import { emitResult } from "../../lib/output.js";
import { formatPayoutDetail } from "./payouts.js";
import { handlePaymentsError, loadAuthTokenOrExit } from "./shared.js";
/**
* Action handler for `ttctl payments show-many <id...>`.
* Batch-fetches several payouts in one wire round-trip via
* `payments.showMany` (`PaymentsByIDs`), emitting the found payouts in
* input order. Ids that resolve to no payout are reported (pretty: a
* trailing "Not found" line; json / yaml consumers diff the returned
* `id`s against their input).
*/
export async function runPaymentsShowMany(ids, output) {
const token = await loadAuthTokenOrExit("payments show-many", output);
let items;
try {
items = await payments.showMany(token, ids);
}
catch (err) {
handlePaymentsError("payments show-many", err, output);
}
const found = new Set(items.map((p) => p.id));
const missing = ids.filter((id) => !found.has(id));
emitResult(items, output, {
pretty: (data) => formatPayoutDetails(data, missing),
});
}
/**
* Render several payout detail views as one pretty block — each payout's
* {@link formatPayoutDetail} output separated by a horizontal rule, with a
* trailing "Not found" line listing any requested ids the API did not
* return. Pure — directly unit-testable.
*/
export function formatPayoutDetails(items, missing) {
const blocks = items.map(formatPayoutDetail);
if (missing.length > 0) {
blocks.push(`Not found (${missing.length.toString()}): ${missing.join(", ")}`);
}
if (blocks.length === 0)
return "No payments found.";
return blocks.join("\n\n————————————————————————————————\n\n");
}
import type { OutputFormat } from "../../lib/output.js";
/**
* Action handler for `ttctl timesheet update <id>` (#458).
*
* Edits a draft timesheet's comment and/or per-day records. `UpdateTimesheet`
* is a full-replacement contract, so the core service does read-modify-write
* (fetch → merge overrides by date → resend the complete set); the CLI only
* supplies the partial overrides.
*
* `--record <date=minutes>` / `--note <date=text>` are repeatable and merged
* by date. `--consent-timesheet-billing` is the ADR-009 ceremony (or set
* `TTCTL_ALLOW_INFERRED_DESTRUCTIVE=1`); absence surfaces a
* `CONSENT_REQUIRED` envelope from the core gate. `--dry-run` previews the
* mutation without any wire call (see {@link DRY_RUN_MERGE_NOTICE}).
*/
export interface TimesheetUpdateOptions {
comment?: string;
/** Raw `--record date=minutes` values (repeatable); parsed in the handler. */
record: string[];
/** Raw `--note date=text` values (repeatable); parsed in the handler. */
note: string[];
consentTimesheetBilling: boolean;
output: OutputFormat;
}
export declare function runTimesheetUpdate(id: string, opts: TimesheetUpdateOptions): Promise<void>;
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Oleksii PELYKH
import { timesheet } from "@ttctl/core";
import { getCliDryRun } from "../../lib/dry-run.js";
import { emitDryRunSuccess, emitUpdateSuccess } from "../../lib/envelopes.js";
import { handleTimesheetError, loadAuthTokenOrExit } from "./shared.js";
import { formatTimesheetDetail } from "./show.js";
/**
* Surfaced on the dry-run envelope: the preview shows only the caller's
* explicit overrides, but the apply path merges them into the full record
* set so unspecified days and the comment are preserved.
*/
const DRY_RUN_MERGE_NOTICE = "Apply path performs read-modify-write: it fetches the current timesheet, merges these overrides into the full record set (by date), and resends the complete set + comment — so unspecified days and the comment are preserved. This preview shows only your requested overrides and issues no wire calls.";
export async function runTimesheetUpdate(id, opts) {
const token = await loadAuthTokenOrExit("timesheet update", opts.output);
const dryRun = getCliDryRun();
let records;
try {
records = mergeRecordFlags(opts.record, opts.note);
}
catch (err) {
handleTimesheetError("timesheet update", err, opts.output);
}
const input = {
timesheetBillingConsentIssued: opts.consentTimesheetBilling,
};
if (opts.comment !== undefined)
input.comment = opts.comment;
if (records.length > 0)
input.records = records;
let outcome;
try {
outcome = await timesheet.update(token, id, input, { dryRun });
}
catch (err) {
handleTimesheetError("timesheet update", err, opts.output);
}
if (outcome.kind === "preview") {
emitDryRunSuccess({
operation: "timesheet.update",
format: opts.output,
preview: outcome.preview,
notice: DRY_RUN_MERGE_NOTICE,
});
return;
}
const { result: updated } = outcome;
emitUpdateSuccess({
operation: "timesheet.update",
format: opts.output,
updated,
prettySummary: `timesheet ${updated.id} updated (${updated.startDate} → ${updated.endDate}, ${updated.hours}h)`,
prettyEntity: (entity) => formatTimesheetDetail(entity),
});
}
/**
* Merge `--record date=minutes` and `--note date=text` flags into per-day
* overrides keyed by date. `duration` stays a string (ADR-006). Throws
* `TimesheetError("VALIDATION_ERROR")` on malformed input so the shared
* router emits a clean envelope.
*/
function mergeRecordFlags(recordFlags, noteFlags) {
const byDate = new Map();
const ensure = (date) => {
const existing = byDate.get(date);
if (existing !== undefined)
return existing;
const created = { date };
byDate.set(date, created);
return created;
};
for (const raw of recordFlags) {
const { date, value } = splitDateEq("--record", raw);
if (!/^\d+(\.\d+)?$/.test(value)) {
throw new timesheet.TimesheetError("VALIDATION_ERROR", `--record ${raw}: duration must be minutes as a decimal number (e.g. 480 or 480.0); got "${value}".`);
}
ensure(date).duration = value;
}
for (const raw of noteFlags) {
const { date, value } = splitDateEq("--note", raw);
// An empty value clears the note (sent as "").
ensure(date).note = value;
}
return [...byDate.values()];
}
/**
* Split a `<date>=<value>` flag on the FIRST `=`. The date must be an
* ISO `YYYY-MM-DD`; the value may be empty (only meaningful for `--note`,
* which treats empty as "clear").
*/
function splitDateEq(flag, raw) {
const eq = raw.indexOf("=");
if (eq === -1) {
throw new timesheet.TimesheetError("VALIDATION_ERROR", `${flag} ${raw}: expected <date>=<value> (e.g. ${flag} 2026-06-01=...).`);
}
const date = raw.slice(0, eq);
const value = raw.slice(eq + 1);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new timesheet.TimesheetError("VALIDATION_ERROR", `${flag} ${raw}: date must be ISO YYYY-MM-DD; got "${date}".`);
}
return { date, value };
}
+10
-25
import { Command } from "commander";
/**
* Build the `ttctl applications` command tree. Read-only access to the
* user's Toptal Talent **Activity** view (which Toptal colloquially
* calls "applications"). Three leaves:
* Build the `ttctl applications` command tree — the user's Toptal Talent
* **Activity** view (which Toptal colloquially calls "applications").
* Read is primary; writes on the application funnel are in scope per
* ADR-008 (`hq/engineering/adr/ADR-008-application-funnel-write-side.md`):
* Interest Request confirm / reject and direct job application are in
* scope; `withdraw` / `edit`, bulk apply, and interview accept / reject
* remain out of scope. The command tree below is the authoritative leaf
* inventory.
*
* | Leaf | Description |
* |-----------|------------------------------------------------------------|
* | `list` | List recent activity rows (filterable by status group) |
* | `show <id>` | Detail view for one row |
* | `stats` | Per-status-group counts (5 server calls in parallel) |
*
* Write operations on the application funnel are in scope per ADR-008
* (ttctl) — `hq/engineering/adr/ADR-008-application-funnel-write-side.md`.
* ADR-008 § Decision relaxes the #15 read-only non-goal and bounds the
* write surface: Interest Request confirm / reject (shipped in #411)
* and direct job application are in scope; `withdraw` / `edit`, bulk
* apply, and interview accept / reject remain explicitly out of scope.
*
* **Pagination (#377)**: the `list` leaf declares `--page` /
* `--per-page` (1-indexed positive integers; same `parsePaginationFlag`
* enforcement as the jobs leaves per #183). `#377` added the
* `$page` / `$pageSize` wire args to the hand-authored
* `JobActivityItems` document.
*
* **Still out of scope** (see `.tmp/workitem-15.md` § Open Questions):
* `--from` / `--to` date filters — captured operation accepts no date
* args.
* Date filters (`--from` / `--to`) on `list` remain out of scope — the
* captured `JobActivityItems` operation accepts no date args.
*/
export declare function buildApplicationsCommand(): Command;

@@ -29,28 +29,13 @@ // SPDX-License-Identifier: AGPL-3.0-only

/**
* Build the `ttctl applications` command tree. Read-only access to the
* user's Toptal Talent **Activity** view (which Toptal colloquially
* calls "applications"). Three leaves:
* Build the `ttctl applications` command tree — the user's Toptal Talent
* **Activity** view (which Toptal colloquially calls "applications").
* Read is primary; writes on the application funnel are in scope per
* ADR-008 (`hq/engineering/adr/ADR-008-application-funnel-write-side.md`):
* Interest Request confirm / reject and direct job application are in
* scope; `withdraw` / `edit`, bulk apply, and interview accept / reject
* remain out of scope. The command tree below is the authoritative leaf
* inventory.
*
* | Leaf | Description |
* |-----------|------------------------------------------------------------|
* | `list` | List recent activity rows (filterable by status group) |
* | `show <id>` | Detail view for one row |
* | `stats` | Per-status-group counts (5 server calls in parallel) |
*
* Write operations on the application funnel are in scope per ADR-008
* (ttctl) — `hq/engineering/adr/ADR-008-application-funnel-write-side.md`.
* ADR-008 § Decision relaxes the #15 read-only non-goal and bounds the
* write surface: Interest Request confirm / reject (shipped in #411)
* and direct job application are in scope; `withdraw` / `edit`, bulk
* apply, and interview accept / reject remain explicitly out of scope.
*
* **Pagination (#377)**: the `list` leaf declares `--page` /
* `--per-page` (1-indexed positive integers; same `parsePaginationFlag`
* enforcement as the jobs leaves per #183). `#377` added the
* `$page` / `$pageSize` wire args to the hand-authored
* `JobActivityItems` document.
*
* **Still out of scope** (see `.tmp/workitem-15.md` § Open Questions):
* `--from` / `--to` date filters — captured operation accepts no date
* args.
* Date filters (`--from` / `--to`) on `list` remain out of scope — the
* captured `JobActivityItems` operation accepts no date args.
*/

@@ -57,0 +42,0 @@ export function buildApplicationsCommand() {

@@ -16,6 +16,9 @@ import { Command } from "commander";

* | `breaks reschedule <break-id> --from <date> --to <date>` | Move an existing break to a new window |
* | `payments list <job-id> [--limit N] [--after <id>]` | List per-engagement payments |
*
* `<id>` is always the `jobActivityItem.id` (the row id from
* `engagements list`); `<break-id>` is the `engagementBreak.id` (from
* `breaks list`).
* `breaks list`). `payments list` is the exception — its `<job-id>` is
* the `job.id` (the wire op `GetEngagementPayments` takes `$jobId`, not
* the activity-item id — #388).
*

@@ -22,0 +25,0 @@ * **Out of scope for v1** (per #147 spec):

@@ -10,2 +10,3 @@ // SPDX-License-Identifier: AGPL-3.0-only

import { runEngagementsList } from "./list.js";
import { runEngagementsPaymentsList } from "./payments.js";
import { runEngagementsShow } from "./show.js";

@@ -41,6 +42,9 @@ import { runEngagementsStats } from "./stats.js";

* | `breaks reschedule <break-id> --from <date> --to <date>` | Move an existing break to a new window |
* | `payments list <job-id> [--limit N] [--after <id>]` | List per-engagement payments |
*
* `<id>` is always the `jobActivityItem.id` (the row id from
* `engagements list`); `<break-id>` is the `engagementBreak.id` (from
* `breaks list`).
* `breaks list`). `payments list` is the exception — its `<job-id>` is
* the `job.id` (the wire op `GetEngagementPayments` takes `$jobId`, not
* the activity-item id — #388).
*

@@ -182,2 +186,25 @@ * **Out of scope for v1** (per #147 spec):

});
// ----- Payments sub-group (#388) ---------------------------------------
// Read-only, so no mutation marking. Addressed by JOB id (the wire op
// `GetEngagementPayments` takes `$jobId`), unlike the activity-item-id
// `show` / `breaks.*` leaves — reconciled from the issue's
// `--engagement` sketch to the wire input.
const payments = cmd.command("payments").description("View per-engagement payments");
payments
.command("list")
.description("List payments under an engagement (by job id)")
.argument("<job-id>", "job id (the `job.id` from `engagements list` / `jobs list` — NOT the activity-item row id)", parseIdArg)
.addOption(new Option("--limit <number>", "max payments to return").argParser((raw) => parsePaginationFlag("--limit", raw)))
.addOption(new Option("--after <id>", "forward cursor — return payments after this payment id"))
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (jobId, options) => {
const opts = { output: options.output };
if (options.limit !== undefined)
opts.limit = options.limit;
if (options.after !== undefined)
opts.after = options.after;
await runEngagementsPaymentsList(jobId, opts);
});
return cmd;

@@ -184,0 +211,0 @@ }

@@ -11,2 +11,4 @@ import { Command } from "commander";

* | `show <id>` | Job detail view |
* | `match-quality <id>` | Per-criterion match-quality breakdown |
* | `rate-insight <id>` | Per-job rate-intelligence panel |
* | `apply <id> --consent [...]` | Direct-apply to a job (DESTRUCTIVE — see ADR-008) |

@@ -13,0 +15,0 @@ * | `save <id>` | Mark a job as saved (bookmark) |

@@ -8,6 +8,9 @@ // SPDX-License-Identifier: AGPL-3.0-only

import { runJobsApply } from "./apply.js";
import { runJobsDashboard, runJobsDashboardCount } from "./dashboard.js";
import { runJobsClearInterest, runJobsMarkViewed, runJobsNotInterested, runJobsSave, runJobsUnsave, } from "./interest.js";
import { runJobsList, runJobsNotInterestedList, runJobsSaved, runJobsViewed } from "./list.js";
import { runJobsList, runJobsNotInterestedList, runJobsRecommended, runJobsSaved, runJobsViewed } from "./list.js";
import { runJobsMatchQuality } from "./match-quality.js";
import { runJobsRateInsight } from "./rate-insight.js";
import { runJobsSearchList, runJobsSearchRemove, runJobsSearchSave } from "./search.js";
import { runJobsShow } from "./show.js";
import { runJobsShow, runJobsShowMany } from "./show.js";
/**

@@ -34,2 +37,4 @@ * Page-number option factory (#183). Each paginating leaf declares its

* | `show <id>` | Job detail view |
* | `match-quality <id>` | Per-criterion match-quality breakdown |
* | `rate-insight <id>` | Per-job rate-intelligence panel |
* | `apply <id> --consent [...]` | Direct-apply to a job (DESTRUCTIVE — see ADR-008) |

@@ -144,2 +149,74 @@ * | `save <id>` | Mark a job as saved (bookmark) |

});
cmd
.command("show-many")
.description("Show several jobs by id in one batch fetch (≤20 ids; results in input order)")
.argument("<id...>", "job ids (from `jobs list`)", parseIdsArg)
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (ids, options) => {
await runJobsShowMany(ids, options.output);
});
cmd
.command("match-quality")
.description("Show the platform's per-criterion match-quality breakdown for a job")
.argument("<id>", "job id (from `jobs list`)", parseIdArg)
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (id, options) => {
await runJobsMatchQuality(id, options.output);
});
cmd
.command("rate-insight")
.description("Show the platform's per-job rate-intelligence panel for a job")
.argument("<id>", "job id (from `jobs list`)", parseIdArg)
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (id, options) => {
await runJobsRateInsight(id, options.output);
});
cmd
.command("recommended")
.description("List algorithmically-recommended job opportunities (paginated via --page / --per-page)")
.addOption(pageOption())
.addOption(perPageOption())
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (options) => {
const listOpts = { output: options.output };
if (options.page !== undefined)
listOpts.page = options.page;
if (options.perPage !== undefined)
listOpts.perPage = options.perPage;
await runJobsRecommended(listOpts);
});
cmd
.command("dashboard")
.description("List dashboard job-activity items (engagements / applications / pending actions; paginated)")
.addOption(pageOption())
.addOption(perPageOption())
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (options) => {
const dashOpts = { output: options.output };
if (options.page !== undefined)
dashOpts.page = options.page;
if (options.perPage !== undefined)
dashOpts.perPage = options.perPage;
await runJobsDashboard(dashOpts);
});
cmd
.command("dashboard-count")
.description("Count dashboard job-activity items in a status group (e.g. ACTIVE_ENGAGEMENT)")
.argument("<status-group>", "JobActivityStatusGroup value (e.g. ACTIVE_ENGAGEMENT, CLOSED_ENGAGEMENT, ON_CLIENT_REVIEW, ON_RECRUITER_REVIEW)", parseStatusGroupArg)
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (statusGroup, options) => {
await runJobsDashboardCount(statusGroup, options.output);
});
// #430 — Direct-apply to a job. Per ADR-008 § Decision Part 5: the

@@ -382,1 +459,18 @@ // CLI verb lives on `jobs` (reads naturally: "apply to a job") while

}
// `JobActivityStatusGroup` is a bare scalar in the synthesized SDL, so we
// accept any non-empty token (the server validates the value). The
// description enumerates the empirically-observed groups as a hint.
function parseStatusGroupArg(value) {
const trimmed = value.trim();
if (trimmed.length === 0) {
throw new InvalidArgumentError("status-group must not be empty");
}
return trimmed;
}
// Variadic accumulator for `<id...>`. Commander threads `previous` per
// value when a custom parser is supplied to a variadic argument, so the
// parser must accumulate (a single-value parser would yield only the
// last id). Trims + rejects empty ids per element, like `parseIdArg`.
function parseIdsArg(value, previous = []) {
return [...previous, parseIdArg(value)];
}

@@ -44,4 +44,11 @@ import type { OutputFormat } from "../../lib/output.js";

export type JobsNotInterestedListOptions = FilterlessPaginatedOptions;
export type JobsRecommendedOptions = FilterlessPaginatedOptions;
export declare function runJobsList(opts: JobsListOptions): Promise<void>;
/**
* Action handler for `ttctl jobs recommended`. Wraps `jobs.recommended()`
* (the algorithmic `recommendedJobsV2` feed) — same list envelope and
* pretty footer as `jobs list`, paginated via `--page` / `--per-page`.
*/
export declare function runJobsRecommended(opts: JobsRecommendedOptions): Promise<void>;
/**
* Action handler for `ttctl jobs saved`. Wraps `jobs.saved()` (which

@@ -48,0 +55,0 @@ * issues `eligibleJobs(filter: {saved: true})`).

@@ -45,2 +45,28 @@ // SPDX-License-Identifier: AGPL-3.0-only

/**
* Action handler for `ttctl jobs recommended`. Wraps `jobs.recommended()`
* (the algorithmic `recommendedJobsV2` feed) — same list envelope and
* pretty footer as `jobs list`, paginated via `--page` / `--per-page`.
*/
export async function runJobsRecommended(opts) {
const token = await loadAuthTokenOrExit("jobs recommended", opts.output);
const listOpts = {};
if (opts.page !== undefined)
listOpts.page = opts.page;
if (opts.perPage !== undefined)
listOpts.perPage = opts.perPage;
let page;
try {
page = await jobs.recommended(token, listOpts);
}
catch (err) {
handleJobsError("jobs recommended", err, opts.output);
}
const pageInfo = buildJobsPageInfo(page);
emitResult(wrapListEnvelope(page.items, pageInfo), opts.output, {
pretty: (data) => renderJobsListPretty(data.items, page),
table: (data) => renderJobsListPretty(data.items, page),
empty: { command: "jobs.recommended" },
});
}
/**
* Action handler for `ttctl jobs saved`. Wraps `jobs.saved()` (which

@@ -47,0 +73,0 @@ * issues `eligibleJobs(filter: {saved: true})`).

@@ -88,3 +88,7 @@ import { jobs } from "@ttctl/core";

*/
export declare function buildJobsPageInfo(page: jobs.JobListPage): {
export declare function buildJobsPageInfo(page: {
totalCount: number;
perPage: number;
page: number;
}): {
currentPage: number;

@@ -91,0 +95,0 @@ perPage: number;

@@ -61,2 +61,17 @@ import { applications, jobs } from "@ttctl/core";

/**
* Action handler for `ttctl jobs show-many <id...>`.
* Batch-fetches several jobs' detail views in one wire round-trip via
* `jobs.showMany`, emitting the found jobs in input order. Ids that
* resolve to no job are reported (pretty: a trailing "Not found" line;
* json / yaml consumers diff the returned `id`s against their input).
*/
export declare function runJobsShowMany(ids: string[], output: OutputFormat): Promise<void>;
/**
* Render several job detail views as one pretty block — each job's
* {@link formatJobDetail} output separated by a horizontal rule, with a
* trailing "Not found" line listing any requested ids the API did not
* return. Pure — directly unit-testable.
*/
export declare function formatJobDetails(items: jobs.JobDetail[], missing: string[]): string;
/**
* Render the matcher + expertise question inventories as two

@@ -63,0 +78,0 @@ * sectioned multi-line blocks. Sections fire unconditionally when

@@ -71,2 +71,39 @@ // SPDX-License-Identifier: AGPL-3.0-only

/**
* Action handler for `ttctl jobs show-many <id...>`.
* Batch-fetches several jobs' detail views in one wire round-trip via
* `jobs.showMany`, emitting the found jobs in input order. Ids that
* resolve to no job are reported (pretty: a trailing "Not found" line;
* json / yaml consumers diff the returned `id`s against their input).
*/
export async function runJobsShowMany(ids, output) {
const token = await loadAuthTokenOrExit("jobs show-many", output);
let items;
try {
items = await jobs.showMany(token, ids);
}
catch (err) {
handleJobsError("jobs show-many", err, output);
}
const found = new Set(items.map((j) => j.id));
const missing = ids.filter((id) => !found.has(id));
emitResult(items, output, {
pretty: (data) => formatJobDetails(data, missing),
});
}
/**
* Render several job detail views as one pretty block — each job's
* {@link formatJobDetail} output separated by a horizontal rule, with a
* trailing "Not found" line listing any requested ids the API did not
* return. Pure — directly unit-testable.
*/
export function formatJobDetails(items, missing) {
const blocks = items.map(formatJobDetail);
if (missing.length > 0) {
blocks.push(`Not found (${missing.length.toString()}): ${missing.join(", ")}`);
}
if (blocks.length === 0)
return "No jobs found.";
return blocks.join("\n\n————————————————————————————————\n\n");
}
/**
* Render the matcher + expertise question inventories as two

@@ -73,0 +110,0 @@ * sectioned multi-line blocks. Sections fire unconditionally when

@@ -11,2 +11,3 @@ // SPDX-License-Identifier: AGPL-3.0-only

import { runPaymentsRateChange, runPaymentsRateCurrent, runPaymentsRateQuestions, runPaymentsRateShow, } from "./rate.js";
import { runPaymentsShowMany } from "./show-many.js";
import { runPaymentsSummary } from "./summary.js";

@@ -73,2 +74,16 @@ /**

});
// ----- show-many (top-level batch fetch) -----------------------------
// Batch sibling of `payouts show <id>` (the singular `Payment` fetch);
// top-level rather than under `payouts` so the leaf maps cleanly to the
// `ttctl_payments_show_many` MCP tool (#456).
cmd
.command("show-many")
.description("Show several payments by id in one batch fetch (≤20 ids; results in input order)")
.argument("<id...>", "payment ids (TalentPayment.id from `payments payouts list`)", parseIdsArg)
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (ids, options) => {
await runPaymentsShowMany(ids, options.output);
});
// ----- payouts sub-group ---------------------------------------------

@@ -206,1 +221,7 @@ const payouts = cmd.command("payouts").description("Historical payouts (read-only)");

}
// Variadic `<id...>`: Commander invokes a custom parser once per value with
// the accumulator threaded as `previous`, so it must accumulate (a single-value
// parser would keep only the last id). Trims + rejects empties per element.
function parseIdsArg(value, previous = []) {
return [...previous, parseIdArg(value)];
}

@@ -15,3 +15,4 @@ import { payments } from "@ttctl/core";

export declare function runPaymentsMethodsShow(id: string, output: OutputFormat): Promise<void>;
export declare function formatMethodsList(items: payments.PaymentMethod[], availableMethods: string[]): string;
export declare function formatMethodsTable(items: payments.PaymentMethod[]): string;
export declare function formatMethodDetail(m: payments.PaymentMethod): string;

@@ -15,5 +15,5 @@ // SPDX-License-Identifier: AGPL-3.0-only

const token = await loadAuthTokenOrExit("payments methods list", output);
let items;
let result;
try {
items = await payments.methods.list(token);
result = await payments.methods.list(token);
}

@@ -23,6 +23,8 @@ catch (err) {

}
emitResult(wrapListEnvelope(items), output, {
pretty: (data) => formatMethodsTable(data.items),
table: (data) => formatMethodsTable(data.items),
empty: { command: "payments.methods.list" },
// No empty-state wrapper here: `availableMethods` is a sibling dataset
// that must surface even when zero methods are configured (the wrapper
// keys on `items` and would drop it from both JSON and pretty output).
emitResult({ ...wrapListEnvelope(result.methods), availableMethods: result.availableMethods }, output, {
pretty: (data) => formatMethodsList(data.items, data.availableMethods),
table: (data) => formatMethodsList(data.items, data.availableMethods),
});

@@ -48,2 +50,9 @@ }

}
export function formatMethodsList(items, availableMethods) {
const sections = [items.length === 0 ? "No payment methods configured." : formatMethodsTable(items)];
if (availableMethods.length > 0) {
sections.push(`Available methods to add: ${availableMethods.join(", ")}`);
}
return sections.join("\n\n");
}
export function formatMethodsTable(items) {

@@ -50,0 +59,0 @@ const table = new Table({

@@ -30,2 +30,6 @@ // SPDX-License-Identifier: AGPL-3.0-only

.description("Print a summary of the signed-in user's Toptal Talent profile")
// `--full` (not `--verbose`): the root program owns a global `--verbose`
// (#139 transport logging) that Commander binds in every position, so a
// command-level `--verbose` would never receive the flag (#469).
.option("--full", "fetch the full portal GetViewer projection (legal docs, market state, pending work, rate insight)")
.addOption(new Option("-o, --output <format>", "output format")

@@ -35,3 +39,3 @@ .choices(OUTPUT_FORMATS)

.action(async (options) => {
await runProfileBasicShow(options.output);
await runProfileBasicShow(options.output, options.full === true);
});

@@ -58,12 +62,2 @@ // Marked as a mutation (issue #52) so the global `--dry-run` flag

.default("pretty"))
.addHelpText("after", [
"",
"Precondition (account state):",
" Toptal requires SMS-notification consent to be enabled in your",
" account settings (talent.toptal.com → notifications) before",
" profile-update mutations will succeed. If SMS consent is",
" disabled, this command will fail with a USER_ERROR (or similar)",
" surfaced from the Toptal API — ttctl cannot toggle the consent",
" flag for you. Toggle it in the web UI, then retry. See issue #536.",
].join("\n"))
.action(async (options) => {

@@ -70,0 +64,0 @@ await runProfileBasicUpdate(options);

@@ -28,11 +28,7 @@ import { profile } from "@ttctl/core";

*
* Account-state precondition (#536): the underlying `UPDATE_BASIC_INFO`
* mutation requires SMS-notification consent to be enabled in the
* user's Toptal account (talent.toptal.com → notifications). When the
* consent is off, the mutation fails — surfaced today via whichever
* `profile.basic.ProfileError` branch catches the API's rejection
* (most likely `USER_ERROR` or `GRAPHQL_ERROR`). TTCtl deliberately
* does NOT expose `UpdateSmsNotificationsSettings` (README § Out of
* scope), so the remediation is a one-time web-UI toggle by the user.
* See `profile.basic.set` JSDoc for the full rationale.
* SMS consent is client-side only, not a server gate (#540): the web
* profile editor's "agree to receive text messages" checkbox is
* enforced client-side, so `UPDATE_BASIC_INFO` is not gated on it
* server-side and `profile basic update` is never blocked by consent
* state. See `profile.basic.set` JSDoc for the evidence.
*

@@ -39,0 +35,0 @@ * Note on naming: the user-facing CLI verb is `update` (per #69 AC and the

@@ -35,11 +35,7 @@ // SPDX-License-Identifier: AGPL-3.0-only

*
* Account-state precondition (#536): the underlying `UPDATE_BASIC_INFO`
* mutation requires SMS-notification consent to be enabled in the
* user's Toptal account (talent.toptal.com → notifications). When the
* consent is off, the mutation fails — surfaced today via whichever
* `profile.basic.ProfileError` branch catches the API's rejection
* (most likely `USER_ERROR` or `GRAPHQL_ERROR`). TTCtl deliberately
* does NOT expose `UpdateSmsNotificationsSettings` (README § Out of
* scope), so the remediation is a one-time web-UI toggle by the user.
* See `profile.basic.set` JSDoc for the full rationale.
* SMS consent is client-side only, not a server gate (#540): the web
* profile editor's "agree to receive text messages" checkbox is
* enforced client-side, so `UPDATE_BASIC_INFO` is not gated on it
* server-side and `profile basic update` is never blocked by consent
* state. See `profile.basic.set` JSDoc for the evidence.
*

@@ -46,0 +42,0 @@ * Note on naming: the user-facing CLI verb is `update` (per #69 AC and the

@@ -53,3 +53,3 @@ import { profile } from "@ttctl/core";

*/
export declare function runProfileBasicShow(format: OutputFormat): Promise<void>;
export declare function runProfileBasicShow(format: OutputFormat, full?: boolean): Promise<void>;
/**

@@ -94,3 +94,12 @@ * Format the merged `basic show` payload as the post-#129 `pretty`

export declare function formatProfileTable(payload: BasicShowPayload, terminalWidth?: number): string;
/**
* Curated `pretty` summary for `profile show --verbose` (#469). Surfaces
* the rich-projection fields the trimmed default omits — legal-doc
* acceptance, post-activation/market state, hire-me banner, rate insight —
* on top of base identity/role. The complete `GetViewer` object (incl. the
* inline legal-doc bodies and operational scopes) is available via
* `--output json` / `yaml`; pretty stays a digest.
*/
export declare function formatRichViewerPretty(viewer: profile.RichViewer): string;
declare function truncate(s: string, width: number): string;
export { truncate };

@@ -35,4 +35,20 @@ // SPDX-License-Identifier: AGPL-3.0-only

*/
export async function runProfileBasicShow(format) {
export async function runProfileBasicShow(format, full = false) {
const token = await loadAuthTokenOrExit("profile show", format);
// Full-projection path (#469): fetch the FULL portal `GetViewer` projection
// instead of the trimmed `ProfileShow` default. JSON/YAML emit the whole
// rich object; `pretty` renders a curated extended summary. The heavy
// legal-doc bodies + operational scopes only ride the wire on demand.
if (full) {
let rich;
try {
rich = await profile.showRich(token);
}
catch (err) {
handleProfileShowError(err, format);
return;
}
emitResult(rich, format, { pretty: formatRichViewerPretty });
return;
}
let profilePayload;

@@ -303,2 +319,49 @@ try {

}
/**
* Curated `pretty` summary for `profile show --verbose` (#469). Surfaces
* the rich-projection fields the trimmed default omits — legal-doc
* acceptance, post-activation/market state, hire-me banner, rate insight —
* on top of base identity/role. The complete `GetViewer` object (incl. the
* inline legal-doc bodies and operational scopes) is available via
* `--output json` / `yaml`; pretty stays a digest.
*/
export function formatRichViewerPretty(viewer) {
const role = viewer.viewerRole;
const indent = " ";
const lines = [role.fullName, `${indent}${role.email}`];
if (role.phoneNumber !== "")
lines.push(`${indent}${role.phoneNumber}`);
lines.push(`${indent}Vertical: ${role.vertical.name}`);
const specs = role.specializations.slice(0, 3).map((s) => s.title);
if (specs.length > 0)
lines.push(`${indent}Specializations: ${specs.join(", ")}`);
lines.push(`${indent}Availability: ${role.availability} (${role.hiredHours.toString()}/${role.allocatedHours.toString()}h)`);
lines.push(`${indent}Rate: ${role.hourlyRate.verbose}/hr`);
lines.push(`${indent}TimeZone: ${role.timeZone.value}`);
lines.push(`${indent}Post-activation: ${role.postActivationStepsStatus}`);
lines.push(`${indent}Specialization type: ${role.specializationType}`);
if (role.blockedStatus.isBlocked) {
lines.push(`${indent}Blocked: ${unsetOr(role.blockedStatus.reason, "yes")}`);
}
// Market condition (the talent's vertical demand signal).
lines.push(`${indent}Market: ${role.vertical.marketCondition.condition} ` +
`(global: ${role.vertical.globalMarketCondition.condition})`);
// Rate insight — competitiveness + recommended.
const ri = role.rateInsight.hourly;
lines.push(`${indent}Rate insight: ${ri.currentRateCompetitive ? "competitive" : "below market"}, ` +
`recommended ${ri.recommendedRate}`);
// Hire-me banner state.
lines.push(`${indent}Hire-me banner: ${viewer.hireMeBanner.enabled ? "enabled" : "disabled"} ` +
`(verification: ${viewer.hireMeBanner.verificationStatus})`);
// Legal-document acceptance — Code of Conduct + Terms of Service.
lines.push(`${indent}Code of Conduct: ${viewer.codeOfConduct.acceptedAt ? "accepted" : "not accepted"}`);
lines.push(`${indent}Terms of Service: action ${viewer.termsOfService.requiredAction ?? "NONE"}`);
// Pending-work counters (surveys, quizzes, notifications, job activity).
lines.push(`${indent}Pending: ${viewer.pendingSurveys.length.toString()} surveys, ` +
`${viewer.pendingQuizzes.length.toString()} quizzes, ` +
`${viewer.pendingNotifications.length.toString()} notifications`);
lines.push(`${indent}Active job activity: ${viewer.jobActivityList.entities.length.toString()}`);
lines.push(`${indent}Public résumé: ${unsetOr(role.publicResumeUrl)}`);
return lines.join("\n");
}
function truncate(s, width) {

@@ -305,0 +368,0 @@ if (s.length <= width)

@@ -7,3 +7,3 @@ import { profile } from "@ttctl/core";

* Five leaves:
* - `add --institution --degree [--from --to]`
* - `add --institution --degree --from --to --field-of-study --location`
* - `update <id> [field-flags]`

@@ -10,0 +10,0 @@ * - `remove <id>`

@@ -13,3 +13,3 @@ // SPDX-License-Identifier: AGPL-3.0-only

* Five leaves:
* - `add --institution --degree [--from --to]`
* - `add --institution --degree --from --to --field-of-study --location`
* - `update <id> [field-flags]`

@@ -32,6 +32,7 @@ * - `remove <id>`

.requiredOption("--degree <type>", "degree (e.g. BSc, MSc, PhD)")
.option("--from <date>", "start date — ISO-8601 (YYYY-MM-DD) or year (YYYY)")
.option("--to <date>", "end date — ISO-8601 (YYYY-MM-DD) or year (YYYY)")
.option("--field-of-study <text>", "field of study (optional)")
.option("--location <text>", "city / country (optional)")
.requiredOption("--from <date>", "start year — ISO-8601 (YYYY-MM-DD) or year (YYYY)")
.requiredOption("--to <date>", "end year — ISO-8601 (YYYY-MM-DD) or year (YYYY)")
.requiredOption("--field-of-study <text>", "field of study")
.requiredOption("--location <text>", "city / country")
.option("--skill-id <id>", "catalog Skill id (repeatable). Discover via `ttctl profile skills list`. The Toptal wire requires at least one skill on create (#612).", (value, prev) => (prev ? [...prev, value] : [value]))
.addOption(new Option("-o, --output <format>", "output format")

@@ -54,2 +55,3 @@ .choices(OUTPUT_FORMATS)

.option("--highlight <bool>", "set highlight flag (true|false)")
.option("--skill-id <id>", "catalog Skill id (repeatable; when supplied, replaces the entry's skill set — omit to preserve). Discover via `ttctl profile skills list`. The Toptal wire rejects an empty skill set — supply at least one id when using this flag.", (value, prev) => (prev ? [...prev, value] : [value]))
.addOption(new Option("-o, --output <format>", "output format")

@@ -110,2 +112,6 @@ .choices(OUTPUT_FORMATS)

applyOptionalStrings(fields, options);
// `name` is "" — the server keys on `id` (mirrors employment #541).
if (options.skillId !== undefined && options.skillId.length > 0) {
fields.skills = options.skillId.map((id) => ({ id, name: "" }));
}
const token = await loadAuthTokenOrExit("profile education add", options.output);

@@ -155,2 +161,8 @@ let result;

applyDateFlags(fields, options, "profile education update", options.output);
// Replace-on-supply: a supplied set replaces the wire skill set (and counts
// toward the field-flag check below); omitted → core preserves current.skills.
// `name` is "" — the server keys on `id` (mirrors employment #541).
if (options.skillId !== undefined && options.skillId.length > 0) {
fields.skills = options.skillId.map((id) => ({ id, name: "" }));
}
if (Object.keys(fields).length === 0) {

@@ -157,0 +169,0 @@ emitErrorAndExit({

@@ -43,2 +43,6 @@ // SPDX-License-Identifier: AGPL-3.0-only

.description("Print a summary of the signed-in user's Toptal Talent profile (alias for `profile basic show`)")
// `--full` (not `--verbose`): the root program owns a global `--verbose`
// (#139 transport logging) that Commander binds in every position, so a
// command-level `--verbose` would never receive the flag (#469).
.option("--full", "fetch the full portal GetViewer projection (legal docs, market state, pending work, rate insight)")
.addOption(new Option("-o, --output <format>", "output format")

@@ -48,3 +52,3 @@ .choices(OUTPUT_FORMATS)

.action(async (options) => {
await runProfileBasicShow(options.output);
await runProfileBasicShow(options.output, options.full === true);
});

@@ -51,0 +55,0 @@ // Marked as a mutation (issue #52) so the global `--dry-run` flag

@@ -12,2 +12,3 @@ import { Command } from "commander";

* | `submit [id] [--confirm]` | Submit timesheet for billing (destructive) |
* | `update <id> [--comment …]` | Edit a draft timesheet (comment / per-day records)|
*

@@ -28,7 +29,9 @@ * **Wire identity model**:

*
* **Out of scope for v1** (per #13 spec): editing timesheet records,
* uploading attachments, reminder settings, rejection/approval
* workflow. The web UI handles record entry; this CLI surfaces the
* read paths and the submit verb.
* **Editing** (`update`, #458): `UpdateTimesheet` is a full-replacement
* contract; the core service does read-modify-write so partial CLI flags
* (`--comment` / `--record` / `--note`) don't null unspecified fields.
*
* **Out of scope** (per #13 spec): uploading attachments, reminder
* settings, rejection/approval workflow.
*/
export declare function buildTimesheetCommand(): Command;

@@ -11,2 +11,3 @@ // SPDX-License-Identifier: AGPL-3.0-only

import { runTimesheetSubmit } from "./submit.js";
import { runTimesheetUpdate } from "./update.js";
/**

@@ -22,2 +23,3 @@ * Build the `ttctl timesheet` command tree (#13). Three leaves plus a

* | `submit [id] [--confirm]` | Submit timesheet for billing (destructive) |
* | `update <id> [--comment …]` | Edit a draft timesheet (comment / per-day records)|
*

@@ -38,6 +40,8 @@ * **Wire identity model**:

*
* **Out of scope for v1** (per #13 spec): editing timesheet records,
* uploading attachments, reminder settings, rejection/approval
* workflow. The web UI handles record entry; this CLI surfaces the
* read paths and the submit verb.
* **Editing** (`update`, #458): `UpdateTimesheet` is a full-replacement
* contract; the core service does read-modify-write so partial CLI flags
* (`--comment` / `--record` / `--note`) don't null unspecified fields.
*
* **Out of scope** (per #13 spec): uploading attachments, reminder
* settings, rejection/approval workflow.
*/

@@ -105,5 +109,40 @@ export function buildTimesheetCommand() {

markMutation(submitCmd);
const updateCmd = cmd
.command("update")
.description("Edit a draft timesheet's comment and/or per-day records (read-modify-write)")
.argument("<id>", "timesheet id (BillingCycle.id from `timesheet list`)", parseIdArg)
.option("--comment <text>", "set the timesheet comment (replaces the existing comment)")
.addOption(new Option("--record <date=minutes>", "override a day's duration in MINUTES (wire-native: 480 = 8h), e.g. 2026-06-01=480; repeatable")
.argParser(collectFlag)
.default([]))
.addOption(new Option("--note <date=text>", 'override a day\'s note, e.g. 2026-06-01="fixed build"; empty value clears it; repeatable')
.argParser(collectFlag)
.default([]))
.option("--consent-timesheet-billing", "acknowledge this edits billing data on your behalf (ADR-009 timesheet-billing consent; required)", false)
.addOption(new Option("-o, --output <format>", "output format")
.choices(OUTPUT_FORMATS)
.default("pretty"))
.action(async (id, options) => {
const updateOpts = {
record: options.record,
note: options.note,
consentTimesheetBilling: options.consentTimesheetBilling,
output: options.output,
};
if (options.comment !== undefined)
updateOpts.comment = options.comment;
await runTimesheetUpdate(id, updateOpts);
});
markMutation(updateCmd);
return cmd;
}
/**
* Accumulator for repeatable options (`--record`, `--note`). Collects raw
* `date=value` strings; parsing/validation happens in the action handler so
* malformed input routes through the domain error envelope.
*/
function collectFlag(value, previous) {
return [...previous, value];
}
/**
* Parse the positional `<id>` argument for `show`. Rejects empty /

@@ -110,0 +149,0 @@ * whitespace-only strings — Commander would otherwise pass them

@@ -45,2 +45,8 @@ // SPDX-License-Identifier: AGPL-3.0-only

},
// Performed actions are a server-driven audit log — entries materialize
// as the viewer acts (status changes, applications submitted). Empty is
// a happy state for new users; no add verb.
"me.actions.list": {
entityPlural: "performed actions",
},
});

@@ -47,0 +53,0 @@ /**

@@ -12,2 +12,3 @@ // SPDX-License-Identifier: AGPL-3.0-only

import { buildJobsCommand } from "./commands/jobs/index.js";
import { buildMeCommand } from "./commands/me/index.js";
import { buildPaymentsCommand } from "./commands/payments/index.js";

@@ -269,2 +270,3 @@ import { buildSurveysCommand } from "./commands/surveys/index.js";

program.addCommand(buildTimesheetCommand());
program.addCommand(buildMeCommand());
return program;

@@ -271,0 +273,0 @@ }

{
"name": "@ttctl/cli",
"version": "0.1.0-rc.16",
"version": "0.1.0-rc.17",
"description": "TTCtl CLI commands and program definition",

@@ -44,3 +44,3 @@ "type": "module",

"zod": "^4.4.3",
"@ttctl/core": "^0.1.0-rc.16"
"@ttctl/core": "^0.1.0-rc.17"
},

@@ -47,0 +47,0 @@ "scripts": {