shiply-cli 0.14.5 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ /** B14 — `shiply contract <subcommand>` — CLI parity with the contract REST
2
+ * + MCP surface shipped in B1-B13. The 7 subcommands cover the full contract
3
+ * lifecycle: list / draft / show / send / pdf / amend / retract.
4
+ *
5
+ * Every endpoint here is dev-only (Bearer auth via `~/.shiply/credentials`).
6
+ * Customer-side reads happen through the intake portal cookie, not the CLI. */
7
+ import { writeFile } from 'node:fs/promises';
8
+ import { api, ApiError, resolveBase } from './publish.js';
9
+ const headers = (apiKey) => ({
10
+ 'content-type': 'application/json',
11
+ authorization: `Bearer ${apiKey}`,
12
+ });
13
+ function fmtMoney(cents, currency) {
14
+ if (cents === null || cents === undefined)
15
+ return '—';
16
+ const cur = currency ?? 'USD';
17
+ const sign = cents < 0 ? '-' : '';
18
+ const abs = Math.abs(cents);
19
+ const whole = Math.floor(abs / 100);
20
+ const frac = String(abs % 100).padStart(2, '0');
21
+ return `${sign}${cur} ${whole}.${frac}`;
22
+ }
23
+ function kind(c) {
24
+ return c.parentContractId ? 'amendment' : 'parent';
25
+ }
26
+ export async function contractList(ctx, projectId) {
27
+ if (!projectId)
28
+ throw new Error('usage: shiply contract list <project-id>');
29
+ const base = resolveBase(ctx.base);
30
+ const r = await api(`${base}/api/v1/projects/${encodeURIComponent(projectId)}/contracts`, { headers: headers(ctx.apiKey) });
31
+ if (!r.parent) {
32
+ console.log('No contract yet for this project. Draft one: shiply contract draft ' + projectId);
33
+ return;
34
+ }
35
+ const p = r.parent;
36
+ console.log(`Parent ${p.id} [${p.status}] ${fmtMoney(p.feeCents, p.currency)} scope="${(p.scopeSummary ?? '').slice(0, 60)}${(p.scopeSummary ?? '').length > 60 ? '…' : ''}"`);
37
+ if (p.sentAt)
38
+ console.log(` sent ${p.sentAt}`);
39
+ if (p.signedAt)
40
+ console.log(` signed ${p.signedAt}`);
41
+ for (const a of r.amendments) {
42
+ console.log(`Amend ${a.id} [${a.status}] Δ${fmtMoney(a.feeDeltaCents, a.currency ?? p.currency)} scope="${(a.scopeDelta ?? '').slice(0, 60)}${(a.scopeDelta ?? '').length > 60 ? '…' : ''}"`);
43
+ if (a.sentAt)
44
+ console.log(` sent ${a.sentAt}`);
45
+ if (a.signedAt)
46
+ console.log(` signed ${a.signedAt}`);
47
+ }
48
+ }
49
+ export async function contractDraft(ctx, projectId) {
50
+ if (!projectId)
51
+ throw new Error('usage: shiply contract draft <project-id>');
52
+ const base = resolveBase(ctx.base);
53
+ const r = await api(`${base}/api/v1/projects/${encodeURIComponent(projectId)}/contract`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
54
+ console.log(`✔ draft contract ${r.contract.id} created`);
55
+ console.log(' edit fields via the dashboard, then: shiply contract send ' + r.contract.id);
56
+ }
57
+ export async function contractShow(ctx, contractId) {
58
+ if (!contractId)
59
+ throw new Error('usage: shiply contract show <contract-id>');
60
+ const base = resolveBase(ctx.base);
61
+ const r = await api(`${base}/api/v1/contracts/${encodeURIComponent(contractId)}`, { headers: headers(ctx.apiKey) });
62
+ console.log(JSON.stringify(r.contract, null, 2));
63
+ }
64
+ export async function contractSend(ctx, contractId) {
65
+ if (!contractId)
66
+ throw new Error('usage: shiply contract send <contract-id>');
67
+ const base = resolveBase(ctx.base);
68
+ const r = await api(`${base}/api/v1/contracts/${encodeURIComponent(contractId)}/send`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
69
+ const k = kind(r.contract);
70
+ console.log(`✔ ${k} ${r.contract.id} sent — status=${r.contract.status}`);
71
+ if (r.contract.sentAt)
72
+ console.log(` sent at ${r.contract.sentAt}`);
73
+ console.log(' customer receives an email + sees it on the intake portal');
74
+ }
75
+ export async function contractPdf(ctx, contractId, outPath) {
76
+ if (!contractId)
77
+ throw new Error('usage: shiply contract pdf <contract-id> -o file.pdf');
78
+ if (!outPath)
79
+ throw new Error('--out / -o <file.pdf> is required for shiply contract pdf');
80
+ const base = resolveBase(ctx.base);
81
+ const url = `${base}/api/v1/contracts/${encodeURIComponent(contractId)}/pdf`;
82
+ const res = await fetch(url, { headers: { authorization: `Bearer ${ctx.apiKey}` } });
83
+ if (!res.ok) {
84
+ // Use the same ApiError envelope the rest of the CLI does so 409
85
+ // contract_not_signed surfaces with a useful message + exit 1.
86
+ const body = await res.json().catch(() => ({}));
87
+ const err = body.error;
88
+ throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
89
+ }
90
+ const bytes = new Uint8Array(await res.arrayBuffer());
91
+ await writeFile(outPath, bytes);
92
+ // Quick magic-bytes sanity check — `%PDF-` at the start of a real PDF.
93
+ const head = String.fromCharCode(...bytes.subarray(0, 5));
94
+ const looksLikePdf = head === '%PDF-';
95
+ console.log(`✔ wrote ${outPath} (${bytes.byteLength} bytes${looksLikePdf ? ', %PDF- header' : ', WARNING: not a PDF header'})`);
96
+ }
97
+ export async function contractAmend(ctx, contractId, opts) {
98
+ if (!contractId)
99
+ throw new Error('usage: shiply contract amend <contract-id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]');
100
+ if (!opts.scope)
101
+ throw new Error('--scope "<change description>" is required');
102
+ const base = resolveBase(ctx.base);
103
+ const body = { scopeDelta: opts.scope };
104
+ if (opts.feeDelta !== undefined) {
105
+ const n = Number(opts.feeDelta);
106
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
107
+ throw new Error('--fee-delta must be an integer (cents — use a negative number to discount)');
108
+ }
109
+ body.feeDeltaCents = n;
110
+ }
111
+ if (opts.targetDate)
112
+ body.targetCompletionDate = opts.targetDate;
113
+ const r = await api(`${base}/api/v1/contracts/${encodeURIComponent(contractId)}/amend`, { method: 'POST', headers: headers(ctx.apiKey), body: JSON.stringify(body) });
114
+ console.log(`✔ amendment ${r.contract.id} drafted against parent ${contractId}`);
115
+ console.log(` Δscope : ${r.contract.scopeDelta ?? '—'}`);
116
+ console.log(` Δfee : ${fmtMoney(r.contract.feeDeltaCents, null)}`);
117
+ if (r.contract.targetCompletionDate)
118
+ console.log(` new target: ${r.contract.targetCompletionDate}`);
119
+ // Spec says: amend auto-sends unless the dev wants a beat. The default is
120
+ // to send immediately so the contract reaches the customer in one command;
121
+ // pass --keep-as-draft on `shiply contract retract` style usage if the dev
122
+ // wants to hold. Here we send unconditionally — amendments are a quick fix
123
+ // path, and the customer-portal preview is non-binding until signed.
124
+ const sent = await api(`${base}/api/v1/contracts/${encodeURIComponent(r.contract.id)}/send`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
125
+ console.log(`✔ amendment ${sent.contract.id} sent — status=${sent.contract.status}`);
126
+ }
127
+ export async function contractRetract(ctx, contractId, opts) {
128
+ if (!contractId)
129
+ throw new Error('usage: shiply contract retract <contract-id> [--keep-as-draft]');
130
+ const base = resolveBase(ctx.base);
131
+ const r = await api(`${base}/api/v1/contracts/${encodeURIComponent(contractId)}/retract`, {
132
+ method: 'POST',
133
+ headers: headers(ctx.apiKey),
134
+ body: JSON.stringify({ recoverAsDraft: Boolean(opts.keepAsDraft) }),
135
+ });
136
+ if (opts.keepAsDraft) {
137
+ console.log(`✔ contract ${contractId} retracted to draft — edit + re-send when ready`);
138
+ }
139
+ else {
140
+ console.log(`✔ contract ${contractId} retracted (status=${r.contract.status})`);
141
+ }
142
+ console.log(' project state reverted to brief_ready');
143
+ }
package/dist/email.js ADDED
@@ -0,0 +1,147 @@
1
+ /** `shiply email <subcommand>` — agent email send + inbox.
2
+ *
3
+ * Pattern mirrors functions.ts: readFlags helper, exported runEmail
4
+ * entry point, individual async command functions. Top-level flags
5
+ * (--base / --key) are forwarded from index.ts via InheritedFlags. */
6
+ import { loadApiKey } from './config.js';
7
+ import { api, resolveBase } from './publish.js';
8
+ function readFlags(argv, inherited = {}) {
9
+ const raw = {};
10
+ const rest = [];
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a.startsWith('--')) {
14
+ const eq = a.indexOf('=');
15
+ if (eq >= 0) {
16
+ raw[a.slice(2, eq)] = a.slice(eq + 1);
17
+ }
18
+ else {
19
+ const next = argv[i + 1];
20
+ if (next !== undefined && !next.startsWith('--')) {
21
+ raw[a.slice(2)] = next;
22
+ i++;
23
+ }
24
+ else {
25
+ raw[a.slice(2)] = true;
26
+ }
27
+ }
28
+ }
29
+ else {
30
+ rest.push(a);
31
+ }
32
+ }
33
+ // For each field: local argv flag takes precedence, then fall back to the
34
+ // value forwarded from index.ts parseArgs via inherited (the top-level
35
+ // parseArgs consumed these flags before argv reached us).
36
+ const flags = {
37
+ base: (typeof raw.base === 'string' ? raw.base : undefined) ?? inherited.base,
38
+ key: (typeof raw.key === 'string' ? raw.key : undefined) ?? inherited.key,
39
+ site: (typeof raw.site === 'string' ? raw.site : undefined) ?? inherited.site,
40
+ to: (typeof raw.to === 'string' ? raw.to : undefined) ?? inherited.to,
41
+ subject: (typeof raw.subject === 'string' ? raw.subject : undefined) ?? inherited.subject,
42
+ html: (typeof raw.html === 'string' ? raw.html : undefined) ?? inherited.html,
43
+ text: (typeof raw.text === 'string' ? raw.text : undefined) ?? inherited.text,
44
+ };
45
+ return { rest, flags };
46
+ }
47
+ const headers = (apiKey) => ({
48
+ 'content-type': 'application/json',
49
+ authorization: `Bearer ${apiKey}`,
50
+ });
51
+ async function getApiKey(flags, what) {
52
+ const k = flags.key ?? (await loadApiKey());
53
+ if (!k)
54
+ throw new Error(`${what} commands need an API key — run \`shiply login\` first`);
55
+ return k;
56
+ }
57
+ const EMAIL_USAGE = [
58
+ 'Usage: shiply email <send|inbox>',
59
+ ' shiply email send --site <slug> --to <addr> --subject <s> --html <h> [--text <t>]',
60
+ ' shiply email inbox [--site <slug>]',
61
+ ].join('\n');
62
+ export async function runEmail(argv, inherited = {}) {
63
+ const action = argv[0];
64
+ const rest = argv.slice(1);
65
+ switch (action) {
66
+ case 'send':
67
+ return emailSendCmd(rest, inherited);
68
+ case 'inbox':
69
+ return emailInboxCmd(rest, inherited);
70
+ default:
71
+ console.error(EMAIL_USAGE);
72
+ process.exit(1);
73
+ }
74
+ }
75
+ async function emailSendCmd(argv, inherited) {
76
+ const { flags } = readFlags(argv, inherited);
77
+ if (!flags.site) {
78
+ console.error('--site <slug> is required');
79
+ console.error(EMAIL_USAGE);
80
+ process.exit(1);
81
+ }
82
+ if (!flags.to) {
83
+ console.error('--to <addr> is required');
84
+ console.error(EMAIL_USAGE);
85
+ process.exit(1);
86
+ }
87
+ if (!flags.subject) {
88
+ console.error('--subject <s> is required');
89
+ console.error(EMAIL_USAGE);
90
+ process.exit(1);
91
+ }
92
+ if (!flags.html) {
93
+ console.error('--html <h> is required');
94
+ console.error(EMAIL_USAGE);
95
+ process.exit(1);
96
+ }
97
+ const apiKey = await getApiKey(flags, 'email');
98
+ const base = resolveBase(flags.base);
99
+ const body = {
100
+ slug: flags.site,
101
+ to: flags.to,
102
+ subject: flags.subject,
103
+ html: flags.html,
104
+ };
105
+ if (flags.text)
106
+ body.text = flags.text;
107
+ const r = await api(`${base}/api/v1/email/send`, {
108
+ method: 'POST',
109
+ headers: headers(apiKey),
110
+ body: JSON.stringify(body),
111
+ });
112
+ console.log(`✔ sent`);
113
+ console.log(` messageId: ${r.messageId}`);
114
+ }
115
+ async function emailInboxCmd(argv, inherited) {
116
+ const { flags } = readFlags(argv, inherited);
117
+ const apiKey = await getApiKey(flags, 'email');
118
+ const base = resolveBase(flags.base);
119
+ const qs = flags.site ? `?slug=${encodeURIComponent(flags.site)}` : '';
120
+ const r = await api(`${base}/api/v1/email/inbox${qs}`, {
121
+ headers: headers(apiKey),
122
+ });
123
+ if (!r.threads.length) {
124
+ console.log('No threads yet.');
125
+ return;
126
+ }
127
+ // Compact table: ID | SITE | FROM | SUBJECT | LAST
128
+ const cols = r.threads.map((t) => ({
129
+ id: t.id,
130
+ site: t.siteSlug ?? '',
131
+ from: t.from ?? '',
132
+ subject: t.subject ?? '',
133
+ last: t.lastAt ?? '',
134
+ count: String(t.messageCount ?? 1),
135
+ }));
136
+ const w = {
137
+ id: Math.max('ID'.length, ...cols.map((c) => c.id.length)),
138
+ site: Math.max('SITE'.length, ...cols.map((c) => c.site.length)),
139
+ from: Math.max('FROM'.length, ...cols.map((c) => c.from.length)),
140
+ subject: Math.max('SUBJECT'.length, ...cols.map((c) => c.subject.length)),
141
+ count: Math.max('#'.length, ...cols.map((c) => c.count.length)),
142
+ };
143
+ console.log(`${'ID'.padEnd(w.id)} ${'SITE'.padEnd(w.site)} ${'FROM'.padEnd(w.from)} ${'SUBJECT'.padEnd(w.subject)} ${'#'.padEnd(w.count)} LAST`);
144
+ for (const c of cols) {
145
+ console.log(`${c.id.padEnd(w.id)} ${c.site.padEnd(w.site)} ${c.from.padEnd(w.from)} ${c.subject.padEnd(w.subject)} ${c.count.padEnd(w.count)} ${c.last}`);
146
+ }
147
+ }
package/dist/index.js CHANGED
@@ -14,7 +14,10 @@ import { domainAdd, domainConnect, domainLs, domainPrimary, domainSubAdd, domain
14
14
  import { runProject } from './projects.js';
15
15
  import { runListing } from './listings.js';
16
16
  import { runSendingDomain } from './sending-domains.js';
17
+ import { contractAmend, contractDraft, contractList, contractPdf, contractRetract, contractSend, contractShow, } from './contract.js';
17
18
  import { runCron, runFunction, runSecret } from './functions.js';
19
+ import { runEmail } from './email.js';
20
+ import { runMailbox } from './mailbox.js';
18
21
  import { loadApiKey, saveApiKey } from './config.js';
19
22
  import { loginViaDeviceFlow } from './login.js';
20
23
  import { api, ApiError, DEFAULT_BASE, publish, resolveBase } from './publish.js';
@@ -84,6 +87,25 @@ Usage:
84
87
  in your worker on next deploy/restart.
85
88
  shiply cron <ls|set|rm> <slug> Manage cron triggers on the deployed worker
86
89
  shiply cron set <slug> /api/cron/foo "0 9 * * *"
90
+ shiply contract <list|draft|show|send|pdf|amend|retract>
91
+ Customer contracts — full lifecycle CLI (B14).
92
+ list <project-id> — parent + amendments table
93
+ draft <project-id> — creates a draft you can edit
94
+ show <contract-id> — prints the full row as JSON
95
+ send <contract-id> — emails it to the customer
96
+ pdf <contract-id> -o file.pdf — signed PDF download
97
+ amend <id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]
98
+ retract <id> [--keep-as-draft] — undo a sent contract
99
+ shiply email send --site <slug> --to <addr> --subject <s> --html <h> [--text <t>]
100
+ Send a transactional email from a site's inbox address
101
+ shiply email inbox [--site <slug>] List email threads (optionally filtered by site)
102
+ shiply mailbox set <slug> <collection> [--confirm] [--no-confirm] [--notify] [--no-notify]
103
+ [--notify-to <email>] [--from <sendingDomainId>]
104
+ Update mailbox settings (double-opt-in, owner notifications…)
105
+ shiply mailbox broadcast <slug> <collection> --subject <s> --html <h> [--text <t>]
106
+ Send a broadcast to all contacts in a mailbox collection
107
+ shiply mailbox contacts <slug> <collection> [--status <signed_up|confirmed|unsubscribed>]
108
+ List contacts in a mailbox collection
87
109
  shiply data init [dir] Scaffold a starter .shiply/data.json in <dir> (default cwd)
88
110
  shiply data list <slug> List collections on a site with counts
89
111
  shiply data query <slug> <coll> [--limit N] [--cursor C] [--where '<json>']
@@ -197,6 +219,21 @@ async function main() {
197
219
  limit: { type: 'string' },
198
220
  cursor: { type: 'string' },
199
221
  where: { type: 'string' },
222
+ scope: { type: 'string' },
223
+ 'fee-delta': { type: 'string' },
224
+ 'target-date': { type: 'string' },
225
+ 'keep-as-draft': { type: 'boolean' },
226
+ to: { type: 'string' },
227
+ subject: { type: 'string' },
228
+ html: { type: 'string' },
229
+ text: { type: 'string' },
230
+ status: { type: 'string' },
231
+ confirm: { type: 'boolean' },
232
+ 'no-confirm': { type: 'boolean' },
233
+ notify: { type: 'boolean' },
234
+ 'no-notify': { type: 'boolean' },
235
+ 'notify-to': { type: 'string' },
236
+ from: { type: 'string' },
200
237
  help: { type: 'boolean', short: 'h' },
201
238
  version: { type: 'boolean', short: 'v' },
202
239
  },
@@ -601,6 +638,95 @@ async function main() {
601
638
  });
602
639
  break;
603
640
  }
641
+ case 'contract': {
642
+ const apiKey = values.key ?? (await loadApiKey());
643
+ if (!apiKey)
644
+ throw new Error('contract commands need an API key — run `shiply login` first');
645
+ const cctx = { base: values.base, apiKey };
646
+ const sub = dir; // positionals[1]
647
+ const arg = positionals[2]; // contract or project id
648
+ switch (sub) {
649
+ case 'list':
650
+ if (!arg)
651
+ throw new Error('usage: shiply contract list <project-id>');
652
+ await contractList(cctx, arg);
653
+ return;
654
+ case 'draft':
655
+ if (!arg)
656
+ throw new Error('usage: shiply contract draft <project-id>');
657
+ await contractDraft(cctx, arg);
658
+ return;
659
+ case 'show':
660
+ if (!arg)
661
+ throw new Error('usage: shiply contract show <contract-id>');
662
+ await contractShow(cctx, arg);
663
+ return;
664
+ case 'send':
665
+ if (!arg)
666
+ throw new Error('usage: shiply contract send <contract-id>');
667
+ await contractSend(cctx, arg);
668
+ return;
669
+ case 'pdf':
670
+ if (!arg)
671
+ throw new Error('usage: shiply contract pdf <contract-id> -o file.pdf');
672
+ await contractPdf(cctx, arg, values.out);
673
+ return;
674
+ case 'amend':
675
+ if (!arg)
676
+ throw new Error('usage: shiply contract amend <contract-id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]');
677
+ await contractAmend(cctx, arg, {
678
+ scope: values.scope,
679
+ feeDelta: values['fee-delta'],
680
+ targetDate: values['target-date'],
681
+ });
682
+ return;
683
+ case 'retract':
684
+ if (!arg)
685
+ throw new Error('usage: shiply contract retract <contract-id> [--keep-as-draft]');
686
+ await contractRetract(cctx, arg, { keepAsDraft: Boolean(values['keep-as-draft']) });
687
+ return;
688
+ default:
689
+ throw new Error('usage: shiply contract <list|draft|show|send|pdf|amend|retract>');
690
+ }
691
+ }
692
+ case 'email': {
693
+ await runEmail(positionals.slice(1), {
694
+ base: values.base,
695
+ key: values.key,
696
+ site: values.site,
697
+ to: values.to,
698
+ subject: values.subject,
699
+ html: values.html,
700
+ text: values.text,
701
+ });
702
+ break;
703
+ }
704
+ case 'mailbox': {
705
+ // Resolve tri-state for confirm/notify:
706
+ // --confirm → true (values.confirm === true)
707
+ // --no-confirm → false (values['no-confirm'] === true)
708
+ // neither provided → undefined (omit from PATCH body)
709
+ const confirmValue = values.confirm === true ? true :
710
+ values['no-confirm'] === true ? false :
711
+ undefined;
712
+ const notifyValue = values.notify === true ? true :
713
+ values['no-notify'] === true ? false :
714
+ undefined;
715
+ await runMailbox(positionals.slice(1), {
716
+ base: values.base,
717
+ key: values.key,
718
+ site: values.site,
719
+ subject: values.subject,
720
+ html: values.html,
721
+ text: values.text,
722
+ status: values.status,
723
+ ...(confirmValue !== undefined ? { confirm: confirmValue } : {}),
724
+ ...(notifyValue !== undefined ? { notify: notifyValue } : {}),
725
+ 'notify-to': values['notify-to'],
726
+ from: values.from,
727
+ });
728
+ break;
729
+ }
604
730
  case 'claim': {
605
731
  if (dir !== 'verify') {
606
732
  console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
@@ -0,0 +1,199 @@
1
+ /** `shiply mailbox <subcommand>` — mailbox settings, broadcasts, contacts.
2
+ *
3
+ * Pattern mirrors functions.ts: readFlags helper, exported runMailbox
4
+ * entry point, individual async command functions. Top-level flags
5
+ * (--base / --key) are forwarded from index.ts via InheritedFlags. */
6
+ import { loadApiKey } from './config.js';
7
+ import { api, resolveBase } from './publish.js';
8
+ function readFlags(argv, inherited = {}) {
9
+ const raw = {};
10
+ const rest = [];
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a.startsWith('--no-')) {
14
+ // --no-confirm, --no-notify → store as false
15
+ raw[a.slice(5)] = false;
16
+ }
17
+ else if (a.startsWith('--')) {
18
+ const eq = a.indexOf('=');
19
+ if (eq >= 0) {
20
+ raw[a.slice(2, eq)] = a.slice(eq + 1);
21
+ }
22
+ else {
23
+ const next = argv[i + 1];
24
+ if (next !== undefined && !next.startsWith('--')) {
25
+ raw[a.slice(2)] = next;
26
+ i++;
27
+ }
28
+ else {
29
+ raw[a.slice(2)] = true;
30
+ }
31
+ }
32
+ }
33
+ else {
34
+ rest.push(a);
35
+ }
36
+ }
37
+ // For each field: local argv flag takes precedence, then fall back to the
38
+ // value forwarded from index.ts parseArgs via inherited (the top-level
39
+ // parseArgs consumed these flags before argv reached us).
40
+ const flags = {
41
+ base: (typeof raw.base === 'string' ? raw.base : undefined) ?? inherited.base,
42
+ key: (typeof raw.key === 'string' ? raw.key : undefined) ?? inherited.key,
43
+ from: (typeof raw.from === 'string' ? raw.from : undefined) ?? inherited.from,
44
+ 'notify-to': (typeof raw['notify-to'] === 'string' ? raw['notify-to'] : undefined) ?? inherited['notify-to'],
45
+ subject: (typeof raw.subject === 'string' ? raw.subject : undefined) ?? inherited.subject,
46
+ html: (typeof raw.html === 'string' ? raw.html : undefined) ?? inherited.html,
47
+ text: (typeof raw.text === 'string' ? raw.text : undefined) ?? inherited.text,
48
+ status: (typeof raw.status === 'string' ? raw.status : undefined) ?? inherited.status,
49
+ };
50
+ // Boolean toggles — only set when the flag was explicitly provided (local
51
+ // argv), or when index.ts forwarded a parsed value via inherited.
52
+ if ('confirm' in raw) {
53
+ flags.confirm = Boolean(raw.confirm);
54
+ }
55
+ else if (inherited.confirm !== undefined) {
56
+ flags.confirm = inherited.confirm;
57
+ }
58
+ if ('notify' in raw) {
59
+ flags.notify = Boolean(raw.notify);
60
+ }
61
+ else if (inherited.notify !== undefined) {
62
+ flags.notify = inherited.notify;
63
+ }
64
+ return { rest, flags };
65
+ }
66
+ const headers = (apiKey) => ({
67
+ 'content-type': 'application/json',
68
+ authorization: `Bearer ${apiKey}`,
69
+ });
70
+ async function getApiKey(flags, what) {
71
+ const k = flags.key ?? (await loadApiKey());
72
+ if (!k)
73
+ throw new Error(`${what} commands need an API key — run \`shiply login\` first`);
74
+ return k;
75
+ }
76
+ const MAILBOX_USAGE = [
77
+ 'Usage: shiply mailbox <set|broadcast|contacts>',
78
+ ' shiply mailbox set <slug> <collection> [--confirm] [--no-confirm] [--notify] [--no-notify] [--notify-to <email>] [--from <sendingDomainId>]',
79
+ ' shiply mailbox broadcast <slug> <collection> --subject <s> --html <h> [--text <t>]',
80
+ ' shiply mailbox contacts <slug> <collection> [--status <signed_up|confirmed|unsubscribed>]',
81
+ ].join('\n');
82
+ export async function runMailbox(argv, inherited = {}) {
83
+ const action = argv[0];
84
+ const rest = argv.slice(1);
85
+ switch (action) {
86
+ case 'set':
87
+ return mailboxSetCmd(rest, inherited);
88
+ case 'broadcast':
89
+ return mailboxBroadcastCmd(rest, inherited);
90
+ case 'contacts':
91
+ return mailboxContactsCmd(rest, inherited);
92
+ default:
93
+ console.error(MAILBOX_USAGE);
94
+ process.exit(1);
95
+ }
96
+ }
97
+ async function mailboxSetCmd(argv, inherited) {
98
+ const { rest, flags } = readFlags(argv, inherited);
99
+ const [slug, collection] = rest;
100
+ if (!slug || !collection) {
101
+ console.error('usage: shiply mailbox set <slug> <collection> [options]');
102
+ console.error(MAILBOX_USAGE);
103
+ process.exit(1);
104
+ }
105
+ const apiKey = await getApiKey(flags, 'mailbox');
106
+ const base = resolveBase(flags.base);
107
+ // Only include fields that were explicitly provided
108
+ const body = {};
109
+ if (flags.confirm !== undefined)
110
+ body.doubleOptIn = flags.confirm;
111
+ if (flags.notify !== undefined)
112
+ body.notifyOwner = flags.notify;
113
+ if (flags['notify-to'] !== undefined)
114
+ body.notifyTo = flags['notify-to'];
115
+ if (flags.from !== undefined)
116
+ body.sendingDomainId = flags.from;
117
+ const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}`, {
118
+ method: 'PATCH',
119
+ headers: headers(apiKey),
120
+ body: JSON.stringify(body),
121
+ });
122
+ console.log(`✔ mailbox updated for ${slug}/${collection}`);
123
+ if (r.doubleOptIn !== undefined)
124
+ console.log(` doubleOptIn: ${r.doubleOptIn}`);
125
+ if (r.notifyOwner !== undefined)
126
+ console.log(` notifyOwner: ${r.notifyOwner}`);
127
+ if (r.notifyTo !== undefined && r.notifyTo !== null)
128
+ console.log(` notifyTo: ${r.notifyTo}`);
129
+ if (r.sendingDomainId !== undefined && r.sendingDomainId !== null)
130
+ console.log(` sendingDomainId: ${r.sendingDomainId}`);
131
+ }
132
+ async function mailboxBroadcastCmd(argv, inherited) {
133
+ const { rest, flags } = readFlags(argv, inherited);
134
+ const [slug, collection] = rest;
135
+ if (!slug || !collection) {
136
+ console.error('usage: shiply mailbox broadcast <slug> <collection> --subject <s> --html <h>');
137
+ console.error(MAILBOX_USAGE);
138
+ process.exit(1);
139
+ }
140
+ if (!flags.subject) {
141
+ console.error('--subject <s> is required');
142
+ console.error(MAILBOX_USAGE);
143
+ process.exit(1);
144
+ }
145
+ if (!flags.html) {
146
+ console.error('--html <h> is required');
147
+ console.error(MAILBOX_USAGE);
148
+ process.exit(1);
149
+ }
150
+ const apiKey = await getApiKey(flags, 'mailbox');
151
+ const base = resolveBase(flags.base);
152
+ const body = {
153
+ subject: flags.subject,
154
+ html: flags.html,
155
+ };
156
+ if (flags.text)
157
+ body.text = flags.text;
158
+ const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}/broadcast`, {
159
+ method: 'POST',
160
+ headers: headers(apiKey),
161
+ body: JSON.stringify(body),
162
+ });
163
+ console.log(`✔ broadcast queued`);
164
+ console.log(` broadcastId: ${r.broadcastId}`);
165
+ console.log(` recipientCount: ${r.recipientCount}`);
166
+ }
167
+ async function mailboxContactsCmd(argv, inherited) {
168
+ const { rest, flags } = readFlags(argv, inherited);
169
+ const [slug, collection] = rest;
170
+ if (!slug || !collection) {
171
+ console.error('usage: shiply mailbox contacts <slug> <collection> [--status <...>]');
172
+ console.error(MAILBOX_USAGE);
173
+ process.exit(1);
174
+ }
175
+ const apiKey = await getApiKey(flags, 'mailbox');
176
+ const base = resolveBase(flags.base);
177
+ const qs = flags.status ? `?status=${encodeURIComponent(flags.status)}` : '';
178
+ const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}/contacts${qs}`, { headers: headers(apiKey) });
179
+ if (!r.contacts.length) {
180
+ console.log(`No contacts in ${slug}/${collection}${flags.status ? ` (status: ${flags.status})` : ''} yet.`);
181
+ return;
182
+ }
183
+ // Compact table: EMAIL | STATUS | CONFIRMED | CREATED
184
+ const cols = r.contacts.map((c) => ({
185
+ email: c.email,
186
+ status: c.status ?? '',
187
+ confirmed: c.confirmedAt ?? '',
188
+ created: c.createdAt ?? '',
189
+ }));
190
+ const w = {
191
+ email: Math.max('EMAIL'.length, ...cols.map((c) => c.email.length)),
192
+ status: Math.max('STATUS'.length, ...cols.map((c) => c.status.length)),
193
+ confirmed: Math.max('CONFIRMED'.length, ...cols.map((c) => c.confirmed.length)),
194
+ };
195
+ console.log(`${'EMAIL'.padEnd(w.email)} ${'STATUS'.padEnd(w.status)} ${'CONFIRMED'.padEnd(w.confirmed)} CREATED`);
196
+ for (const c of cols) {
197
+ console.log(`${c.email.padEnd(w.email)} ${c.status.padEnd(w.status)} ${c.confirmed.padEnd(w.confirmed)} ${c.created}`);
198
+ }
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.14.5",
3
+ "version": "0.16.0",
4
4
  "description": "Publish static sites to shiply.now from the command line — instant web hosting for agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -18,7 +18,7 @@ description: Publish static sites to the web instantly with shiply.now and manag
18
18
  > fresh — your cached copy may be missing features like databases, projects,
19
19
  > marketplace, or sending domains.
20
20
  >
21
- > **Last updated: 2026-06-20**
21
+ > **Last updated: 2026-06-22**
22
22
 
23
23
  ---
24
24
 
@@ -154,6 +154,8 @@ lines for automation; exit code 0 = ready.
154
154
  (+ "Authorization: Bearer shp_…" for permanent owned sites)
155
155
  2. PUT each file's bytes to response upload.uploads[].url
156
156
  3. POST upload.finalizeUrl with {"versionId":"..."}
157
+ → both the publish and finalize responses include a "toUpdate" string: the
158
+ exact call to update THIS site next time. Follow it — never create a new one.
157
159
  ```
158
160
 
159
161
  **ALWAYS include `agentName` on anonymous publishes.** The response then
@@ -164,9 +166,11 @@ poll `poll_url` every `interval` seconds with the `device_code`, and on
164
166
  is claimed in the same Allow click. Without `agentName` the user has to
165
167
  do a separate manual claim step every time.
166
168
 
167
- **Updates:** anonymous include `"claimToken":"..."` (returned ONCE by the
168
- first publish save it); owned include `"slug":"..."`. Hashes make updates
169
- cheap: unchanged files are skipped server-side.
169
+ **Updates (the response tells you how):** every publish/finalize response
170
+ includes a `toUpdate` string with the exact call follow it verbatim. In short:
171
+ anonymous → include `"claimToken":"..."` (returned ONCE by the first publish —
172
+ save it); owned → include `"slug":"..."`. Hashes make updates cheap: unchanged
173
+ files are skipped server-side.
170
174
 
171
175
  ## The lifecycle to explain to users
172
176
  - Anonymous site: live instantly, expires in 24 h. Give the user the
@@ -265,6 +269,98 @@ validated server-side; the owner reads them in the dashboard (Data section,
265
269
  CSV export) or GET /api/v1/publishes/<slug>/data/<collection> with a Bearer
266
270
  key. Owned sites only. Docs: /docs/site-data
267
271
 
272
+ ## Agent Email — inbox, send, capture, broadcast (built into every owned site)
273
+
274
+ Every owned site has a real email address and can send, receive, capture signups,
275
+ and broadcast — in one line. Like AgentMail, built into the site.
276
+
277
+ ### Capture (public, zero-config)
278
+ ```
279
+ POST /.shiply/email { "email": "user@example.com", ...anyExtraFields }
280
+ ```
281
+ No manifest needed. Works on the **relative path** from the published page.
282
+ On success: record lands in the site inbox + owner gets a notification ping +
283
+ a double-opt-in confirmation email goes to the visitor (all on by default).
284
+ **Owned sites only** — anonymous sites get `403 email_requires_account` (claim
285
+ the site first). A non-empty `company` field is treated as a honeypot (bot
286
+ submissions are silently dropped). Caps: 16 KB body, 30 fields max.
287
+
288
+ ### Send (authenticated — Bearer key only)
289
+ The public page can NEVER send; only authenticated calls can.
290
+ ```
291
+ POST https://shiply.now/api/v1/email/send
292
+ Authorization: Bearer shp_…
293
+ { "slug": "my-site", "to": "user@example.com", "subject": "Hi", "html": "<p>Hi</p>", "text": "Hi" }
294
+ → { "messageId": "..." }
295
+ ```
296
+
297
+ ### Read inbox
298
+ ```
299
+ GET https://shiply.now/api/v1/email/inbox # all sites
300
+ GET https://shiply.now/api/v1/email/inbox?slug=my-site
301
+ Authorization: Bearer shp_…
302
+ ```
303
+
304
+ ### Typed upgrade (optional)
305
+ Declare an `email` block on a `.shiply/data.json` collection for typed fields +
306
+ fine-grained control:
307
+ ```json
308
+ {
309
+ "collections": {
310
+ "signups": {
311
+ "fields": { "email": { "type": "email", "required": true } },
312
+ "email": {
313
+ "emailField": "email",
314
+ "confirm": true,
315
+ "notify": true,
316
+ "audience": true
317
+ }
318
+ }
319
+ }
320
+ }
321
+ ```
322
+ All three flags default to `true`. With this manifest, POST to
323
+ `/.shiply/data/signups` as usual — the `email` block activates the email
324
+ layer on that collection.
325
+
326
+ ### Confirm / unsubscribe (public — in the confirmation email)
327
+ ```
328
+ GET /api/email/<slug>/<collection>/confirm?token=<token>
329
+ GET /api/email/<slug>/<collection>/unsubscribe?email=<address>
330
+ ```
331
+
332
+ ### Audience + broadcast
333
+ Confirmed (double-opt-in) signups form a list. Broadcast to them:
334
+ ```
335
+ POST /api/v1/publishes/<slug>/mailboxes/<collection>/broadcast
336
+ Authorization: Bearer shp_…
337
+ { "subject": "Launch day", "html": "<p>We're live!</p>" }
338
+ ```
339
+ Spam-checked before send; unsubscribe footer is auto-added; requires ≥1
340
+ confirmed subscriber.
341
+
342
+ Configure a mailbox: `GET/PATCH /api/v1/publishes/<slug>/mailboxes/<collection>`
343
+ List contacts: `GET /api/v1/publishes/<slug>/mailboxes/<collection>/contacts?status=confirmed`
344
+
345
+ ### MCP tools
346
+ | Tool | Purpose |
347
+ |---|---|
348
+ | `send_email` | Send transactional email from a site (Bearer) |
349
+ | `list_site_inbox` | Read the inbox (all threads or filter by slug) |
350
+ | `set_mailbox` | Configure a collection's mailbox settings |
351
+ | `list_mailbox_contacts` | List audience contacts (filter by status) |
352
+ | `send_mailbox_broadcast` | Broadcast to confirmed audience (spam-checked) |
353
+
354
+ ### CLI
355
+ ```bash
356
+ shiply email send --site <slug> --to <address> --subject <subject> --html <html>
357
+ shiply email inbox [--site <slug>]
358
+ shiply mailbox set <slug> <collection> [--confirm] [--notify] [--from <domain>]
359
+ shiply mailbox broadcast <slug> <collection> --subject <subject> --html <html>
360
+ ```
361
+
362
+ Docs: https://shiply.now/docs/agent-email
363
+
268
364
  ## SQL databases (Cloudflare D1 + Neon Postgres)
269
365
  Two engines, same CLI/REST/MCP surface. **D1 (SQLite at the edge)** is
270
366
  free on every plan and queryable from the browser via a built-in fetch
@@ -493,6 +589,56 @@ CLI: `shiply project ls · shiply project create <label> [--customer-email <e>]
493
589
 
494
590
  Docs: https://shiply.now/docs/projects
495
591
 
592
+ ## Contracts — draft, sign, amend (extends Projects)
593
+
594
+ Once a project reaches `brief_ready`, the dev drafts a signed contract from
595
+ the brief, the customer e-signs in the portal (typed-name + checkbox), and
596
+ the dev amends it later as scope evolves. A signed contract is immutable
597
+ — any change is a new amendment row chained off the parent. The portal
598
+ sends the customer; the dev can resend / remind / retract. A 7-day
599
+ reminder cron nudges unsigned contracts automatically.
600
+
601
+ MCP tools:
602
+ ```
603
+ contract_draft — draft a contract from a brief_ready project (returns the new contract id)
604
+ contract_send — flip status='sent', fire customer email (parent OR amendment)
605
+ contract_amend — create an amendment draft on a SIGNED parent (scopeDelta required, fee + date optional)
606
+ contract_status — read state + amendments (poll this to see status='signed')
607
+ contract_pdf — base64-encoded signed PDF (parent + cert + signed amendments)
608
+ ```
609
+
610
+ Edit-before-send today is REST-only: `PATCH /api/v1/contracts/{id}` lets
611
+ the dev tweak the 8 fields (scopeSummary, feeCents, currency, dates,
612
+ revisionCount, revisionOverageCents, jurisdiction). After PATCH, call
613
+ `contract_send` to fire it. Amendments are sent the same way (call
614
+ `contract_send` with the amendment id returned by `contract_amend`).
615
+
616
+ Example agent flow (draft → tweak fee → send → poll → amend):
617
+ 1. `contract_draft({projectId})` → returns `{id, scopeSummary, feeCents:null, ...}`
618
+ 2. `PATCH /api/v1/contracts/{id}` `{feeCents: 450000}` (Bearer key)
619
+ 3. `contract_send({contractId: id})` → project flips to `contract_sent`
620
+ 4. Poll `contract_status({contractId: id})` until `contract.status === 'signed'`
621
+ 5. `contract_amend({parentContractId: id, scopeDelta: "Add login flow", feeDeltaCents: 50000})` → returns the amendment row
622
+ 6. `contract_send({contractId: amendment.id})` (no PATCH needed if the amend fields are right)
623
+
624
+ REST (Bearer for dev, portal cookie for customer):
625
+ `POST /api/v1/projects/{id}/contracts` (draft) ·
626
+ `GET/PATCH /api/v1/contracts/{id}` (read either-party / dev edit) ·
627
+ `POST /api/v1/contracts/{id}/send` (dev) ·
628
+ `POST /api/v1/contracts/{id}/view` (customer ping) ·
629
+ `POST /api/v1/contracts/{id}/sign` (customer e-sign) ·
630
+ `POST /api/v1/contracts/{id}/retract` (dev; `?recoverAsDraft=true` to
631
+ recover the draft instead of voiding) ·
632
+ `POST /api/v1/contracts/{id}/amend` (dev; signed parents only) ·
633
+ `POST /api/v1/contracts/{id}/remind` ·
634
+ `POST /api/v1/contracts/{id}/resend` (manual nudges, rate-limited 3/24h) ·
635
+ `GET /api/v1/contracts/{id}/pdf` (binary, signed only).
636
+
637
+ `contract_pdf` returns `{filename, contentType:"application/pdf", base64,
638
+ byteLength}` so the MCP client can decode and save the bytes.
639
+
640
+ Docs: https://shiply.now/docs/contracts
641
+
496
642
  ## Marketplace — sell built sites
497
643
 
498
644
  Devs can list any owned site for sale: set a price + short pitch, choose