+124
| function firstTimestamp(...values) { | ||
| for (const value of values) { | ||
| if (typeof value === "string" && value.trim()) | ||
| return value; | ||
| } | ||
| return null; | ||
| } | ||
| function safeString(value) { | ||
| return typeof value === "string" && value.trim() ? value : undefined; | ||
| } | ||
| function mapHistoryItem(item) { | ||
| const at = firstTimestamp(item?.enteredStageAt, item?.createdAt, item?.updatedAt); | ||
| if (!at) | ||
| return null; | ||
| return { | ||
| kind: "stage_changed", | ||
| at, | ||
| title: item?.title ? `Entered stage: ${item.title}` : "Entered stage", | ||
| detail: safeString(item?.stageId), | ||
| sourceId: safeString(item?.id), | ||
| raw: item, | ||
| }; | ||
| } | ||
| function mapNoteItem(item) { | ||
| const at = firstTimestamp(item?.createdAt, item?.updatedAt); | ||
| if (!at) | ||
| return null; | ||
| const author = [safeString(item?.author?.firstName), safeString(item?.author?.lastName)].filter(Boolean).join(" ").trim() || | ||
| safeString(item?.author?.email); | ||
| return { | ||
| kind: "note_added", | ||
| at, | ||
| title: author ? `Note by ${author}` : "Note added", | ||
| detail: safeString(item?.note), | ||
| sourceId: safeString(item?.id), | ||
| raw: item, | ||
| }; | ||
| } | ||
| function mapFeedbackItem(item) { | ||
| const at = firstTimestamp(item?.submittedAt, item?.createdAt, item?.updatedAt); | ||
| if (!at) | ||
| return null; | ||
| const author = [safeString(item?.author?.firstName), safeString(item?.author?.lastName)].filter(Boolean).join(" ").trim() || | ||
| safeString(item?.author?.email); | ||
| return { | ||
| kind: "feedback_submitted", | ||
| at, | ||
| title: author ? `Feedback from ${author}` : "Feedback submitted", | ||
| detail: safeString(item?.recommendation) || safeString(item?.feedbackFormDefinition?.title), | ||
| sourceId: safeString(item?.id), | ||
| raw: item, | ||
| }; | ||
| } | ||
| function mapScheduleItem(item) { | ||
| const at = firstTimestamp(item?.updatedAt, item?.createdAt); | ||
| if (!at) | ||
| return null; | ||
| return { | ||
| kind: "interview_schedule", | ||
| at, | ||
| title: item?.status ? `Interview schedule: ${item.status}` : "Interview scheduled", | ||
| detail: safeString(item?.interviewStageId), | ||
| sourceId: safeString(item?.id), | ||
| raw: item, | ||
| }; | ||
| } | ||
| function mapInterviewEventItem(item) { | ||
| const at = firstTimestamp(item?.startTime, item?.updatedAt, item?.createdAt); | ||
| if (!at) | ||
| return null; | ||
| const interviewerNames = Array.isArray(item?.interviewers) | ||
| ? item.interviewers | ||
| .map((interviewer) => [safeString(interviewer?.firstName), safeString(interviewer?.lastName)].filter(Boolean).join(" ").trim()) | ||
| .filter(Boolean) | ||
| .join(", ") | ||
| : ""; | ||
| return { | ||
| kind: "interview_event", | ||
| at, | ||
| title: interviewerNames ? `Interview event with ${interviewerNames}` : "Interview event", | ||
| detail: safeString(item?.meetingLink) || safeString(item?.location), | ||
| sourceId: safeString(item?.id), | ||
| raw: item, | ||
| }; | ||
| } | ||
| export function buildApplicationFeed(input) { | ||
| const scheduleItems = (input.schedules || []).flatMap((schedule) => { | ||
| const mapped = []; | ||
| const scheduleItem = mapScheduleItem(schedule); | ||
| if (scheduleItem) | ||
| mapped.push(scheduleItem); | ||
| if (Array.isArray(schedule?.interviewEvents)) { | ||
| for (const interviewEvent of schedule.interviewEvents) { | ||
| const eventItem = mapInterviewEventItem(interviewEvent); | ||
| if (eventItem) | ||
| mapped.push(eventItem); | ||
| } | ||
| } | ||
| return mapped; | ||
| }); | ||
| const explicitEventItems = (input.interviewEvents || []) | ||
| .map((item) => mapInterviewEventItem(item)) | ||
| .filter((item) => Boolean(item)); | ||
| const all = [ | ||
| ...(input.history || []).map((item) => mapHistoryItem(item)), | ||
| ...(input.notes || []).map((item) => mapNoteItem(item)), | ||
| ...(input.feedback || []).map((item) => mapFeedbackItem(item)), | ||
| ...scheduleItems, | ||
| ...explicitEventItems, | ||
| ].filter((item) => Boolean(item)); | ||
| const seen = new Set(); | ||
| const deduped = []; | ||
| for (const item of all) { | ||
| const key = `${item.kind}:${item.sourceId || ""}:${item.at}`; | ||
| if (seen.has(key)) | ||
| continue; | ||
| seen.add(key); | ||
| deduped.push(item); | ||
| } | ||
| return deduped.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); | ||
| } | ||
| export function formatFeedItem(item) { | ||
| return `${item.at}\t${item.kind}\t${item.title}${item.detail ? `\t${item.detail}` : ""}`; | ||
| } |
+18
-0
@@ -74,2 +74,8 @@ const BASE_URL = "https://api.ashbyhq.com"; | ||
| } | ||
| async applicationListHistory(applicationId) { | ||
| return this.request("application.listHistory", { applicationId }); | ||
| } | ||
| async applicationFeedbackList(applicationId) { | ||
| return this.request("applicationFeedback.list", { applicationId }); | ||
| } | ||
| async applicationCreate(input) { | ||
@@ -84,2 +90,14 @@ return this.request("application.create", input); | ||
| } | ||
| async candidateListNotes(candidateId, cursor) { | ||
| return this.request("candidate.listNotes", cursor ? { candidateId, cursor } : { candidateId }); | ||
| } | ||
| async interviewScheduleList(input = {}) { | ||
| return this.request("interviewSchedule.list", input); | ||
| } | ||
| async interviewEventList(input = {}) { | ||
| return this.request("interviewEvent.list", input); | ||
| } | ||
| async interviewInfo(interviewId) { | ||
| return this.request("interview.info", { interviewId }); | ||
| } | ||
| } |
+156
-0
@@ -11,2 +11,3 @@ #!/usr/bin/env node | ||
| import { formatCandidateRow, validateCandidateSearchInput } from "./candidates.js"; | ||
| import { buildApplicationFeed, formatFeedItem } from "./feed.js"; | ||
| import { fail, makeError, ok, printJson } from "./output.js"; | ||
@@ -60,2 +61,10 @@ function getCliVersion() { | ||
| } | ||
| function printJsonHuman(value) { | ||
| console.log(JSON.stringify(value, null, 2)); | ||
| } | ||
| function printFeedHuman(items) { | ||
| for (const item of items) { | ||
| console.log(formatFeedItem(item)); | ||
| } | ||
| } | ||
| async function runAction(opts, action, human, meta) { | ||
@@ -305,2 +314,22 @@ try { | ||
| candidate | ||
| .command("notes") | ||
| .description("List notes for a candidate") | ||
| .requiredOption("--candidate-id <candidate-id>", "Candidate id") | ||
| .option("--cursor <cursor>", "Pagination cursor") | ||
| .option("--json", "Emit JSON output") | ||
| .action(async (opts) => { | ||
| const apiKey = await requireApiKey(opts); | ||
| if (!apiKey) | ||
| return; | ||
| await runAction(opts, async () => { | ||
| const response = await createClient(apiKey).candidateListNotes(opts.candidateId, opts.cursor); | ||
| return { | ||
| count: (response.results || []).length, | ||
| items: response.results || [], | ||
| nextCursor: response.nextCursor, | ||
| moreDataAvailable: response.moreDataAvailable || false, | ||
| }; | ||
| }, (value) => printJsonHuman(value.items)); | ||
| }); | ||
| candidate | ||
| .command("create") | ||
@@ -382,2 +411,74 @@ .description("Create a candidate") | ||
| application | ||
| .command("history") | ||
| .description("List application stage/history entries") | ||
| .requiredOption("--application-id <application-id>", "Application id") | ||
| .option("--json", "Emit JSON output") | ||
| .action(async (opts) => { | ||
| const apiKey = await requireApiKey(opts); | ||
| if (!apiKey) | ||
| return; | ||
| await runAction(opts, async () => { | ||
| const response = await createClient(apiKey).applicationListHistory(opts.applicationId); | ||
| return { | ||
| count: (response.results || []).length, | ||
| items: response.results || [], | ||
| moreDataAvailable: response.moreDataAvailable || false, | ||
| nextCursor: response.nextCursor, | ||
| }; | ||
| }, (value) => printJsonHuman(value.items)); | ||
| }); | ||
| application | ||
| .command("feedback") | ||
| .description("List application feedback") | ||
| .requiredOption("--application-id <application-id>", "Application id") | ||
| .option("--json", "Emit JSON output") | ||
| .action(async (opts) => { | ||
| const apiKey = await requireApiKey(opts); | ||
| if (!apiKey) | ||
| return; | ||
| await runAction(opts, async () => { | ||
| const response = await createClient(apiKey).applicationFeedbackList(opts.applicationId); | ||
| return { | ||
| count: (response.results || []).length, | ||
| items: response.results || [], | ||
| moreDataAvailable: response.moreDataAvailable || false, | ||
| nextCursor: response.nextCursor, | ||
| }; | ||
| }, (value) => printJsonHuman(value.items)); | ||
| }); | ||
| application | ||
| .command("feed") | ||
| .description("Build a synthetic application feed from public API surfaces") | ||
| .requiredOption("--application-id <application-id>", "Application id") | ||
| .option("--no-notes", "Do not include candidate notes") | ||
| .option("--json", "Emit JSON output") | ||
| .action(async (opts) => { | ||
| const apiKey = await requireApiKey(opts); | ||
| if (!apiKey) | ||
| return; | ||
| await runAction(opts, async () => { | ||
| const client = createClient(apiKey); | ||
| const app = await client.applicationInfo(opts.applicationId); | ||
| const candidateId = app.results?.candidate?.id; | ||
| const [historyResponse, feedbackResponse, scheduleResponse] = await Promise.all([ | ||
| client.applicationListHistory(opts.applicationId), | ||
| client.applicationFeedbackList(opts.applicationId), | ||
| client.interviewScheduleList({ applicationId: opts.applicationId }), | ||
| ]); | ||
| const notesResponse = opts.notes === false || !candidateId ? { results: [], moreDataAvailable: false } : await client.candidateListNotes(candidateId); | ||
| const feed = buildApplicationFeed({ | ||
| history: historyResponse.results || app.results?.applicationHistory || [], | ||
| notes: notesResponse.results || [], | ||
| feedback: feedbackResponse.results || [], | ||
| schedules: scheduleResponse.results || [], | ||
| }); | ||
| return { | ||
| applicationId: opts.applicationId, | ||
| candidateId: candidateId || null, | ||
| count: feed.length, | ||
| items: feed, | ||
| }; | ||
| }, (value) => printFeedHuman(value.items)); | ||
| }); | ||
| application | ||
| .command("create") | ||
@@ -427,2 +528,3 @@ .description("Create an application for a candidate") | ||
| const stage = program.command("stage").description("Interview stage metadata"); | ||
| const interview = program.command("interview").description("Interview schedule and event operations"); | ||
| stage | ||
@@ -442,2 +544,56 @@ .command("list") | ||
| }); | ||
| interview | ||
| .command("schedules") | ||
| .description("List interview schedules") | ||
| .option("--application-id <application-id>", "Filter by application id") | ||
| .option("--interview-schedule-id <interview-schedule-id>", "Filter by interview schedule id") | ||
| .option("--interview-id <interview-id>", "Filter by interview id") | ||
| .option("--cursor <cursor>", "Pagination cursor") | ||
| .option("--json", "Emit JSON output") | ||
| .action(async (opts) => { | ||
| const apiKey = await requireApiKey(opts); | ||
| if (!apiKey) | ||
| return; | ||
| await runAction(opts, async () => { | ||
| const response = await createClient(apiKey).interviewScheduleList({ | ||
| applicationId: opts.applicationId, | ||
| interviewScheduleId: opts.interviewScheduleId, | ||
| interviewId: opts.interviewId, | ||
| cursor: opts.cursor, | ||
| }); | ||
| return { | ||
| count: (response.results || []).length, | ||
| items: response.results || [], | ||
| moreDataAvailable: response.moreDataAvailable || false, | ||
| nextCursor: response.nextCursor, | ||
| }; | ||
| }, (value) => printJsonHuman(value.items)); | ||
| }); | ||
| interview | ||
| .command("events") | ||
| .description("List interview events") | ||
| .option("--application-id <application-id>", "Filter by application id") | ||
| .option("--interview-schedule-id <interview-schedule-id>", "Filter by interview schedule id") | ||
| .option("--interview-id <interview-id>", "Filter by interview id") | ||
| .option("--cursor <cursor>", "Pagination cursor") | ||
| .option("--json", "Emit JSON output") | ||
| .action(async (opts) => { | ||
| const apiKey = await requireApiKey(opts); | ||
| if (!apiKey) | ||
| return; | ||
| await runAction(opts, async () => { | ||
| const response = await createClient(apiKey).interviewEventList({ | ||
| applicationId: opts.applicationId, | ||
| interviewScheduleId: opts.interviewScheduleId, | ||
| interviewId: opts.interviewId, | ||
| cursor: opts.cursor, | ||
| }); | ||
| return { | ||
| count: (response.results || []).length, | ||
| items: response.results || [], | ||
| moreDataAvailable: response.moreDataAvailable || false, | ||
| nextCursor: response.nextCursor, | ||
| }; | ||
| }, (value) => printJsonHuman(value.items)); | ||
| }); | ||
| program.parseAsync(process.argv); |
+1
-1
| { | ||
| "name": "ashby-cli", | ||
| "version": "0.1.2", | ||
| "version": "0.1.3", | ||
| "description": "Agent-first CLI for Ashby's official API", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+25
-0
@@ -10,2 +10,5 @@ # ashby-cli | ||
| - candidate notes | ||
| - application history and feedback | ||
| - interview schedules | ||
| - synthetic feed reconstruction from public API surfaces | ||
| - hiring pipeline state | ||
@@ -103,2 +106,3 @@ | ||
| ashby candidate get <candidate-id> --json | ||
| ashby candidate notes --candidate-id <candidate-id> --json | ||
| ashby candidate create --name "Jane Doe" --email "jane@example.com" --linkedin-url "https://linkedin.com/in/jane" --json | ||
@@ -113,2 +117,5 @@ ashby note create --candidate-id <candidate-id> --note "Strong fast-track candidate" --json | ||
| ashby application get <application-id> --json | ||
| ashby application history --application-id <application-id> --json | ||
| ashby application feedback --application-id <application-id> --json | ||
| ashby application feed --application-id <application-id> --json | ||
| ashby application create --candidate-id <candidate-id> --job-id <job-id> --interview-stage-id <stage-id> --json | ||
@@ -122,2 +129,4 @@ ashby application stage-change --application-id <application-id> --interview-stage-id <stage-id> --json | ||
| ashby stage list --interview-plan-id <plan-id> --json | ||
| ashby interview schedules --application-id <application-id> --json | ||
| ashby interview events --application-id <application-id> --json | ||
| ``` | ||
@@ -138,2 +147,18 @@ | ||
| ## Feed coverage | ||
| `ashby application feed` reconstructs a useful candidate/application timeline from public API data: | ||
| - application history | ||
| - candidate notes | ||
| - feedback | ||
| - interview schedules | ||
| - nested interview events | ||
| It does **not** provide full parity with the Ashby web UI feed. In particular, public API coverage still appears weak or absent for: | ||
| - synced/sent email thread history | ||
| - text thread history | ||
| - the exact fully merged UI feed object | ||
| See `docs/CONTRACT_V1.md` for the stable CLI contract. |
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.
45624
38.7%12
9.09%1010
41.85%160
18.52%