@indemn/cli 1.7.8 → 1.7.10
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +2 -2
- package/dist/mcp/tool-meta.d.ts.map +1 -1
- package/dist/mcp/tool-meta.js +52 -2
- package/dist/mcp/tool-meta.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +141 -6
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/access.d.ts +4 -0
- package/dist/sdk/access.d.ts.map +1 -1
- package/dist/sdk/access.js +11 -0
- package/dist/sdk/access.js.map +1 -1
- package/dist/sdk/campaigns.d.ts +82 -3
- package/dist/sdk/campaigns.d.ts.map +1 -1
- package/dist/sdk/campaigns.js +106 -4
- package/dist/sdk/campaigns.js.map +1 -1
- package/dist/sdk/orgs.d.ts +16 -0
- package/dist/sdk/orgs.d.ts.map +1 -1
- package/dist/sdk/orgs.js +30 -0
- package/dist/sdk/orgs.js.map +1 -1
- package/package.json +1 -1
- package/src/mcp/tool-meta.ts +54 -2
- package/src/mcp/tools.ts +181 -7
- package/src/sdk/access.ts +12 -0
- package/src/sdk/campaigns.ts +125 -4
- package/src/sdk/orgs.ts +37 -0
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +8 -0
- package/test/mcp/access.test.ts +18 -0
- package/test/mcp/campaigns.test.ts +165 -0
- package/test/mcp/tools-snapshot.test.ts +2 -2
- package/test/sdk/campaigns.test.ts +76 -18
- package/test/sdk/orgs.test.ts +31 -0
package/src/mcp/tools.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod/v3';
|
|
2
|
-
import { currentAccess, orgDenial, refreshAccess, roleIn, usableSomewhere } from '../sdk/access.js';
|
|
2
|
+
import { currentAccess, inviteDenial, inviteProject, orgDenial, refreshAccess, roleIn, usableSomewhere } from '../sdk/access.js';
|
|
3
3
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import type { IndemnClient } from '../sdk/client.js';
|
|
5
5
|
import { AgentInsightsSDK, AgentsSDK } from '../sdk/agents.js';
|
|
@@ -13,7 +13,7 @@ import { DistributionSDK } from '../sdk/distribution.js';
|
|
|
13
13
|
import { AnalyticsSDK, AuditSDK, OrgSettingsSDK } from '../sdk/analytics.js';
|
|
14
14
|
import { TicketsSDK } from '../sdk/tickets.js';
|
|
15
15
|
import { BillingSDK } from '../sdk/billing.js';
|
|
16
|
-
import { CampaignsSDK } from '../sdk/campaigns.js';
|
|
16
|
+
import { CampaignsSDK, type Touchpoint } from '../sdk/campaigns.js';
|
|
17
17
|
import { PlatformSDK } from '../sdk/platform.js';
|
|
18
18
|
import { jarvisChat } from '../sdk/jarvis-chat.js';
|
|
19
19
|
import { applyConfigChange, describePlan, liveText, mayBeLive, modelArg, planConfigChange, type ConfigChange } from '../sdk/config-change.js';
|
|
@@ -28,7 +28,7 @@ import { exchange, openTestChat } from '../sdk/test-chat.js';
|
|
|
28
28
|
import { ConversationsSDK, conversationToTestSet, conversationUrl, isCliConversation, isTestConversation, projectOfAgent } from '../sdk/conversations.js';
|
|
29
29
|
import { exportEvalMarkdown, exportEvalReport, exportAgentCard } from '../report/export.js';
|
|
30
30
|
import { createRegistry, type Registry } from './registry.js';
|
|
31
|
-
import { OrgsSDK } from '../sdk/orgs.js';
|
|
31
|
+
import { INVITE_ROLES, OrgsSDK } from '../sdk/orgs.js';
|
|
32
32
|
import { whoami } from '../sdk/auth.js';
|
|
33
33
|
import { NeedsInputError, SessionExpiredError } from '../core/runtime.js';
|
|
34
34
|
|
|
@@ -168,6 +168,11 @@ const skillDefinition = z.preprocess(parseJsonText, z.object({
|
|
|
168
168
|
}).passthrough()).optional(),
|
|
169
169
|
}).passthrough());
|
|
170
170
|
|
|
171
|
+
// Campaigns are on for an org only when Indemn has turned Solutions on: the dashboard hides them otherwise, and the
|
|
172
|
+
// campaigns service does not check it, so these tools ask first (before any approval card).
|
|
173
|
+
const CAMPAIGN_TOOLS = new Set(['get_campaigns', 'save_campaign', 'set_touchpoint', 'publish_campaign', 'set_campaign_status', 'save_campaign_template', 'save_template_skill', 'save_book']);
|
|
174
|
+
const SOLUTIONS_OFF = 'Campaigns are off for this organization: Solutions is not turned on (the dashboard hides campaigns too). Indemn staff can turn it on with set_org_admin_settings (solutions: true); anyone else asks the Indemn team to turn it on.';
|
|
175
|
+
|
|
171
176
|
export function registerTools(server: McpServer, client: IndemnClient): Registry {
|
|
172
177
|
// Role awareness: the folder's linked org (or INDEMN_ORG_ID) narrows what counts; otherwise any org.
|
|
173
178
|
const orgInView = () => process.env.INDEMN_ORG_ID || client.getLink()?.org_id || undefined;
|
|
@@ -177,6 +182,11 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
|
|
|
177
182
|
visible: (name) => usableSomewhere(name, currentAccess(client), orgInView()),
|
|
178
183
|
// Refuse early only when the saved access still says no after asking again (it may be stale).
|
|
179
184
|
deny: async (name, orgId) => {
|
|
185
|
+
if (name === 'invite_org_member') return inviteDenial((await refreshAccess(client)) ?? currentAccess(client), orgId);
|
|
186
|
+
if (CAMPAIGN_TOOLS.has(name) && orgId) {
|
|
187
|
+
const on = await client.copilotGet<{ enableSolutions?: boolean }>(`/organization/${orgId}/configurations`).then((c) => c?.enableSolutions === true, () => false);
|
|
188
|
+
if (!on) return SOLUTIONS_OFF;
|
|
189
|
+
}
|
|
180
190
|
if (!orgDenial(name, currentAccess(client), orgId)) return null;
|
|
181
191
|
return orgDenial(name, await refreshAccess(client), orgId);
|
|
182
192
|
},
|
|
@@ -219,7 +229,12 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
|
|
|
219
229
|
add(args.ticket_id, null, (id) => tickets.get(id).then((t) => { const x = t as { ticket_ref?: string; title?: string }; return label([x.ticket_ref, x.title].filter(Boolean).join(' ')); })),
|
|
220
230
|
add(args.rule_id, null, (id) => tickets.listRules({ includeTrashed: true }).then((rules) => label((rules as Array<{ _id?: string; id?: string; name?: string }>).find((r) => (r._id ?? r.id) === id)?.name))),
|
|
221
231
|
add(args.distribution_id, null, (id) => distribution.list().then((d) => { const p = (d.paths ?? []).find((x) => x.id === id); return label(p ? `${p.path_name ?? ''} (${p.host_name ?? '*'}${p.path_expression ?? ''})`.trim() : undefined); })),
|
|
222
|
-
|
|
232
|
+
// A website template, else a campaign template.
|
|
233
|
+
add(args.template_id, null, (id) => distribution.listTemplates().then((ts) => label((ts as Array<{ _id?: string; name?: string }>).find((t) => t._id === id)?.name))
|
|
234
|
+
.then((l) => l.name ? l : new CampaignsSDK(client).template(id).then((t) => label((t as { name?: unknown }).name), () => l))),
|
|
235
|
+
add(args.campaign_id, 'campaign', (id) => new CampaignsSDK(client).get(id) as Promise<{ name?: unknown }>),
|
|
236
|
+
add(args.book_id, 'book', (id) => new CampaignsSDK(client).book(id)),
|
|
237
|
+
add(args.template_skill_id, 'template skill', (id) => new CampaignsSDK(client).templateSkill(id)),
|
|
223
238
|
add(args.owner_id, null, (id) => orgs.listMembers().then((ms) => { const m = ms.find((x) => x.user_id === id); return label(m?.name ?? m?.email); })),
|
|
224
239
|
agentId && typeof args.function_id === 'string' ? add(args.parameter_id, null, (id) => skillOf(agentId, String(args.function_id)).then((s) => functions.listParameters(agentId, s._id)).then((ps) => label(ps.find((p) => p._id === id)?.name))) : undefined,
|
|
225
240
|
add(orgId, null, (id) => client.copilotGet<{ organization?: { name?: string } }>(`/organization/${id}`).then((r) => r.organization)),
|
|
@@ -278,6 +293,22 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
|
|
|
278
293
|
},
|
|
279
294
|
);
|
|
280
295
|
|
|
296
|
+
reg.tool(
|
|
297
|
+
'invite_org_member',
|
|
298
|
+
'Invite someone to the organization by email, as the dashboard\'s Invite user page does. Only owners and admins can invite. Roles: admin (manages agents, knowledge bases, conversations, teammates and organization settings; not billing), agent (handles live conversations, with full AI Studio access; cannot manage teammates or settings) or member (tests and reviews agents and sees conversations). Ask for the role if the person did not say one; there is no owner role here. Someone without an Indemn account gets an email to sign up; an existing account joins at once. The host asks the person to approve.',
|
|
299
|
+
{
|
|
300
|
+
email: z.string().trim().email().describe('Email address of the person to invite'),
|
|
301
|
+
role: z.enum(INVITE_ROLES).describe('admin, agent or member'),
|
|
302
|
+
},
|
|
303
|
+
async (args) => {
|
|
304
|
+
try {
|
|
305
|
+
const project = inviteProject(currentAccess(client) ?? await refreshAccess(client), await client.currentOrgId());
|
|
306
|
+
if (!project) throw new Error('Could not find a project in this organization where you are an owner or admin. Nothing was sent.');
|
|
307
|
+
return success(await orgs.inviteMember(project.project_id, args.email, args.role));
|
|
308
|
+
} catch (err) { return error(err); }
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
|
|
281
312
|
reg.tool(
|
|
282
313
|
'list_channels',
|
|
283
314
|
'List the channels an agent can be published on, such as WEB or VOICE.',
|
|
@@ -1860,10 +1891,10 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
|
|
|
1860
1891
|
|
|
1861
1892
|
reg.tool(
|
|
1862
1893
|
'get_campaigns',
|
|
1863
|
-
'Read
|
|
1894
|
+
'Read campaigns: list, one campaign (campaign_id, with its draft and touchpoints), its counts / forecast / next-touchpoints / versions, templates, one template (template_id, versions: true for subject and body), template skills, books, or one book (book_id).',
|
|
1864
1895
|
{
|
|
1865
|
-
what: z.enum(['list', 'campaign', 'counts', 'forecast', 'next-touchpoints', 'versions', 'templates', 'template', 'template-skills']),
|
|
1866
|
-
campaign_id: z.string().optional(), template_id: z.string().optional(), versions: z.boolean().optional(),
|
|
1896
|
+
what: z.enum(['list', 'campaign', 'counts', 'forecast', 'next-touchpoints', 'versions', 'templates', 'template', 'template-skills', 'books', 'book']),
|
|
1897
|
+
campaign_id: z.string().optional(), template_id: z.string().optional(), book_id: z.string().optional(), versions: z.boolean().optional(),
|
|
1867
1898
|
status: z.string().optional(), solution_id: z.string().optional(), channel: z.string().optional(),
|
|
1868
1899
|
days: z.number().int().min(1).max(365).optional(), limit: z.number().int().min(1).max(500).optional(),
|
|
1869
1900
|
},
|
|
@@ -1877,12 +1908,155 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
|
|
|
1877
1908
|
case 'templates': return success(await c.templates({ channel: args.channel, solutionId: args.solution_id, limit: args.limit }));
|
|
1878
1909
|
case 'template': return success(await c.template(need(args.template_id, 'template_id'), Boolean(args.versions)));
|
|
1879
1910
|
case 'template-skills': return success(await c.templateSkills(args.solution_id));
|
|
1911
|
+
case 'books': return success(await c.books({ solutionId: args.solution_id, limit: args.limit }));
|
|
1912
|
+
case 'book': return success(await c.book(need(args.book_id, 'book_id')));
|
|
1880
1913
|
default: return success(await c.detail(need(args.campaign_id, 'campaign_id'), args.what, args.days));
|
|
1881
1914
|
}
|
|
1882
1915
|
} catch (err) { return error(err); }
|
|
1883
1916
|
},
|
|
1884
1917
|
);
|
|
1885
1918
|
|
|
1919
|
+
// Campaigns configuration: every change is approved on a card. Nothing here sends a message, runs a book sync or
|
|
1920
|
+
// deletes (CampaignsSDK WRITABLE); a campaign ends archived. A save_* tool given an id changes that item, else creates.
|
|
1921
|
+
const campaigns = new CampaignsSDK(client);
|
|
1922
|
+
type CampaignArgs = Record<string, any>;
|
|
1923
|
+
// Only the tool's own arguments reach the service: not confirm/org_id, and not the "" or null a model fills unused
|
|
1924
|
+
// fields with (an empty value never blanks a saved field).
|
|
1925
|
+
const own = (args: CampaignArgs): CampaignArgs => Object.fromEntries(Object.entries(args).filter(([k, v]) => k !== 'confirm' && k !== 'org_id' && v !== '' && v !== null));
|
|
1926
|
+
const lacking = (a: CampaignArgs, keys: string[]) => keys.filter((k) => a[k] === undefined);
|
|
1927
|
+
// `problem` is checked before the card: a call that cannot work is answered at once, without asking or sending.
|
|
1928
|
+
const campaignTool = (name: string, description: string, shape: Record<string, z.ZodTypeAny>, run: (a: CampaignArgs) => Promise<unknown>, problem: (a: CampaignArgs) => string | null = () => null) =>
|
|
1929
|
+
reg.tool(name, description, shape, async (args) => {
|
|
1930
|
+
try {
|
|
1931
|
+
const a = own(args);
|
|
1932
|
+
const p = problem(a);
|
|
1933
|
+
if (p) throw new Error(p);
|
|
1934
|
+
return success(await run(a));
|
|
1935
|
+
} catch (err) { return error(err); }
|
|
1936
|
+
}, { guardWhen: async (args) => problem(own(args)) === null });
|
|
1937
|
+
// A create and then a change: if the change is refused, the item exists, so it is changed with its id next time.
|
|
1938
|
+
const thenChange = async (what: string, made: { id: string }, change: () => Promise<unknown>) => {
|
|
1939
|
+
try { return await change(); } catch (err) { throw new Error(`The ${what} was created (id ${made.id}), but ${err instanceof Error ? err.message : String(err)}. Change it with its id; do not create it again.`); }
|
|
1940
|
+
};
|
|
1941
|
+
const rule = (what: string) => jsonObject().describe(`${what}: a rule as an object or JSON text, e.g. {"all":[{"field":"status","op":"eq","value":"active"},{"field":"derived.days_out","op":"lte","value":60}]}. Ops: eq ne lt lte gt gte in nin exists missing contains; all / any / not combine rules.`);
|
|
1942
|
+
const DRAFT_PARTS = ['audience', 'escalation_policy', 'channel_policy'];
|
|
1943
|
+
|
|
1944
|
+
campaignTool(
|
|
1945
|
+
'save_campaign',
|
|
1946
|
+
"Create a campaign (no campaign_id) as a draft for a solution's book, or change a campaign's draft: its audience (which records belong) and escalation and channel policies; what is not given stays. Touchpoints are set with set_touchpoint. Nothing is sent until the campaign is published (publish_campaign) and started (set_campaign_status running). Find the solution with list_solutions and its book with get_campaigns (what: books).",
|
|
1947
|
+
{
|
|
1948
|
+
campaign_id: z.string().optional().describe('The campaign to change; leave out to create one'),
|
|
1949
|
+
solution_id: z.string().optional(), book_id: z.string().optional(), name: z.string().optional(),
|
|
1950
|
+
campaign_type: z.string().optional().describe('To create: e.g. renewal, outreach'),
|
|
1951
|
+
entry: z.enum(['rolling', 'cohort']).optional().describe('To create: rolling (records join as they match, the default) or cohort (a fixed set, with closes)'),
|
|
1952
|
+
closes: z.string().optional().describe('To create a cohort campaign: ISO date it closes'),
|
|
1953
|
+
audience: rule('Which records belong').optional(),
|
|
1954
|
+
escalation_policy: jsonObject().optional().describe('e.g. {"after":{"touches_exhausted":true},"on_hard_failure":"escalate_immediately","then":"create_work_item","assign_to":null}'),
|
|
1955
|
+
channel_policy: jsonObject().optional().describe('e.g. {"quiet_hours":null,"max_per_record_per_window":null}'),
|
|
1956
|
+
},
|
|
1957
|
+
async ({ campaign_id, audience, escalation_policy, channel_policy, ...create }) => {
|
|
1958
|
+
const parts = { audience, escalation_policy, channel_policy };
|
|
1959
|
+
if (campaign_id) return campaigns.updateDraft(campaign_id, parts);
|
|
1960
|
+
const made = await campaigns.createCampaign(create as Parameters<CampaignsSDK['createCampaign']>[0]);
|
|
1961
|
+
return DRAFT_PARTS.some((k) => (parts as CampaignArgs)[k] !== undefined) ? thenChange('campaign', made, async () => ({ ...made, draft: await campaigns.updateDraft(made.id, parts) })) : made;
|
|
1962
|
+
},
|
|
1963
|
+
(a) => a.campaign_id
|
|
1964
|
+
? (DRAFT_PARTS.some((k) => a[k] !== undefined) ? null : 'Nothing to change: give audience, escalation_policy or channel_policy (a campaign is not renamed here).')
|
|
1965
|
+
: (lacking(a, ['solution_id', 'book_id', 'name', 'campaign_type']).length ? `To create a campaign also give ${lacking(a, ['solution_id', 'book_id', 'name', 'campaign_type']).join(', ')}; to change one, give its campaign_id.` : null),
|
|
1966
|
+
);
|
|
1967
|
+
|
|
1968
|
+
campaignTool(
|
|
1969
|
+
'set_touchpoint',
|
|
1970
|
+
"Add a touchpoint to a campaign's draft, or change the one with the same key (only the fields given change). when is a trigger rule: days before the due date ({\"field\":\"derived.days_out\",\"op\":\"lte\",\"value\":45}), a day of the month (calendar.day_of_month), days without a reply (silence.days) or an inbound intent (intent). terminal_action draft (the default) prepares the message for a person to review; send sends it to the customer, so use send only when the person asks for it. A template given without a version is pinned to its latest.",
|
|
1971
|
+
{
|
|
1972
|
+
campaign_id: z.string(),
|
|
1973
|
+
key: z.string().trim().min(1).describe('Unique in the campaign, e.g. t2_reminder_45'),
|
|
1974
|
+
when: rule('When it fires').optional(),
|
|
1975
|
+
channel: z.enum(['email', 'voice', 'sms']).optional(),
|
|
1976
|
+
terminal_action: z.enum(['draft', 'send']).optional(),
|
|
1977
|
+
order: z.number().int().optional(),
|
|
1978
|
+
template_id: z.string().optional(), template_version_n: z.number().int().optional(),
|
|
1979
|
+
grouping: jsonObject().optional().describe('e.g. {"key":"insured_account","recipient":"resolved"} (key: record, insured_account or fixed_recipient)'),
|
|
1980
|
+
required_variables: jsonArray(z.string()).optional(),
|
|
1981
|
+
},
|
|
1982
|
+
async ({ campaign_id, ...touchpoint }) => campaigns.setTouchpoint(campaign_id, touchpoint as Touchpoint),
|
|
1983
|
+
);
|
|
1984
|
+
|
|
1985
|
+
campaignTool(
|
|
1986
|
+
'publish_campaign',
|
|
1987
|
+
"Publish a campaign's draft as its next version (refused, with every problem listed, while it has no audience or touchpoint). A running campaign uses the new version from its next run.",
|
|
1988
|
+
{ campaign_id: z.string(), change_summary: z.string().optional() },
|
|
1989
|
+
async (a) => campaigns.publish(a.campaign_id, a.change_summary),
|
|
1990
|
+
);
|
|
1991
|
+
|
|
1992
|
+
campaignTool(
|
|
1993
|
+
'set_campaign_status',
|
|
1994
|
+
'Start (running), pause (paused) or archive (archived, for good) a campaign. Starting needs a published version; it then enrols the matching records of its book and runs its touchpoints. Campaigns are never deleted: asked to delete one, offer to archive it and say it is archived, not deleted.',
|
|
1995
|
+
{ campaign_id: z.string(), status: z.enum(['running', 'paused', 'archived']) },
|
|
1996
|
+
async (a) => campaigns.setStatus(a.campaign_id, a.status),
|
|
1997
|
+
);
|
|
1998
|
+
|
|
1999
|
+
const TEMPLATE_PARTS = ['subject', 'body', 'html', 'uses_variables', 'skills', 'selection_tags'];
|
|
2000
|
+
campaignTool(
|
|
2001
|
+
'save_campaign_template',
|
|
2002
|
+
'Create a campaign message template (no template_id), or change one: a change is saved as its new version, made from the latest one with the fields given. Touchpoints keep the version they are set to until set_touchpoint sets the template again.',
|
|
2003
|
+
{
|
|
2004
|
+
template_id: z.string().optional().describe('The template to change; leave out to create one'),
|
|
2005
|
+
solution_id: z.string().optional(), name: z.string().optional(), channel: z.enum(['email', 'voice', 'sms']).optional(),
|
|
2006
|
+
subject: z.string().optional().describe('Email subject. Placeholders: {{ record.<book field> }}, e.g. {{ record.policy.number }}, or contact.*, campaign.*, enrollment.*'),
|
|
2007
|
+
body: z.string().optional().describe('Message text, with placeholders as in subject'),
|
|
2008
|
+
html: z.string().optional(),
|
|
2009
|
+
uses_variables: jsonArray(z.string()).optional().describe('Every placeholder key the template uses, e.g. ["record.policy.number","contact.first_name"] (the service refuses an undeclared one)'),
|
|
2010
|
+
skills: jsonObject().optional(), selection_tags: jsonObject().optional(),
|
|
2011
|
+
change_summary: z.string().optional(),
|
|
2012
|
+
},
|
|
2013
|
+
async ({ template_id, solution_id, name, channel, ...content }) => template_id
|
|
2014
|
+
? campaigns.updateTemplate(template_id, content)
|
|
2015
|
+
: campaigns.createTemplate({ solution_id, name, channel, ...content }),
|
|
2016
|
+
(a) => a.template_id
|
|
2017
|
+
? (TEMPLATE_PARTS.some((k) => a[k] !== undefined) ? null : `Nothing to change: give ${TEMPLATE_PARTS.join(', ')}.`)
|
|
2018
|
+
: (lacking(a, ['solution_id', 'name', 'channel', 'body']).length ? `To create a template also give ${lacking(a, ['solution_id', 'name', 'channel', 'body']).join(', ')}; to change one, give its template_id.` : null),
|
|
2019
|
+
);
|
|
2020
|
+
|
|
2021
|
+
campaignTool(
|
|
2022
|
+
'save_template_skill',
|
|
2023
|
+
"Create a template skill (no template_skill_id): writing instructions a template uses when its message is composed. Or change one's name or instructions; templates using it write with the new text from their campaigns' next run, with no publish.",
|
|
2024
|
+
{
|
|
2025
|
+
template_skill_id: z.string().optional().describe('The skill to change; leave out to create one'),
|
|
2026
|
+
solution_id: z.string().optional(), key: z.string().optional().describe('To create: unique in the solution, e.g. friendly_tone'),
|
|
2027
|
+
name: z.string().optional(), instructions: z.string().optional(),
|
|
2028
|
+
},
|
|
2029
|
+
async ({ template_skill_id, solution_id, key, name, instructions }) => template_skill_id
|
|
2030
|
+
? campaigns.updateTemplateSkill(template_skill_id, { name, instructions })
|
|
2031
|
+
: campaigns.createTemplateSkill({ solution_id, key, name, instructions }),
|
|
2032
|
+
(a) => a.template_skill_id
|
|
2033
|
+
? (a.name !== undefined || a.instructions !== undefined ? null : 'Nothing to change: give name or instructions.')
|
|
2034
|
+
: (lacking(a, ['solution_id', 'key', 'name', 'instructions']).length ? `To create a template skill also give ${lacking(a, ['solution_id', 'key', 'name', 'instructions']).join(', ')}; to change one, give its template_skill_id.` : null),
|
|
2035
|
+
);
|
|
2036
|
+
|
|
2037
|
+
const BOOK_PARTS = ['source', 'field_map', 'identity_keys', 'display'];
|
|
2038
|
+
campaignTool(
|
|
2039
|
+
'save_book',
|
|
2040
|
+
"Create a solution's book, the records a campaign works from (no book_id; a solution has one book, so look with get_campaigns what: books first). Or change a book's source, field map (which source column fills which field), identity keys (what makes a record unique; only while it has no records) or display (fields shown for a record); each part given replaces the book's. Keys are the book's schema keys, e.g. policy.number, insured.name.",
|
|
2041
|
+
{
|
|
2042
|
+
book_id: z.string().optional().describe('The book to change; leave out to create one'),
|
|
2043
|
+
solution_id: z.string().optional(), name: z.string().optional(),
|
|
2044
|
+
schema_id: z.string().optional().describe("To create: the entity schema; left out, the solution type's default fields"),
|
|
2045
|
+
source: jsonObject().optional().describe('{"kind":"upload"} (records uploaded as CSV in the dashboard) or {"kind":"connection","integration_id":"…"}'),
|
|
2046
|
+
field_map: jsonArray(jsonObject()).optional().describe('e.g. [{"schema_key":"policy.number","source_path":"Policy #"}]'),
|
|
2047
|
+
identity_keys: jsonArray(z.string()).optional().describe('e.g. ["policy.number"]'),
|
|
2048
|
+
display: jsonObject().optional().describe('e.g. {"primary":["policy.number"],"secondary":["insured.name"]}'),
|
|
2049
|
+
},
|
|
2050
|
+
async ({ book_id, solution_id, name, schema_id, source, ...parts }) => {
|
|
2051
|
+
if (book_id) return campaigns.updateBook(book_id, { source, ...parts });
|
|
2052
|
+
const made = await campaigns.createBook({ solution_id, name, schema_id, source });
|
|
2053
|
+
return Object.keys(parts).length ? thenChange('book', made, () => campaigns.updateBook(made.id, parts)) : made;
|
|
2054
|
+
},
|
|
2055
|
+
(a) => a.book_id
|
|
2056
|
+
? (BOOK_PARTS.some((k) => a[k] !== undefined) ? null : `Nothing to change: give ${BOOK_PARTS.join(', ')}.`)
|
|
2057
|
+
: (lacking(a, ['solution_id', 'name']).length ? `To create a book also give ${lacking(a, ['solution_id', 'name']).join(', ')}; to change one, give its book_id.` : null),
|
|
2058
|
+
);
|
|
2059
|
+
|
|
1886
2060
|
const platform = new PlatformSDK(client);
|
|
1887
2061
|
const platformResource = z.enum(['agents', 'templates', 'components', 'connectors', 'connector-bindings', 'scenarios']);
|
|
1888
2062
|
|
package/src/sdk/access.ts
CHANGED
|
@@ -120,6 +120,18 @@ export function roleIn(access: Access | null, orgId: string | undefined): string
|
|
|
120
120
|
return `${org.role ?? 'member'} in ${org.org_name}`;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** The org's project where the person can invite people: copilot's invite route needs owner or admin there. */
|
|
124
|
+
export function inviteProject(access: Access | null, orgId: string): AccessProject | undefined {
|
|
125
|
+
return access?.orgs.find((o) => o.org_id === orgId)?.projects.find((p) => p.role === 'owner' || p.role === 'admin');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Why the person can't invite people to this org, or null (also when access is unknown: the server decides). */
|
|
129
|
+
export function inviteDenial(access: Access | null, orgId: string | undefined): string | null {
|
|
130
|
+
const org = orgId ? access?.orgs.find((o) => o.org_id === orgId) : undefined;
|
|
131
|
+
if (!org || inviteProject(access, org.org_id)) return null;
|
|
132
|
+
return `Only owners and admins can invite people, and your role in ${org.org_name} is ${org.role ?? 'member'}.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
/**
|
|
124
136
|
* For org-scoped requirements: why this org can't be used, or null. Project-scoped
|
|
125
137
|
* requirements are decided by the server (the project comes from the agent).
|
package/src/sdk/campaigns.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { IndemnClient } from './client.js';
|
|
2
2
|
import { CliError, EXIT } from '../core/runtime.js';
|
|
3
3
|
|
|
4
|
-
/** Paths of the campaigns service the CLI may read. Everything else
|
|
4
|
+
/** Paths of the campaigns service the CLI may read. Everything else is refused. */
|
|
5
5
|
const READABLE = [
|
|
6
6
|
/^\/campaigns$/,
|
|
7
7
|
/^\/campaigns\/[\w-]+$/,
|
|
@@ -11,12 +11,44 @@ const READABLE = [
|
|
|
11
11
|
/^\/templates\/[\w-]+\/versions$/,
|
|
12
12
|
/^\/template-skills$/,
|
|
13
13
|
/^\/template-skills\/[\w-]+$/,
|
|
14
|
+
/^\/books$/,
|
|
15
|
+
/^\/books\/[\w-]+$/,
|
|
14
16
|
];
|
|
15
17
|
|
|
18
|
+
type WriteMethod = 'POST' | 'PUT' | 'PATCH';
|
|
19
|
+
/** What the CLI may change: campaigns and their drafts (touchpoints live there), publish and status, templates
|
|
20
|
+
* (each change a new version), template skills and books. Never sending or resolving anything, book runs,
|
|
21
|
+
* uploads, test ticks or enrollments, and no deletes: those act on real customers or cannot be undone. */
|
|
22
|
+
const WRITABLE: Array<[WriteMethod, RegExp]> = [
|
|
23
|
+
['POST', /^\/campaigns$/],
|
|
24
|
+
['PUT', /^\/campaigns\/[\w-]+\/draft$/],
|
|
25
|
+
['POST', /^\/campaigns\/[\w-]+\/(publish|status)$/],
|
|
26
|
+
['POST', /^\/templates$/],
|
|
27
|
+
['POST', /^\/templates\/[\w-]+\/versions$/],
|
|
28
|
+
['POST', /^\/template-skills$/],
|
|
29
|
+
['PUT', /^\/template-skills\/[\w-]+$/],
|
|
30
|
+
['POST', /^\/books$/],
|
|
31
|
+
['PATCH', /^\/books\/[\w-]+\/source$/],
|
|
32
|
+
['PUT', /^\/books\/[\w-]+\/(field-map|identity-keys|display)$/],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export type CampaignStatus = 'draft' | 'running' | 'paused' | 'archived';
|
|
36
|
+
/** A touchpoint in a campaign's draft, found by its key. */
|
|
37
|
+
export type Touchpoint = { key: string } & Record<string, unknown>;
|
|
38
|
+
type Draft = { audience?: unknown; touchpoints?: Touchpoint[]; escalation_policy?: unknown; channel_policy?: unknown };
|
|
39
|
+
type Envelope = { total?: number; items?: Array<Record<string, unknown>> };
|
|
40
|
+
// A template version is whole: a change starts from the latest version's content.
|
|
41
|
+
const TEMPLATE_CONTENT = ['subject', 'body', 'html', 'uses_variables', 'selection_tags', 'skills', 'batch_row'];
|
|
42
|
+
const given = <T extends object>(o: T) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;
|
|
43
|
+
type Variable = { key: string; required?: boolean; example?: unknown };
|
|
44
|
+
// Templates declare the variables they read as {key, required, example}; given as keys, a key the template already
|
|
45
|
+
// declares keeps its entry.
|
|
46
|
+
const declare = (keys: string[] | undefined, current: Variable[] = []) => keys?.map((key) => current.find((v) => v.key === key) ?? { key });
|
|
47
|
+
|
|
16
48
|
/**
|
|
17
|
-
* Campaigns
|
|
18
|
-
*
|
|
19
|
-
*
|
|
49
|
+
* Campaigns through copilot's proxy to the campaigns service: reads, and
|
|
50
|
+
* writes to configuration only (WRITABLE). The CLI never sends a
|
|
51
|
+
* communication, runs a book sync or deletes; those act on real customers.
|
|
20
52
|
*/
|
|
21
53
|
export class CampaignsSDK {
|
|
22
54
|
constructor(private client: IndemnClient) {}
|
|
@@ -26,6 +58,11 @@ export class CampaignsSDK {
|
|
|
26
58
|
return this.client.copilotGet<T>(`/organization/:org_id/campaigns-service${path}`, query);
|
|
27
59
|
}
|
|
28
60
|
|
|
61
|
+
private async write<T>(method: WriteMethod, path: string, body: unknown): Promise<T> {
|
|
62
|
+
if (!WRITABLE.some(([m, r]) => m === method && r.test(path))) throw new CliError(`Campaigns ${method} ${path} is not allowed from the CLI.`, EXIT.NEEDS_INPUT);
|
|
63
|
+
return this.client.copilotRequestRaw<T>(method, `/organization/:org_id/campaigns-service${path}`, body);
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
list(filter: { status?: string; solutionId?: string; limit?: number; skip?: number } = {}): Promise<{ total?: number; items?: unknown[] }> {
|
|
30
67
|
const q: Record<string, string> = { limit: String(Math.min(filter.limit ?? 50, 500)), skip: String(filter.skip ?? 0) };
|
|
31
68
|
if (filter.status) q.status = filter.status;
|
|
@@ -57,4 +94,88 @@ export class CampaignsSDK {
|
|
|
57
94
|
templateSkills(solutionId?: string): Promise<unknown> {
|
|
58
95
|
return this.read('/template-skills', solutionId ? { solution_id: solutionId } : undefined);
|
|
59
96
|
}
|
|
97
|
+
|
|
98
|
+
templateSkill(id: string): Promise<{ name?: string; instructions?: string }> {
|
|
99
|
+
return this.read(`/template-skills/${id}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
books(filter: { solutionId?: string; limit?: number } = {}): Promise<Envelope> {
|
|
103
|
+
const q: Record<string, string> = { limit: String(Math.min(filter.limit ?? 50, 200)) }; // the service allows 200
|
|
104
|
+
if (filter.solutionId) q.solution_id = filter.solutionId;
|
|
105
|
+
return this.read('/books', q);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
book(id: string): Promise<{ name?: string }> {
|
|
109
|
+
return this.read(`/books/${id}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
createCampaign(input: { solution_id: string; book_id: string; name: string; campaign_type: string; entry?: string; closes?: string }): Promise<{ id: string }> {
|
|
113
|
+
return this.write('POST', '/campaigns', given(input));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Change parts of the draft; the rest of it (its touchpoints among it) stays as it is. */
|
|
117
|
+
async updateDraft(id: string, parts: Omit<Draft, 'touchpoints'>): Promise<Draft> {
|
|
118
|
+
const { draft } = await this.read<{ draft?: Draft }>(`/campaigns/${id}`);
|
|
119
|
+
return this.write('PUT', `/campaigns/${id}/draft`, { ...draft, ...given(parts) });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Add a touchpoint, or change the one with its key (only the fields given). A new one drafts unless told to
|
|
123
|
+
* send; a template given without a version is pinned to its latest. */
|
|
124
|
+
async setTouchpoint(id: string, touchpoint: Touchpoint): Promise<Draft> {
|
|
125
|
+
const { draft } = await this.read<{ draft?: Draft }>(`/campaigns/${id}`);
|
|
126
|
+
const current = draft?.touchpoints ?? [];
|
|
127
|
+
const existing = current.find((t) => t.key === touchpoint.key);
|
|
128
|
+
const tp: Touchpoint = { ...(existing ?? { terminal_action: 'draft' }), ...given(touchpoint), key: touchpoint.key };
|
|
129
|
+
if (typeof touchpoint.template_id === 'string' && touchpoint.template_version_n === undefined) {
|
|
130
|
+
tp.template_version_n = Number((await this.latestTemplateVersion(touchpoint.template_id))?.version_n ?? 1);
|
|
131
|
+
}
|
|
132
|
+
const touchpoints = existing ? current.map((t) => (t.key === tp.key ? tp : t)) : [...current, tp];
|
|
133
|
+
return this.write('PUT', `/campaigns/${id}/draft`, { ...draft, touchpoints });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
publish(id: string, changeSummary?: string): Promise<unknown> {
|
|
137
|
+
return this.write('POST', `/campaigns/${id}/publish`, { change_summary: changeSummary ?? '' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setStatus(id: string, status: CampaignStatus): Promise<unknown> {
|
|
141
|
+
return this.write('POST', `/campaigns/${id}/status`, { status });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
createTemplate(input: Record<string, unknown> & { uses_variables?: string[] }): Promise<unknown> {
|
|
145
|
+
return this.write('POST', '/templates', given({ ...input, uses_variables: declare(input.uses_variables) }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async latestTemplateVersion(id: string): Promise<Record<string, unknown> | undefined> {
|
|
149
|
+
return (await this.read<Envelope>(`/templates/${id}/versions`)).items?.[0]; // newest first
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A new version of the template: its latest one with the changes given. */
|
|
153
|
+
async updateTemplate(id: string, changes: Record<string, unknown> & { uses_variables?: string[] }): Promise<unknown> {
|
|
154
|
+
const latest = (await this.latestTemplateVersion(id)) ?? {};
|
|
155
|
+
const kept = Object.fromEntries(TEMPLATE_CONTENT.filter((k) => k in latest).map((k) => [k, latest[k]]));
|
|
156
|
+
return this.write('POST', `/templates/${id}/versions`, { ...kept, ...given({ ...changes, uses_variables: declare(changes.uses_variables, latest.uses_variables as Variable[]) }) });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
createTemplateSkill(input: { solution_id?: string; key: string; name: string; instructions: string }): Promise<unknown> {
|
|
160
|
+
return this.write('POST', '/template-skills', input);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async updateTemplateSkill(id: string, changes: { name?: string; instructions?: string }): Promise<unknown> {
|
|
164
|
+
const skill = await this.templateSkill(id);
|
|
165
|
+
return this.write('PUT', `/template-skills/${id}`, { name: skill.name, instructions: skill.instructions, ...given(changes) });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
createBook(input: { solution_id: string; name: string; schema_id?: string; source?: unknown }): Promise<{ id: string }> {
|
|
169
|
+
return this.write('POST', '/books', given(input));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Change the parts of a book that are given, each its own call in the service. Keys are the book's schema
|
|
173
|
+
* keys; identity keys change only while the book has no records. */
|
|
174
|
+
async updateBook(id: string, changes: { source?: unknown; field_map?: unknown[]; identity_keys?: string[]; display?: unknown }): Promise<unknown> {
|
|
175
|
+
if (changes.source !== undefined) await this.write('PATCH', `/books/${id}/source`, { source: changes.source });
|
|
176
|
+
if (changes.field_map !== undefined) await this.write('PUT', `/books/${id}/field-map`, { field_map: changes.field_map });
|
|
177
|
+
if (changes.identity_keys !== undefined) await this.write('PUT', `/books/${id}/identity-keys`, { identity_keys: changes.identity_keys });
|
|
178
|
+
if (changes.display !== undefined) await this.write('PUT', `/books/${id}/display`, { display: changes.display });
|
|
179
|
+
return this.book(id);
|
|
180
|
+
}
|
|
60
181
|
}
|
package/src/sdk/orgs.ts
CHANGED
|
@@ -8,6 +8,18 @@ interface RawProjectUser {
|
|
|
8
8
|
id_project?: { name?: string } | string | null;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** The roles the dashboard's Invite user page offers. copilot saves any role it is sent, so never owner here. */
|
|
12
|
+
export const INVITE_ROLES = ['admin', 'agent', 'member'] as const;
|
|
13
|
+
export type InviteRole = (typeof INVITE_ROLES)[number];
|
|
14
|
+
|
|
15
|
+
export interface InviteResult {
|
|
16
|
+
email: string;
|
|
17
|
+
role: InviteRole;
|
|
18
|
+
/** invited: no Indemn account yet, a sign-up email went out; added: an existing account joined now. The others changed nothing. */
|
|
19
|
+
outcome: 'invited' | 'added' | 'already_invited' | 'already_member';
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
export interface SandboxOrg {
|
|
12
24
|
organization_id: string;
|
|
13
25
|
name: string;
|
|
@@ -59,6 +71,31 @@ export class OrgsSDK {
|
|
|
59
71
|
return [...byUser.values()];
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Invite someone to the org's project, as the dashboard's Invite user page does (copilot allows owners and
|
|
76
|
+
* admins only). A new email gets a sign-up invitation; an existing Indemn account is added at once and told
|
|
77
|
+
* by email. Already invited or already a member: nothing is sent, and the result says so.
|
|
78
|
+
*/
|
|
79
|
+
async inviteMember(projectId: string, email: string, role: InviteRole): Promise<InviteResult> {
|
|
80
|
+
const address = email.trim().toLowerCase();
|
|
81
|
+
const result = (outcome: InviteResult['outcome'], message: string): InviteResult => ({ email: address, role, outcome, message });
|
|
82
|
+
try {
|
|
83
|
+
const res = await this.client.copilotPost<{ pendingInvitation?: unknown }>(`/${projectId}/project_users/invite`, {
|
|
84
|
+
email: address, role, user_available: false, id_organization: await this.client.currentOrgId(),
|
|
85
|
+
});
|
|
86
|
+
return res?.pendingInvitation
|
|
87
|
+
? result('invited', `Invitation sent to ${address}. They join as ${role} once they sign up from the email.`)
|
|
88
|
+
: result('added', `${address} already had an Indemn account and is now in the organization as ${role}. They were told by email.`);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// copilot answers these with 200 {success:false} and 500/403 with a code; the dashboard shows them as messages.
|
|
91
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
92
|
+
if (/Pending Invitation already exist/i.test(message)) return result('already_invited', `${address} already has a pending invitation. Nothing was sent again.`);
|
|
93
|
+
if (/already a member/i.test(message)) return result('already_member', `${address} is already a member of the organization. Nothing was changed.`);
|
|
94
|
+
if (/invite oneself/i.test(message)) throw new Error('You can\'t invite yourself. Nothing was sent.');
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
62
99
|
/**
|
|
63
100
|
* A sandbox organization for a prospect. Only the Indemn support account may create one (copilot
|
|
64
101
|
* refuses anyone else), and copilot always puts it on the sandbox plan set for the platform.
|
|
@@ -68,6 +68,7 @@ exports[`MCP tool list > gives every tool a title and annotations 1`] = `
|
|
|
68
68
|
"import_agent",
|
|
69
69
|
"import_functions",
|
|
70
70
|
"import_kb",
|
|
71
|
+
"invite_org_member",
|
|
71
72
|
"jarvis_chat",
|
|
72
73
|
"jarvis_create_job",
|
|
73
74
|
"jarvis_jobs",
|
|
@@ -111,10 +112,15 @@ exports[`MCP tool list > gives every tool a title and annotations 1`] = `
|
|
|
111
112
|
"platform_list",
|
|
112
113
|
"platform_save",
|
|
113
114
|
"promote_prod_call",
|
|
115
|
+
"publish_campaign",
|
|
114
116
|
"reimport_kb_source",
|
|
115
117
|
"remove_holiday",
|
|
116
118
|
"run_evaluation",
|
|
117
119
|
"run_simulation",
|
|
120
|
+
"save_book",
|
|
121
|
+
"save_campaign",
|
|
122
|
+
"save_campaign_template",
|
|
123
|
+
"save_template_skill",
|
|
118
124
|
"save_ticket_rule",
|
|
119
125
|
"scrape_url",
|
|
120
126
|
"search_phone_numbers",
|
|
@@ -122,10 +128,12 @@ exports[`MCP tool list > gives every tool a title and annotations 1`] = `
|
|
|
122
128
|
"set_agent_transcriber",
|
|
123
129
|
"set_agent_voice",
|
|
124
130
|
"set_business_hours",
|
|
131
|
+
"set_campaign_status",
|
|
125
132
|
"set_config",
|
|
126
133
|
"set_org_admin_settings",
|
|
127
134
|
"set_org_timezone",
|
|
128
135
|
"set_prompt",
|
|
136
|
+
"set_touchpoint",
|
|
129
137
|
"simulation_status",
|
|
130
138
|
"test_function",
|
|
131
139
|
"transfer_agent_owner",
|
package/test/mcp/access.test.ts
CHANGED
|
@@ -127,6 +127,24 @@ describe('role-aware MCP tools', () => {
|
|
|
127
127
|
await pair.close();
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it('invites only where the person is an owner or admin, into that project, after approval', async () => {
|
|
131
|
+
const { calls } = recordFetch([
|
|
132
|
+
on('GET', '/auth/me/access', { json: accessWith() }),
|
|
133
|
+
on('POST', '/project_users/invite', { json: { pendingInvitation: { _id: 'i1' } } }),
|
|
134
|
+
]);
|
|
135
|
+
const pair = await connectPair(setup, { elicit: () => ({ action: 'accept', content: { confirm: true } }) });
|
|
136
|
+
const refused = await pair.call('invite_org_member', { org_id: B, email: 'jane@acme.test', role: 'agent' });
|
|
137
|
+
expect(refused.isError).toBe(true);
|
|
138
|
+
expect(refused.body).toContain('Only owners and admins can invite people, and your role in QA B is guest.');
|
|
139
|
+
expect(pair.questions).toHaveLength(0);
|
|
140
|
+
const sent = await pair.call('invite_org_member', { org_id: A, email: 'jane@acme.test', role: 'agent' });
|
|
141
|
+
expect(sent.body.data).toMatchObject({ outcome: 'invited', email: 'jane@acme.test' });
|
|
142
|
+
expect(pair.questions[0].message).toContain('This will invite jane@acme.test to the organization as Agent.');
|
|
143
|
+
const invites = calls.filter((c) => c.url.includes('/project_users/invite'));
|
|
144
|
+
expect(invites.map((c) => new URL(c.url).pathname)).toEqual(['/api/p1/project_users/invite']);
|
|
145
|
+
await pair.close();
|
|
146
|
+
});
|
|
147
|
+
|
|
130
148
|
it('tells the host when the visible tools change', async () => {
|
|
131
149
|
vi.stubEnv('INDEMN_ORG_ID', B);
|
|
132
150
|
recordFetch([on('GET', '/auth/me/access', { json: accessWith(true) })]);
|