@ttctl/cli
Advanced tools
| 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 }; | ||
| } |
| 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 @@ /** |
+2
-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 @@ } |
+2
-2
| { | ||
| "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": { |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1057255
5%242
6.14%22776
4.94%Updated