@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.
- package/LICENSE +21 -0
- package/README.md +412 -0
- package/index.ts +1 -0
- package/package.json +61 -0
- package/prompts/teams-catch-up.md +24 -0
- package/prompts/teams-channel-digest.md +23 -0
- package/prompts/teams-doctor.md +21 -0
- package/prompts/teams-meeting-prep.md +22 -0
- package/prompts/teams-standup.md +21 -0
- package/prompts/teams-triage.md +22 -0
- package/skills/teams-collaboration/SKILL.md +143 -0
- package/src/auth/client-credentials.ts +72 -0
- package/src/auth/device-code.ts +276 -0
- package/src/auth/index.ts +105 -0
- package/src/auth/jwt.ts +50 -0
- package/src/auth/token-store.ts +156 -0
- package/src/config/index.ts +685 -0
- package/src/config/scope.ts +272 -0
- package/src/extension/index.ts +336 -0
- package/src/graph/calendar.ts +165 -0
- package/src/graph/chats.ts +159 -0
- package/src/graph/client.ts +234 -0
- package/src/graph/mappers.ts +268 -0
- package/src/graph/me.ts +80 -0
- package/src/graph/messages.ts +383 -0
- package/src/graph/presence.ts +160 -0
- package/src/graph/search.ts +111 -0
- package/src/graph/teams.ts +271 -0
- package/src/safety/audit.ts +57 -0
- package/src/safety/index.ts +186 -0
- package/src/status.ts +90 -0
- package/src/tools/resolve.ts +138 -0
- package/src/tools/shared.ts +124 -0
- package/src/tools/teams-accounts.ts +169 -0
- package/src/tools/teams-create-channel.ts +89 -0
- package/src/tools/teams-create-chat.ts +112 -0
- package/src/tools/teams-delete-message.ts +114 -0
- package/src/tools/teams-doctor.ts +182 -0
- package/src/tools/teams-find-user.ts +64 -0
- package/src/tools/teams-inbox.ts +142 -0
- package/src/tools/teams-list-channels.ts +62 -0
- package/src/tools/teams-list-chats.ts +74 -0
- package/src/tools/teams-list-files.ts +54 -0
- package/src/tools/teams-list-members.ts +72 -0
- package/src/tools/teams-list-teams.ts +65 -0
- package/src/tools/teams-login.ts +199 -0
- package/src/tools/teams-meetings.ts +342 -0
- package/src/tools/teams-permissions.ts +169 -0
- package/src/tools/teams-presence.ts +216 -0
- package/src/tools/teams-react.ts +109 -0
- package/src/tools/teams-read-channel.ts +75 -0
- package/src/tools/teams-read-chat.ts +73 -0
- package/src/tools/teams-read-thread.ts +88 -0
- package/src/tools/teams-search-messages.ts +71 -0
- package/src/tools/teams-send-channel-message.ts +250 -0
- package/src/tools/teams-send-chat-message.ts +152 -0
- package/src/tools/teams-setup.ts +245 -0
- package/src/tools/teams-status.ts +73 -0
- package/src/tools/tool-names.ts +30 -0
- package/src/types.ts +192 -0
- package/src/utils/errors.ts +133 -0
- package/src/utils/formatting.ts +356 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration — accounts, tenants, safety levels and scope rules.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth: `~/.pi/agent/pi-teams.json` (override the directory
|
|
5
|
+
* with PI_CODING_AGENT_DIR). No environment variables are read for account
|
|
6
|
+
* data, so what the file says is what pi may do.
|
|
7
|
+
*
|
|
8
|
+
* Shape
|
|
9
|
+
* -----
|
|
10
|
+
* accounts[] one entry per Teams identity you sign in as
|
|
11
|
+
* .tenants[] additional tenants that identity reaches
|
|
12
|
+
* (guest access, customer tenants, sub-companies)
|
|
13
|
+
*
|
|
14
|
+
* Both levels may narrow `safetyLevel` and `permissions`; the most specific
|
|
15
|
+
* setting wins for safety, and scope rules are layered global → account →
|
|
16
|
+
* tenant (see ./scope.ts).
|
|
17
|
+
*
|
|
18
|
+
* The file can hold a client secret, so it is written 0600 via an atomic
|
|
19
|
+
* temp-file rename — the same treatment the token cache gets.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
chmodSync,
|
|
28
|
+
statSync,
|
|
29
|
+
unlinkSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
} from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import {
|
|
35
|
+
defaultPermissions,
|
|
36
|
+
resolvePermissions,
|
|
37
|
+
type PermissionBlock,
|
|
38
|
+
type ResolvedPermissions,
|
|
39
|
+
} from "./scope.ts";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Types
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How pi obtains a token.
|
|
47
|
+
*
|
|
48
|
+
* - `device-code`: delegated. pi acts **as the signed-in user** — messages are
|
|
49
|
+
* sent from their account and everything they can see, pi can see.
|
|
50
|
+
* - `client-credentials`: app-only. No user identity; Microsoft Graph does not
|
|
51
|
+
* allow posting chat or channel messages this way (outside of migration
|
|
52
|
+
* scenarios), so writes are refused with an explanation.
|
|
53
|
+
* - `auto`: use `client-credentials` when a secret is configured, otherwise
|
|
54
|
+
* `device-code`.
|
|
55
|
+
*/
|
|
56
|
+
export type AuthMode = "device-code" | "client-credentials" | "auto";
|
|
57
|
+
|
|
58
|
+
/** Safety level for mutation operations. */
|
|
59
|
+
export type SafetyLevel = "open" | "confirm" | "readonly";
|
|
60
|
+
|
|
61
|
+
/** A tenant reachable by an account — guest access, a customer, a sub-company. */
|
|
62
|
+
export interface TenantConfig {
|
|
63
|
+
/** Short label used in tool parameters, e.g. "kunde-alpha" */
|
|
64
|
+
name: string;
|
|
65
|
+
/** Tenant ID or domain, e.g. "contoso.onmicrosoft.com" */
|
|
66
|
+
tenantId: string;
|
|
67
|
+
/** Client ID override (defaults to the account's) */
|
|
68
|
+
clientId?: string;
|
|
69
|
+
/** Client secret for app-only access in this tenant */
|
|
70
|
+
clientSecret?: string;
|
|
71
|
+
/** Auth mode override */
|
|
72
|
+
authMode?: AuthMode;
|
|
73
|
+
/** Requested Graph scopes override */
|
|
74
|
+
scopes?: string[];
|
|
75
|
+
/** Safety level override — most specific wins */
|
|
76
|
+
safetyLevel?: SafetyLevel;
|
|
77
|
+
/** Scope rules layered on top of the account's */
|
|
78
|
+
permissions?: PermissionBlock;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A Teams identity pi can sign in as. */
|
|
82
|
+
export interface AccountConfig {
|
|
83
|
+
/** Short label used in tool parameters, e.g. "neoimpulse" */
|
|
84
|
+
name: string;
|
|
85
|
+
/** Friendly name for status output */
|
|
86
|
+
displayName?: string;
|
|
87
|
+
/** Home tenant ID or domain */
|
|
88
|
+
tenantId: string;
|
|
89
|
+
/** Entra ID application (client) ID */
|
|
90
|
+
clientId: string;
|
|
91
|
+
/** Client secret — only for client-credentials mode */
|
|
92
|
+
clientSecret?: string;
|
|
93
|
+
/** Auth mode (default: auto) */
|
|
94
|
+
authMode?: AuthMode;
|
|
95
|
+
/** Requested Graph scopes (default: DEFAULT_SCOPES) */
|
|
96
|
+
scopes?: string[];
|
|
97
|
+
/** Safety level override */
|
|
98
|
+
safetyLevel?: SafetyLevel;
|
|
99
|
+
/** Scope rules layered on top of the global ones */
|
|
100
|
+
permissions?: PermissionBlock;
|
|
101
|
+
/** Additional tenants this identity reaches */
|
|
102
|
+
tenants?: TenantConfig[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Root shape of pi-teams.json. */
|
|
106
|
+
export interface TeamsRootConfig {
|
|
107
|
+
accounts: AccountConfig[];
|
|
108
|
+
/** Account used when a tool omits `account` (default: first) */
|
|
109
|
+
defaultAccount?: string;
|
|
110
|
+
/** Tenant used when a tool omits `tenant` (default: the account's home tenant) */
|
|
111
|
+
defaultTenant?: string;
|
|
112
|
+
/** Global safety level (default: confirm) */
|
|
113
|
+
safetyLevel?: SafetyLevel;
|
|
114
|
+
/** Global scope rules */
|
|
115
|
+
permissions?: PermissionBlock;
|
|
116
|
+
/** Default page size for message listings (default: 25) */
|
|
117
|
+
maxMessages?: number;
|
|
118
|
+
/** Append every write to ~/.pi/agent/pi-teams-audit.jsonl (default: true) */
|
|
119
|
+
audit?: boolean;
|
|
120
|
+
/** Graph base URL override (sovereign clouds) */
|
|
121
|
+
graphBaseUrl?: string;
|
|
122
|
+
/** Login authority host override (sovereign clouds) */
|
|
123
|
+
authorityHost?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A single resolved account+tenant connection. */
|
|
127
|
+
export interface TeamsConnection {
|
|
128
|
+
/** Account label */
|
|
129
|
+
account: string;
|
|
130
|
+
/** Friendly account name */
|
|
131
|
+
accountDisplayName: string;
|
|
132
|
+
/** Tenant label — equals the account label for the home tenant */
|
|
133
|
+
tenant: string;
|
|
134
|
+
/** Tenant ID or domain used as the authority */
|
|
135
|
+
tenantId: string;
|
|
136
|
+
clientId: string;
|
|
137
|
+
clientSecret?: string;
|
|
138
|
+
/** Effective auth mode — never "auto" after resolution */
|
|
139
|
+
authMode: "device-code" | "client-credentials";
|
|
140
|
+
scopes: string[];
|
|
141
|
+
safetyLevel: SafetyLevel;
|
|
142
|
+
permissions: ResolvedPermissions;
|
|
143
|
+
maxMessages: number;
|
|
144
|
+
audit: boolean;
|
|
145
|
+
graphBaseUrl: string;
|
|
146
|
+
authorityHost: string;
|
|
147
|
+
/** Every configured account, for multi-account awareness */
|
|
148
|
+
allAccounts: AccountConfig[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Thrown when the configuration cannot produce a usable connection. */
|
|
152
|
+
export class ConfigError extends Error {
|
|
153
|
+
readonly missing: string[];
|
|
154
|
+
constructor(missing: string[], message?: string) {
|
|
155
|
+
super(message ?? `Missing required Teams configuration: ${missing.join(", ")}`);
|
|
156
|
+
this.name = "ConfigError";
|
|
157
|
+
this.missing = missing;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Defaults
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Delegated scopes requested by default.
|
|
167
|
+
*
|
|
168
|
+
* `offline_access` is what makes the sign-in last: without it pi would have to
|
|
169
|
+
* ask for a device code again every hour.
|
|
170
|
+
*/
|
|
171
|
+
export const DEFAULT_SCOPES: string[] = [
|
|
172
|
+
"offline_access",
|
|
173
|
+
"openid",
|
|
174
|
+
"profile",
|
|
175
|
+
"User.Read",
|
|
176
|
+
"User.ReadBasic.All",
|
|
177
|
+
"Team.ReadBasic.All",
|
|
178
|
+
"Channel.ReadBasic.All",
|
|
179
|
+
"ChannelMessage.Read.All",
|
|
180
|
+
"ChannelMessage.Send",
|
|
181
|
+
"ChannelMember.Read.All",
|
|
182
|
+
"Chat.ReadWrite",
|
|
183
|
+
"ChatMessage.Send",
|
|
184
|
+
"Presence.ReadWrite",
|
|
185
|
+
"Presence.Read.All",
|
|
186
|
+
"Calendars.ReadWrite",
|
|
187
|
+
"OnlineMeetings.ReadWrite",
|
|
188
|
+
"Files.Read.All",
|
|
189
|
+
"Sites.Read.All",
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
/** Scope set for app-only tokens (the `.default` of the app registration). */
|
|
193
|
+
export const CLIENT_CREDENTIALS_SCOPE = "https://graph.microsoft.com/.default";
|
|
194
|
+
|
|
195
|
+
const DEFAULTS = {
|
|
196
|
+
safetyLevel: "confirm" as SafetyLevel,
|
|
197
|
+
authMode: "auto" as AuthMode,
|
|
198
|
+
maxMessages: 25,
|
|
199
|
+
audit: true,
|
|
200
|
+
graphBaseUrl: "https://graph.microsoft.com/v1.0",
|
|
201
|
+
authorityHost: "https://login.microsoftonline.com",
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const VALID_AUTH_MODES = new Set<string>(["device-code", "client-credentials", "auto"]);
|
|
205
|
+
const VALID_SAFETY_LEVELS = new Set<string>(["open", "confirm", "readonly"]);
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Paths
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
export function getAgentDir(): string {
|
|
212
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getConfigPath(): string {
|
|
216
|
+
return join(getAgentDir(), "pi-teams.json");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// File I/O
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export function readRootConfig(): TeamsRootConfig {
|
|
224
|
+
const configPath = getConfigPath();
|
|
225
|
+
if (!existsSync(configPath)) {
|
|
226
|
+
ensureConfigTemplate();
|
|
227
|
+
return { accounts: [] };
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
// Older versions may have written the file world-readable; it can hold a
|
|
231
|
+
// client secret, so tighten it whenever we touch it.
|
|
232
|
+
try {
|
|
233
|
+
const mode = statSync(configPath).mode & 0o777;
|
|
234
|
+
if (mode !== 0o600) chmodSync(configPath, 0o600);
|
|
235
|
+
} catch {
|
|
236
|
+
/* ignore */
|
|
237
|
+
}
|
|
238
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as TeamsRootConfig;
|
|
239
|
+
if (!parsed || typeof parsed !== "object") return { accounts: [] };
|
|
240
|
+
return { ...parsed, accounts: Array.isArray(parsed.accounts) ? parsed.accounts : [] };
|
|
241
|
+
} catch {
|
|
242
|
+
return { accounts: [] };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Write the config atomically with 0600 permissions. */
|
|
247
|
+
export function writeRootConfig(config: TeamsRootConfig): void {
|
|
248
|
+
const configPath = getConfigPath();
|
|
249
|
+
const dir = dirname(configPath);
|
|
250
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
251
|
+
|
|
252
|
+
const tmpPath = `${configPath}.${process.pid}.tmp`;
|
|
253
|
+
try {
|
|
254
|
+
writeFileSync(tmpPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
255
|
+
chmodSync(tmpPath, 0o600);
|
|
256
|
+
// renameSync is atomic within a filesystem, so a crash mid-write cannot
|
|
257
|
+
// leave a truncated config behind.
|
|
258
|
+
renameSync(tmpPath, configPath);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
try {
|
|
261
|
+
unlinkSync(tmpPath);
|
|
262
|
+
} catch {
|
|
263
|
+
/* ignore */
|
|
264
|
+
}
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Validation helpers
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
function validateAuthMode(value: string | undefined): AuthMode | undefined {
|
|
274
|
+
if (!value) return undefined;
|
|
275
|
+
const normalized = value.toLowerCase().trim();
|
|
276
|
+
return VALID_AUTH_MODES.has(normalized) ? (normalized as AuthMode) : undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function validateSafetyLevel(value: string | undefined): SafetyLevel | undefined {
|
|
280
|
+
if (!value) return undefined;
|
|
281
|
+
const normalized = value.toLowerCase().trim();
|
|
282
|
+
return VALID_SAFETY_LEVELS.has(normalized) ? (normalized as SafetyLevel) : undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Effective safety level: tenant > account > global > default.
|
|
287
|
+
* Exported for tests — the cascade is easy to get subtly wrong.
|
|
288
|
+
*/
|
|
289
|
+
export function resolveEffectiveSafetyLevel(
|
|
290
|
+
globalLevel: SafetyLevel | undefined,
|
|
291
|
+
account: AccountConfig | undefined,
|
|
292
|
+
tenant: TenantConfig | undefined,
|
|
293
|
+
): SafetyLevel {
|
|
294
|
+
return (
|
|
295
|
+
validateSafetyLevel(tenant?.safetyLevel) ??
|
|
296
|
+
validateSafetyLevel(account?.safetyLevel) ??
|
|
297
|
+
validateSafetyLevel(globalLevel) ??
|
|
298
|
+
DEFAULTS.safetyLevel
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Effective auth mode, resolving "auto" against the available credentials. */
|
|
303
|
+
export function resolveAuthMode(
|
|
304
|
+
account: AccountConfig | undefined,
|
|
305
|
+
tenant: TenantConfig | undefined,
|
|
306
|
+
hasSecret: boolean,
|
|
307
|
+
): "device-code" | "client-credentials" {
|
|
308
|
+
const configured =
|
|
309
|
+
validateAuthMode(tenant?.authMode) ??
|
|
310
|
+
validateAuthMode(account?.authMode) ??
|
|
311
|
+
DEFAULTS.authMode;
|
|
312
|
+
if (configured === "auto") return hasSecret ? "client-credentials" : "device-code";
|
|
313
|
+
return configured;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Lookup
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
export function findAccount(
|
|
321
|
+
config: TeamsRootConfig,
|
|
322
|
+
name: string | undefined,
|
|
323
|
+
): AccountConfig | undefined {
|
|
324
|
+
if (config.accounts.length === 0) return undefined;
|
|
325
|
+
if (!name) return config.accounts[0];
|
|
326
|
+
const lower = name.toLowerCase();
|
|
327
|
+
return config.accounts.find(
|
|
328
|
+
(a) =>
|
|
329
|
+
a.name?.toLowerCase() === lower ||
|
|
330
|
+
a.displayName?.toLowerCase() === lower ||
|
|
331
|
+
a.tenantId?.toLowerCase() === lower,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function findTenant(
|
|
336
|
+
account: AccountConfig,
|
|
337
|
+
name: string | undefined,
|
|
338
|
+
): TenantConfig | undefined {
|
|
339
|
+
if (!name) return undefined;
|
|
340
|
+
const lower = name.toLowerCase();
|
|
341
|
+
// The account label also addresses the home tenant.
|
|
342
|
+
if (account.name.toLowerCase() === lower) return undefined;
|
|
343
|
+
return (account.tenants ?? []).find(
|
|
344
|
+
(t) => t.name?.toLowerCase() === lower || t.tenantId?.toLowerCase() === lower,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Resolution
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Resolve one account+tenant into a usable connection.
|
|
354
|
+
*
|
|
355
|
+
* @throws {ConfigError} when no account matches or required fields are missing
|
|
356
|
+
*/
|
|
357
|
+
export function resolveConnection(
|
|
358
|
+
accountName?: string,
|
|
359
|
+
tenantName?: string,
|
|
360
|
+
root?: TeamsRootConfig,
|
|
361
|
+
): TeamsConnection {
|
|
362
|
+
const config = root ?? readRootConfig();
|
|
363
|
+
|
|
364
|
+
if (config.accounts.length === 0) {
|
|
365
|
+
throw new ConfigError(
|
|
366
|
+
["accounts"],
|
|
367
|
+
`No Teams accounts configured. Run the teams_setup tool, or edit ${getConfigPath()}.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const account = findAccount(config, accountName ?? config.defaultAccount);
|
|
372
|
+
if (!account) {
|
|
373
|
+
throw new ConfigError(
|
|
374
|
+
[`account "${accountName}"`],
|
|
375
|
+
`Account "${accountName}" not found. Available: ${config.accounts.map((a) => a.name).join(", ")}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const requestedTenant = tenantName ?? (accountName ? undefined : config.defaultTenant);
|
|
380
|
+
const tenant = findTenant(account, requestedTenant);
|
|
381
|
+
|
|
382
|
+
if (requestedTenant && !tenant && account.name.toLowerCase() !== requestedTenant.toLowerCase()) {
|
|
383
|
+
const available = [account.name, ...(account.tenants ?? []).map((t) => t.name)].join(", ");
|
|
384
|
+
throw new ConfigError(
|
|
385
|
+
[`tenant "${requestedTenant}"`],
|
|
386
|
+
`Tenant "${requestedTenant}" not found in account "${account.name}". Available: ${available}`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const missing: string[] = [];
|
|
391
|
+
const tenantId = tenant?.tenantId ?? account.tenantId;
|
|
392
|
+
const clientId = tenant?.clientId ?? account.clientId;
|
|
393
|
+
if (!tenantId) missing.push(`tenantId for account "${account.name}"`);
|
|
394
|
+
if (!clientId) missing.push(`clientId for account "${account.name}"`);
|
|
395
|
+
if (missing.length > 0) throw new ConfigError(missing);
|
|
396
|
+
|
|
397
|
+
const clientSecret = tenant?.clientSecret ?? account.clientSecret ?? undefined;
|
|
398
|
+
const authMode = resolveAuthMode(account, tenant, !!clientSecret);
|
|
399
|
+
|
|
400
|
+
if (authMode === "client-credentials" && !clientSecret) {
|
|
401
|
+
throw new ConfigError(
|
|
402
|
+
["clientSecret"],
|
|
403
|
+
`Account "${account.name}" is set to client-credentials but has no clientSecret.`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const scopes =
|
|
408
|
+
tenant?.scopes ??
|
|
409
|
+
account.scopes ??
|
|
410
|
+
(authMode === "client-credentials" ? [CLIENT_CREDENTIALS_SCOPE] : DEFAULT_SCOPES);
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
account: account.name,
|
|
414
|
+
accountDisplayName: account.displayName ?? account.name,
|
|
415
|
+
tenant: tenant?.name ?? account.name,
|
|
416
|
+
tenantId,
|
|
417
|
+
clientId,
|
|
418
|
+
clientSecret,
|
|
419
|
+
authMode,
|
|
420
|
+
scopes,
|
|
421
|
+
safetyLevel: resolveEffectiveSafetyLevel(config.safetyLevel, account, tenant),
|
|
422
|
+
permissions: resolvePermissions([config.permissions, account.permissions, tenant?.permissions]),
|
|
423
|
+
maxMessages: config.maxMessages ?? DEFAULTS.maxMessages,
|
|
424
|
+
audit: config.audit ?? DEFAULTS.audit,
|
|
425
|
+
graphBaseUrl: (config.graphBaseUrl ?? DEFAULTS.graphBaseUrl).replace(/\/+$/, ""),
|
|
426
|
+
authorityHost: (config.authorityHost ?? DEFAULTS.authorityHost).replace(/\/+$/, ""),
|
|
427
|
+
allAccounts: config.accounts,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function tryResolveConnection(
|
|
432
|
+
accountName?: string,
|
|
433
|
+
tenantName?: string,
|
|
434
|
+
): TeamsConnection | undefined {
|
|
435
|
+
try {
|
|
436
|
+
return resolveConnection(accountName, tenantName);
|
|
437
|
+
} catch {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Every configured account × tenant combination.
|
|
444
|
+
* Used by `teams_doctor` to validate the whole file at once.
|
|
445
|
+
*/
|
|
446
|
+
export function resolveAllConnections(): { connections: TeamsConnection[]; errors: string[] } {
|
|
447
|
+
const config = readRootConfig();
|
|
448
|
+
const errors: string[] = [];
|
|
449
|
+
const connections: TeamsConnection[] = [];
|
|
450
|
+
|
|
451
|
+
if (config.accounts.length === 0) {
|
|
452
|
+
errors.push("No accounts configured in pi-teams.json");
|
|
453
|
+
return { connections, errors };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const account of config.accounts) {
|
|
457
|
+
const tenantNames: (string | undefined)[] = [undefined, ...(account.tenants ?? []).map((t) => t.name)];
|
|
458
|
+
for (const tenantName of tenantNames) {
|
|
459
|
+
try {
|
|
460
|
+
connections.push(resolveConnection(account.name, tenantName, config));
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const label = tenantName ? `${account.name}/${tenantName}` : account.name;
|
|
463
|
+
errors.push(`${label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { connections, errors };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Default permissions, exported so callers never have to build them by hand. */
|
|
472
|
+
export { defaultPermissions };
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Mutations — used by teams_setup / teams_accounts
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
/** Insert or update an account, keeping existing tenants unless replaced. */
|
|
479
|
+
export function upsertAccount(account: AccountConfig): TeamsRootConfig {
|
|
480
|
+
const config = readRootConfig();
|
|
481
|
+
const index = config.accounts.findIndex((a) => a.name.toLowerCase() === account.name.toLowerCase());
|
|
482
|
+
|
|
483
|
+
if (index >= 0) {
|
|
484
|
+
const existing = config.accounts[index]!;
|
|
485
|
+
config.accounts[index] = {
|
|
486
|
+
...existing,
|
|
487
|
+
...account,
|
|
488
|
+
tenants: account.tenants ?? existing.tenants,
|
|
489
|
+
permissions: account.permissions ?? existing.permissions,
|
|
490
|
+
};
|
|
491
|
+
} else {
|
|
492
|
+
config.accounts.push(account);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!config.defaultAccount) config.defaultAccount = config.accounts[0]!.name;
|
|
496
|
+
if (!config.safetyLevel) config.safetyLevel = DEFAULTS.safetyLevel;
|
|
497
|
+
|
|
498
|
+
writeRootConfig(config);
|
|
499
|
+
return config;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Insert or update a tenant beneath an account. */
|
|
503
|
+
export function upsertTenant(accountName: string, tenant: TenantConfig): TeamsRootConfig {
|
|
504
|
+
const config = readRootConfig();
|
|
505
|
+
const account = config.accounts.find((a) => a.name.toLowerCase() === accountName.toLowerCase());
|
|
506
|
+
if (!account) {
|
|
507
|
+
throw new ConfigError(
|
|
508
|
+
[`account "${accountName}"`],
|
|
509
|
+
`Account "${accountName}" not found. Available: ${config.accounts.map((a) => a.name).join(", ") || "none"}`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
account.tenants = account.tenants ?? [];
|
|
514
|
+
const index = account.tenants.findIndex((t) => t.name.toLowerCase() === tenant.name.toLowerCase());
|
|
515
|
+
if (index >= 0) {
|
|
516
|
+
account.tenants[index] = { ...account.tenants[index]!, ...tenant };
|
|
517
|
+
} else {
|
|
518
|
+
account.tenants.push(tenant);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
writeRootConfig(config);
|
|
522
|
+
return config;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function removeAccount(name: string): boolean {
|
|
526
|
+
const config = readRootConfig();
|
|
527
|
+
const index = config.accounts.findIndex((a) => a.name.toLowerCase() === name.toLowerCase());
|
|
528
|
+
if (index < 0) return false;
|
|
529
|
+
config.accounts.splice(index, 1);
|
|
530
|
+
if (config.defaultAccount?.toLowerCase() === name.toLowerCase()) {
|
|
531
|
+
config.defaultAccount = config.accounts[0]?.name;
|
|
532
|
+
}
|
|
533
|
+
writeRootConfig(config);
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function removeTenant(accountName: string, tenantName: string): boolean {
|
|
538
|
+
const config = readRootConfig();
|
|
539
|
+
const account = config.accounts.find((a) => a.name.toLowerCase() === accountName.toLowerCase());
|
|
540
|
+
if (!account?.tenants) return false;
|
|
541
|
+
const index = account.tenants.findIndex((t) => t.name.toLowerCase() === tenantName.toLowerCase());
|
|
542
|
+
if (index < 0) return false;
|
|
543
|
+
account.tenants.splice(index, 1);
|
|
544
|
+
writeRootConfig(config);
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function setDefaultAccount(name: string, tenantName?: string): void {
|
|
549
|
+
const config = readRootConfig();
|
|
550
|
+
const account = config.accounts.find((a) => a.name.toLowerCase() === name.toLowerCase());
|
|
551
|
+
if (!account) {
|
|
552
|
+
throw new ConfigError(
|
|
553
|
+
[`account "${name}"`],
|
|
554
|
+
`Account "${name}" not found. Available: ${config.accounts.map((a) => a.name).join(", ") || "none"}`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
config.defaultAccount = account.name;
|
|
558
|
+
config.defaultTenant = tenantName;
|
|
559
|
+
writeRootConfig(config);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Set the safety level globally, on an account, or on a tenant. */
|
|
563
|
+
export function setSafetyLevel(
|
|
564
|
+
level: SafetyLevel,
|
|
565
|
+
accountName?: string,
|
|
566
|
+
tenantName?: string,
|
|
567
|
+
): void {
|
|
568
|
+
const config = readRootConfig();
|
|
569
|
+
|
|
570
|
+
if (!accountName) {
|
|
571
|
+
config.safetyLevel = level;
|
|
572
|
+
writeRootConfig(config);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const account = config.accounts.find((a) => a.name.toLowerCase() === accountName.toLowerCase());
|
|
577
|
+
if (!account) {
|
|
578
|
+
throw new ConfigError([`account "${accountName}"`], `Account "${accountName}" not found.`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!tenantName) {
|
|
582
|
+
account.safetyLevel = level;
|
|
583
|
+
} else {
|
|
584
|
+
const tenant = (account.tenants ?? []).find(
|
|
585
|
+
(t) => t.name.toLowerCase() === tenantName.toLowerCase(),
|
|
586
|
+
);
|
|
587
|
+
if (!tenant) {
|
|
588
|
+
throw new ConfigError(
|
|
589
|
+
[`tenant "${tenantName}"`],
|
|
590
|
+
`Tenant "${tenantName}" not found in account "${accountName}".`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
tenant.safetyLevel = level;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
writeRootConfig(config);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Replace the permission block globally, on an account, or on a tenant. */
|
|
600
|
+
export function setPermissionBlock(
|
|
601
|
+
permissions: PermissionBlock,
|
|
602
|
+
accountName?: string,
|
|
603
|
+
tenantName?: string,
|
|
604
|
+
): void {
|
|
605
|
+
const config = readRootConfig();
|
|
606
|
+
|
|
607
|
+
if (!accountName) {
|
|
608
|
+
config.permissions = permissions;
|
|
609
|
+
writeRootConfig(config);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const account = config.accounts.find((a) => a.name.toLowerCase() === accountName.toLowerCase());
|
|
614
|
+
if (!account) {
|
|
615
|
+
throw new ConfigError([`account "${accountName}"`], `Account "${accountName}" not found.`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (!tenantName) {
|
|
619
|
+
account.permissions = permissions;
|
|
620
|
+
} else {
|
|
621
|
+
const tenant = (account.tenants ?? []).find(
|
|
622
|
+
(t) => t.name.toLowerCase() === tenantName.toLowerCase(),
|
|
623
|
+
);
|
|
624
|
+
if (!tenant) {
|
|
625
|
+
throw new ConfigError(
|
|
626
|
+
[`tenant "${tenantName}"`],
|
|
627
|
+
`Tenant "${tenantName}" not found in account "${accountName}".`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
tenant.permissions = permissions;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
writeRootConfig(config);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// Template
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
const TEMPLATE_JSON = `{
|
|
641
|
+
"accounts": [
|
|
642
|
+
{
|
|
643
|
+
"name": "work",
|
|
644
|
+
"displayName": "Work account",
|
|
645
|
+
"tenantId": "contoso.onmicrosoft.com",
|
|
646
|
+
"clientId": "00000000-0000-0000-0000-000000000000",
|
|
647
|
+
"authMode": "device-code",
|
|
648
|
+
"safetyLevel": "confirm",
|
|
649
|
+
"permissions": {
|
|
650
|
+
"read": {
|
|
651
|
+
"teams": { "allow": ["*"] },
|
|
652
|
+
"chats": { "allow": ["*"] }
|
|
653
|
+
},
|
|
654
|
+
"write": {
|
|
655
|
+
"channels": { "allow": ["*"], "deny": ["*/Announcements"] },
|
|
656
|
+
"chats": { "allow": ["*"] },
|
|
657
|
+
"people": { "deny": [] }
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
"tenants": []
|
|
661
|
+
}
|
|
662
|
+
],
|
|
663
|
+
"defaultAccount": "work",
|
|
664
|
+
"safetyLevel": "confirm",
|
|
665
|
+
"maxMessages": 25,
|
|
666
|
+
"audit": true
|
|
667
|
+
}
|
|
668
|
+
`;
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Create `~/.pi/agent/pi-teams.json` from a template when it does not exist.
|
|
672
|
+
* Returns true when a file was created.
|
|
673
|
+
*/
|
|
674
|
+
export function ensureConfigTemplate(): boolean {
|
|
675
|
+
const configPath = getConfigPath();
|
|
676
|
+
if (existsSync(configPath)) return false;
|
|
677
|
+
try {
|
|
678
|
+
mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
679
|
+
writeFileSync(configPath, TEMPLATE_JSON, { encoding: "utf-8", mode: 0o600 });
|
|
680
|
+
chmodSync(configPath, 0o600);
|
|
681
|
+
return true;
|
|
682
|
+
} catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
}
|