talentos 0.3.0 → 1.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.
package/dist/format.js ADDED
@@ -0,0 +1,54 @@
1
+ const WHITESPACE = /\s+/g;
2
+ const ANSI = {
3
+ bold: "",
4
+ dim: "",
5
+ reset: "",
6
+ };
7
+ function paint(code, text) {
8
+ return process.stdout.isTTY ? `${code}${text}${ANSI.reset}` : text;
9
+ }
10
+ export function bold(text) {
11
+ return paint(ANSI.bold, text);
12
+ }
13
+ export function dim(text) {
14
+ return paint(ANSI.dim, text);
15
+ }
16
+ /** Left-aligned columns padded to the widest cell; the last column is not padded. */
17
+ export function renderTable(headers, rows) {
18
+ if (rows.length === 0) {
19
+ return "";
20
+ }
21
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
22
+ const line = (cells) => cells
23
+ .map((cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index] ?? 0))
24
+ .join(" ")
25
+ .trimEnd();
26
+ return [dim(line(headers)), ...rows.map(line)].join("\n");
27
+ }
28
+ export function emit(text) {
29
+ process.stdout.write(`${text}\n`);
30
+ }
31
+ /** The app deep link a tool result carries; absent when the backend has no app url. */
32
+ export function emitLink(url) {
33
+ if (url) {
34
+ emit(dim(`Open: ${url}`));
35
+ }
36
+ }
37
+ /** Warnings go to stderr so `--json` on stdout stays machine-readable. */
38
+ export function warn(text) {
39
+ process.stderr.write(`${dim(text)}\n`);
40
+ }
41
+ export function emitJson(value) {
42
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
43
+ }
44
+ export function truncate(text, max) {
45
+ const flat = text.replace(WHITESPACE, " ").trim();
46
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}...`;
47
+ }
48
+ export function formatDate(value) {
49
+ if (value === undefined) {
50
+ return "";
51
+ }
52
+ const date = new Date(value);
53
+ return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
54
+ }
package/dist/index.js CHANGED
@@ -1,99 +1,147 @@
1
1
  #!/usr/bin/env node
2
- // Stdio MCP server wrapping the TalentOS external HTTP API — the MCP wiring
3
- // only. The tool handlers live in api.ts (pure fetch wrappers) so they
4
- // unit-test without this file's SDK dependencies.
5
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { z } from "zod";
8
- import { createSkill, getContext, getWorkflow, listNodes, listWorkflows, updateSkill, } from "./api.js";
9
- const TRAILING_SLASHES = /\/+$/;
10
- // The production TalentOS API origin. The server defaults to it so users only
11
- // need to supply TALENTOS_API_KEY; set TALENTOS_API_URL to override (e.g. a dev
12
- // deployment). Must be the .convex.site HTTP-action domain, not .convex.cloud.
13
- const DEFAULT_API_URL = "https://woozy-oyster-769.convex.site";
14
- const SKILL_SCHEMA = {
15
- instructions: z
16
- .string()
17
- .describe("Complete plain-text instructions for the Skill."),
18
- name: z.string().describe("Short title for the Skill."),
19
- requestedTools: z
20
- .array(z.string())
21
- .optional()
22
- .describe("Tool names exactly as the user named them."),
23
- successCriteria: z
24
- .string()
25
- .optional()
26
- .describe("How a reviewer knows the Skill did its job well."),
27
- summary: z.string().describe("One-sentence summary of what the Skill does."),
2
+ import { readFileSync } from "node:fs";
3
+ import { redactAgentOutput } from "./agent-link.js";
4
+ import { parseArgv } from "./args.js";
5
+ import { emit } from "./format.js";
6
+ const USAGE = `talentos — your team's skills, memory and Talent, from any coding agent.
7
+
8
+ Usage: talentos <command> [options]
9
+
10
+ Commands:
11
+ init Connect this machine's agents to TalentOS
12
+ agent <link|status|list|unlink|logs> Bring your own agent into Rooms
13
+ login Authorize this machine
14
+ logout Forget the key stored on this machine
15
+ whoami Show the member and org this key acts as
16
+ mcp Run the stdio MCP proxy (for stdio-only clients)
17
+ mcp install <target> [--apply] Configure one client, or print its snippet
18
+ skills <list|get|push|pull|share> Move skills; sharing stays in TalentOS
19
+ memory <search|add> Read and write personal and company memory
20
+ ask "<message>" Delegate work to your Talent
21
+ routines <list|run> See and run your routines
22
+
23
+ Options:
24
+ --json Print the raw JSON result
25
+ --api-url <url> Override the TalentOS API url
26
+ -h, --help Show help
27
+ -v, --version Show the version
28
+
29
+ Run \`talentos <command> --help\` for command options.`;
30
+ const GROUP_USAGE = {
31
+ agent: `talentos agent link --url <setup-url-with-#token> [--yes] [--name <device>]
32
+ talentos agent status | list [--json] [--agent <id>]
33
+ talentos agent unlink [name] [--agent <id>]
34
+ talentos agent logs [--agent <id>]
35
+ talentos agent serve --agent <id>
36
+
37
+ The link command checks the provider's CLI login, mints the key locally, and
38
+ installs one user service per agent. Only the key hash is sent to redeem.
39
+ Config is private under ~/.talentos/agents. Never print the setup fragment.
40
+ Serve is supplied by the S6 gateway release; Connected requires its heartbeat.`,
41
+ init: `talentos init [--for claude|cursor|codex|all] [--no-pull] [--name <device>]
42
+
43
+ One command: signs you in (skipped when your stored key still works), writes the
44
+ TalentOS MCP config for every coding agent on this machine at USER level, and
45
+ pulls the company's skills into ./.agents/skills (plus ./.claude/skills when
46
+ Claude Code is here). Nothing else is written inside the repo. Safe to re-run.
47
+
48
+ --for <agent> Configure these agents instead of the detected ones
49
+ --no-pull Configure the agents but do not write any skills
50
+ --name <device> Device name shown on the approval screen`,
51
+ login: `talentos login [--key <key>] [--api-url <url>] [--name <device>]
52
+
53
+ Mints a key on this machine, then asks you to approve it in TalentOS. The raw
54
+ key never leaves the machine; only its hash is registered.
55
+
56
+ --key <key> Store a key you already have instead of the approval flow
57
+ --name <device> Device name shown on the approval screen`,
58
+ logout: "talentos logout\n\nRemoves the stored key from this machine.",
59
+ whoami: "talentos whoami [--json]",
60
+ mcp: `talentos mcp
61
+ talentos mcp install <claude|cursor|codex|json> [--apply]
62
+
63
+ \`mcp\` runs a stdio MCP server that forwards to your TalentOS account.
64
+ \`mcp install\` prints the config snippet for a client; \`--apply\` writes it to
65
+ that client's user-level config (claude, cursor or codex).`,
66
+ skills: `talentos skills list [--mine|--company] [-q <query>]
67
+ talentos skills get <id|slug> [--out <dir>]
68
+ talentos skills push <SKILL.md|dir> [--id <skillId>]
69
+ talentos skills pull [--for claude|cursor|codex|all] [--global] [--to <dir>] [--mine|--company]
70
+ talentos skills share <id> Refuses: Share this Skill from TalentOS.
71
+
72
+ \`pull\` writes to ./.agents/skills by default; \`--for claude\` writes to
73
+ ./.claude/skills, \`--for all\` writes both, and \`--global\` puts them under your
74
+ home directory. An explicit \`--to\` wins.`,
75
+ memory: `talentos memory search "<query>" [--personal|--company]
76
+ talentos memory add "<fact>" [--company]`,
77
+ ask: `talentos ask "<message>" [--thread <id>] [--no-wait]
78
+
79
+ Waits for your Talent's reply (up to 10 minutes) unless --no-wait.`,
80
+ routines: `talentos routines list
81
+ talentos routines run <id>`,
28
82
  };
29
- async function main() {
30
- const config = loadConfig();
31
- const server = new McpServer({
32
- name: "talentos",
33
- version: "0.0.0",
34
- });
35
- server.registerTool("list_workflows", {
36
- title: "List workflows",
37
- description: "List the TalentOS workflows visible to this API key: published workflows plus drafts created by the key's owner. Returns status, title, id, step count, department, and description for each so you can pick one to fetch or update.",
38
- }, () => listWorkflows(config));
39
- server.registerTool("get_workflow", {
40
- title: "Get workflow",
41
- description: "Fetch a single visible TalentOS workflow as an agent-ready Markdown runbook. Pass the workflow id from list_workflows.",
42
- inputSchema: {
43
- workflowId: z
44
- .string()
45
- .describe("The workflow id, as returned by list_workflows."),
46
- },
47
- }, ({ workflowId }) => getWorkflow(config, workflowId));
48
- server.registerTool("list_nodes", {
49
- title: "List nodes",
50
- description: "List the org's company-wide TalentOS nodes (reusable, admin-promoted workflow steps). Returns name, id, step type, and summary for each.",
51
- }, () => listNodes(config));
52
- server.registerTool("get_context", {
53
- title: "Get org context",
54
- description: "One compact summary of the org this API key belongs to: company profile basics plus the names of its published workflows and nodes. Call this first to prime yourself.",
55
- }, () => getContext(config));
56
- server.registerTool("create_skill", {
57
- title: "Create Skill",
58
- description: "Create a new private TalentOS Skill from a name, summary, instructions, and success criteria. The Skill is private to the API key's owner, who curates it in TalentOS.",
59
- inputSchema: SKILL_SCHEMA,
60
- }, ({ instructions, name, requestedTools, successCriteria, summary }) => createSkill(config, {
61
- instructions,
62
- name,
63
- ...(requestedTools === undefined ? {} : { requestedTools }),
64
- ...(successCriteria === undefined ? {} : { successCriteria }),
65
- summary,
66
- }));
67
- server.registerTool("update_skill", {
68
- title: "Replace Skill",
69
- description: "Replace the API key owner's private TalentOS Skill with a complete name, summary, instructions, and success criteria.",
70
- inputSchema: {
71
- skillId: z.string().describe("The skill id from create_skill."),
72
- ...SKILL_SCHEMA,
73
- },
74
- }, ({ instructions, name, requestedTools, skillId, successCriteria, summary, }) => updateSkill(config, skillId, {
75
- instructions,
76
- name,
77
- ...(requestedTools === undefined ? {} : { requestedTools }),
78
- ...(successCriteria === undefined ? {} : { successCriteria }),
79
- summary,
80
- }));
81
- await server.connect(new StdioServerTransport());
83
+ async function main(argv) {
84
+ const parsed = parseArgv(argv);
85
+ if (parsed.options.version) {
86
+ emit(version());
87
+ return;
88
+ }
89
+ if (parsed.group === undefined || parsed.options.help) {
90
+ emit(helpFor(parsed.group));
91
+ return;
92
+ }
93
+ await dispatch(parsed);
82
94
  }
83
- function loadConfig() {
84
- const apiKey = process.env.TALENTOS_API_KEY?.trim();
85
- const rawUrl = process.env.TALENTOS_API_URL?.trim() || DEFAULT_API_URL;
86
- if (!apiKey) {
87
- exitWithConfigError("TALENTOS_API_KEY is not set. Create an API key in the TalentOS admin UI and pass it via the env block of your MCP client config.");
95
+ function helpFor(group) {
96
+ if (group === undefined) {
97
+ return USAGE;
88
98
  }
89
- return { apiKey, baseUrl: rawUrl.replace(TRAILING_SLASHES, "") };
99
+ return GROUP_USAGE[group] ?? USAGE;
90
100
  }
91
- function exitWithConfigError(message) {
92
- process.stderr.write(`[talentos-mcp] ${message}\n`);
93
- process.exit(1);
101
+ async function dispatch(parsed) {
102
+ switch (parsed.group) {
103
+ case "agent":
104
+ return await (await import("./commands/agent.js")).runAgent(parsed);
105
+ case "init":
106
+ return await (await import("./commands/init.js")).runInit(parsed);
107
+ case "login":
108
+ return await (await import("./commands/login.js")).runLogin(parsed);
109
+ case "logout":
110
+ return await (await import("./commands/login.js")).runLogout();
111
+ case "whoami":
112
+ return await (await import("./commands/whoami.js")).runWhoami(parsed);
113
+ case "mcp":
114
+ return await (await import("./commands/mcp.js")).runMcp(parsed);
115
+ case "skills":
116
+ return await (await import("./commands/skills.js")).runSkills(parsed);
117
+ case "memory":
118
+ return await (await import("./commands/memory.js")).runMemory(parsed);
119
+ case "ask":
120
+ return await (await import("./commands/ask.js")).runAsk(parsed);
121
+ case "routines":
122
+ return await (await import("./commands/routines.js")).runRoutines(parsed);
123
+ case "help": {
124
+ emit(helpFor(parsed.args[0]));
125
+ return;
126
+ }
127
+ default:
128
+ throw new Error(`Unknown command: ${parsed.group}. Run \`talentos --help\`.`);
129
+ }
130
+ }
131
+ function version() {
132
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
133
+ return manifest.version;
94
134
  }
95
- main().catch((error) => {
96
- const detail = error instanceof Error ? error.message : String(error);
97
- process.stderr.write(`[talentos-mcp] fatal: ${detail}\n`);
135
+ main(process.argv.slice(2)).catch((error) => {
136
+ const message = error instanceof Error ? error.message : String(error);
137
+ const fragments = process.argv.flatMap((arg) => {
138
+ try {
139
+ return new URLSearchParams(new URL(arg).hash.slice(1)).getAll("token");
140
+ }
141
+ catch {
142
+ return [];
143
+ }
144
+ });
145
+ process.stderr.write(`${redactAgentOutput(message, fragments).split("\n")[0]}\n`);
98
146
  process.exit(1);
99
147
  });
@@ -0,0 +1,253 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ export const AGENT_TARGETS = ["claude", "cursor", "codex"];
7
+ export const MCP_INSTALL_TARGETS = [...AGENT_TARGETS, "json"];
8
+ /** Cursor and Codex both auto-load `.agents/skills`; Claude Code reads its own. */
9
+ const AGENTS_SKILLS_DIR = ".agents/skills";
10
+ const CLAUDE_SKILLS_DIR = ".claude/skills";
11
+ const CODEX_HEADER = "[mcp_servers.talentos]";
12
+ const CODEX_SUBTABLE = "[mcp_servers.talentos.";
13
+ const TOML_HEADER_START = "[";
14
+ const TRAILING_NEWLINES = /\n*$/;
15
+ const CONFIG_MODE = 0o600;
16
+ const GENERIC_HINT = "Add to your client's MCP config:";
17
+ /** A dead MCP server makes `claude mcp list` hang; `init` must not hang with it. */
18
+ const CLAUDE_LIST_TIMEOUT_MS = 10_000;
19
+ /** One entry per agent, so nothing about a client is spread across the file. */
20
+ const AGENT_SPECS = {
21
+ claude: {
22
+ label: "Claude Code",
23
+ hint: "Run this once:",
24
+ detect: (probe) => probe.which("claude") || probe.exists(join(probe.home, ".claude")),
25
+ render: (credentials) => `claude mcp add --scope user --transport http talentos ${mcpUrl(credentials)} --header "Authorization: ${bearer(credentials)}"`,
26
+ skillDirs: [CLAUDE_SKILLS_DIR],
27
+ },
28
+ cursor: {
29
+ label: "Cursor",
30
+ hint: "Add to ~/.cursor/mcp.json:",
31
+ detect: (probe) => probe.exists(join(probe.home, ".cursor")) ||
32
+ probe.exists(join(probe.cwd, ".cursor")),
33
+ configPath: (home) => join(home, ".cursor", "mcp.json"),
34
+ render: (credentials) => JSON.stringify(cursorEntry(credentials), null, 2),
35
+ merge: (existing, credentials) => mergeCursorConfig(existing, credentials),
36
+ skillDirs: [AGENTS_SKILLS_DIR],
37
+ },
38
+ codex: {
39
+ label: "Codex",
40
+ hint: "Add to ~/.codex/config.toml:",
41
+ detect: (probe) => probe.exists(join(probe.home, ".codex")),
42
+ configPath: (home) => join(home, ".codex", "config.toml"),
43
+ // Codex speaks stdio only, so it launches this CLI as a proxy. The url
44
+ // travels with the key: a staging key must not reach production.
45
+ render: (credentials) => [
46
+ CODEX_HEADER,
47
+ 'command = "npx"',
48
+ 'args = ["-y", "talentos@1", "mcp"]',
49
+ `env = { TALENTOS_API_KEY = "${credentials.apiKey}", TALENTOS_API_URL = "${credentials.apiUrl}" }`,
50
+ ].join("\n"),
51
+ merge: (existing, credentials) => upsertCodexBlock(existing, credentials),
52
+ skillDirs: [AGENTS_SKILLS_DIR],
53
+ },
54
+ };
55
+ export function isMcpInstallTarget(value) {
56
+ return MCP_INSTALL_TARGETS.includes(value);
57
+ }
58
+ export function isAgentTarget(value) {
59
+ return AGENT_TARGETS.includes(value);
60
+ }
61
+ export function agentLabel(target) {
62
+ return AGENT_SPECS[target].label;
63
+ }
64
+ /** The agents a `--for` value stands for; `all` means every supported one. */
65
+ export function agentTargetsFor(value) {
66
+ if (value === "all") {
67
+ return [...AGENT_TARGETS];
68
+ }
69
+ if (isAgentTarget(value)) {
70
+ return [value];
71
+ }
72
+ throw new Error(`Unknown --for value: ${value}. Use ${AGENT_TARGETS.join(", ")} or all.`);
73
+ }
74
+ export function installHint(target) {
75
+ return isAgentTarget(target) ? AGENT_SPECS[target].hint : GENERIC_HINT;
76
+ }
77
+ export function buildInstallSnippet(target, credentials) {
78
+ return isAgentTarget(target)
79
+ ? AGENT_SPECS[target].render(credentials)
80
+ : JSON.stringify(cursorEntry(credentials), null, 2);
81
+ }
82
+ export function homeDir(env = process.env) {
83
+ // An empty HOME is unset; taking it literally would write the key into
84
+ // whatever directory the CLI was started from.
85
+ return env.HOME === undefined || env.HOME === "" ? homedir() : env.HOME;
86
+ }
87
+ export function defaultProbe(env = process.env) {
88
+ return {
89
+ home: homeDir(env),
90
+ cwd: process.cwd(),
91
+ exists: existsSync,
92
+ which: onPath,
93
+ };
94
+ }
95
+ export function detectAgents(probe) {
96
+ return AGENT_TARGETS.filter((target) => AGENT_SPECS[target].detect(probe));
97
+ }
98
+ /** Where `skills pull` writes: explicit `--to`, else the folders `--for` reads. */
99
+ export function pullDirs(targets) {
100
+ if (targets.to !== undefined) {
101
+ return [targets.to];
102
+ }
103
+ const dirs = skillDirNames(targets.target);
104
+ return dirs.map((dir) => targets.global ? join(targets.home, dir) : `./${dir}`);
105
+ }
106
+ export function mergeCursorConfig(existing, credentials) {
107
+ const config = existing === undefined ? {} : parseCursorConfig(existing);
108
+ const servers = config.mcpServers && typeof config.mcpServers === "object"
109
+ ? config.mcpServers
110
+ : {};
111
+ const next = {
112
+ ...config,
113
+ mcpServers: {
114
+ ...servers,
115
+ talentos: cursorEntry(credentials).mcpServers.talentos,
116
+ },
117
+ };
118
+ return `${JSON.stringify(next, null, 2)}\n`;
119
+ }
120
+ export function upsertCodexBlock(existing, credentials) {
121
+ const block = AGENT_SPECS.codex.render(credentials);
122
+ if (existing === undefined || existing.trim() === "") {
123
+ return `${block}\n`;
124
+ }
125
+ const lines = existing.split("\n");
126
+ const start = lines.findIndex((line) => line.trim() === CODEX_HEADER);
127
+ if (start === -1) {
128
+ return `${existing.replace(TRAILING_NEWLINES, "")}\n\n${block}\n`;
129
+ }
130
+ const rest = lines.slice(start + 1).findIndex(isForeignHeader);
131
+ const end = rest === -1 ? lines.length : start + 1 + rest;
132
+ const replacement = end === lines.length ? [block] : [block, ""];
133
+ return `${[...lines.slice(0, start), ...replacement, ...lines.slice(end)].join("\n").replace(TRAILING_NEWLINES, "")}\n`;
134
+ }
135
+ /**
136
+ * Writes the MCP config for one agent at USER level. Nothing lands inside the
137
+ * repo: a checked-in config would leak the member's key to the whole team.
138
+ */
139
+ export async function applyInstall(target, credentials, home = defaultProbe().home) {
140
+ const spec = AGENT_SPECS[target];
141
+ const path = spec.configPath?.(home);
142
+ if (path === undefined || spec.merge === undefined) {
143
+ return applyClaudeInstall(credentials);
144
+ }
145
+ const next = spec.merge(await readIfPresent(path), credentials);
146
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
147
+ await writeFile(path, next, { mode: CONFIG_MODE });
148
+ // writeFile's mode only applies when it creates the file, and this one holds
149
+ // the member's API key.
150
+ await chmod(path, CONFIG_MODE);
151
+ return { applied: true, detail: path };
152
+ }
153
+ /**
154
+ * On Windows `claude` is the `claude.cmd` npm shim, which only a shell can
155
+ * resolve — and Node joins argv with spaces in shell mode, so quote each one.
156
+ */
157
+ export function shellArgs(args, platform = process.platform) {
158
+ return platform === "win32" ? args.map((arg) => `"${arg}"`) : args;
159
+ }
160
+ function applyClaudeInstall(credentials) {
161
+ if (!onPath("claude")) {
162
+ return { applied: false, detail: "the `claude` command is not on PATH" };
163
+ }
164
+ const listed = claudeRun(["mcp", "list"], {
165
+ timeout: CLAUDE_LIST_TIMEOUT_MS,
166
+ });
167
+ // A timeout leaves `error` set: treat it as "not listed" rather than wait.
168
+ if (listed.error === undefined && listed.stdout?.includes("talentos")) {
169
+ // Re-adding on top of an existing entry keeps the stale key, so drop it.
170
+ claudeRun(["mcp", "remove", "--scope", "user", "talentos"]);
171
+ }
172
+ const added = claudeRun([
173
+ "mcp",
174
+ "add",
175
+ "--scope",
176
+ "user",
177
+ "--transport",
178
+ "http",
179
+ "talentos",
180
+ mcpUrl(credentials),
181
+ "--header",
182
+ `Authorization: ${bearer(credentials)}`,
183
+ ]);
184
+ if (added.status !== 0) {
185
+ throw new Error(firstLine(added.stderr) || "`claude mcp add` failed");
186
+ }
187
+ return { applied: true, detail: "claude mcp add --scope user" };
188
+ }
189
+ function claudeRun(args, options = {}) {
190
+ return spawnSync("claude", shellArgs(args), {
191
+ encoding: "utf8",
192
+ shell: process.platform === "win32",
193
+ windowsHide: true,
194
+ ...options,
195
+ });
196
+ }
197
+ function isForeignHeader(line) {
198
+ const trimmed = line.trimStart();
199
+ return (trimmed.startsWith(TOML_HEADER_START) && !trimmed.startsWith(CODEX_SUBTABLE));
200
+ }
201
+ function skillDirNames(target) {
202
+ if (target === undefined) {
203
+ return [AGENTS_SKILLS_DIR];
204
+ }
205
+ const dirs = agentTargetsFor(target).flatMap((agent) => AGENT_SPECS[agent].skillDirs);
206
+ return [...new Set(dirs)];
207
+ }
208
+ function parseCursorConfig(existing) {
209
+ try {
210
+ const parsed = JSON.parse(existing);
211
+ return parsed && typeof parsed === "object"
212
+ ? parsed
213
+ : {};
214
+ }
215
+ catch {
216
+ throw new Error("~/.cursor/mcp.json is not valid JSON. Fix it, then run this again.");
217
+ }
218
+ }
219
+ function cursorEntry(credentials) {
220
+ return {
221
+ mcpServers: {
222
+ talentos: {
223
+ url: mcpUrl(credentials),
224
+ headers: { Authorization: bearer(credentials) },
225
+ },
226
+ },
227
+ };
228
+ }
229
+ async function readIfPresent(path) {
230
+ try {
231
+ return await readFile(path, "utf8");
232
+ }
233
+ catch {
234
+ return;
235
+ }
236
+ }
237
+ function onPath(command) {
238
+ const probe = spawnSync(command, shellArgs(["--version"]), {
239
+ stdio: "ignore",
240
+ shell: process.platform === "win32",
241
+ windowsHide: true,
242
+ });
243
+ return probe.error === undefined && probe.status === 0;
244
+ }
245
+ function mcpUrl(credentials) {
246
+ return `${credentials.apiUrl}/mcp`;
247
+ }
248
+ function bearer(credentials) {
249
+ return `Bearer ${credentials.apiKey}`;
250
+ }
251
+ function firstLine(text) {
252
+ return text?.trim().split("\n")[0] ?? "";
253
+ }
package/dist/login.js ADDED
@@ -0,0 +1,86 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { hostname, userInfo } from "node:os";
4
+ export const KEY_PREFIX = "tos_";
5
+ const KEY_BYTES = 32;
6
+ const PREFIX_LENGTH = 8;
7
+ /** `tos_` plus 32 bytes of hex. */
8
+ const KEY_LENGTH = KEY_PREFIX.length + KEY_BYTES * 2;
9
+ /** The raw key is minted here and never leaves the machine; only its hash is sent. */
10
+ export function mintKey() {
11
+ const rawKey = `${KEY_PREFIX}${randomBytes(KEY_BYTES).toString("hex")}`;
12
+ return {
13
+ rawKey,
14
+ hashedKey: hashKey(rawKey),
15
+ keyPrefix: rawKey.slice(0, PREFIX_LENGTH),
16
+ };
17
+ }
18
+ /** Catches a truncated paste here, rather than as a 401 from the server. */
19
+ export function checkPastedKey(key) {
20
+ if (!key.startsWith(KEY_PREFIX) || key.length !== KEY_LENGTH) {
21
+ throw new Error(`That does not look like a TalentOS key: they start with \`${KEY_PREFIX}\` and are ${KEY_LENGTH} characters.`);
22
+ }
23
+ return key;
24
+ }
25
+ export function hashKey(rawKey) {
26
+ return createHash("sha256").update(rawKey).digest("hex");
27
+ }
28
+ export function defaultDeviceName() {
29
+ return `${hostname()} (${userInfo().username})`;
30
+ }
31
+ export async function startLogin(apiUrl, body) {
32
+ const response = await fetch(`${apiUrl}/agent/v1/login/start`, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify(body),
36
+ });
37
+ if (!response.ok) {
38
+ throw new Error(`Could not start login (HTTP ${response.status}).`);
39
+ }
40
+ return (await response.json());
41
+ }
42
+ export async function fetchLoginStatus(apiUrl, code, rawKey) {
43
+ const url = `${apiUrl}/agent/v1/login/status?code=${encodeURIComponent(code)}`;
44
+ const response = await fetch(url, {
45
+ headers: { Authorization: `Bearer ${rawKey}` },
46
+ });
47
+ if (!response.ok) {
48
+ throw new Error(`Could not check login status (HTTP ${response.status}).`);
49
+ }
50
+ const body = (await response.json());
51
+ return isLoginStatus(body.status) ? body.status : "unknown";
52
+ }
53
+ /** Best effort: a headless box has no browser and that is not an error. */
54
+ export function openBrowser(url) {
55
+ const [command, args] = browserCommand(process.platform, url);
56
+ try {
57
+ const child = spawn(command, args, {
58
+ detached: true,
59
+ stdio: "ignore",
60
+ windowsHide: true,
61
+ });
62
+ child.on("error", () => undefined);
63
+ child.unref();
64
+ }
65
+ catch {
66
+ // no browser on this machine; the printed URL is the fallback
67
+ }
68
+ }
69
+ export function browserCommand(platform, url) {
70
+ if (platform === "darwin") {
71
+ return ["open", [url]];
72
+ }
73
+ if (platform === "win32") {
74
+ // `start` is a cmd.exe builtin, not an executable, and its first argument
75
+ // is the window title — the empty one keeps the url out of that slot.
76
+ return ["cmd", ["/c", "start", "", url]];
77
+ }
78
+ return ["xdg-open", [url]];
79
+ }
80
+ function isLoginStatus(value) {
81
+ return (value === "pending" ||
82
+ value === "approved" ||
83
+ value === "denied" ||
84
+ value === "expired" ||
85
+ value === "unknown");
86
+ }
@@ -0,0 +1,17 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ /**
5
+ * stdio front end for clients that cannot speak streamable HTTP (Codex, older
6
+ * desktop builds). It forwards tool listing and calls verbatim so the tool set
7
+ * an agent sees over stdio is the one the remote server owns.
8
+ */
9
+ export async function startMcpProxy(client) {
10
+ const server = new Server({ name: "talentos", version: "1.0.0" }, { capabilities: { tools: {} } });
11
+ server.setRequestHandler(ListToolsRequestSchema, async () => await client.listTools());
12
+ server.setRequestHandler(CallToolRequestSchema, async (request) => await client.callTool({
13
+ name: request.params.name,
14
+ arguments: request.params.arguments ?? {},
15
+ }));
16
+ await server.connect(new StdioServerTransport());
17
+ }