diff --git a/src/cli/index.ts b/src/cli/index.ts
index 0d2c91d..ad39403 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -35,7 +35,11 @@ import {
import { VERSION } from '../config.ts';
import { ago, formatSalary } from '../schema/text.ts';
import { APPLICATION_DECISIONS, isApplicationDecision } from '../schema/job.ts';
-import type { Job, JobQuery } from '../schema/index.ts';
+// The vocabulary, not the storage: core/resumes.ts is pure apart from a type
+// import of pg, so the CLI can name the same three values the server does
+// rather than keeping a second copy of them in step by hand.
+import { VISIBILITIES } from '../core/resumes.ts';
+import type { Job, JobQuery, Organisation } from '../schema/index.ts';
const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self-host
@@ -68,8 +72,12 @@ const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self
Resumes
resume list
resume show
- resume save [--slug s] [--title t]
+ resume save [--slug s] [--title t] [--visibility v]
resume import pdf, docx, txt or md, converted to Markdown
+ resume publish list it at /candidates
+ resume unpublish make it private again
+ resume visibility
+ resume delete --yes
Updates
news the updates on this board
@@ -81,6 +89,11 @@ const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self
unfollow stop
Hiring
+ employer list the employers you can post under
+ employer show
+ employer create [--website u] [--description d] [--logo u]
+ employer update [--name n] [--website u] [--description d]
+ employer delete --yes
post post a job; stays a draft until you publish
new import a job from a URL, as a draft
update re-read that URL into the listing it created
@@ -280,6 +293,10 @@ async function run(args: Args): Promise {
case 'resume':
return commandResume(args);
+ case 'employer':
+ case 'employers':
+ return commandEmployer(args);
+
case 'post':
return commandPost(args);
case 'new':
@@ -771,11 +788,17 @@ async function commandResume(args: Args): Promise {
}
const slug = flagString(args, 'slug');
const title = flagString(args, 'title');
+ const visibility = visibilityFrom(args);
+ if (visibility instanceof Error) {
+ process.stderr.write(`${visibility.message}\n`);
+ return 1;
+ }
const saved = await client.saveResume(await readFile(path, 'utf8'), {
...(slug === undefined ? {} : { slug }),
...(title === undefined ? {} : { title }),
+ ...(visibility === undefined ? {} : { visibility }),
});
- return out(args, 'Saved.', saved);
+ return out(args, savedMessage(client.server, saved), saved);
}
if (action === 'import') {
@@ -787,18 +810,237 @@ async function commandResume(args: Args): Promise {
const { importDocument } = await import('../core/import.ts');
const imported = await importDocument(path, await readFile(path));
const title = flagString(args, 'title');
+ const visibility = visibilityFrom(args);
+ if (visibility instanceof Error) {
+ process.stderr.write(`${visibility.message}\n`);
+ return 1;
+ }
// Converted on this machine, so the original file never leaves it.
const saved = await client.saveResume(imported.markdown, {
...(title === undefined ? {} : { title }),
+ ...(visibility === undefined ? {} : { visibility }),
});
for (const warning of imported.warnings) process.stderr.write(`note: ${warning}\n`);
- return out(args, `Converted from ${imported.via} and saved.`, saved);
+ return out(args, `Converted from ${imported.via}. ${savedMessage(client.server, saved)}`, saved);
+ }
+
+ /**
+ * Publishing is its own word because it is its own decision.
+ *
+ * Saving a resume and letting the board list it are different acts, and the
+ * flag on `save` is there for the person who means both at once. This is
+ * for the far more common case of changing your mind about a document that
+ * is already written.
+ */
+ if (action === 'publish' || action === 'unpublish' || action === 'visibility') {
+ const slug = args.positional[1];
+ if (slug === undefined) {
+ process.stderr.write(`Which one? agenticjobs resume ${action} \n`);
+ return 1;
+ }
+
+ let wanted: string;
+ if (action === 'publish') wanted = 'public';
+ else if (action === 'unpublish') wanted = 'private';
+ else {
+ const given = args.positional[2];
+ if (given === undefined || !(VISIBILITIES as readonly string[]).includes(given)) {
+ process.stderr.write(
+ `Which visibility? agenticjobs resume visibility <${VISIBILITIES.join('|')}>\n`,
+ );
+ return 1;
+ }
+ wanted = given;
+ }
+
+ const result = await client.setResumeVisibility(slug, wanted);
+ return out(args, savedMessage(client.server, result), result);
+ }
+
+ if (action === 'delete') {
+ const slug = args.positional[1];
+ if (slug === undefined) {
+ process.stderr.write('Which one? agenticjobs resume delete \n');
+ return 1;
+ }
+ if (!flagBool(args, 'yes', 'y')) {
+ process.stderr.write(
+ `This deletes ${slug} and cannot be undone. Add --yes if you mean it.\n`,
+ );
+ return 1;
+ }
+ const removed = await client.deleteResume(slug);
+ return out(args, `Deleted ${slug}.`, removed);
}
process.stderr.write(`No resume command called "${action}".\n`);
return 1;
}
+// --- employers ------------------------------------------------------------
+
+/**
+ * The employer you post under, from the terminal.
+ *
+ * Posting a job needed an employer and there was no way to make one without a
+ * browser, so the documented path for a board whose whole pitch is that an
+ * agent can use it was "open the website first". This is the missing half.
+ */
+async function commandEmployer(args: Args): Promise {
+ const action = args.positional[0] ?? 'list';
+ const client = clientFor(args);
+
+ if (action === 'list') {
+ const orgs = await client.myOrgs();
+ if (orgs.length === 0) {
+ return out(
+ args,
+ `No employers yet. ${dim('agenticjobs employer create "Example Works"')}`,
+ { items: orgs },
+ );
+ }
+ return out(args, orgs.map(employerLine).join('\n'), { items: orgs });
+ }
+
+ if (action === 'show') {
+ const slug = args.positional[1];
+ if (slug === undefined) {
+ process.stderr.write('Which one? agenticjobs employer show \n');
+ return 1;
+ }
+ const result = await client.request<{ org: Organisation; jobs: { total: number } }>(
+ 'GET',
+ `/api/v1/orgs/${encodeURIComponent(slug)}`,
+ );
+ return out(
+ args,
+ [
+ employerLine(result.org),
+ result.org.description === null ? '' : ` ${result.org.description}`,
+ ` ${dim(`${result.jobs.total} published`)}`,
+ ]
+ .filter((line) => line !== '')
+ .join('\n'),
+ result,
+ );
+ }
+
+ if (action === 'create') {
+ const name = args.positional.slice(1).join(' ').trim() || (flagString(args, 'name') ?? '');
+ if (name === '') {
+ process.stderr.write('What is it called? agenticjobs employer create "Example Works"\n');
+ return 1;
+ }
+ const created = await client.createOrg({ name, ...employerFields(args) });
+ return out(
+ args,
+ `Created ${created.org.slug}.\n${dim(` post to it: agenticjobs post job.md --org ${created.org.slug}`)}`,
+ created,
+ );
+ }
+
+ if (action === 'update') {
+ const slug = args.positional[1];
+ if (slug === undefined) {
+ process.stderr.write('Which one? agenticjobs employer update --name "New Name"\n');
+ return 1;
+ }
+ const name = flagString(args, 'name');
+ const fields = { ...(name === undefined ? {} : { name }), ...employerFields(args) };
+ if (Object.keys(fields).length === 0) {
+ process.stderr.write(
+ 'Nothing to change. Pass --name, --website, --description or --logo.\n',
+ );
+ return 1;
+ }
+ const updated = await client.updateOrg(slug, fields);
+ // The slug is deliberately not derived again from a new name, so say so:
+ // somebody who renames an employer will otherwise go looking for a URL
+ // that never changed.
+ return out(
+ args,
+ `Updated ${updated.org.name}. ${dim(`Still ${updated.org.slug}, so every link to it still works.`)}`,
+ updated,
+ );
+ }
+
+ if (action === 'delete') {
+ const slug = args.positional[1];
+ if (slug === undefined) {
+ process.stderr.write('Which one? agenticjobs employer delete --yes\n');
+ return 1;
+ }
+ if (!flagBool(args, 'yes', 'y')) {
+ process.stderr.write(`This deletes ${slug} and cannot be undone. Add --yes if you mean it.\n`);
+ return 1;
+ }
+ const removed = await client.deleteOrg(slug);
+ return out(args, `Deleted ${slug}.`, removed);
+ }
+
+ process.stderr.write(`No employer command called "${action}".\n`);
+ return 1;
+}
+
+/**
+ * The optional details, only when they were given.
+ *
+ * An absent flag has to stay absent all the way to the request: the API
+ * leaves out what it is not sent, and sending `undefined` as `null` here
+ * would turn "I did not mention the website" into "clear the website".
+ */
+function employerFields(args: Args): Record {
+ const website = flagString(args, 'website');
+ const description = flagString(args, 'description');
+ const logo = flagString(args, 'logo', 'logo-url');
+ return {
+ ...(website === undefined ? {} : { website }),
+ ...(description === undefined ? {} : { description }),
+ ...(logo === undefined ? {} : { logoUrl: logo }),
+ };
+}
+
+function employerLine(org: Organisation): string {
+ const bits = [org.slug, org.website].filter(
+ (bit): bit is string => typeof bit === 'string' && bit !== '',
+ );
+ return `${org.name} ${dim(bits.join(' '))}`;
+}
+
+/** `--visibility public`, checked here so a typo is not silently ignored. */
+function visibilityFrom(args: Args): string | undefined | Error {
+ const given = flagString(args, 'visibility');
+ if (given === undefined) return undefined;
+ if (!(VISIBILITIES as readonly string[]).includes(given)) {
+ return new Error(`Not a visibility: ${given}. Use ${VISIBILITIES.join(', ')}.`);
+ }
+ return given;
+}
+
+/**
+ * What happened, said in terms of who can now see it.
+ *
+ * "Saved." was true and useless: the whole question a candidate has after
+ * saving is whether anybody can read it yet, and the address is the answer.
+ */
+function savedMessage(server: string, result: unknown): string {
+ const resume = (result as { resume?: { visibility?: string; publicSlug?: string | null } })
+ ?.resume;
+ const visibility = resume?.visibility;
+ const publicSlug = resume?.publicSlug ?? null;
+
+ if (visibility === 'public' && publicSlug !== null) {
+ return `Saved and listed: ${server}/candidates/${publicSlug}`;
+ }
+ if (visibility === 'link' && publicSlug !== null) {
+ return `Saved. Anyone with the link: ${server}/candidates/${publicSlug}`;
+ }
+ if (visibility === 'private') {
+ return `Saved, and private. ${dim('agenticjobs resume publish lists it.')}`;
+ }
+ return 'Saved.';
+}
+
// --- hiring ---------------------------------------------------------------
/**
diff --git a/src/client/client.ts b/src/client/client.ts
index 02fca90..2f5573a 100644
--- a/src/client/client.ts
+++ b/src/client/client.ts
@@ -180,18 +180,46 @@ export class BoardClient {
async saveResume(
markdown: string,
- options: { slug?: string; title?: string } = {},
+ options: { slug?: string; title?: string; visibility?: string } = {},
): Promise {
- if (options.slug !== undefined) {
- return this.request('PATCH', `/api/v1/resumes/${encodeURIComponent(options.slug)}`, {
- markdown,
- ...(options.title === undefined ? {} : { title: options.title }),
- });
- }
- return this.request('POST', '/api/v1/resumes', {
+ const body = {
markdown,
...(options.title === undefined ? {} : { title: options.title }),
- });
+ ...(options.visibility === undefined ? {} : { visibility: options.visibility }),
+ };
+ if (options.slug !== undefined) {
+ return this.request('PATCH', `/api/v1/resumes/${encodeURIComponent(options.slug)}`, body);
+ }
+ return this.request('POST', '/api/v1/resumes', body);
+ }
+
+ /** Change only a resume's visibility, leaving the document alone. */
+ async setResumeVisibility(
+ slug: string,
+ visibility: string,
+ ): Promise<{ resume: { slug: string; visibility: string; publicSlug: string | null } }> {
+ return this.request('PATCH', `/api/v1/resumes/${encodeURIComponent(slug)}`, { visibility });
+ }
+
+ async deleteResume(slug: string): Promise<{ ok: boolean }> {
+ return this.request('DELETE', `/api/v1/resumes/${encodeURIComponent(slug)}`);
+ }
+
+ /** The employers this account belongs to, which is not the public list. */
+ async myOrgs(): Promise {
+ return (await this.me()).orgs;
+ }
+
+ async createOrg(input: Record): Promise<{ org: Organisation }> {
+ return this.request('POST', '/api/v1/orgs', input);
+ }
+
+ async updateOrg(slug: string, input: Record): Promise<{ org: Organisation }> {
+ return this.request('PATCH', `/api/v1/orgs/${encodeURIComponent(slug)}`, input);
+ }
+
+ async deleteOrg(slug: string): Promise<{ ok: boolean; deleted: string }> {
+ return this.request('DELETE', `/api/v1/orgs/${encodeURIComponent(slug)}`);
}
async postJob(input: Record): Promise<{ job: Job }> {
diff --git a/src/core/orgs.ts b/src/core/orgs.ts
index 1871b2f..9268389 100644
--- a/src/core/orgs.ts
+++ b/src/core/orgs.ts
@@ -134,6 +134,93 @@ export async function createOrg(
}
}
+/**
+ * Change an employer's details, one field at a time.
+ *
+ * Only the fields that were sent move. An employer edited from a form that
+ * only carries a name must not have its website silently cleared, and an
+ * agent updating a description has no business also blanking a logo it never
+ * read. `null` is therefore a value that clears a field and `undefined` is
+ * "leave it", which is the distinction the whole signature exists to keep.
+ *
+ * The slug never moves, even when the name does. It is the URL that listings,
+ * links and the directory all point at, and a rename is the most ordinary
+ * thing an employer does: a company that becomes "Example Works Inc" has not
+ * become a different employer, and every link to it must survive that.
+ */
+export async function updateOrg(
+ pool: pg.Pool,
+ slug: string,
+ input: Partial,
+): Promise {
+ const existing = await getOrgBySlug(pool, slug);
+ if (existing === null) return `No employer here with the slug ${slug}.`;
+
+ let name = existing.name;
+ if (input.name !== undefined) {
+ name = clean(input.name, 120);
+ if (name.length < 2) return 'An employer name of at least 2 characters is required.';
+ }
+
+ const website = input.website === undefined ? existing.website : normaliseUrl(input.website);
+ const logoUrl = input.logoUrl === undefined ? existing.logoUrl : normaliseUrl(input.logoUrl);
+ const description =
+ input.description === undefined
+ ? existing.description
+ : clean(input.description, 2000) || null;
+
+ const result = await pool.query(
+ `update organisations set name = $2, website = $3, description = $4, logo_url = $5
+ where id = $1
+ returning id, slug, name, website, logo_url, description, created_at`,
+ [existing.id, name, website, description, logoUrl],
+ );
+ const row = result.rows[0];
+ return row === undefined ? `No employer here with the slug ${slug}.` : toOrg(row);
+}
+
+/** An employer's listings, split by whether they were ever public. */
+export async function countJobsForOrg(
+ pool: pg.Pool,
+ orgId: string,
+): Promise<{ total: number; live: number }> {
+ const result = await pool.query<{ total: string; live: string }>(
+ `select count(*)::text as total,
+ count(*) filter (where status <> 'draft')::text as live
+ from jobs where org_id = $1`,
+ [orgId],
+ );
+ const row = result.rows[0];
+ return { total: Number(row?.total ?? 0), live: Number(row?.live ?? 0) };
+}
+
+/**
+ * Delete an employer that never published anything.
+ *
+ * `jobs.org_id` cascades and `applications.job_id` cascades behind it, so a
+ * plain delete here would quietly take published listings and every
+ * application people sent to them. A listing that has been public is part of
+ * a record other people are in, and one line of SQL is not the right amount
+ * of ceremony for removing it.
+ *
+ * Drafts are different: nobody has seen them, nothing can have been sent to
+ * one, and an employer whose listings are all drafts is almost always an
+ * employer typed in wrong five minutes ago. Those cascade, which is what
+ * makes this useful rather than a delete that always refuses.
+ *
+ * The refusal is permanent by design and says so, because there is no
+ * unpublish-and-then-delete path to send somebody down: closing a listing
+ * keeps it, which is the point of closing it.
+ */
+export async function deleteOrg(pool: pg.Pool, orgId: string): Promise {
+ const jobs = await countJobsForOrg(pool, orgId);
+ if (jobs.live > 0) {
+ return `That employer has ${jobs.live} listing${jobs.live === 1 ? '' : 's'} that went live, and the applications sent to them would go too. An employer that has posted publicly stays.`;
+ }
+ const result = await pool.query(`delete from organisations where id = $1`, [orgId]);
+ return result.rowCount === 0 ? 'No such employer.' : true;
+}
+
export async function listOrgs(pool: pg.Pool, limit = 100): Promise {
const result = await pool.query(
`select o.id, o.slug, o.name, o.website, o.logo_url, o.description, o.created_at
diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts
index c05d7fb..9b6c22e 100644
--- a/src/server/routes/api.ts
+++ b/src/server/routes/api.ts
@@ -49,7 +49,15 @@ import {
updateJobFromImport,
} from '../../core/jobs.ts';
import { extractJob, JobImportProblem, type ImportedJob } from '../../core/import-job.ts';
-import { createOrg, getOrgBySlug, isMember, listOrgs, listOrgsForUser } from '../../core/orgs.ts';
+import {
+ createOrg,
+ deleteOrg,
+ getOrgBySlug,
+ isMember,
+ listOrgs,
+ listOrgsForUser,
+ updateOrg,
+} from '../../core/orgs.ts';
import {
createResume,
deleteResume,
@@ -583,6 +591,67 @@ export function apiRoutes(): Hono {
return c.json({ org: created }, 201);
});
+ /**
+ * Change an employer.
+ *
+ * Absent fields are left alone rather than cleared, so a caller that knows
+ * about a name and nothing else cannot blank a website it never read. That
+ * is also what makes this safe to call from a script that only ever sets
+ * one thing.
+ */
+ api.patch('/orgs/:slug', async (c) => {
+ const { pool } = c.get('deps');
+ const viewer = viewerOf(c);
+ if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in to edit an employer.');
+
+ const org = await getOrgBySlug(pool, c.req.param('slug'));
+ if (org === null) return fail(c, 404, 'not_found', 'No such employer.');
+ if (!(await isMember(pool, viewer.id, org.id))) {
+ return fail(c, 403, 'not_a_member', `You are not a member of ${org.name}.`);
+ }
+
+ const body = await readBody(c);
+ const field = (key: string): string | null | undefined => {
+ const value = body[key];
+ if (value === undefined) return undefined;
+ // An explicit null clears the field; a string sets it. Anything else is
+ // not an answer, so it is treated as not having been sent.
+ if (value === null) return null;
+ return typeof value === 'string' ? value : undefined;
+ };
+
+ const updated = await updateOrg(pool, org.slug, {
+ ...(field('name') === undefined ? {} : { name: field('name') ?? '' }),
+ ...(field('website') === undefined ? {} : { website: field('website') }),
+ ...(field('description') === undefined ? {} : { description: field('description') }),
+ ...(field('logoUrl') === undefined ? {} : { logoUrl: field('logoUrl') }),
+ });
+ if (typeof updated === 'string') return fail(c, 400, 'invalid', updated);
+ return c.json({ org: updated });
+ });
+
+ /**
+ * Delete an employer that never published anything.
+ *
+ * The rule lives in the model rather than here, because it is a fact about
+ * what deleting an organisation drags with it and not a fact about HTTP.
+ */
+ api.delete('/orgs/:slug', async (c) => {
+ const { pool } = c.get('deps');
+ const viewer = viewerOf(c);
+ if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in to delete an employer.');
+
+ const org = await getOrgBySlug(pool, c.req.param('slug'));
+ if (org === null) return fail(c, 404, 'not_found', 'No such employer.');
+ if (!(await isMember(pool, viewer.id, org.id))) {
+ return fail(c, 403, 'not_a_member', `You are not a member of ${org.name}.`);
+ }
+
+ const removed = await deleteOrg(pool, org.id);
+ if (typeof removed === 'string') return fail(c, 409, 'has_listings', removed);
+ return c.json({ ok: true, deleted: org.slug });
+ });
+
// --- me and resumes ---------------------------------------------------
api.get('/me', async (c) => {
diff --git a/src/server/routes/discovery.ts b/src/server/routes/discovery.ts
index 257490c..c17a5a5 100644
--- a/src/server/routes/discovery.ts
+++ b/src/server/routes/discovery.ts
@@ -753,8 +753,17 @@ export function discoveryRoutes(): Hono {
'',
`- POST ${config.publicUrl}/api/v1/resumes with {"markdown": "# Name\\n..."} saves one`,
`- PATCH ${config.publicUrl}/api/v1/resumes/{slug} with {"visibility": "public"} lists it`,
+ `- DELETE ${config.publicUrl}/api/v1/resumes/{slug} removes it`,
`- POST ${config.publicUrl}/api/v1/resumes/import converts a pdf, docx or txt upload`,
'',
+ 'From a terminal that is the same three steps: `agenticjobs resume save resume.md`,',
+ 'then `agenticjobs resume publish `. `resume import` converts a PDF or a Word',
+ 'document locally, so the original file never leaves the machine.',
+ '',
+ 'An agent should also say how many of it there are. Two contact bullets carry that:',
+ '"- **Agents**: 10" and "- **Rate**: $100/hour/agent". An unmarked rate is read as the',
+ 'price for the whole swarm, so mark it per-agent when it is per-agent.',
+ '',
'Visibility is private (the default), link (an address, unlisted) or public (listed at',
'/candidates). The address is minted on first share and then kept, so a link already',
'sent to an employer never comes to point at a different person.',
@@ -764,10 +773,19 @@ export function discoveryRoutes(): Hono {
'Both steps need the token from `agenticjobs login`, and an employer comes first.',
'',
`- POST ${config.publicUrl}/api/v1/orgs with {"name": "Example Works"}, once`,
+ `- PATCH or DELETE ${config.publicUrl}/api/v1/orgs/{slug} edits or removes it`,
`- POST ${config.publicUrl}/api/v1/jobs with {"org": "", "title", "description",`,
' "agentPolicy", and whatever else the listing needs',
`- POST ${config.publicUrl}/api/v1/jobs/{slug}/publish takes a draft live`,
'',
+ 'From a terminal: `agenticjobs employer create "Example Works"`, then',
+ '`agenticjobs post job.md --org ` and `agenticjobs publish `.',
+ '',
+ 'A PATCH changes only the fields it carries, so a caller that knows about a name cannot',
+ 'blank a website it never read. Renaming never moves the slug, because that slug is the',
+ 'URL every listing and every link already points at. An employer that has published',
+ 'cannot be deleted: the listings and the applications sent to them would go with it.',
+ '',
'A listing arrives as a DRAFT unless you send "publish": true. A person reading what an',
'agent wrote before it goes live is the point, not an obstacle to route around.',
'',
diff --git a/src/server/routes/openapi.ts b/src/server/routes/openapi.ts
index b4b0c80..18fb2b8 100644
--- a/src/server/routes/openapi.ts
+++ b/src/server/routes/openapi.ts
@@ -170,6 +170,24 @@ export function openApiDocument(config: Config): Record {
parameters: [pathParam('slug')],
responses: { 200: ok('The employer.'), 404: err() },
},
+ patch: {
+ tags: ['employers'],
+ summary: 'Change an employer. Only the fields you send move.',
+ description:
+ 'An absent field is left alone and an explicit null clears it, so a caller that knows about a name cannot blank a website it never read. Renaming never moves the slug: that slug is the URL every listing already points at.',
+ security: [{ bearer: [] }],
+ parameters: [pathParam('slug')],
+ responses: { 200: ok('The employer.'), 400: err(), 401: err(), 403: err(), 404: err() },
+ },
+ delete: {
+ tags: ['employers'],
+ summary: 'Delete an employer that never published a listing.',
+ description:
+ 'Listings and the applications sent to them cascade, so this is refused with 409 once anything has gone live. Drafts do not count: nobody has seen one.',
+ security: [{ bearer: [] }],
+ parameters: [pathParam('slug')],
+ responses: { 200: ok('Deleted.'), 401: err(), 403: err(), 404: err(), 409: err() },
+ },
},
'/api/v1/me': {
get: {
diff --git a/src/views/docs.tsx b/src/views/docs.tsx
index 69bb344..1e84300 100644
--- a/src/views/docs.tsx
+++ b/src/views/docs.tsx
@@ -130,19 +130,18 @@ Chief Programmer (1842 - 1843)
- {`# 1. an account, and this terminal signed in to it
-agenticjobs signup you@example.com
-
-# 2. the document: write it, or convert one you already have
-agenticjobs resume save resume.md --title "Backend engineer"
-agenticjobs resume import ~/cv.pdf # pdf, docx, txt or md
-
-# 3. list it, which is a separate decision from saving it
-curl -X PATCH ${publicUrl}/api/v1/resumes/SLUG \\
- -H "authorization: Bearer $TOKEN" \\
- -H 'content-type: application/json' \\
- -d '{"visibility": "public"}'`}
+ {`agenticjobs signup you@example.com # account, and this terminal
+agenticjobs resume save resume.md # the document
+agenticjobs resume publish # list it at /candidates`}
+
+ Already have one written? agenticjobs resume import ~/cv.pdf converts a PDF,
+ Word document or text file on your own machine and saves the Markdown, so the original
+ never leaves it. --visibility public on save or{' '}
+ import does both steps at once. The rest of the set is{' '}
+ resume list, show, unpublish,{' '}
+ visibility <slug> <value> and delete <slug> --yes.
+
Three visibilities. private is the default and is yours alone;{' '}
link gives it an address you can send to one employer without it appearing
@@ -158,6 +157,14 @@ curl -X PATCH ${publicUrl}/api/v1/resumes/SLUG \\
are split, so Languages: Go, TypeScript is two tags rather than one. If you
want to be found by a skill, the section has to be there.
+
+ If you are an agent, say how many of you there are. Two more contact
+ bullets carry it: - **Agents**: 10 and{' '}
+ - **Rate**: $100/hour/agent. That is the question a human resume never had to
+ answer, and the difference between a contractor and a firm. The /agent marker
+ is what stops a swarm price being read as a per-agent one, so mark it or the rate is taken
+ as the total for all of you.
+
Your contact details are withheld from anonymous readers. Anything in the
contact block that is a way to reach you - an email address, a phone number, a profile link
@@ -173,9 +180,12 @@ ${publicUrl}/api/v1/candidates/SLUG # the same thing as data
${publicUrl}/candidates/feed?tags=go,postgres`}
- In a browser instead: /me/resumes/new writes the template for
- you and takes the upload. The token above is the one agenticjobs login saved
- in ~/.config/agenticjobs/config.json.
+ Every one of those commands is a REST call underneath, if you would rather make it
+ yourself: POST, PATCH and DELETE{' '}
+ /api/v1/resumes, with {`{"visibility": "public"}`} as the body
+ that lists one. In a browser instead:{' '}
+ /me/resumes/new writes the template for you and takes the
+ upload.
@@ -190,16 +200,17 @@ ${publicUrl}/candidates/feed?tags=go,postgres`}
- {`# 1. the employer you post under, once
-curl -X POST ${publicUrl}/api/v1/orgs \\
- -H "authorization: Bearer $TOKEN" \\
- -H 'content-type: application/json' \\
- -d '{"name": "Example Works", "website": "https://example.com"}'
-
-# 2. the listing
+ {`agenticjobs employer create "Example Works" --website https://example.com
agenticjobs post job.md --org example-works
-agenticjobs publish SLUG # after a person has read it`}
+agenticjobs publish # after a person has read it`}
+
+ The employer is made once and posted to for as long as you hire.{' '}
+ agenticjobs employer list shows the ones you can post under,{' '}
+ update <slug> changes the details, and{' '}
+ delete <slug> --yes removes one that never published anything. A rename
+ keeps the slug: it is the URL your listings and every link to them already point at.
+
A job is a Markdown file with front matter: the structured fields above the rule, the
description below it. That is a file a listing can live in a repository as, go through
@@ -264,10 +275,11 @@ agenticjobs edit SLUG job.md # rewrite it, keeping its URL
agenticjobs close SLUG`}
- The same two steps in a browser: /me/employers/new, then{' '}
- /post. Or in one request: POST /api/v1/jobs with an{' '}
+ As REST: POST, PATCH and DELETE{' '}
+ /api/v1/orgs for the employer, then POST /api/v1/jobs with an{' '}
org slug and "publish": true when you have already read what you
- are posting.
+ are posting. In a browser: /me/employers/new, then{' '}
+ /post.
diff --git a/test/api.test.ts b/test/api.test.ts
index de7090c..b62a0b3 100644
--- a/test/api.test.ts
+++ b/test/api.test.ts
@@ -55,6 +55,21 @@ async function del(path: string, headers: Record = {}): Promise<
return app.fetch(new Request(`http://board.test${path}`, { method: 'DELETE', headers }));
}
+async function patch(
+ path: string,
+ body: unknown,
+ headers: Record = {},
+): Promise {
+ if (app === null) throw new Error('no app');
+ return app.fetch(
+ new Request(`http://board.test${path}`, {
+ method: 'PATCH',
+ headers: { 'content-type': 'application/json', ...headers },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
/**
* Set up at module scope, not in before().
*
@@ -458,6 +473,107 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` },
});
});
+ describe('employers, as CRUD', () => {
+ /** A signed-in account with one employer of its own. */
+ const employer = async (name: string) => {
+ const { createSession, ensureUser } = await import('../dist/core/auth.js');
+ const { createOrg } = await import('../dist/core/orgs.js');
+ const stamp = `${Date.now()}${Math.random().toString(36).slice(2, 7)}`;
+ const user = await ensureUser(pool as never, `org+${stamp}@example.com`, name);
+ const token = await createSession(pool as never, user.id, { label: 't' });
+ const org = await createOrg(pool as never, user.id, {
+ name: `${name} ${stamp}`,
+ website: 'https://example.com',
+ });
+ if (typeof org === 'string') throw new Error(org);
+ return { org, stamp, auth: { authorization: `Bearer ${token}` } };
+ };
+
+ test('a rename keeps the slug, because the slug is the URL', async () => {
+ if (pool === null) return;
+ const { org, auth, stamp } = await employer('Rename Co');
+
+ const response = await patch(`/api/v1/orgs/${org.slug}`, { name: `Renamed ${stamp}` }, auth);
+ assert.equal(response.status, 200);
+ const body = (await response.json()) as { org: { slug: string; name: string } };
+ assert.equal(body.org.name, `Renamed ${stamp}`);
+ assert.equal(body.org.slug, org.slug, 'a company that renamed is not a different employer');
+
+ // The old address is the one every listing and link already points at,
+ // so the test that matters is that it still resolves.
+ assert.equal((await get(`/api/v1/orgs/${org.slug}`)).status, 200);
+ });
+
+ test('a patch leaves alone what it does not carry', async () => {
+ if (pool === null) return;
+ const { org, auth } = await employer('Partial Co');
+
+ // Only a description. A caller that never read the website must not be
+ // able to clear it by not mentioning it.
+ await patch(`/api/v1/orgs/${org.slug}`, { description: 'We make examples.' }, auth);
+ const kept = (await (await get(`/api/v1/orgs/${org.slug}`)).json()) as {
+ org: { website: string | null; description: string | null };
+ };
+ assert.equal(kept.org.description, 'We make examples.');
+ assert.ok(kept.org.website?.includes('example.com'), 'the website survived a patch about something else');
+
+ // An explicit null is the way to actually clear one.
+ await patch(`/api/v1/orgs/${org.slug}`, { website: null }, auth);
+ const cleared = (await (await get(`/api/v1/orgs/${org.slug}`)).json()) as {
+ org: { website: string | null };
+ };
+ assert.equal(cleared.org.website, null);
+ });
+
+ test('somebody else cannot edit or delete your employer', async () => {
+ if (pool === null) return;
+ const { org } = await employer('Mine Co');
+ const stranger = await employer('Stranger Co');
+
+ assert.equal((await patch(`/api/v1/orgs/${org.slug}`, { name: 'Theirs' }, stranger.auth)).status, 403);
+ assert.equal((await del(`/api/v1/orgs/${org.slug}`, stranger.auth)).status, 403);
+ // And with no token at all, which is a different code path.
+ assert.equal((await patch(`/api/v1/orgs/${org.slug}`, { name: 'Theirs' })).status, 401);
+ });
+
+ test('an employer that published cannot be deleted, one that never did can', async () => {
+ if (pool === null) return;
+ const { org, auth, stamp } = await employer('Delete Co');
+
+ // Nothing attached yet: it goes.
+ const spare = await employer('Spare Co');
+ const gone = await del(`/api/v1/orgs/${spare.org.slug}`, spare.auth);
+ assert.equal(gone.status, 200);
+ assert.equal((await get(`/api/v1/orgs/${spare.org.slug}`)).status, 404);
+
+ // One published listing, and the same call is refused - because the
+ // cascade would take the listing and every application with it.
+ const job = (await (
+ await post(
+ '/api/v1/jobs',
+ {
+ org: org.slug,
+ title: `Kept Role ${stamp}`,
+ description: 'A listing that has been public, which is why its employer stays.',
+ agentPolicy: 'welcome',
+ },
+ auth,
+ )
+ ).json()) as { job: { slug: string } };
+ await post(`/api/v1/jobs/${job.job.slug}/publish`, {}, auth);
+
+ const refused = await del(`/api/v1/orgs/${org.slug}`, auth);
+ assert.equal(refused.status, 409);
+ const problem = (await refused.json()) as { error?: { message?: string } };
+ assert.match(
+ problem.error?.message ?? '',
+ /listing/i,
+ 'the refusal has to say what is in the way',
+ );
+ assert.equal((await get(`/api/v1/jobs/${job.job.slug}`)).status, 200, 'the listing survived');
+ });
+ });
+
describe('unpaid roles', () => {
test('an unpaid listing says so, and stays out of salary filters', async () => {
if (pool === null) return;
diff --git a/test/views.test.ts b/test/views.test.ts
index cf5f86c..0bbd398 100644
--- a/test/views.test.ts
+++ b/test/views.test.ts
@@ -360,12 +360,32 @@ test('the docs say how to get listed as a candidate, not only how to apply', ()
test('the docs say how to post a job, employer first', () => {
const html = docs();
- const orgs = html.indexOf('/api/v1/orgs');
+ const employer = html.indexOf('agenticjobs employer create');
const post = html.indexOf('agenticjobs post job.md');
- assert.ok(orgs !== -1, 'creating the employer has to be on the page');
+ assert.ok(employer !== -1, 'creating the employer has to be on the page');
assert.ok(post !== -1, 'and so does posting the listing');
- assert.ok(orgs < post, 'in that order: a listing has nowhere to go without an employer');
+ assert.ok(employer < post, 'in that order: a listing has nowhere to go without an employer');
assert.match(html, /agent_policy|agentPolicy/, 'the field this board exists for');
assert.match(html, /draft/i, 'a posted job is a draft until a person publishes it');
assert.match(html, /agenticjobs publish/);
});
+
+/**
+ * Both flows have to be doable with the client the page tells you to install.
+ *
+ * They were documented as curl with a hand-copied bearer token, because the
+ * CLI genuinely could not create an employer or publish a resume. Asserting
+ * the commands rather than the endpoints is what keeps the page from drifting
+ * back to that: a curl example passes an endpoint assertion happily.
+ */
+test('neither flow sends you to curl for a step the CLI cannot do', () => {
+ const html = docs();
+ assert.match(html, /agenticjobs employer create/, 'employers are made from the terminal');
+ assert.match(html, /agenticjobs resume publish/, 'and resumes are listed from it');
+ // The REST equivalents stay documented; what must not come back is a curl
+ // as the only way through either flow.
+ assert.ok(
+ !/curl -X (POST|PATCH) [^\n]*\/api\/v1\/(orgs|resumes)/.test(html),
+ 'a curl with a bearer token is no longer the documented path for either step',
+ );
+});