talentos 0.2.0 → 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/README.md +114 -104
- package/dist/args.js +49 -0
- package/dist/client.js +87 -0
- package/dist/commands/ask.js +74 -0
- package/dist/commands/init.js +98 -0
- package/dist/commands/login.js +125 -0
- package/dist/commands/mcp.js +48 -0
- package/dist/commands/memory.js +61 -0
- package/dist/commands/routines.js +44 -0
- package/dist/commands/skills.js +183 -0
- package/dist/commands/whoami.js +29 -0
- package/dist/config.js +70 -0
- package/dist/format.js +54 -0
- package/dist/index.js +118 -151
- package/dist/install.js +253 -0
- package/dist/login.js +86 -0
- package/dist/mcp-proxy.js +17 -0
- package/dist/skill-files.js +109 -0
- package/package.json +13 -6
- package/dist/api.js +0 -209
package/dist/index.js
CHANGED
|
@@ -1,158 +1,125 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
server.registerTool("list_workflows", {
|
|
83
|
-
title: "List workflows",
|
|
84
|
-
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.",
|
|
85
|
-
}, () => listWorkflows(config));
|
|
86
|
-
server.registerTool("get_workflow", {
|
|
87
|
-
title: "Get workflow",
|
|
88
|
-
description: "Fetch a single visible TalentOS workflow as an agent-ready Markdown runbook. Pass the workflow id from list_workflows.",
|
|
89
|
-
inputSchema: {
|
|
90
|
-
workflowId: z
|
|
91
|
-
.string()
|
|
92
|
-
.describe("The workflow id, as returned by list_workflows."),
|
|
93
|
-
},
|
|
94
|
-
}, ({ workflowId }) => getWorkflow(config, workflowId));
|
|
95
|
-
server.registerTool("list_nodes", {
|
|
96
|
-
title: "List nodes",
|
|
97
|
-
description: "List the org's company-wide TalentOS nodes (reusable, admin-promoted workflow steps). Returns name, id, step type, and summary for each.",
|
|
98
|
-
}, () => listNodes(config));
|
|
99
|
-
server.registerTool("get_context", {
|
|
100
|
-
title: "Get org context",
|
|
101
|
-
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.",
|
|
102
|
-
}, () => getContext(config));
|
|
103
|
-
server.registerTool("create_workflow", {
|
|
104
|
-
title: "Create workflow draft",
|
|
105
|
-
description: "Create a new TalentOS workflow draft from a full title/steps/connections plan. Steps may include agent contract fields: outputs, successCriteria, and runtime. Writes always create drafts that a human publishes in the app.",
|
|
106
|
-
inputSchema: {
|
|
107
|
-
title: z.string().describe("Short name for the whole workflow."),
|
|
108
|
-
steps: z
|
|
109
|
-
.array(WORKFLOW_STEP_SCHEMA)
|
|
110
|
-
.describe("The ordered workflow steps to materialize."),
|
|
111
|
-
connections: z
|
|
112
|
-
.array(WORKFLOW_CONNECTION_SCHEMA)
|
|
113
|
-
.optional()
|
|
114
|
-
.describe("Directed edges between the steps. Omit for no edges."),
|
|
115
|
-
},
|
|
116
|
-
}, ({ connections, steps, title }) => createWorkflow(config, {
|
|
117
|
-
connections: connections ?? [],
|
|
118
|
-
steps,
|
|
119
|
-
title,
|
|
120
|
-
}));
|
|
121
|
-
server.registerTool("update_workflow", {
|
|
122
|
-
title: "Replace workflow draft",
|
|
123
|
-
description: "Replace an existing TalentOS workflow draft with a full title/steps/connections plan. Steps may include agent contract fields: outputs, successCriteria, and runtime. Drafts only: updating a published workflow returns a clear conflict message.",
|
|
124
|
-
inputSchema: {
|
|
125
|
-
workflowId: z.string().describe("The workflow id from list_workflows."),
|
|
126
|
-
title: z.string().describe("Short name for the whole workflow."),
|
|
127
|
-
steps: z
|
|
128
|
-
.array(WORKFLOW_STEP_SCHEMA)
|
|
129
|
-
.describe("The complete replacement step list."),
|
|
130
|
-
connections: z
|
|
131
|
-
.array(WORKFLOW_CONNECTION_SCHEMA)
|
|
132
|
-
.optional()
|
|
133
|
-
.describe("The complete replacement edge list. Omit for no edges."),
|
|
134
|
-
},
|
|
135
|
-
}, ({ connections, steps, title, workflowId }) => updateWorkflow(config, workflowId, {
|
|
136
|
-
connections: connections ?? [],
|
|
137
|
-
steps,
|
|
138
|
-
title,
|
|
139
|
-
}));
|
|
140
|
-
await server.connect(new StdioServerTransport());
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parseArgv } from "./args.js";
|
|
4
|
+
import { emit } from "./format.js";
|
|
5
|
+
const USAGE = `talentos — your team's skills, memory and Talent, from any coding agent.
|
|
6
|
+
|
|
7
|
+
Usage: talentos <command> [options]
|
|
8
|
+
|
|
9
|
+
Commands:
|
|
10
|
+
init Connect this machine's agents to TalentOS
|
|
11
|
+
login Authorize this machine
|
|
12
|
+
logout Forget the key stored on this machine
|
|
13
|
+
whoami Show the member and org this key acts as
|
|
14
|
+
mcp Run the stdio MCP proxy (for stdio-only clients)
|
|
15
|
+
mcp install <target> [--apply] Configure one client, or print its snippet
|
|
16
|
+
skills <list|get|push|pull|share> Move skills; sharing stays in TalentOS
|
|
17
|
+
memory <search|add> Read and write personal and company memory
|
|
18
|
+
ask "<message>" Delegate work to your Talent
|
|
19
|
+
routines <list|run> See and run your routines
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--json Print the raw JSON result
|
|
23
|
+
--api-url <url> Override the TalentOS API url
|
|
24
|
+
-h, --help Show help
|
|
25
|
+
-v, --version Show the version
|
|
26
|
+
|
|
27
|
+
Run \`talentos <command> --help\` for command options.`;
|
|
28
|
+
const GROUP_USAGE = {
|
|
29
|
+
init: `talentos init [--for claude|cursor|codex|all] [--no-pull] [--name <device>]
|
|
30
|
+
|
|
31
|
+
One command: signs you in (skipped when your stored key still works), writes the
|
|
32
|
+
TalentOS MCP config for every coding agent on this machine at USER level, and
|
|
33
|
+
pulls the company's skills into ./.agents/skills (plus ./.claude/skills when
|
|
34
|
+
Claude Code is here). Nothing else is written inside the repo. Safe to re-run.
|
|
35
|
+
|
|
36
|
+
--for <agent> Configure these agents instead of the detected ones
|
|
37
|
+
--no-pull Configure the agents but do not write any skills
|
|
38
|
+
--name <device> Device name shown on the approval screen`,
|
|
39
|
+
login: `talentos login [--key <key>] [--api-url <url>] [--name <device>]
|
|
40
|
+
|
|
41
|
+
Mints a key on this machine, then asks you to approve it in TalentOS. The raw
|
|
42
|
+
key never leaves the machine; only its hash is registered.
|
|
43
|
+
|
|
44
|
+
--key <key> Store a key you already have instead of the approval flow
|
|
45
|
+
--name <device> Device name shown on the approval screen`,
|
|
46
|
+
logout: "talentos logout\n\nRemoves the stored key from this machine.",
|
|
47
|
+
whoami: "talentos whoami [--json]",
|
|
48
|
+
mcp: `talentos mcp
|
|
49
|
+
talentos mcp install <claude|cursor|codex|json> [--apply]
|
|
50
|
+
|
|
51
|
+
\`mcp\` runs a stdio MCP server that forwards to your TalentOS account.
|
|
52
|
+
\`mcp install\` prints the config snippet for a client; \`--apply\` writes it to
|
|
53
|
+
that client's user-level config (claude, cursor or codex).`,
|
|
54
|
+
skills: `talentos skills list [--mine|--company] [-q <query>]
|
|
55
|
+
talentos skills get <id|slug> [--out <dir>]
|
|
56
|
+
talentos skills push <SKILL.md|dir> [--id <skillId>]
|
|
57
|
+
talentos skills pull [--for claude|cursor|codex|all] [--global] [--to <dir>] [--mine|--company]
|
|
58
|
+
talentos skills share <id> Refuses: Share this Skill from TalentOS.
|
|
59
|
+
|
|
60
|
+
\`pull\` writes to ./.agents/skills by default; \`--for claude\` writes to
|
|
61
|
+
./.claude/skills, \`--for all\` writes both, and \`--global\` puts them under your
|
|
62
|
+
home directory. An explicit \`--to\` wins.`,
|
|
63
|
+
memory: `talentos memory search "<query>" [--personal|--company]
|
|
64
|
+
talentos memory add "<fact>" [--company]`,
|
|
65
|
+
ask: `talentos ask "<message>" [--thread <id>] [--no-wait]
|
|
66
|
+
|
|
67
|
+
Waits for your Talent's reply (up to 10 minutes) unless --no-wait.`,
|
|
68
|
+
routines: `talentos routines list
|
|
69
|
+
talentos routines run <id>`,
|
|
70
|
+
};
|
|
71
|
+
async function main(argv) {
|
|
72
|
+
const parsed = parseArgv(argv);
|
|
73
|
+
if (parsed.options.version) {
|
|
74
|
+
emit(version());
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (parsed.group === undefined || parsed.options.help) {
|
|
78
|
+
emit(helpFor(parsed.group));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await dispatch(parsed);
|
|
141
82
|
}
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!apiKey) {
|
|
146
|
-
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.");
|
|
83
|
+
function helpFor(group) {
|
|
84
|
+
if (group === undefined) {
|
|
85
|
+
return USAGE;
|
|
147
86
|
}
|
|
148
|
-
return
|
|
87
|
+
return GROUP_USAGE[group] ?? USAGE;
|
|
149
88
|
}
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
89
|
+
async function dispatch(parsed) {
|
|
90
|
+
switch (parsed.group) {
|
|
91
|
+
case "init":
|
|
92
|
+
return await (await import("./commands/init.js")).runInit(parsed);
|
|
93
|
+
case "login":
|
|
94
|
+
return await (await import("./commands/login.js")).runLogin(parsed);
|
|
95
|
+
case "logout":
|
|
96
|
+
return await (await import("./commands/login.js")).runLogout();
|
|
97
|
+
case "whoami":
|
|
98
|
+
return await (await import("./commands/whoami.js")).runWhoami(parsed);
|
|
99
|
+
case "mcp":
|
|
100
|
+
return await (await import("./commands/mcp.js")).runMcp(parsed);
|
|
101
|
+
case "skills":
|
|
102
|
+
return await (await import("./commands/skills.js")).runSkills(parsed);
|
|
103
|
+
case "memory":
|
|
104
|
+
return await (await import("./commands/memory.js")).runMemory(parsed);
|
|
105
|
+
case "ask":
|
|
106
|
+
return await (await import("./commands/ask.js")).runAsk(parsed);
|
|
107
|
+
case "routines":
|
|
108
|
+
return await (await import("./commands/routines.js")).runRoutines(parsed);
|
|
109
|
+
case "help": {
|
|
110
|
+
emit(helpFor(parsed.args[0]));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
throw new Error(`Unknown command: ${parsed.group}. Run \`talentos --help\`.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function version() {
|
|
118
|
+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
119
|
+
return manifest.version;
|
|
153
120
|
}
|
|
154
|
-
main().catch((error) => {
|
|
155
|
-
const
|
|
156
|
-
process.stderr.write(
|
|
121
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
122
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
123
|
+
process.stderr.write(`${message.split("\n")[0]}\n`);
|
|
157
124
|
process.exit(1);
|
|
158
125
|
});
|
package/dist/install.js
ADDED
|
@@ -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
|
+
}
|