@oxygen-agent/cli 1.1010.905 → 1.1010.952
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.
- package/README.md +1 -1
- package/dist/contract-flags.d.ts +42 -0
- package/dist/contract-flags.js +220 -0
- package/dist/index.js +43 -184
- package/dist/sender-limits-flags.d.ts +14 -0
- package/dist/sender-limits-flags.js +157 -0
- package/dist/ugc-commands.js +16 -2
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +3 -3
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +4 -0
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +20 -8
- package/node_modules/@oxygen/cli-ugc/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/cli-ugc/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +2 -2
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +11 -0
- package/node_modules/@oxygen/shared/dist/contracts/schema.d.ts +186 -0
- package/node_modules/@oxygen/shared/dist/contracts/schema.js +260 -0
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.d.ts +67 -0
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +88 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +41 -0
- package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +4 -2
- package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/table-capacity.js +18 -3
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// `oxygen senders limits set` (and `whatsapp limits set`, which shares the body
|
|
2
|
+
// builder): flags generated from SENDER_LIMITS_PATCH_CONTRACT, the contract the
|
|
3
|
+
// MCP tool and the route use too. This overlay only keeps the flag names and
|
|
4
|
+
// help text the command shipped with before the contract existed.
|
|
5
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
6
|
+
import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "@oxygen/shared/linkedin-sequences";
|
|
7
|
+
import { SENDER_LIMITS_PATCH_CONTRACT } from "@oxygen/shared/contracts/sender-limits";
|
|
8
|
+
import { readContractFlags } from "./contract-flags.js";
|
|
9
|
+
/** "<text> (default N, max M)." for a LinkedIn sender limit flag, from the enforced constants. */
|
|
10
|
+
function linkedInLimitHelp(key, text) {
|
|
11
|
+
return `${text} (default ${LINKEDIN_SENDER_LIMIT_DEFAULTS[key]}, max ${LINKEDIN_SENDER_LIMIT_MAXIMUMS[key]}).`;
|
|
12
|
+
}
|
|
13
|
+
const LIMIT_FLAG_HELP = {
|
|
14
|
+
invites_per_day: "Daily LinkedIn connection invites cap",
|
|
15
|
+
invites_per_week: "Weekly LinkedIn connection invites cap",
|
|
16
|
+
messages_per_day: "Daily direct messages cap",
|
|
17
|
+
inmails_per_day: "Daily InMail cap",
|
|
18
|
+
inmails_per_month: "InMail cap over a rolling 30 days",
|
|
19
|
+
profile_views_per_day: "Daily profile views cap; the member is notified",
|
|
20
|
+
profile_lookups_per_day: "Daily invisible profile and company lookups cap",
|
|
21
|
+
endorsements_per_day: "Daily skill endorsements cap",
|
|
22
|
+
withdrawals_per_day: "Daily cap on withdrawing sent invitations; reading the sent list uses the read budgets",
|
|
23
|
+
posts_per_day: "Daily posts to the account's own feed, from every caller; not counted in the total",
|
|
24
|
+
follows_per_day: "Daily follows cap",
|
|
25
|
+
likes_per_day: "Daily likes cap",
|
|
26
|
+
comments_per_day: "Daily comments cap",
|
|
27
|
+
total_actions_per_day: "Daily cap across all send/action types except posts",
|
|
28
|
+
relations_reads_per_day: "Daily cap on relations/connections list reads, the scrape ban vector",
|
|
29
|
+
messages_reads_per_day: "Daily cap on chat and message-history reads",
|
|
30
|
+
searches_per_day: "Daily cap on LinkedIn search executions",
|
|
31
|
+
sales_nav_search_results_per_day: "Daily cap on Sales Navigator search result rows fetched",
|
|
32
|
+
api_reads_per_day: "Daily cap on all other LinkedIn API reads",
|
|
33
|
+
total_reads_per_day: "Daily cap across all read units",
|
|
34
|
+
min_action_spacing_seconds: "Minimum seconds between actions",
|
|
35
|
+
action_spacing_jitter_seconds: "Random jitter seconds added to action spacing",
|
|
36
|
+
interactive_min_spacing_seconds: "Minimum seconds between human-sent Unibox actions",
|
|
37
|
+
interactive_spacing_jitter_seconds: "Random jitter seconds added to human-sent Unibox action spacing",
|
|
38
|
+
interactive_sends_per_hour: "Hourly cap on human-sent Unibox actions",
|
|
39
|
+
relations_ingest_reads_per_day: "Daily cap on background connection, follower and sent-invite list reads (imports and the withdrawal drip)",
|
|
40
|
+
api_ingest_reads_per_day: "Daily cap on background engagement, post-metric and profile-viewer reads",
|
|
41
|
+
messages_ingest_reads_per_day: "Daily cap on background message-history backfill reads",
|
|
42
|
+
network_backfill_reads_per_day: "Daily network-capture reads while the first walk of connections and followers runs",
|
|
43
|
+
network_delta_reads_per_day: "Daily network-capture reads once that first walk is done",
|
|
44
|
+
};
|
|
45
|
+
const LIMIT_FLAG_NAMES = {
|
|
46
|
+
min_action_spacing_seconds: "min-spacing-seconds",
|
|
47
|
+
action_spacing_jitter_seconds: "spacing-jitter-seconds",
|
|
48
|
+
};
|
|
49
|
+
const WORKING_DAY_NAMES = {
|
|
50
|
+
mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4,
|
|
51
|
+
fri: 5, friday: 5, sat: 6, saturday: 6, sun: 7, sunday: 7,
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* `--working-days` → ISO weekday numbers. Accepts day names or 1-7, comma
|
|
55
|
+
* separated, and ranges (`mon-fri`, `1-5`). A token it cannot read is sent as
|
|
56
|
+
* given so the server's invalid_working_hours error names it.
|
|
57
|
+
*/
|
|
58
|
+
export function parseWorkingDaysOption(value) {
|
|
59
|
+
const day = (token) => {
|
|
60
|
+
const lower = token.trim().toLowerCase();
|
|
61
|
+
if (/^[1-7]$/.test(lower))
|
|
62
|
+
return Number(lower);
|
|
63
|
+
return WORKING_DAY_NAMES[lower] ?? null;
|
|
64
|
+
};
|
|
65
|
+
const days = [];
|
|
66
|
+
for (const token of value.split(",").map((part) => part.trim()).filter(Boolean)) {
|
|
67
|
+
const [from, to, ...rest] = token.split("-");
|
|
68
|
+
const start = from !== undefined ? day(from) : null;
|
|
69
|
+
const end = to !== undefined ? day(to) : null;
|
|
70
|
+
if (rest.length === 0 && start !== null && end !== null && start <= end) {
|
|
71
|
+
for (let current = start; current <= end; current += 1)
|
|
72
|
+
days.push(current);
|
|
73
|
+
}
|
|
74
|
+
else if (to === undefined && start !== null) {
|
|
75
|
+
days.push(start);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
days.push(token);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return days;
|
|
82
|
+
}
|
|
83
|
+
const limitFlags = Object.fromEntries(Object.keys(LIMIT_FLAG_HELP).map((key) => [
|
|
84
|
+
`limits.${key}`,
|
|
85
|
+
{
|
|
86
|
+
...(LIMIT_FLAG_NAMES[key] ? { flag: LIMIT_FLAG_NAMES[key] } : {}),
|
|
87
|
+
valueName: "<n>",
|
|
88
|
+
help: linkedInLimitHelp(key, LIMIT_FLAG_HELP[key]),
|
|
89
|
+
},
|
|
90
|
+
]));
|
|
91
|
+
export const SENDER_LIMITS_FLAG_OVERLAY = {
|
|
92
|
+
limits: { flatten: true },
|
|
93
|
+
...limitFlags,
|
|
94
|
+
"working_hours.timezone": {
|
|
95
|
+
flag: "timezone",
|
|
96
|
+
valueName: "<tz>",
|
|
97
|
+
help: "IANA timezone for the working hours and the daily counter reset, e.g. Europe/Berlin. Defaults to the zone of the country chosen at connect.",
|
|
98
|
+
},
|
|
99
|
+
"working_hours.days": {
|
|
100
|
+
flag: "working-days",
|
|
101
|
+
valueName: "<days>",
|
|
102
|
+
parse: parseWorkingDaysOption,
|
|
103
|
+
help: "Days LinkedIn actions may run: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday). A weekday-only window leaves a visible two-day gap every week.",
|
|
104
|
+
},
|
|
105
|
+
"working_hours.start": { valueName: "<HH:MM>", help: "Local time working hours start, e.g. 09:00." },
|
|
106
|
+
"working_hours.end": { valueName: "<HH:MM>", help: "Local time working hours end (exclusive), e.g. 18:00. Must be after the start." },
|
|
107
|
+
"warmup.enabled": {
|
|
108
|
+
pair: {
|
|
109
|
+
on: "warmup-restart",
|
|
110
|
+
onHelp: "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.",
|
|
111
|
+
off: "warmup-disable",
|
|
112
|
+
offHelp: "Turn off warm-up for this account (treat it as already warm and use its full configured caps).",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
// `--warmup-disable` already sends the disable; no separate --clear flag.
|
|
116
|
+
"warmup.start_date": {
|
|
117
|
+
valueName: "<date>",
|
|
118
|
+
noClear: true,
|
|
119
|
+
help: "Set the warm-up start date (ISO, e.g. 2026-01-31). A past date credits prior warming and advances the ramp.",
|
|
120
|
+
},
|
|
121
|
+
read_warmup: {
|
|
122
|
+
pair: {
|
|
123
|
+
on: "read-warmup-restart",
|
|
124
|
+
onHelp: "Restart the READ warm-up ramp from today, so network capture eases back in from 5 reads a day. Also clears --read-warmup-disable.",
|
|
125
|
+
off: "read-warmup-disable",
|
|
126
|
+
offHelp: "Turn off the READ warm-up ramp for this account. Network capture (`oxygen linkedin network setup`) eases a newly armed account in at 5 → 10 → 20 reads a day over two weeks; this runs it at the full read budget immediately. Separate from --warmup-disable, which governs invites and messages.",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
warmup_preset: {
|
|
130
|
+
valueName: "<preset>",
|
|
131
|
+
help: "Warm-up ramp curve: conservative (~3 weeks), standard (~2 weeks, default), or fast (~1 week for an aged account).",
|
|
132
|
+
},
|
|
133
|
+
randomize_daily_caps: {
|
|
134
|
+
flag: "randomize-caps",
|
|
135
|
+
help: "Randomise this account's daily send caps to 80-100% of configured so daily volume varies instead of hitting the same cap every day.",
|
|
136
|
+
negateHelp: "Turn off randomised daily caps (use the exact configured caps every day).",
|
|
137
|
+
},
|
|
138
|
+
weekend_full_volume: {
|
|
139
|
+
help: "Keep full daily send caps on Saturday and Sunday (by default they run at half).",
|
|
140
|
+
negateHelp: "Run daily send caps at half on Saturday and Sunday again (the default).",
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* The limits PATCH body for the flags given. A warm-up start date wins over
|
|
145
|
+
* --warmup-restart/--warmup-disable (the route reads it first anyway), so the
|
|
146
|
+
* body carries only the one it will act on. Throws when no flag was given.
|
|
147
|
+
*/
|
|
148
|
+
export function buildSenderLimitsBody(options) {
|
|
149
|
+
const body = readContractFlags(options, SENDER_LIMITS_PATCH_CONTRACT, SENDER_LIMITS_FLAG_OVERLAY);
|
|
150
|
+
const warmup = body.warmup;
|
|
151
|
+
if (warmup && warmup.start_date !== undefined)
|
|
152
|
+
delete warmup.enabled;
|
|
153
|
+
if (Object.keys(body).length === 0) {
|
|
154
|
+
throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), a working-hours flag (--timezone, --working-days, --working-hours-start, --working-hours-end), a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date), --read-warmup-disable / --read-warmup-restart, --warmup-preset, --randomize-caps, or --weekend-full-volume.", { exitCode: 1 });
|
|
155
|
+
}
|
|
156
|
+
return body;
|
|
157
|
+
}
|
package/dist/ugc-commands.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
import { registerUgcCommands as registerCommands, } from "@oxygen/cli-ugc";
|
|
1
|
+
import { registerUgcCommands as registerCommands, UgcUsageError, } from "@oxygen/cli-ugc";
|
|
2
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
2
3
|
import { requestOxygen } from "./http-client.js";
|
|
3
4
|
export function registerUgcCommands(program, handle) {
|
|
4
|
-
registerCommands(program, {
|
|
5
|
+
registerCommands(program, {
|
|
6
|
+
request: requestOxygen,
|
|
7
|
+
// A malformed flag is the caller's input, so it reports as invalid_request.
|
|
8
|
+
handle: (command, options, action) => handle(command, options, async () => {
|
|
9
|
+
try {
|
|
10
|
+
return await action();
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error instanceof UgcUsageError)
|
|
14
|
+
throw new OxygenError("invalid_request", error.message, { exitCode: 1 });
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
5
19
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseField } from "./field-parser.js";
|
|
1
|
+
import { parseField, UgcUsageError } from "./field-parser.js";
|
|
2
2
|
export function registerUgcCommands(program, { request: requestOxygen, handle }) {
|
|
3
3
|
const ugc = program
|
|
4
4
|
.command("ugc")
|
|
@@ -1316,10 +1316,10 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
|
|
|
1316
1316
|
}
|
|
1317
1317
|
if (spec.action === "invite_create") {
|
|
1318
1318
|
if ((body.email === undefined) === (body.emails === undefined))
|
|
1319
|
-
throw new
|
|
1319
|
+
throw new UgcUsageError("Pass exactly one of --email or --emails.");
|
|
1320
1320
|
body.send_email = options.linkOnly !== true;
|
|
1321
1321
|
if (options.linkOnly && (body.approved || body.preview_token))
|
|
1322
|
-
throw new
|
|
1322
|
+
throw new UgcUsageError("--link-only cannot be combined with --approved or --preview-token.");
|
|
1323
1323
|
}
|
|
1324
1324
|
return requestOxygen("/api/cli/ugc", {
|
|
1325
1325
|
method: "POST",
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** A malformed flag value: the CLI reports it as `invalid_request`, not `unexpected_error`. */
|
|
2
|
+
export class UgcUsageError extends Error {
|
|
3
|
+
code = "invalid_request";
|
|
4
|
+
}
|
|
1
5
|
/** `2/week`, `4/month`, `10/total` or `none` (clears); a count of 1–100. */
|
|
2
6
|
export function parsePostingTarget(value) {
|
|
3
7
|
const text = value.trim().toLowerCase();
|
|
@@ -6,7 +10,7 @@ export function parsePostingTarget(value) {
|
|
|
6
10
|
const match = /^(\d{1,3})\s*\/\s*(week|month|total)$/.exec(text);
|
|
7
11
|
const count = match ? Number(match[1]) : 0;
|
|
8
12
|
if (!match || count < 1 || count > 100)
|
|
9
|
-
throw new
|
|
13
|
+
throw new UgcUsageError("--posting-target must be <n>/month, <n>/total or <n>/week with n from 1 to 100, or none.");
|
|
10
14
|
return { count, period: match[2] };
|
|
11
15
|
}
|
|
12
16
|
/** A JSON array of emails, or emails separated by commas, semicolons or whitespace. */
|
|
@@ -14,15 +18,15 @@ export function parseEmailList(value) {
|
|
|
14
18
|
const text = value.trim();
|
|
15
19
|
let items;
|
|
16
20
|
if (text.startsWith("[")) {
|
|
17
|
-
items =
|
|
21
|
+
items = parseJson(text, "--emails");
|
|
18
22
|
if (!Array.isArray(items) || !items.every((item) => typeof item === "string"))
|
|
19
|
-
throw new
|
|
23
|
+
throw new UgcUsageError("--emails must be a JSON array of strings or a comma-separated list.");
|
|
20
24
|
}
|
|
21
25
|
else
|
|
22
26
|
items = text.split(/[\s,;]+/);
|
|
23
27
|
const emails = items.map((item) => item.trim()).filter(Boolean);
|
|
24
28
|
if (!emails.length)
|
|
25
|
-
throw new
|
|
29
|
+
throw new UgcUsageError("--emails needs at least one email address.");
|
|
26
30
|
return emails;
|
|
27
31
|
}
|
|
28
32
|
export function parseField(value, field) {
|
|
@@ -34,18 +38,18 @@ export function parseField(value, field) {
|
|
|
34
38
|
if (field.kind === "text_list")
|
|
35
39
|
return String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
|
36
40
|
if (field.kind === "array" || field.kind === "object") {
|
|
37
|
-
const parsed =
|
|
41
|
+
const parsed = parseJson(String(value), field.name);
|
|
38
42
|
if (field.kind === "array"
|
|
39
43
|
? !Array.isArray(parsed) ||
|
|
40
44
|
!parsed.every((item) => typeof item === "string")
|
|
41
45
|
: !parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
42
|
-
throw new
|
|
46
|
+
throw new UgcUsageError(`${field.name} must be a JSON ${field.kind === "array" ? "array of strings" : "object"}.`);
|
|
43
47
|
return parsed;
|
|
44
48
|
}
|
|
45
49
|
if (field.kind === "number") {
|
|
46
50
|
const parsed = Number(value);
|
|
47
51
|
if (!Number.isFinite(parsed) || parsed < 0)
|
|
48
|
-
throw new
|
|
52
|
+
throw new UgcUsageError(`${field.name} must be a nonnegative finite number.`);
|
|
49
53
|
return parsed;
|
|
50
54
|
}
|
|
51
55
|
if (field.kind === "boolean") {
|
|
@@ -53,7 +57,15 @@ export function parseField(value, field) {
|
|
|
53
57
|
return true;
|
|
54
58
|
if (value === false || value === "false")
|
|
55
59
|
return false;
|
|
56
|
-
throw new
|
|
60
|
+
throw new UgcUsageError(`${field.name} must be true or false.`);
|
|
57
61
|
}
|
|
58
62
|
return value;
|
|
59
63
|
}
|
|
64
|
+
function parseJson(text, name) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new UgcUsageError(`${name} must be valid JSON.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registerUgcCommands as registerCommands } from "./commands.js";
|
|
2
|
+
export { UgcUsageError } from "./field-parser.js";
|
|
2
3
|
/** Register the existing UGC command tree without performing requests. */
|
|
3
4
|
export function registerUgcCommands(program, dependencies) {
|
|
4
5
|
registerCommands(program, dependencies);
|
|
@@ -80,10 +80,10 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
80
80
|
execution: "Read billing state or set a hosted budget policy; exact action ceilings remain primitive-owned.",
|
|
81
81
|
posture: "mixed",
|
|
82
82
|
gatewayTools: ["oxygen_billing_balance", "oxygen_billing_usage", "oxygen_billing_estimate", "oxygen_budget_list"],
|
|
83
|
-
gatewayCommands: ["billing balance", "billing usage", "billing estimate", "budget list"],
|
|
83
|
+
gatewayCommands: ["billing balance", "billing usage", "billing estimate", "budget list", "billing invoices"],
|
|
84
84
|
skills: ["oxygen-gtm", "oxygen-diagnostics"],
|
|
85
85
|
endpointSections: ["billing", "budget", "limits"],
|
|
86
|
-
intentTerms: ["billing", "credits", "credit balance", "usage", "spend", "cost", "cost estimate", "monthly cost", "budget", "subscription", "invoice", "cancel plan", "change plan", "upgrade", "downgrade"],
|
|
86
|
+
intentTerms: ["billing", "credits", "credit balance", "usage", "spend", "cost", "cost estimate", "monthly cost", "budget", "subscription", "invoice", "vat", "vat number", "tax id", "cancel plan", "change plan", "upgrade", "downgrade"],
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
id: "support-and-feedback",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry per customer operation that edits a resource: the route it calls,
|
|
3
|
+
* the contract its body satisfies, and the names it carries on the CLI and MCP.
|
|
4
|
+
* The CLI command, the MCP tool and the route read the same spec, so the three
|
|
5
|
+
* cannot disagree on which fields exist. Leaf and browser-safe.
|
|
6
|
+
*/
|
|
7
|
+
import type { ContractObject } from "./schema.js";
|
|
8
|
+
export type OperationSpec<C extends ContractObject = ContractObject> = {
|
|
9
|
+
/** Stable id, `<resource>.<verb>`. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Route template; `{name}` segments are path parameters. */
|
|
12
|
+
route: string;
|
|
13
|
+
method: "POST" | "PATCH";
|
|
14
|
+
/** When set, the contract describes `body[bodyKey]` rather than the whole body. */
|
|
15
|
+
bodyKey?: string;
|
|
16
|
+
contract: C;
|
|
17
|
+
cli: {
|
|
18
|
+
/** Command words after `oxygen`, e.g. `["senders", "limits", "set"]`. */
|
|
19
|
+
path: readonly string[];
|
|
20
|
+
summary: string;
|
|
21
|
+
/** The positional argument that fills the route's single path parameter. */
|
|
22
|
+
argument?: {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
mcp: {
|
|
28
|
+
name: string;
|
|
29
|
+
title: string;
|
|
30
|
+
description: string;
|
|
31
|
+
/** The MCP toolset domain (pack) the tool registers under. */
|
|
32
|
+
pack: string;
|
|
33
|
+
};
|
|
34
|
+
mutates: boolean;
|
|
35
|
+
spends: boolean;
|
|
36
|
+
};
|
|
37
|
+
/** The route with its `{param}` segments filled in, each URL-encoded. */
|
|
38
|
+
export declare function operationPath(spec: Pick<OperationSpec, "route">, params: Readonly<Record<string, string>>): string;
|
|
39
|
+
export declare const OPERATION_SPECS: readonly OperationSpec[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SENDER_LIMITS_SET_OPERATION } from "./sender-limits.js";
|
|
2
|
+
/** The route with its `{param}` segments filled in, each URL-encoded. */
|
|
3
|
+
export function operationPath(spec, params) {
|
|
4
|
+
return spec.route.replace(/\{(\w+)\}/g, (_match, name) => {
|
|
5
|
+
const value = params[name];
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
throw new Error(`Missing path parameter ${name} for ${spec.route}`);
|
|
8
|
+
return encodeURIComponent(value);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION];
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One contract per editable resource, in the plain JSON-schema dialect MCP tool
|
|
3
|
+
* input schemas already use (founder decision 2026-09-28: no zod, no new
|
|
4
|
+
* dependency). The same object generates the CLI flags
|
|
5
|
+
* (`packages/cli/src/contract-flags.ts`), the MCP input schema
|
|
6
|
+
* (`packages/mcp-server/src/tools/contract-tool.ts`) and validates the API
|
|
7
|
+
* route body (`validateContract`), so a field cannot exist on one surface and
|
|
8
|
+
* not another.
|
|
9
|
+
*
|
|
10
|
+
* Leaf and browser-safe: no imports, no IO.
|
|
11
|
+
*/
|
|
12
|
+
export type StringProperty = {
|
|
13
|
+
type: "string";
|
|
14
|
+
description?: string;
|
|
15
|
+
enum?: readonly string[];
|
|
16
|
+
minLength?: number;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
};
|
|
19
|
+
export type NumberProperty = {
|
|
20
|
+
type: "number" | "integer";
|
|
21
|
+
description?: string;
|
|
22
|
+
minimum?: number;
|
|
23
|
+
maximum?: number;
|
|
24
|
+
exclusiveMinimum?: number;
|
|
25
|
+
/**
|
|
26
|
+
* The route clamps a value above `maximum` down to it instead of refusing
|
|
27
|
+
* it, when `validateContract` runs with `overMax: "clamp"`. Annotation only:
|
|
28
|
+
* `contractToolProperties` strips it from the MCP schema.
|
|
29
|
+
*/
|
|
30
|
+
"x-clamp"?: true;
|
|
31
|
+
};
|
|
32
|
+
export type BooleanProperty = {
|
|
33
|
+
type: "boolean";
|
|
34
|
+
description?: string;
|
|
35
|
+
};
|
|
36
|
+
export type NullProperty = {
|
|
37
|
+
type: "null";
|
|
38
|
+
};
|
|
39
|
+
export interface ArrayProperty<I extends JsonProperty = JsonProperty> {
|
|
40
|
+
type: "array";
|
|
41
|
+
description?: string;
|
|
42
|
+
items: I;
|
|
43
|
+
minItems?: number;
|
|
44
|
+
maxItems?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface ObjectProperty<P extends Record<string, JsonProperty> = Record<string, JsonProperty>> {
|
|
47
|
+
type: "object";
|
|
48
|
+
description?: string;
|
|
49
|
+
additionalProperties?: boolean | JsonProperty;
|
|
50
|
+
properties?: P;
|
|
51
|
+
required?: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
export interface OneOfProperty {
|
|
54
|
+
oneOf: JsonProperty[];
|
|
55
|
+
description?: string;
|
|
56
|
+
}
|
|
57
|
+
export type JsonProperty = {
|
|
58
|
+
$ref: string;
|
|
59
|
+
} | StringProperty | NumberProperty | BooleanProperty | NullProperty | ObjectProperty | ArrayProperty | OneOfProperty;
|
|
60
|
+
/** The top level of a contract: a closed object. */
|
|
61
|
+
export type ContractObject<P extends Record<string, JsonProperty> = Record<string, JsonProperty>, R extends string = string> = {
|
|
62
|
+
type: "object";
|
|
63
|
+
description?: string;
|
|
64
|
+
additionalProperties: false;
|
|
65
|
+
properties: P;
|
|
66
|
+
required?: readonly R[];
|
|
67
|
+
};
|
|
68
|
+
type Described = {
|
|
69
|
+
description?: string;
|
|
70
|
+
};
|
|
71
|
+
type NumberOptions = Described & {
|
|
72
|
+
minimum?: number;
|
|
73
|
+
maximum?: number;
|
|
74
|
+
clamp?: boolean;
|
|
75
|
+
};
|
|
76
|
+
export declare function int(options?: NumberOptions): {
|
|
77
|
+
type: "integer";
|
|
78
|
+
description?: string;
|
|
79
|
+
minimum?: number;
|
|
80
|
+
maximum?: number;
|
|
81
|
+
"x-clamp"?: true;
|
|
82
|
+
};
|
|
83
|
+
export declare function num(options?: NumberOptions): {
|
|
84
|
+
type: "number";
|
|
85
|
+
description?: string;
|
|
86
|
+
minimum?: number;
|
|
87
|
+
maximum?: number;
|
|
88
|
+
"x-clamp"?: true;
|
|
89
|
+
};
|
|
90
|
+
export declare function bool(options?: Described): BooleanProperty;
|
|
91
|
+
export declare function str(options?: Described & {
|
|
92
|
+
minLength?: number;
|
|
93
|
+
maxLength?: number;
|
|
94
|
+
}): StringProperty;
|
|
95
|
+
export declare function enumOf<const V extends string>(values: readonly V[], options?: Described): {
|
|
96
|
+
type: "string";
|
|
97
|
+
enum: readonly V[];
|
|
98
|
+
description?: string;
|
|
99
|
+
};
|
|
100
|
+
export declare function arr<const I extends JsonProperty>(items: I, options?: Described & {
|
|
101
|
+
minItems?: number;
|
|
102
|
+
maxItems?: number;
|
|
103
|
+
}): ArrayProperty<I>;
|
|
104
|
+
export declare function obj<const P extends Record<string, JsonProperty>, const R extends keyof P & string = never>(properties: P, options?: Described & {
|
|
105
|
+
required?: readonly R[];
|
|
106
|
+
}): ContractObject<P, R>;
|
|
107
|
+
/** `null` is accepted and passed through; it means "clear this field". */
|
|
108
|
+
export declare function nullable<const P extends JsonProperty>(property: P): {
|
|
109
|
+
oneOf: [P, NullProperty];
|
|
110
|
+
description?: string;
|
|
111
|
+
};
|
|
112
|
+
type Simplify<T> = {
|
|
113
|
+
[K in keyof T]: T[K];
|
|
114
|
+
} & {};
|
|
115
|
+
type InferObject<M, R> = Simplify<{
|
|
116
|
+
[K in keyof M as K extends R ? K : never]: Infer<M[K]>;
|
|
117
|
+
} & {
|
|
118
|
+
[K in keyof M as K extends R ? never : K]?: Infer<M[K]>;
|
|
119
|
+
}>;
|
|
120
|
+
/** The TypeScript type of a value that passes `validateContract`. Fields not in `required` are optional. */
|
|
121
|
+
export type Infer<P> = P extends {
|
|
122
|
+
oneOf: readonly [infer A, NullProperty];
|
|
123
|
+
} ? Infer<A> | null : P extends {
|
|
124
|
+
type: "integer" | "number";
|
|
125
|
+
} ? number : P extends {
|
|
126
|
+
type: "boolean";
|
|
127
|
+
} ? boolean : P extends {
|
|
128
|
+
type: "string";
|
|
129
|
+
enum: readonly (infer E)[];
|
|
130
|
+
} ? E : P extends {
|
|
131
|
+
type: "string";
|
|
132
|
+
} ? string : P extends {
|
|
133
|
+
type: "array";
|
|
134
|
+
items: infer I;
|
|
135
|
+
} ? Infer<I>[] : P extends {
|
|
136
|
+
type: "object";
|
|
137
|
+
properties: infer M;
|
|
138
|
+
} ? InferObject<M, P extends {
|
|
139
|
+
required?: readonly (infer R)[];
|
|
140
|
+
} ? R : never> : unknown;
|
|
141
|
+
/** A property's non-null branch, and whether null is allowed. */
|
|
142
|
+
export declare function unwrapNullable(property: JsonProperty): {
|
|
143
|
+
property: JsonProperty;
|
|
144
|
+
nullable: boolean;
|
|
145
|
+
};
|
|
146
|
+
export type ContractLeaf = {
|
|
147
|
+
/** Dotted path, e.g. `limits.invites_per_day`. */
|
|
148
|
+
key: string;
|
|
149
|
+
path: readonly string[];
|
|
150
|
+
/** The leaf's non-null schema. */
|
|
151
|
+
property: JsonProperty;
|
|
152
|
+
nullable: boolean;
|
|
153
|
+
};
|
|
154
|
+
/** Every non-object field of a contract, depth first in declaration order. */
|
|
155
|
+
export declare function contractLeafPaths(contract: ContractObject | ObjectProperty, prefix?: readonly string[]): ContractLeaf[];
|
|
156
|
+
export type ContractIssue = {
|
|
157
|
+
path: string;
|
|
158
|
+
message: string;
|
|
159
|
+
};
|
|
160
|
+
export type ValidateContractOptions = {
|
|
161
|
+
/**
|
|
162
|
+
* `warn`: an unknown key is listed in `unknownKeys` AND kept in `value`, so a
|
|
163
|
+
* route that still reads it behaves exactly as before the contract existed.
|
|
164
|
+
* `reject`: an unknown key is an issue.
|
|
165
|
+
*/
|
|
166
|
+
unknown: "warn" | "reject";
|
|
167
|
+
/** `clamp` lowers an over-maximum value to the maximum, only where the field carries `x-clamp`. */
|
|
168
|
+
overMax: "clamp" | "reject";
|
|
169
|
+
};
|
|
170
|
+
export type ContractValidation<T> = {
|
|
171
|
+
ok: true;
|
|
172
|
+
value: T;
|
|
173
|
+
unknownKeys: string[];
|
|
174
|
+
clamped: string[];
|
|
175
|
+
} | {
|
|
176
|
+
ok: false;
|
|
177
|
+
issues: ContractIssue[];
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Check a request body against its contract. Absent (`undefined`) fields are
|
|
181
|
+
* skipped, `null` passes only where the field is `nullable`, and every issue is
|
|
182
|
+
* collected rather than stopping at the first. On success `value` is a copy with
|
|
183
|
+
* clamps applied (see the options for what happens to unknown keys).
|
|
184
|
+
*/
|
|
185
|
+
export declare function validateContract<C extends ContractObject>(contract: C, value: unknown, options: ValidateContractOptions): ContractValidation<Infer<C>>;
|
|
186
|
+
export {};
|