shiply-cli 0.14.5 → 0.15.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/index.js CHANGED
@@ -14,6 +14,7 @@ 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';
18
19
  import { loadApiKey, saveApiKey } from './config.js';
19
20
  import { loginViaDeviceFlow } from './login.js';
@@ -84,6 +85,15 @@ Usage:
84
85
  in your worker on next deploy/restart.
85
86
  shiply cron <ls|set|rm> <slug> Manage cron triggers on the deployed worker
86
87
  shiply cron set <slug> /api/cron/foo "0 9 * * *"
88
+ shiply contract <list|draft|show|send|pdf|amend|retract>
89
+ Customer contracts — full lifecycle CLI (B14).
90
+ list <project-id> — parent + amendments table
91
+ draft <project-id> — creates a draft you can edit
92
+ show <contract-id> — prints the full row as JSON
93
+ send <contract-id> — emails it to the customer
94
+ pdf <contract-id> -o file.pdf — signed PDF download
95
+ amend <id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]
96
+ retract <id> [--keep-as-draft] — undo a sent contract
87
97
  shiply data init [dir] Scaffold a starter .shiply/data.json in <dir> (default cwd)
88
98
  shiply data list <slug> List collections on a site with counts
89
99
  shiply data query <slug> <coll> [--limit N] [--cursor C] [--where '<json>']
@@ -197,6 +207,10 @@ async function main() {
197
207
  limit: { type: 'string' },
198
208
  cursor: { type: 'string' },
199
209
  where: { type: 'string' },
210
+ scope: { type: 'string' },
211
+ 'fee-delta': { type: 'string' },
212
+ 'target-date': { type: 'string' },
213
+ 'keep-as-draft': { type: 'boolean' },
200
214
  help: { type: 'boolean', short: 'h' },
201
215
  version: { type: 'boolean', short: 'v' },
202
216
  },
@@ -601,6 +615,57 @@ async function main() {
601
615
  });
602
616
  break;
603
617
  }
618
+ case 'contract': {
619
+ const apiKey = values.key ?? (await loadApiKey());
620
+ if (!apiKey)
621
+ throw new Error('contract commands need an API key — run `shiply login` first');
622
+ const cctx = { base: values.base, apiKey };
623
+ const sub = dir; // positionals[1]
624
+ const arg = positionals[2]; // contract or project id
625
+ switch (sub) {
626
+ case 'list':
627
+ if (!arg)
628
+ throw new Error('usage: shiply contract list <project-id>');
629
+ await contractList(cctx, arg);
630
+ return;
631
+ case 'draft':
632
+ if (!arg)
633
+ throw new Error('usage: shiply contract draft <project-id>');
634
+ await contractDraft(cctx, arg);
635
+ return;
636
+ case 'show':
637
+ if (!arg)
638
+ throw new Error('usage: shiply contract show <contract-id>');
639
+ await contractShow(cctx, arg);
640
+ return;
641
+ case 'send':
642
+ if (!arg)
643
+ throw new Error('usage: shiply contract send <contract-id>');
644
+ await contractSend(cctx, arg);
645
+ return;
646
+ case 'pdf':
647
+ if (!arg)
648
+ throw new Error('usage: shiply contract pdf <contract-id> -o file.pdf');
649
+ await contractPdf(cctx, arg, values.out);
650
+ return;
651
+ case 'amend':
652
+ if (!arg)
653
+ throw new Error('usage: shiply contract amend <contract-id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]');
654
+ await contractAmend(cctx, arg, {
655
+ scope: values.scope,
656
+ feeDelta: values['fee-delta'],
657
+ targetDate: values['target-date'],
658
+ });
659
+ return;
660
+ case 'retract':
661
+ if (!arg)
662
+ throw new Error('usage: shiply contract retract <contract-id> [--keep-as-draft]');
663
+ await contractRetract(cctx, arg, { keepAsDraft: Boolean(values['keep-as-draft']) });
664
+ return;
665
+ default:
666
+ throw new Error('usage: shiply contract <list|draft|show|send|pdf|amend|retract>');
667
+ }
668
+ }
604
669
  case 'claim': {
605
670
  if (dir !== 'verify') {
606
671
  console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.14.5",
3
+ "version": "0.15.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-21**
22
22
 
23
23
  ---
24
24
 
@@ -493,6 +493,56 @@ CLI: `shiply project ls · shiply project create <label> [--customer-email <e>]
493
493
 
494
494
  Docs: https://shiply.now/docs/projects
495
495
 
496
+ ## Contracts — draft, sign, amend (extends Projects)
497
+
498
+ Once a project reaches `brief_ready`, the dev drafts a signed contract from
499
+ the brief, the customer e-signs in the portal (typed-name + checkbox), and
500
+ the dev amends it later as scope evolves. A signed contract is immutable
501
+ — any change is a new amendment row chained off the parent. The portal
502
+ sends the customer; the dev can resend / remind / retract. A 7-day
503
+ reminder cron nudges unsigned contracts automatically.
504
+
505
+ MCP tools:
506
+ ```
507
+ contract_draft — draft a contract from a brief_ready project (returns the new contract id)
508
+ contract_send — flip status='sent', fire customer email (parent OR amendment)
509
+ contract_amend — create an amendment draft on a SIGNED parent (scopeDelta required, fee + date optional)
510
+ contract_status — read state + amendments (poll this to see status='signed')
511
+ contract_pdf — base64-encoded signed PDF (parent + cert + signed amendments)
512
+ ```
513
+
514
+ Edit-before-send today is REST-only: `PATCH /api/v1/contracts/{id}` lets
515
+ the dev tweak the 8 fields (scopeSummary, feeCents, currency, dates,
516
+ revisionCount, revisionOverageCents, jurisdiction). After PATCH, call
517
+ `contract_send` to fire it. Amendments are sent the same way (call
518
+ `contract_send` with the amendment id returned by `contract_amend`).
519
+
520
+ Example agent flow (draft → tweak fee → send → poll → amend):
521
+ 1. `contract_draft({projectId})` → returns `{id, scopeSummary, feeCents:null, ...}`
522
+ 2. `PATCH /api/v1/contracts/{id}` `{feeCents: 450000}` (Bearer key)
523
+ 3. `contract_send({contractId: id})` → project flips to `contract_sent`
524
+ 4. Poll `contract_status({contractId: id})` until `contract.status === 'signed'`
525
+ 5. `contract_amend({parentContractId: id, scopeDelta: "Add login flow", feeDeltaCents: 50000})` → returns the amendment row
526
+ 6. `contract_send({contractId: amendment.id})` (no PATCH needed if the amend fields are right)
527
+
528
+ REST (Bearer for dev, portal cookie for customer):
529
+ `POST /api/v1/projects/{id}/contracts` (draft) ·
530
+ `GET/PATCH /api/v1/contracts/{id}` (read either-party / dev edit) ·
531
+ `POST /api/v1/contracts/{id}/send` (dev) ·
532
+ `POST /api/v1/contracts/{id}/view` (customer ping) ·
533
+ `POST /api/v1/contracts/{id}/sign` (customer e-sign) ·
534
+ `POST /api/v1/contracts/{id}/retract` (dev; `?recoverAsDraft=true` to
535
+ recover the draft instead of voiding) ·
536
+ `POST /api/v1/contracts/{id}/amend` (dev; signed parents only) ·
537
+ `POST /api/v1/contracts/{id}/remind` ·
538
+ `POST /api/v1/contracts/{id}/resend` (manual nudges, rate-limited 3/24h) ·
539
+ `GET /api/v1/contracts/{id}/pdf` (binary, signed only).
540
+
541
+ `contract_pdf` returns `{filename, contentType:"application/pdf", base64,
542
+ byteLength}` so the MCP client can decode and save the bytes.
543
+
544
+ Docs: https://shiply.now/docs/contracts
545
+
496
546
  ## Marketplace — sell built sites
497
547
 
498
548
  Devs can list any owned site for sale: set a price + short pitch, choose