@patimweb/pi-teams 1.0.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -0
  3. package/index.ts +1 -0
  4. package/package.json +61 -0
  5. package/prompts/teams-catch-up.md +24 -0
  6. package/prompts/teams-channel-digest.md +23 -0
  7. package/prompts/teams-doctor.md +21 -0
  8. package/prompts/teams-meeting-prep.md +22 -0
  9. package/prompts/teams-standup.md +21 -0
  10. package/prompts/teams-triage.md +22 -0
  11. package/skills/teams-collaboration/SKILL.md +143 -0
  12. package/src/auth/client-credentials.ts +72 -0
  13. package/src/auth/device-code.ts +276 -0
  14. package/src/auth/index.ts +105 -0
  15. package/src/auth/jwt.ts +50 -0
  16. package/src/auth/token-store.ts +156 -0
  17. package/src/config/index.ts +685 -0
  18. package/src/config/scope.ts +272 -0
  19. package/src/extension/index.ts +336 -0
  20. package/src/graph/calendar.ts +165 -0
  21. package/src/graph/chats.ts +159 -0
  22. package/src/graph/client.ts +234 -0
  23. package/src/graph/mappers.ts +268 -0
  24. package/src/graph/me.ts +80 -0
  25. package/src/graph/messages.ts +383 -0
  26. package/src/graph/presence.ts +160 -0
  27. package/src/graph/search.ts +111 -0
  28. package/src/graph/teams.ts +271 -0
  29. package/src/safety/audit.ts +57 -0
  30. package/src/safety/index.ts +186 -0
  31. package/src/status.ts +90 -0
  32. package/src/tools/resolve.ts +138 -0
  33. package/src/tools/shared.ts +124 -0
  34. package/src/tools/teams-accounts.ts +169 -0
  35. package/src/tools/teams-create-channel.ts +89 -0
  36. package/src/tools/teams-create-chat.ts +112 -0
  37. package/src/tools/teams-delete-message.ts +114 -0
  38. package/src/tools/teams-doctor.ts +182 -0
  39. package/src/tools/teams-find-user.ts +64 -0
  40. package/src/tools/teams-inbox.ts +142 -0
  41. package/src/tools/teams-list-channels.ts +62 -0
  42. package/src/tools/teams-list-chats.ts +74 -0
  43. package/src/tools/teams-list-files.ts +54 -0
  44. package/src/tools/teams-list-members.ts +72 -0
  45. package/src/tools/teams-list-teams.ts +65 -0
  46. package/src/tools/teams-login.ts +199 -0
  47. package/src/tools/teams-meetings.ts +342 -0
  48. package/src/tools/teams-permissions.ts +169 -0
  49. package/src/tools/teams-presence.ts +216 -0
  50. package/src/tools/teams-react.ts +109 -0
  51. package/src/tools/teams-read-channel.ts +75 -0
  52. package/src/tools/teams-read-chat.ts +73 -0
  53. package/src/tools/teams-read-thread.ts +88 -0
  54. package/src/tools/teams-search-messages.ts +71 -0
  55. package/src/tools/teams-send-channel-message.ts +250 -0
  56. package/src/tools/teams-send-chat-message.ts +152 -0
  57. package/src/tools/teams-setup.ts +245 -0
  58. package/src/tools/teams-status.ts +73 -0
  59. package/src/tools/tool-names.ts +30 -0
  60. package/src/types.ts +192 -0
  61. package/src/utils/errors.ts +133 -0
  62. package/src/utils/formatting.ts +356 -0
  63. package/tsconfig.json +16 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Scope rules — which teams, channels, chats and people pi may touch.
3
+ *
4
+ * Pure functions only. No I/O, no Graph calls — everything here is testable
5
+ * with plain objects, which matters because these rules are the last line of
6
+ * defence before pi speaks as the user.
7
+ *
8
+ * Semantics
9
+ * ---------
10
+ * - Rules exist per category (teams / channels / chats / people) and per mode
11
+ * (read / write).
12
+ * - `deny` accumulates across all levels (global → account → tenant) and always
13
+ * wins. A deny anywhere blocks the target.
14
+ * - `allow` narrows: the most specific level that defines a non-empty allow
15
+ * list wins. If no level defines one, everything the signed-in user can
16
+ * reach is allowed (`*`).
17
+ * - A target is matched against several candidate strings (id, display name,
18
+ * "Team/Channel" path, e-mail, UPN). A match on any candidate counts.
19
+ * - Patterns are case-insensitive globs: `*` matches any run of characters
20
+ * (including `/`), `?` matches exactly one. An `id:` prefix is stripped so
21
+ * `id:19:abc@thread.tacv2` reads naturally in a config file.
22
+ */
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Categories a rule can apply to. */
29
+ export type ScopeCategory = "teams" | "channels" | "chats" | "people";
30
+
31
+ /** Read or write access. */
32
+ export type ScopeMode = "read" | "write";
33
+
34
+ /** Allow/deny lists for a single category. */
35
+ export interface ScopeList {
36
+ allow?: string[];
37
+ deny?: string[];
38
+ }
39
+
40
+ /** Per-category rules. */
41
+ export interface ScopeRules {
42
+ teams?: ScopeList;
43
+ channels?: ScopeList;
44
+ chats?: ScopeList;
45
+ people?: ScopeList;
46
+ }
47
+
48
+ /**
49
+ * A permission block as written in pi-teams.json.
50
+ *
51
+ * Category keys at the top level apply to both modes; `read` and `write`
52
+ * blocks refine one mode. A `write` allow list overrides the shorthand allow
53
+ * list at the same level; deny lists from both are unioned.
54
+ */
55
+ export interface PermissionBlock extends ScopeRules {
56
+ read?: ScopeRules;
57
+ write?: ScopeRules;
58
+ }
59
+
60
+ /** Fully resolved rules for every category and mode. */
61
+ export type ResolvedPermissions = {
62
+ [M in ScopeMode]: { [C in ScopeCategory]: Required<ScopeList> };
63
+ };
64
+
65
+ /** The outcome of a scope check. */
66
+ export interface ScopeDecision {
67
+ allowed: boolean;
68
+ /** Which rule produced the decision, for error messages and `/teams-permissions`. */
69
+ reason: string;
70
+ /** The pattern that matched, when one did. */
71
+ pattern?: string;
72
+ }
73
+
74
+ export const SCOPE_CATEGORIES: ScopeCategory[] = ["teams", "channels", "chats", "people"];
75
+ export const SCOPE_MODES: ScopeMode[] = ["read", "write"];
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Glob matching
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** Escape regex metacharacters except `*` and `?`, which become wildcards. */
82
+ function globToRegExp(pattern: string): RegExp {
83
+ const escaped = pattern
84
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
85
+ .replace(/\*/g, "[\\s\\S]*")
86
+ .replace(/\?/g, "[\\s\\S]");
87
+ return new RegExp(`^${escaped}$`, "i");
88
+ }
89
+
90
+ /** Normalize a pattern or candidate: trim, drop an `id:` prefix, lowercase. */
91
+ function normalize(value: string): string {
92
+ return value.trim().replace(/^id:/i, "").toLowerCase();
93
+ }
94
+
95
+ /**
96
+ * Match a single pattern against a single candidate string.
97
+ * Exported for tests and for `teams_permissions` explanations.
98
+ */
99
+ export function matchesPattern(pattern: string, candidate: string): boolean {
100
+ const p = normalize(pattern);
101
+ const c = normalize(candidate);
102
+ if (!p || !c) return false;
103
+ if (p === "*") return true;
104
+ if (p === c) return true;
105
+ return globToRegExp(p).test(c);
106
+ }
107
+
108
+ /** Find the first pattern in `patterns` that matches any of `candidates`. */
109
+ export function findMatch(
110
+ patterns: readonly string[],
111
+ candidates: readonly string[],
112
+ ): string | undefined {
113
+ for (const pattern of patterns) {
114
+ for (const candidate of candidates) {
115
+ if (matchesPattern(pattern, candidate)) return pattern;
116
+ }
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Resolution
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function emptyList(): Required<ScopeList> {
126
+ return { allow: [], deny: [] };
127
+ }
128
+
129
+ /** Rules that allow everything — the default when nothing is configured. */
130
+ export function defaultPermissions(): ResolvedPermissions {
131
+ const forMode = () => ({
132
+ teams: { allow: ["*"], deny: [] as string[] },
133
+ channels: { allow: ["*"], deny: [] as string[] },
134
+ chats: { allow: ["*"], deny: [] as string[] },
135
+ people: { allow: ["*"], deny: [] as string[] },
136
+ });
137
+ return { read: forMode(), write: forMode() };
138
+ }
139
+
140
+ /**
141
+ * Collapse a list of permission blocks (least specific first) into resolved
142
+ * rules. Later blocks are more specific and win on `allow`; `deny` is unioned.
143
+ */
144
+ export function resolvePermissions(
145
+ blocks: readonly (PermissionBlock | undefined)[],
146
+ ): ResolvedPermissions {
147
+ const resolved: ResolvedPermissions = {
148
+ read: { teams: emptyList(), channels: emptyList(), chats: emptyList(), people: emptyList() },
149
+ write: { teams: emptyList(), channels: emptyList(), chats: emptyList(), people: emptyList() },
150
+ };
151
+
152
+ for (const block of blocks) {
153
+ if (!block) continue;
154
+
155
+ for (const mode of SCOPE_MODES) {
156
+ const modeRules = block[mode];
157
+
158
+ for (const category of SCOPE_CATEGORIES) {
159
+ const shorthand = block[category];
160
+ const specific = modeRules?.[category];
161
+ const target = resolved[mode][category];
162
+
163
+ // deny accumulates from every level and both shapes
164
+ for (const pattern of shorthand?.deny ?? []) {
165
+ if (!target.deny.includes(pattern)) target.deny.push(pattern);
166
+ }
167
+ for (const pattern of specific?.deny ?? []) {
168
+ if (!target.deny.includes(pattern)) target.deny.push(pattern);
169
+ }
170
+
171
+ // allow: the most specific non-empty list wins
172
+ if (specific?.allow && specific.allow.length > 0) {
173
+ target.allow = [...specific.allow];
174
+ } else if (shorthand?.allow && shorthand.allow.length > 0) {
175
+ target.allow = [...shorthand.allow];
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ // Nothing configured for a slot means "everything the user can reach"
182
+ for (const mode of SCOPE_MODES) {
183
+ for (const category of SCOPE_CATEGORIES) {
184
+ if (resolved[mode][category].allow.length === 0) {
185
+ resolved[mode][category].allow = ["*"];
186
+ }
187
+ }
188
+ }
189
+
190
+ return resolved;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Checking
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Decide whether a target may be read or written.
199
+ *
200
+ * @param permissions - resolved rules
201
+ * @param mode - read or write
202
+ * @param category - which rule set applies
203
+ * @param candidates - identifiers for the target (id, name, path, e-mail…);
204
+ * empty or blank entries are ignored
205
+ */
206
+ export function checkScope(
207
+ permissions: ResolvedPermissions,
208
+ mode: ScopeMode,
209
+ category: ScopeCategory,
210
+ candidates: readonly (string | undefined)[],
211
+ ): ScopeDecision {
212
+ const list = permissions[mode][category];
213
+ const values = candidates.filter((c): c is string => !!c && c.trim().length > 0);
214
+
215
+ if (values.length === 0) {
216
+ return { allowed: false, reason: `no identifier available to check against the ${category} rules` };
217
+ }
218
+
219
+ const denied = findMatch(list.deny, values);
220
+ if (denied) {
221
+ return {
222
+ allowed: false,
223
+ reason: `blocked by ${mode}.${category}.deny rule "${denied}"`,
224
+ pattern: denied,
225
+ };
226
+ }
227
+
228
+ const allowed = findMatch(list.allow, values);
229
+ if (allowed) {
230
+ return {
231
+ allowed: true,
232
+ reason: allowed === "*"
233
+ ? `allowed by ${mode}.${category} default (*)`
234
+ : `allowed by ${mode}.${category}.allow rule "${allowed}"`,
235
+ pattern: allowed,
236
+ };
237
+ }
238
+
239
+ return {
240
+ allowed: false,
241
+ reason:
242
+ `not covered by any ${mode}.${category}.allow rule ` +
243
+ `(configured: ${list.allow.join(", ") || "none"})`,
244
+ };
245
+ }
246
+
247
+ /** Convenience: keep only the entries a check lets through. */
248
+ export function filterAllowed<T>(
249
+ permissions: ResolvedPermissions,
250
+ mode: ScopeMode,
251
+ category: ScopeCategory,
252
+ items: readonly T[],
253
+ candidatesOf: (item: T) => (string | undefined)[],
254
+ ): T[] {
255
+ return items.filter((item) => checkScope(permissions, mode, category, candidatesOf(item)).allowed);
256
+ }
257
+
258
+ /** Render resolved rules as markdown — used by `teams_permissions` and the doctor. */
259
+ export function formatPermissions(permissions: ResolvedPermissions): string {
260
+ const lines: string[] = [];
261
+ for (const mode of SCOPE_MODES) {
262
+ lines.push(`**${mode === "read" ? "Read" : "Write"}**`);
263
+ for (const category of SCOPE_CATEGORIES) {
264
+ const list = permissions[mode][category];
265
+ const allow = list.allow.join(", ") || "none";
266
+ const deny = list.deny.length > 0 ? ` · deny: ${list.deny.join(", ")}` : "";
267
+ lines.push(`- ${category}: allow: ${allow}${deny}`);
268
+ }
269
+ lines.push("");
270
+ }
271
+ return lines.join("\n").trimEnd();
272
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * pi-teams extension entry point.
3
+ *
4
+ * Registers the tools, the status surfaces, the slash commands, and — most
5
+ * importantly — the `tool_call` interceptor that enforces the safety level
6
+ * before anything is said in the user's name.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { Box, Text } from "@earendil-works/pi-tui";
11
+
12
+ import {
13
+ ensureConfigTemplate,
14
+ getConfigPath,
15
+ tryResolveConnection,
16
+ type TeamsConnection,
17
+ } from "../config/index.ts";
18
+ import { buildConnectionCard, buildConnectionLabel, formatStatusText, type ConnectionCard } from "../status.ts";
19
+ import {
20
+ blockReason,
21
+ formatMutationSummary,
22
+ isMutationTool,
23
+ LOCAL_CONFIG_TOOLS,
24
+ } from "../safety/index.ts";
25
+
26
+ import { teamsSetupTool } from "../tools/teams-setup.ts";
27
+ import { teamsLoginTool, teamsLogoutTool } from "../tools/teams-login.ts";
28
+ import { teamsAccountsTool } from "../tools/teams-accounts.ts";
29
+ import { teamsStatusTool } from "../tools/teams-status.ts";
30
+ import { teamsDoctorTool } from "../tools/teams-doctor.ts";
31
+ import { teamsPermissionsTool } from "../tools/teams-permissions.ts";
32
+ import { teamsListTeamsTool } from "../tools/teams-list-teams.ts";
33
+ import { teamsListChannelsTool } from "../tools/teams-list-channels.ts";
34
+ import { teamsListMembersTool } from "../tools/teams-list-members.ts";
35
+ import { teamsFindUserTool } from "../tools/teams-find-user.ts";
36
+ import { teamsListChatsTool } from "../tools/teams-list-chats.ts";
37
+ import { teamsReadChatTool } from "../tools/teams-read-chat.ts";
38
+ import { teamsSendChatMessageTool } from "../tools/teams-send-chat-message.ts";
39
+ import { teamsCreateChatTool } from "../tools/teams-create-chat.ts";
40
+ import { teamsReadChannelTool } from "../tools/teams-read-channel.ts";
41
+ import { teamsReadThreadTool } from "../tools/teams-read-thread.ts";
42
+ import {
43
+ teamsReplyChannelMessageTool,
44
+ teamsSendChannelMessageTool,
45
+ } from "../tools/teams-send-channel-message.ts";
46
+ import { teamsCreateChannelTool } from "../tools/teams-create-channel.ts";
47
+ import { teamsListFilesTool } from "../tools/teams-list-files.ts";
48
+ import { teamsSearchMessagesTool } from "../tools/teams-search-messages.ts";
49
+ import { teamsReactTool } from "../tools/teams-react.ts";
50
+ import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
51
+ import { teamsInboxTool } from "../tools/teams-inbox.ts";
52
+ import {
53
+ teamsGetPresenceTool,
54
+ teamsSetPresenceTool,
55
+ teamsSetStatusMessageTool,
56
+ } from "../tools/teams-presence.ts";
57
+ import {
58
+ teamsCancelMeetingTool,
59
+ teamsCreateMeetingTool,
60
+ teamsGetMeetingTool,
61
+ teamsListMeetingsTool,
62
+ teamsUpdateMeetingTool,
63
+ } from "../tools/teams-meetings.ts";
64
+
65
+ /* eslint-disable @typescript-eslint/no-explicit-any */
66
+
67
+ /** Every tool, in the order they appear in the documentation. */
68
+ const tools = [
69
+ // Setup & session
70
+ teamsSetupTool,
71
+ teamsLoginTool,
72
+ teamsLogoutTool,
73
+ teamsAccountsTool,
74
+ teamsStatusTool,
75
+ teamsPermissionsTool,
76
+ teamsDoctorTool,
77
+ // Discovery
78
+ teamsListTeamsTool,
79
+ teamsListChannelsTool,
80
+ teamsListMembersTool,
81
+ teamsFindUserTool,
82
+ // Chats
83
+ teamsListChatsTool,
84
+ teamsReadChatTool,
85
+ teamsSendChatMessageTool,
86
+ teamsCreateChatTool,
87
+ // Channels
88
+ teamsReadChannelTool,
89
+ teamsReadThreadTool,
90
+ teamsSendChannelMessageTool,
91
+ teamsReplyChannelMessageTool,
92
+ teamsCreateChannelTool,
93
+ teamsListFilesTool,
94
+ // Cross-cutting
95
+ teamsSearchMessagesTool,
96
+ teamsReactTool,
97
+ teamsDeleteMessageTool,
98
+ teamsInboxTool,
99
+ // Presence
100
+ teamsGetPresenceTool,
101
+ teamsSetPresenceTool,
102
+ teamsSetStatusMessageTool,
103
+ // Calendar
104
+ teamsListMeetingsTool,
105
+ teamsGetMeetingTool,
106
+ teamsCreateMeetingTool,
107
+ teamsUpdateMeetingTool,
108
+ teamsCancelMeetingTool,
109
+ ];
110
+
111
+ export default function (pi: ExtensionAPI) {
112
+ // The session's default connection. Re-resolved when the config changes, so
113
+ // teams_setup takes effect without a restart.
114
+ let connection: TeamsConnection | undefined;
115
+
116
+ const refresh = (): TeamsConnection | undefined => {
117
+ connection = tryResolveConnection();
118
+ return connection;
119
+ };
120
+
121
+ // -----------------------------------------------------------------------
122
+ // Status card — rendered in the transcript, never sent to the model
123
+ // -----------------------------------------------------------------------
124
+
125
+ pi.registerEntryRenderer<ConnectionCard>("teams-connection", (entry, { expanded }, theme) => {
126
+ const card = entry.data;
127
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
128
+ box.addChild(new Text(theme.fg("accent", theme.bold("Microsoft Teams")), 0, 0));
129
+
130
+ if (!card) {
131
+ box.addChild(new Text(theme.fg("warning", "not configured"), 0, 0));
132
+ return box;
133
+ }
134
+
135
+ const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
136
+ box.addChild(new Text(theme.fg("dim", `${scope}${card.user ? ` · ${card.user}` : ""}`), 0, 0));
137
+ box.addChild(
138
+ new Text(
139
+ card.signedIn
140
+ ? theme.fg("muted", `safety: ${card.safetyLevel}`)
141
+ : theme.fg("warning", "not signed in — run /teams-login"),
142
+ 0,
143
+ 0,
144
+ ),
145
+ );
146
+ if (expanded) {
147
+ box.addChild(new Text(theme.fg("muted", formatStatusText(card)), 0, 0));
148
+ }
149
+ return box;
150
+ });
151
+
152
+ // -----------------------------------------------------------------------
153
+ // Commands
154
+ // -----------------------------------------------------------------------
155
+
156
+ pi.registerCommand("teams-status", {
157
+ description: "Show the current Microsoft Teams connection and permissions",
158
+ handler: async (_args, ctx) => {
159
+ const card = buildConnectionCard(refresh());
160
+ if (!card) {
161
+ ctx.ui.notify(`Teams: no configuration found. See ${getConfigPath()}`, "warning");
162
+ return;
163
+ }
164
+ pi.appendEntry<ConnectionCard>("teams-connection", card);
165
+ ctx.ui.setStatus("teams", ctx.ui.theme.fg("success", buildConnectionLabel(card)));
166
+ ctx.ui.notify(buildConnectionLabel(card), "info");
167
+ },
168
+ });
169
+
170
+ pi.registerCommand("teams-login", {
171
+ description: "Sign in to Microsoft Teams as yourself (device code flow)",
172
+ handler: async (args, ctx) => {
173
+ const account = args.trim();
174
+ if (!refresh()) {
175
+ ctx.ui.notify(
176
+ `Teams: no account configured yet. Use the teams_setup tool, or edit ${getConfigPath()}.`,
177
+ "warning",
178
+ );
179
+ return;
180
+ }
181
+ pi.sendUserMessage(
182
+ account
183
+ ? `Please use the teams_login tool to sign in to the Teams account "${account}".`
184
+ : "Please use the teams_login tool to sign me in to Microsoft Teams.",
185
+ { deliverAs: "steer" },
186
+ );
187
+ ctx.ui.notify("Starting Teams sign-in…", "info");
188
+ },
189
+ });
190
+
191
+ pi.registerCommand("teams-inbox", {
192
+ description: "Show what needs your attention in Microsoft Teams",
193
+ handler: async (_args, ctx) => {
194
+ if (!refresh()) {
195
+ ctx.ui.notify("Teams is not configured. Use the teams_setup tool first.", "error");
196
+ return;
197
+ }
198
+ pi.sendUserMessage(
199
+ "Please use teams_inbox to show me what needs my attention in Teams right now, and summarize it.",
200
+ { deliverAs: "steer" },
201
+ );
202
+ ctx.ui.notify("Checking Teams…", "info");
203
+ },
204
+ });
205
+
206
+ pi.registerCommand("teams-doctor", {
207
+ description: "Diagnose the Microsoft Teams configuration and sign-in",
208
+ handler: async (_args, ctx) => {
209
+ pi.sendUserMessage(
210
+ "Please run the teams_doctor tool and summarize what is wrong and how to fix it.",
211
+ { deliverAs: "steer" },
212
+ );
213
+ ctx.ui.notify("Running Teams doctor…", "info");
214
+ },
215
+ });
216
+
217
+ pi.registerCommand("teams-permissions", {
218
+ description: "Show what pi is allowed to do in Microsoft Teams",
219
+ handler: async (_args, ctx) => {
220
+ pi.sendUserMessage(
221
+ "Please run teams_permissions with action 'show' and explain in plain language what you may and may not do in Teams.",
222
+ { deliverAs: "steer" },
223
+ );
224
+ ctx.ui.notify("Reading Teams guardrails…", "info");
225
+ },
226
+ });
227
+
228
+ // -----------------------------------------------------------------------
229
+ // Session start
230
+ // -----------------------------------------------------------------------
231
+
232
+ pi.on("session_start", async (_event, ctx) => {
233
+ const created = ensureConfigTemplate();
234
+ refresh();
235
+
236
+ if (!connection) {
237
+ ctx.ui.notify(
238
+ created
239
+ ? `@patimweb/pi-teams: a config template was created at ${getConfigPath()} — add your Entra ID tenantId and clientId, or run the teams_setup tool.`
240
+ : `@patimweb/pi-teams: no usable Teams account configured. See ${getConfigPath()} or run the teams_setup tool.`,
241
+ "warning",
242
+ );
243
+ return;
244
+ }
245
+
246
+ const card = buildConnectionCard(connection);
247
+ if (!card) return;
248
+
249
+ ctx.ui.setStatus("teams", ctx.ui.theme.fg(card.signedIn ? "success" : "warning", buildConnectionLabel(card)));
250
+
251
+ if (!card.signedIn) {
252
+ ctx.ui.notify(
253
+ `@patimweb/pi-teams: account "${card.account}" is configured but not signed in. Run /teams-login.`,
254
+ "warning",
255
+ );
256
+ } else {
257
+ ctx.ui.notify(
258
+ `@patimweb/pi-teams loaded (${card.account}${card.user ? ` as ${card.user}` : ""}, safety: ${card.safetyLevel})`,
259
+ "info",
260
+ );
261
+ }
262
+
263
+ // Survives /reload, so the transcript keeps saying who pi is acting as.
264
+ const existing = ctx.sessionManager
265
+ .getEntries()
266
+ .some((entry: any) => entry.type === "custom" && entry.customType === "teams-connection");
267
+ if (!existing) pi.appendEntry<ConnectionCard>("teams-connection", card);
268
+ });
269
+
270
+ // -----------------------------------------------------------------------
271
+ // Tools
272
+ // -----------------------------------------------------------------------
273
+
274
+ for (const tool of tools) {
275
+ pi.registerTool({
276
+ name: tool.name,
277
+ label: tool.name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
278
+ description: tool.description,
279
+ parameters: tool.parameters as any,
280
+ promptSnippet: "promptSnippet" in tool ? (tool as any).promptSnippet : undefined,
281
+ promptGuidelines: "promptGuidelines" in tool ? (tool as any).promptGuidelines : undefined,
282
+ async execute(
283
+ toolCallId: string,
284
+ params: any,
285
+ signal: AbortSignal | undefined,
286
+ onUpdate: any,
287
+ ctx: any,
288
+ ) {
289
+ return (tool as any).execute(toolCallId, params, signal, onUpdate, {
290
+ cwd: ctx.cwd,
291
+ connection,
292
+ });
293
+ },
294
+ });
295
+ }
296
+
297
+ // -----------------------------------------------------------------------
298
+ // Safety interceptor
299
+ // -----------------------------------------------------------------------
300
+
301
+ pi.on("tool_call", async (event, ctx) => {
302
+ if (!isMutationTool(event.toolName)) return;
303
+
304
+ // A tool may target a different account than the session default, so the
305
+ // rules are resolved against the connection that will actually be used.
306
+ const params = (event.input ?? {}) as Record<string, unknown>;
307
+ const target =
308
+ tryResolveConnection(
309
+ typeof params.account === "string" ? params.account : undefined,
310
+ typeof params.tenant === "string" ? params.tenant : undefined,
311
+ ) ?? connection;
312
+
313
+ if (!target) {
314
+ // teams_setup has to work before anything is configured — that is what
315
+ // it is for.
316
+ if (LOCAL_CONFIG_TOOLS.has(event.toolName)) return;
317
+ return {
318
+ block: true,
319
+ reason: `Microsoft Teams is not configured. Run the teams_setup tool, or edit ${getConfigPath()}.`,
320
+ };
321
+ }
322
+
323
+ const blocked = blockReason(target.safetyLevel, target.authMode, event.toolName);
324
+ if (blocked) return { block: true, reason: blocked };
325
+
326
+ if (target.safetyLevel === "confirm") {
327
+ const scope = target.tenant === target.account ? target.account : `${target.account}/${target.tenant}`;
328
+ const summary = formatMutationSummary(event.toolName, params);
329
+ const approved = await ctx.ui.confirm(
330
+ "Microsoft Teams — acting as you",
331
+ `${summary}\n\nAccount: ${scope}\n\nAllow this?`,
332
+ );
333
+ if (!approved) return { block: true, reason: `User declined: ${summary}` };
334
+ }
335
+ });
336
+ }