@michaelschnyder/teams-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ import { SKILL_PLATFORMS, detectSkillPlatforms, findProjectRoot, installSkills, loadBundledSkills, lookupSkillPlatform, reinstallSkills, skillDestination, skillManifestFile, } from "../skills.js";
2
+ function resolveSkillPlatforms(input) {
3
+ if (input?.toLowerCase() === "all")
4
+ return [...SKILL_PLATFORMS];
5
+ if (input) {
6
+ const platform = lookupSkillPlatform(input);
7
+ if (!platform) {
8
+ throw new Error(`Unknown skill platform ${input}. Supported: ${SKILL_PLATFORMS.map(({ name }) => name).join(", ")}, all`);
9
+ }
10
+ return [platform];
11
+ }
12
+ const detected = detectSkillPlatforms(findProjectRoot());
13
+ if (!detected.length) {
14
+ throw new Error(`No agent environment detected. Specify one of: ${SKILL_PLATFORMS.map(({ name }) => name).join(", ")}, all`);
15
+ }
16
+ return detected;
17
+ }
18
+ export function registerSkillsCommand(program, storageRoot) {
19
+ const skills = program.command("skills").description("List and install built-in agent skills");
20
+ skills.command("list").description("List packaged teams-cli skills").action(async () => {
21
+ for (const skill of await loadBundledSkills()) {
22
+ process.stdout.write(`${skill.name}\t${skill.description}\n`);
23
+ }
24
+ });
25
+ skills.command("path")
26
+ .description("Show skill installation paths for a platform or detected environments")
27
+ .argument("[platform]", "Platform name or all")
28
+ .option("--project", "Use project-local scope instead of personal scope")
29
+ .action((platformName, options) => {
30
+ const projectRoot = findProjectRoot();
31
+ for (const platform of resolveSkillPlatforms(platformName)) {
32
+ process.stdout.write(`${platform.name}\t${skillDestination(platform, Boolean(options.project), projectRoot)}\n`);
33
+ }
34
+ });
35
+ skills.command("install")
36
+ .description("Install packaged skills for a platform or detected environments")
37
+ .argument("[platform]", "Platform name or all")
38
+ .option("--project", "Install into project-local scope")
39
+ .option("--name <name>", "Install only one named skill")
40
+ .option("--dir <path>", "Install into a custom parent directory")
41
+ .option("--force", "Overwrite existing managed skill files")
42
+ .action(async (platformName, options) => {
43
+ const projectRoot = findProjectRoot();
44
+ const destinations = options.dir
45
+ ? [options.dir]
46
+ : resolveSkillPlatforms(platformName).map((platform) => skillDestination(platform, Boolean(options.project), projectRoot));
47
+ const result = await installSkills({
48
+ destinations,
49
+ ...(options.name ? { names: [options.name] } : {}),
50
+ force: Boolean(options.force),
51
+ manifestFile: skillManifestFile(storageRoot),
52
+ });
53
+ if (!result.filesWritten) {
54
+ throw new Error("No skill files were written. Use --force to replace existing files");
55
+ }
56
+ for (const destination of result.destinations)
57
+ process.stdout.write(`${destination}\n`);
58
+ process.stdout.write(`Installed ${result.filesWritten} skill file${result.filesWritten === 1 ? "" : "s"}.\n`);
59
+ });
60
+ skills.command("reinstall")
61
+ .description("Refresh all recorded CLI-managed skill installations")
62
+ .action(async () => {
63
+ const result = await reinstallSkills(skillManifestFile(storageRoot));
64
+ if (!result.installations) {
65
+ process.stdout.write("No recorded skill installations.\n");
66
+ return;
67
+ }
68
+ process.stdout.write(`Reinstalled ${result.filesWritten} skill file${result.filesWritten === 1 ? "" : "s"} across ${result.installations} destination${result.installations === 1 ? "" : "s"}.\n`);
69
+ });
70
+ }
@@ -0,0 +1,16 @@
1
+ import { upgradeCli } from "../upgrade.js";
2
+ import { CLI_VERSION } from "../version.js";
3
+ export function registerVersionCommand(program) {
4
+ program.command("version")
5
+ .description("Show the installed version or upgrade the global npm installation")
6
+ .option("--upgrade", "Install the latest npm version and refresh managed skills")
7
+ .action(async (options) => {
8
+ if (!options.upgrade) {
9
+ process.stdout.write(`${CLI_VERSION}\n`);
10
+ return;
11
+ }
12
+ process.stderr.write("Upgrading teams-cli through npm…\n");
13
+ await upgradeCli();
14
+ process.stdout.write("teams-cli and recorded skill installations are up to date.\n");
15
+ });
16
+ }
package/dist/config.js ADDED
@@ -0,0 +1,106 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname } from "node:path";
4
+ import { stringify } from "yaml";
5
+ import { parseStrictYaml, rejectUnknownKeys, requireObject } from "./yaml.js";
6
+ function optionalString(value, field) {
7
+ if (value === undefined)
8
+ return undefined;
9
+ if (typeof value !== "string" || value.length === 0)
10
+ throw new Error(`${field} must be a non-empty string`);
11
+ return value;
12
+ }
13
+ function parseBrowser(value, field) {
14
+ if (value === undefined)
15
+ return undefined;
16
+ if (value !== "edge" && value !== "chrome")
17
+ throw new Error(`${field} must be edge or chrome`);
18
+ return value;
19
+ }
20
+ export function parseProfilesConfig(value) {
21
+ const root = requireObject(value, "Profiles configuration");
22
+ rejectUnknownKeys(root, ["version", "profiles"], "Profiles configuration");
23
+ if (root.version !== 1)
24
+ throw new Error("Profiles configuration version must be 1");
25
+ const rawProfiles = requireObject(root.profiles, "profiles");
26
+ const profiles = {};
27
+ for (const [name, raw] of Object.entries(rawProfiles)) {
28
+ if (!name.length)
29
+ throw new Error("Profile names must not be empty");
30
+ const profile = requireObject(raw, `Profile ${name}`);
31
+ rejectUnknownKeys(profile, ["tenantId", "userId", "username", "browser"], `Profile ${name}`);
32
+ const tenantId = optionalString(profile.tenantId, `Profile ${name}.tenantId`);
33
+ const userId = optionalString(profile.userId, `Profile ${name}.userId`);
34
+ const username = optionalString(profile.username, `Profile ${name}.username`);
35
+ const browser = parseBrowser(profile.browser, `Profile ${name}.browser`);
36
+ profiles[name] = {
37
+ ...(tenantId ? { tenantId } : {}),
38
+ ...(userId ? { userId } : {}),
39
+ ...(username ? { username } : {}),
40
+ ...(browser ? { browser } : {}),
41
+ };
42
+ }
43
+ return { version: 1, profiles };
44
+ }
45
+ export async function loadProfiles(paths) {
46
+ let raw;
47
+ try {
48
+ raw = await readFile(paths.configFile, "utf8");
49
+ }
50
+ catch (error) {
51
+ if (error.code === "ENOENT")
52
+ return { version: 1, profiles: {} };
53
+ throw error;
54
+ }
55
+ return parseProfilesConfig(parseStrictYaml(raw, paths.configFile));
56
+ }
57
+ export async function saveProfiles(paths, config) {
58
+ await mkdir(dirname(paths.configFile), { recursive: true, mode: 0o700 });
59
+ await chmod(dirname(paths.configFile), 0o700);
60
+ const temporary = `${paths.configFile}.${randomUUID()}.tmp`;
61
+ await writeFile(temporary, stringify(config), { mode: 0o600 });
62
+ await rename(temporary, paths.configFile);
63
+ await chmod(paths.configFile, 0o600);
64
+ }
65
+ export async function resolveRuntimeContext(paths, overrides, environment = process.env) {
66
+ const config = await loadProfiles(paths);
67
+ const profileName = overrides.profile ?? environment.TEAMS_CLI_PROFILE ?? "default";
68
+ const profile = config.profiles[profileName] ?? {};
69
+ const browserValue = overrides.browser ?? environment.TEAMS_CLI_BROWSER ?? profile.browser ?? "edge";
70
+ const browser = parseBrowser(browserValue, "Browser") ?? "edge";
71
+ const tenantId = overrides.tenant ?? environment.TEAMS_CLI_TENANT ?? profile.tenantId;
72
+ const userId = overrides.user ?? environment.TEAMS_CLI_USER ?? profile.userId;
73
+ return {
74
+ profileName,
75
+ ...(tenantId ? { tenantId } : {}),
76
+ ...(userId ? { userId } : {}),
77
+ ...(profile.username ? { username: profile.username } : {}),
78
+ browser,
79
+ };
80
+ }
81
+ export function requireRuntimeIdentity(context) {
82
+ if (!context.tenantId || !context.userId) {
83
+ throw new Error("Select a tenant and user with --tenant and --user, or configure a profile");
84
+ }
85
+ return { tenantId: context.tenantId, userId: context.userId };
86
+ }
87
+ export async function saveProfile(paths, name, profile) {
88
+ if (!name.length)
89
+ throw new Error("Profile name must not be empty");
90
+ const config = await loadProfiles(paths);
91
+ config.profiles[name] = profile;
92
+ await saveProfiles(paths, config);
93
+ }
94
+ export async function removeProfile(paths, name) {
95
+ const config = await loadProfiles(paths);
96
+ if (!(name in config.profiles))
97
+ return false;
98
+ delete config.profiles[name];
99
+ if (Object.keys(config.profiles).length === 0) {
100
+ await rm(paths.configFile, { force: true });
101
+ }
102
+ else {
103
+ await saveProfiles(paths, config);
104
+ }
105
+ return true;
106
+ }
@@ -0,0 +1,8 @@
1
+ export const TEAMS_CLIENT_ID = "5e3ce6c0-2b1f-4285-8d4b-75ee78787346";
2
+ export const SKYPE_RESOURCE = "https://api.spaces.skype.com";
3
+ export const CHAT_SVC_AGG_RESOURCE = "https://chatsvcagg.teams.microsoft.com";
4
+ export const OUTLOOK_SEARCH_RESOURCE = "https://outlook.office.com/search";
5
+ export const TEAMS_REDIRECT_URI = "https://teams.microsoft.com/go";
6
+ export const TEAMS_AUTHZ_URL = "https://teams.microsoft.com/api/authsvc/v1.0/authz";
7
+ export const TEAMS_WEB_ORIGIN = "https://teams.microsoft.com";
8
+ export const OUTLOOK_SEARCH_URL = "https://substrate.office.com/search/api/v1/suggestions";
package/dist/data.js ADDED
@@ -0,0 +1,56 @@
1
+ import { refreshTokens, } from "./auth.js";
2
+ import { debugDecision, setRequestAttempt, showStatus } from "./diagnostics.js";
3
+ import { secondsUntil } from "./jwt.js";
4
+ import { loadSession, requireCurrentSession, } from "./storage.js";
5
+ import { TeamsApiError } from "./teams-client.js";
6
+ const REFRESH_SKEW_SECONDS = 60;
7
+ function tokenForTarget(session, target) {
8
+ return target === "access"
9
+ ? session.accessToken
10
+ : target === "skype"
11
+ ? session.skypeToken
12
+ : target === "chat"
13
+ ? session.chatToken
14
+ : session.searchToken;
15
+ }
16
+ function label(target) {
17
+ return target === "skype" ? "Skype" : target === "chat" ? "Chat" : target === "search" ? "Search" : "access";
18
+ }
19
+ async function refreshTarget(paths, identity, browser, target) {
20
+ let session = requireCurrentSession(await loadSession(paths, identity));
21
+ if (target === "skype" &&
22
+ secondsUntil(session.accessToken.expiresAt) <= REFRESH_SKEW_SECONDS) {
23
+ showStatus("Refreshing access token…");
24
+ debugDecision("refresh token=access reason=Skype-prerequisite");
25
+ session = (await refreshTokens(paths, identity, "access", browser)).after;
26
+ }
27
+ showStatus(`Refreshing ${label(target)} token…`);
28
+ debugDecision(`refresh token=${target}`);
29
+ return (await refreshTokens(paths, identity, target, browser)).after;
30
+ }
31
+ async function prepareSession(paths, identity, browser, targets, force) {
32
+ let session = requireCurrentSession(await loadSession(paths, identity));
33
+ for (const target of targets) {
34
+ if (force || secondsUntil(tokenForTarget(session, target).expiresAt) <= REFRESH_SKEW_SECONDS) {
35
+ session = await refreshTarget(paths, identity, browser, target);
36
+ }
37
+ }
38
+ return session;
39
+ }
40
+ export async function withDataSession(paths, identity, browser, targets, operation) {
41
+ const required = typeof targets === "string" ? [targets] : [...targets];
42
+ const session = await prepareSession(paths, identity, browser, required, false);
43
+ setRequestAttempt(1);
44
+ try {
45
+ return await operation(session);
46
+ }
47
+ catch (error) {
48
+ if (!(error instanceof TeamsApiError) || (error.status !== 401 && error.status !== 403)) {
49
+ throw error;
50
+ }
51
+ debugDecision(`retry authenticationStatus=${error.status} attempt=2`);
52
+ const refreshed = await prepareSession(paths, identity, browser, required, true);
53
+ setRequestAttempt(2);
54
+ return operation(refreshed);
55
+ }
56
+ }
@@ -0,0 +1,60 @@
1
+ let options = { progress: false, debug: false };
2
+ let activeStatus = null;
3
+ let requestAttempt = 1;
4
+ export function configureDiagnostics(next) {
5
+ options = next;
6
+ activeStatus = null;
7
+ requestAttempt = 1;
8
+ }
9
+ export function setRequestAttempt(attempt) {
10
+ requestAttempt = attempt;
11
+ }
12
+ export function showStatus(message) {
13
+ if (!options.progress)
14
+ return;
15
+ activeStatus = message;
16
+ process.stderr.write(`\r\x1b[2K${message}`);
17
+ }
18
+ export function clearStatus() {
19
+ if (!options.progress || activeStatus === null)
20
+ return;
21
+ process.stderr.write("\r\x1b[2K");
22
+ activeStatus = null;
23
+ }
24
+ function sanitizedUrl(input) {
25
+ const url = new URL(input instanceof Request ? input.url : input.toString());
26
+ const parts = url.pathname.split("/").map((part, index, all) => all[index - 1] === "conversations" || all[index - 1] === "messages" || all[index - 1] === "users"
27
+ ? "<redacted>"
28
+ : part);
29
+ return `${url.origin}${parts.join("/")}`;
30
+ }
31
+ function debugLine(message) {
32
+ if (!options.debug)
33
+ return;
34
+ if (activeStatus !== null)
35
+ process.stderr.write("\r\x1b[2K");
36
+ process.stderr.write(`[debug] ${message}\n`);
37
+ if (activeStatus !== null)
38
+ process.stderr.write(activeStatus);
39
+ }
40
+ export function debugDecision(message) {
41
+ debugLine(message);
42
+ }
43
+ export async function observedFetch(implementation, input, init) {
44
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
45
+ const url = sanitizedUrl(input);
46
+ const started = performance.now();
47
+ debugLine(`request method=${method} url=${url} attempt=${requestAttempt}`);
48
+ try {
49
+ const response = await implementation(input, init);
50
+ debugLine(`response method=${method} url=${url} status=${response.status} durationMs=${Math.round(performance.now() - started)} attempt=${requestAttempt}`);
51
+ return response;
52
+ }
53
+ catch (error) {
54
+ const category = error instanceof DOMException && error.name === "AbortError"
55
+ ? "aborted"
56
+ : error instanceof TypeError ? "network" : "unknown";
57
+ debugLine(`error method=${method} url=${url} category=${category} durationMs=${Math.round(performance.now() - started)} attempt=${requestAttempt}`);
58
+ throw error;
59
+ }
60
+ }
package/dist/jwt.js ADDED
@@ -0,0 +1,42 @@
1
+ export function decodeJwtClaims(token) {
2
+ const payload = token.split(".")[1];
3
+ if (!payload)
4
+ throw new Error("The returned value is not a JWT");
5
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
6
+ if (!claims || typeof claims !== "object" || Array.isArray(claims)) {
7
+ throw new Error("The JWT payload is not a claims object");
8
+ }
9
+ return claims;
10
+ }
11
+ export function readJwtMetadata(token) {
12
+ const claims = decodeJwtClaims(token);
13
+ return {
14
+ ...(claims.aud ? { audience: claims.aud } : {}),
15
+ ...(claims.tid ? { tenantId: claims.tid } : {}),
16
+ ...(claims.oid ? { userId: claims.oid } : {}),
17
+ ...(claims.name ? { name: claims.name } : {}),
18
+ ...(claims.preferred_username || claims.upn
19
+ ? { username: claims.preferred_username ?? claims.upn }
20
+ : {}),
21
+ ...(claims.exp ? { expiresAt: new Date(claims.exp * 1000).toISOString() } : {}),
22
+ };
23
+ }
24
+ export function secondsUntil(expiresAt, now = new Date()) {
25
+ const milliseconds = new Date(expiresAt).getTime() - now.getTime();
26
+ if (!Number.isFinite(milliseconds))
27
+ throw new Error("Invalid token expiry date");
28
+ return Math.max(0, Math.ceil(milliseconds / 1000));
29
+ }
30
+ export function formatDuration(totalSeconds) {
31
+ const seconds = Math.max(0, Math.floor(totalSeconds));
32
+ const days = Math.floor(seconds / 86_400);
33
+ const hours = Math.floor((seconds % 86_400) / 3_600);
34
+ const minutes = Math.floor((seconds % 3_600) / 60);
35
+ const remainder = seconds % 60;
36
+ return [
37
+ ...(days ? [`${days}d`] : []),
38
+ ...(hours ? [`${hours}h`] : []),
39
+ ...(minutes ? [`${minutes}m`] : []),
40
+ ...(!days && !hours ? [`${remainder}s`] : []),
41
+ ].join(" ");
42
+ }
package/dist/oauth.js ADDED
@@ -0,0 +1,146 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chromium } from "playwright-core";
3
+ import { SKYPE_RESOURCE, TEAMS_CLIENT_ID, TEAMS_REDIRECT_URI, } from "./constants.js";
4
+ export function browserChannel(browser) {
5
+ return browser === "edge" ? "msedge" : "chrome";
6
+ }
7
+ export class OAuthRedirectError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = "OAuthRedirectError";
13
+ }
14
+ }
15
+ export function createResourceTokenUrl(resource, tenant = "organizations", prompt = "select_account") {
16
+ const url = new URL(`https://login.microsoftonline.com/${tenant}/oauth2/authorize`);
17
+ url.searchParams.set("client_id", TEAMS_CLIENT_ID);
18
+ url.searchParams.set("response_type", "token");
19
+ url.searchParams.set("redirect_uri", TEAMS_REDIRECT_URI);
20
+ url.searchParams.set("resource", resource);
21
+ url.searchParams.set("state", `${randomUUID()}|${resource}`);
22
+ url.searchParams.set("client-request-id", randomUUID());
23
+ url.searchParams.set("nonce", randomUUID());
24
+ url.searchParams.set("prompt", prompt);
25
+ return url.toString();
26
+ }
27
+ export function createInitialTokenUrl(tenant = "organizations") {
28
+ return createResourceTokenUrl(SKYPE_RESOURCE, tenant);
29
+ }
30
+ export function tokenFromRedirect(urlString) {
31
+ const url = new URL(urlString);
32
+ if (url.origin !== "https://teams.microsoft.com" || url.pathname !== "/go") {
33
+ return undefined;
34
+ }
35
+ const params = new URLSearchParams(url.hash.slice(1));
36
+ const error = params.get("error");
37
+ if (error) {
38
+ throw new OAuthRedirectError(error, params.get("error_description") ?? error);
39
+ }
40
+ return params.get("access_token") ?? undefined;
41
+ }
42
+ async function waitForToken(page, timeoutMs) {
43
+ return new Promise((resolve, reject) => {
44
+ const cleanup = () => {
45
+ clearTimeout(timer);
46
+ page.off("framenavigated", onNavigation);
47
+ page.off("close", onClose);
48
+ };
49
+ const timer = setTimeout(() => {
50
+ cleanup();
51
+ reject(new Error(`Login timed out after ${Math.round(timeoutMs / 1000)} seconds`));
52
+ }, timeoutMs);
53
+ const inspect = (url) => {
54
+ try {
55
+ const token = tokenFromRedirect(url);
56
+ if (token) {
57
+ cleanup();
58
+ resolve(token);
59
+ }
60
+ }
61
+ catch (error) {
62
+ cleanup();
63
+ reject(error);
64
+ }
65
+ };
66
+ const onNavigation = (frame) => {
67
+ if (frame === page.mainFrame())
68
+ inspect(frame.url());
69
+ };
70
+ const onClose = () => {
71
+ cleanup();
72
+ reject(new Error("Edge was closed before login completed"));
73
+ };
74
+ page.on("framenavigated", onNavigation);
75
+ page.on("close", onClose);
76
+ });
77
+ }
78
+ export async function completePasswordLogin(page, username, password, timeoutMs) {
79
+ const usernameInput = page.locator('input[name="loginfmt"], input[type="email"]').first();
80
+ await usernameInput.waitFor({ state: "visible", timeout: timeoutMs });
81
+ await usernameInput.fill(username);
82
+ await page.locator('#idSIButton9, input[type="submit"], button[type="submit"]').first().click();
83
+ const passwordInput = page.locator('input[name="passwd"], input[type="password"]').first();
84
+ await passwordInput.waitFor({ state: "visible", timeout: timeoutMs });
85
+ await passwordInput.fill(password);
86
+ await page.locator('#idSIButton9, input[type="submit"], button[type="submit"]').first().click();
87
+ const staySignedIn = page.locator('input[type="submit"][value="Yes"], button:has-text("Yes")').first();
88
+ try {
89
+ await staySignedIn.waitFor({ state: "visible", timeout: 5_000 });
90
+ await staySignedIn.click();
91
+ }
92
+ catch {
93
+ // The tenant may skip the stay-signed-in prompt.
94
+ }
95
+ }
96
+ export async function acquireInitialToken(options) {
97
+ const acquired = await acquireResourceTokens([SKYPE_RESOURCE], options);
98
+ const token = acquired.tokens.get(SKYPE_RESOURCE);
99
+ if (!token) {
100
+ await acquired.close();
101
+ throw new Error("Microsoft login returned no Skype resource token");
102
+ }
103
+ return { close: acquired.close, token };
104
+ }
105
+ export async function acquireResourceTokens(resources, options) {
106
+ if (resources.length === 0)
107
+ throw new Error("At least one OAuth resource is required");
108
+ let context;
109
+ try {
110
+ context = await chromium.launchPersistentContext(options.profileDirectory, {
111
+ channel: browserChannel(options.browser),
112
+ headless: options.headless ?? !options.interactive,
113
+ viewport: null,
114
+ args: ["--no-first-run"],
115
+ });
116
+ }
117
+ catch (error) {
118
+ const label = options.browser === "edge" ? "Microsoft Edge" : "Google Chrome";
119
+ const message = error instanceof Error ? error.message : String(error);
120
+ throw new Error(`Could not launch ${label}. Confirm it is installed and available: ${message}`);
121
+ }
122
+ const close = () => context.close();
123
+ const page = context.pages()[0] ?? (await context.newPage());
124
+ try {
125
+ const tokens = new Map();
126
+ for (const [index, resource] of resources.entries()) {
127
+ const tokenPromise = waitForToken(page, options.timeoutMs ?? 5 * 60_000);
128
+ try {
129
+ await page.goto(createResourceTokenUrl(resource, options.tenant, options.interactive && index === 0 ? "select_account" : "none"), { waitUntil: "domcontentloaded" });
130
+ if (index === 0 && options.username && options.password) {
131
+ await completePasswordLogin(page, options.username, options.password, options.timeoutMs ?? 5 * 60_000);
132
+ }
133
+ tokens.set(resource, await tokenPromise);
134
+ }
135
+ catch (error) {
136
+ await tokenPromise.catch(() => undefined);
137
+ throw error;
138
+ }
139
+ }
140
+ return { close, tokens };
141
+ }
142
+ catch (error) {
143
+ await close();
144
+ throw error;
145
+ }
146
+ }