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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { AgentClient, UNAUTHORIZED } from "../client.js";
|
|
2
|
+
import { clearConfig, readConfig, resolveApiUrl, writeConfig, } from "../config.js";
|
|
3
|
+
import { bold, emit } from "../format.js";
|
|
4
|
+
import { checkPastedKey, defaultDeviceName, fetchLoginStatus, mintKey, openBrowser, startLogin, } from "../login.js";
|
|
5
|
+
import { fetchWhoami, renderWhoami } from "./whoami.js";
|
|
6
|
+
const POLL_INTERVAL_MS = 2000;
|
|
7
|
+
/** A 5xx or a dropped wifi must not discard a key the member just approved. */
|
|
8
|
+
const MAX_POLL_FAILURES = 5;
|
|
9
|
+
const REJECTED_KEY = "TalentOS rejected that key. Create a new one in Settings → Agents & CLI, or run `talentos login` without --key.";
|
|
10
|
+
export async function runLogin(parsed) {
|
|
11
|
+
const session = await authorize(parsed);
|
|
12
|
+
emit(`${bold("Logged in.")}\n${renderWhoami(session.who)}`);
|
|
13
|
+
}
|
|
14
|
+
/** The handshake: mint or accept a key, prove it works, then store it. */
|
|
15
|
+
export async function authorize(parsed) {
|
|
16
|
+
const stored = await readConfig();
|
|
17
|
+
const apiUrl = resolveApiUrl(parsed.options["api-url"], stored.apiUrl);
|
|
18
|
+
const pasted = parsed.options.key;
|
|
19
|
+
const rawKey = pasted === undefined
|
|
20
|
+
? await approveNewKey(apiUrl, parsed)
|
|
21
|
+
: checkPastedKey(pasted);
|
|
22
|
+
const who = await identify(apiUrl, rawKey).catch((error) => {
|
|
23
|
+
throw pasted === undefined ? asError(error) : rejected(error);
|
|
24
|
+
});
|
|
25
|
+
await writeConfig({ apiUrl, apiKey: rawKey });
|
|
26
|
+
return { credentials: { apiUrl, apiKey: rawKey }, who };
|
|
27
|
+
}
|
|
28
|
+
/** The stored key when it still works, so `init` can skip the handshake. */
|
|
29
|
+
export async function currentSession(parsed) {
|
|
30
|
+
const stored = await readConfig();
|
|
31
|
+
const apiUrl = resolveApiUrl(parsed.options["api-url"], stored.apiUrl);
|
|
32
|
+
if (stored.apiKey === undefined) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return {
|
|
37
|
+
credentials: { apiUrl, apiKey: stored.apiKey },
|
|
38
|
+
who: await identify(apiUrl, stored.apiKey),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function runLogout() {
|
|
46
|
+
await clearConfig();
|
|
47
|
+
emit("Logged out. The key on the server is still valid until you revoke it.");
|
|
48
|
+
}
|
|
49
|
+
async function approveNewKey(apiUrl, parsed) {
|
|
50
|
+
const key = mintKey();
|
|
51
|
+
const deviceName = parsed.options.name ?? defaultDeviceName();
|
|
52
|
+
const start = await startLogin(apiUrl, {
|
|
53
|
+
hashedKey: key.hashedKey,
|
|
54
|
+
keyPrefix: key.keyPrefix,
|
|
55
|
+
deviceName,
|
|
56
|
+
});
|
|
57
|
+
emit(approvalNotice(start));
|
|
58
|
+
if (start.authorizeUrl) {
|
|
59
|
+
openBrowser(start.authorizeUrl);
|
|
60
|
+
}
|
|
61
|
+
const status = await pollUntilDecided(apiUrl, start, key.rawKey);
|
|
62
|
+
if (status !== "approved") {
|
|
63
|
+
throw new Error(`Login ${status}. Run \`talentos login\` to try again.`);
|
|
64
|
+
}
|
|
65
|
+
return key.rawKey;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Only the server knows which app url its keys belong to, so without a url
|
|
69
|
+
* from it the member navigates there themselves rather than to a guessed host.
|
|
70
|
+
*/
|
|
71
|
+
export function approvalNotice(start) {
|
|
72
|
+
const heading = `Approve this device in TalentOS:\n\n ${bold(start.code)}\n`;
|
|
73
|
+
return start.authorizeUrl
|
|
74
|
+
? `${heading} ${start.authorizeUrl}\n`
|
|
75
|
+
: `${heading}\nOpen TalentOS in your browser, go to Settings → Agents & CLI, and enter this code.\n`;
|
|
76
|
+
}
|
|
77
|
+
/** Polls until the member decides, riding out transient failures. */
|
|
78
|
+
export async function pollUntilDecided(apiUrl, start, rawKey, deps = { fetchStatus: fetchLoginStatus, wait: sleep }) {
|
|
79
|
+
let failures = 0;
|
|
80
|
+
while (Date.now() < start.expiresAt) {
|
|
81
|
+
await deps.wait(POLL_INTERVAL_MS);
|
|
82
|
+
try {
|
|
83
|
+
const status = await deps.fetchStatus(apiUrl, start.code, rawKey);
|
|
84
|
+
failures = 0;
|
|
85
|
+
if (status !== "pending") {
|
|
86
|
+
return status;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
failures += 1;
|
|
91
|
+
if (failures > MAX_POLL_FAILURES) {
|
|
92
|
+
throw new Error(lostContact(start.code, error));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return "expired";
|
|
97
|
+
}
|
|
98
|
+
/** Proves the key works before it is written to disk. */
|
|
99
|
+
async function identify(apiUrl, apiKey) {
|
|
100
|
+
const client = new AgentClient({ apiUrl, apiKey });
|
|
101
|
+
try {
|
|
102
|
+
return await fetchWhoami(client);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await client.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** A key the member pasted is theirs to fix; the generic 401 hint misleads. */
|
|
109
|
+
function rejected(error) {
|
|
110
|
+
return asError(error).message === UNAUTHORIZED
|
|
111
|
+
? new Error(REJECTED_KEY)
|
|
112
|
+
: asError(error);
|
|
113
|
+
}
|
|
114
|
+
function asError(error) {
|
|
115
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
116
|
+
}
|
|
117
|
+
function lostContact(code, error) {
|
|
118
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
119
|
+
return `Lost contact with TalentOS while waiting for code ${code} (${reason}). If you approved it in the browser, run \`talentos login\` again and approve the new code.`;
|
|
120
|
+
}
|
|
121
|
+
function sleep(ms) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
setTimeout(resolve, ms);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AgentClient } from "../client.js";
|
|
2
|
+
import { requireCredentials, resolveApiUrl } from "../config.js";
|
|
3
|
+
import { dim, emit } from "../format.js";
|
|
4
|
+
import { agentLabel, applyInstall, buildInstallSnippet, installHint, isAgentTarget, isMcpInstallTarget, MCP_INSTALL_TARGETS, } from "../install.js";
|
|
5
|
+
export async function runMcp(parsed) {
|
|
6
|
+
if (parsed.action === "install") {
|
|
7
|
+
await runMcpInstall(parsed);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (parsed.action !== undefined) {
|
|
11
|
+
throw new Error(`Unknown command: mcp ${parsed.action}`);
|
|
12
|
+
}
|
|
13
|
+
const credentials = await requireCredentials();
|
|
14
|
+
const apiUrl = resolveApiUrl(parsed.options["api-url"], credentials.apiUrl);
|
|
15
|
+
// Loaded here so `mcp install` never pays for the server transport.
|
|
16
|
+
const { startMcpProxy } = await import("../mcp-proxy.js");
|
|
17
|
+
await startMcpProxy(new AgentClient({ ...credentials, apiUrl }));
|
|
18
|
+
}
|
|
19
|
+
async function runMcpInstall(parsed) {
|
|
20
|
+
const target = parsed.args[0];
|
|
21
|
+
if (target === undefined || !isMcpInstallTarget(target)) {
|
|
22
|
+
throw new Error(`Usage: talentos mcp install <${MCP_INSTALL_TARGETS.join("|")}> [--apply]`);
|
|
23
|
+
}
|
|
24
|
+
const stored = await requireCredentials();
|
|
25
|
+
const credentials = {
|
|
26
|
+
...stored,
|
|
27
|
+
apiUrl: resolveApiUrl(parsed.options["api-url"], stored.apiUrl),
|
|
28
|
+
};
|
|
29
|
+
if (parsed.options.apply) {
|
|
30
|
+
await installFor(target, credentials);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Hint on stderr so `talentos mcp install cursor > mcp.json` writes only JSON.
|
|
34
|
+
process.stderr.write(`${dim(installHint(target))}\n\n`);
|
|
35
|
+
emit(buildInstallSnippet(target, credentials));
|
|
36
|
+
}
|
|
37
|
+
async function installFor(target, credentials) {
|
|
38
|
+
if (!isAgentTarget(target)) {
|
|
39
|
+
throw new Error("--apply needs a client to write to: claude, cursor or codex.");
|
|
40
|
+
}
|
|
41
|
+
const outcome = await applyInstall(target, credentials);
|
|
42
|
+
if (outcome.applied) {
|
|
43
|
+
emit(`Configured ${agentLabel(target)} — ${outcome.detail}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
process.stderr.write(`${dim(`${outcome.detail}. Run this yourself:`)}\n\n`);
|
|
47
|
+
emit(buildInstallSnippet(target, credentials));
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { withClient } from "../client.js";
|
|
2
|
+
import { emit, emitJson, formatDate, renderTable, truncate, } from "../format.js";
|
|
3
|
+
const CONTENT_WIDTH = 80;
|
|
4
|
+
export async function runMemory(parsed) {
|
|
5
|
+
switch (parsed.action) {
|
|
6
|
+
case "search":
|
|
7
|
+
return await searchMemory(parsed);
|
|
8
|
+
case "add":
|
|
9
|
+
return await addMemory(parsed);
|
|
10
|
+
default:
|
|
11
|
+
throw new Error('Usage: talentos memory <search "<query>"|add "<fact>"> — see `talentos memory --help`');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function searchScopeFrom(options) {
|
|
15
|
+
if (options.personal) {
|
|
16
|
+
return "personal";
|
|
17
|
+
}
|
|
18
|
+
if (options.company) {
|
|
19
|
+
return "company";
|
|
20
|
+
}
|
|
21
|
+
return "all";
|
|
22
|
+
}
|
|
23
|
+
async function searchMemory(parsed) {
|
|
24
|
+
const query = parsed.args.join(" ").trim();
|
|
25
|
+
if (query === "") {
|
|
26
|
+
throw new Error('Usage: talentos memory search "<query>" [--personal|--company]');
|
|
27
|
+
}
|
|
28
|
+
const { data } = await withClient(parsed.options["api-url"], (client) => client.call("search_memory", {
|
|
29
|
+
query,
|
|
30
|
+
scope: searchScopeFrom(parsed.options),
|
|
31
|
+
}));
|
|
32
|
+
if (parsed.options.json) {
|
|
33
|
+
emitJson(data);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (data.memories.length === 0) {
|
|
37
|
+
emit("Nothing remembered for that yet.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
emit(renderTable(["SCOPE", "KIND", "UPDATED", "MEMORY"], data.memories.map((memory) => [
|
|
41
|
+
memory.scope,
|
|
42
|
+
memory.kind,
|
|
43
|
+
formatDate(memory.updatedAt),
|
|
44
|
+
truncate(memory.content, CONTENT_WIDTH),
|
|
45
|
+
])));
|
|
46
|
+
}
|
|
47
|
+
async function addMemory(parsed) {
|
|
48
|
+
const content = parsed.args.join(" ").trim();
|
|
49
|
+
if (content === "") {
|
|
50
|
+
throw new Error('Usage: talentos memory add "<fact>" [--company]');
|
|
51
|
+
}
|
|
52
|
+
const { text, data } = await withClient(parsed.options["api-url"], (client) => client.call("remember", {
|
|
53
|
+
content,
|
|
54
|
+
scope: parsed.options.company ? "company" : "personal",
|
|
55
|
+
}));
|
|
56
|
+
if (parsed.options.json) {
|
|
57
|
+
emitJson(data);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
emit(text);
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { withClient } from "../client.js";
|
|
2
|
+
import { emit, emitJson, emitLink, formatDate, renderTable, truncate, } from "../format.js";
|
|
3
|
+
const NAME_WIDTH = 40;
|
|
4
|
+
export async function runRoutines(parsed) {
|
|
5
|
+
switch (parsed.action) {
|
|
6
|
+
case "list":
|
|
7
|
+
return await listRoutines(parsed);
|
|
8
|
+
case "run":
|
|
9
|
+
return await runRoutine(parsed);
|
|
10
|
+
default:
|
|
11
|
+
throw new Error("Usage: talentos routines <list|run <id>>");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function listRoutines(parsed) {
|
|
15
|
+
const { data } = await withClient(parsed.options["api-url"], (client) => client.call("list_routines", {}));
|
|
16
|
+
if (parsed.options.json) {
|
|
17
|
+
emitJson(data);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (data.routines.length === 0) {
|
|
21
|
+
emit("No routines yet.");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
emit(renderTable(["ID", "STATE", "SCHEDULE", "NEXT", "NAME"], data.routines.map((routine) => [
|
|
25
|
+
routine.id,
|
|
26
|
+
routine.state,
|
|
27
|
+
truncate(routine.schedule, 24),
|
|
28
|
+
formatDate(routine.nextRunAt ?? undefined),
|
|
29
|
+
truncate(routine.name, NAME_WIDTH),
|
|
30
|
+
])));
|
|
31
|
+
}
|
|
32
|
+
async function runRoutine(parsed) {
|
|
33
|
+
const routineId = parsed.args[0];
|
|
34
|
+
if (routineId === undefined) {
|
|
35
|
+
throw new Error("Usage: talentos routines run <id>");
|
|
36
|
+
}
|
|
37
|
+
const { text, data } = await withClient(parsed.options["api-url"], (client) => client.call("run_routine", { routineId }));
|
|
38
|
+
if (parsed.options.json) {
|
|
39
|
+
emitJson(data);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
emit(text);
|
|
43
|
+
emitLink(data.url);
|
|
44
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { withClient } from "../client.js";
|
|
4
|
+
import { bold, emit, emitJson, emitLink, formatDate, renderTable, truncate, warn, } from "../format.js";
|
|
5
|
+
import { homeDir, pullDirs } from "../install.js";
|
|
6
|
+
import { readSkillPackage, SKILL_ENTRY, writeSkillPackage, } from "../skill-files.js";
|
|
7
|
+
const SUMMARY_WIDTH = 60;
|
|
8
|
+
/** Enough to hide the round-trip latency of 100 skills without flooding. */
|
|
9
|
+
const PULL_CONCURRENCY = 4;
|
|
10
|
+
/** The server's ceiling: asking for less silently truncates at its default 50. */
|
|
11
|
+
const PULL_LIMIT = 100;
|
|
12
|
+
const ID_SUFFIX_LENGTH = 6;
|
|
13
|
+
export async function runSkills(parsed) {
|
|
14
|
+
switch (parsed.action) {
|
|
15
|
+
case "list":
|
|
16
|
+
return await listSkills(parsed);
|
|
17
|
+
case "get":
|
|
18
|
+
return await getSkill(parsed);
|
|
19
|
+
case "push":
|
|
20
|
+
return await pushSkill(parsed);
|
|
21
|
+
case "pull":
|
|
22
|
+
return await pullSkills(parsed);
|
|
23
|
+
case "share":
|
|
24
|
+
return shareSkill();
|
|
25
|
+
default:
|
|
26
|
+
throw new Error("Usage: talentos skills <list|get|push|pull> — see `talentos skills --help`");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function scopeFrom(options) {
|
|
30
|
+
if (options.mine) {
|
|
31
|
+
return "mine";
|
|
32
|
+
}
|
|
33
|
+
if (options.company) {
|
|
34
|
+
return "company";
|
|
35
|
+
}
|
|
36
|
+
return "all";
|
|
37
|
+
}
|
|
38
|
+
async function listSkills(parsed) {
|
|
39
|
+
const { data } = await withClient(parsed.options["api-url"], (client) => client.call("list_skills", {
|
|
40
|
+
scope: scopeFrom(parsed.options),
|
|
41
|
+
...(parsed.options.query ? { query: parsed.options.query } : {}),
|
|
42
|
+
}));
|
|
43
|
+
if (parsed.options.json) {
|
|
44
|
+
emitJson(data);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (data.skills.length === 0) {
|
|
48
|
+
emit("No skills yet. Push one with `talentos skills push <path>`.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
emit(renderTable(["SLUG", "SCOPE", "V", "UPDATED", "NAME"], data.skills.map((skill) => [
|
|
52
|
+
skill.slug,
|
|
53
|
+
skill.scope,
|
|
54
|
+
`v${skill.version}`,
|
|
55
|
+
formatDate(skill.updatedAt),
|
|
56
|
+
truncate(skill.name, SUMMARY_WIDTH),
|
|
57
|
+
])));
|
|
58
|
+
}
|
|
59
|
+
async function getSkill(parsed) {
|
|
60
|
+
const skillId = parsed.args[0];
|
|
61
|
+
if (skillId === undefined) {
|
|
62
|
+
throw new Error("Usage: talentos skills get <id|slug> [--out <dir>]");
|
|
63
|
+
}
|
|
64
|
+
const { text, data } = await withClient(parsed.options["api-url"], (client) => client.call("get_skill", { skillId }));
|
|
65
|
+
if (parsed.options.json) {
|
|
66
|
+
emitJson(data);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const out = parsed.options.out;
|
|
70
|
+
if (out === undefined) {
|
|
71
|
+
emit(text);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const written = await writeSkillPackage(out, data.slug, data.files);
|
|
75
|
+
emit(`Wrote ${data.files.length} file(s) to ${written}`);
|
|
76
|
+
emitLink(data.url);
|
|
77
|
+
}
|
|
78
|
+
async function pushSkill(parsed) {
|
|
79
|
+
const path = parsed.args[0];
|
|
80
|
+
if (path === undefined) {
|
|
81
|
+
throw new Error("Usage: talentos skills push <SKILL.md|dir> [--id <skillId>]");
|
|
82
|
+
}
|
|
83
|
+
const files = await readSkillPackage(resolve(path));
|
|
84
|
+
const { data } = await withClient(parsed.options["api-url"], (client) => client.call("save_skill", {
|
|
85
|
+
files,
|
|
86
|
+
...(parsed.options.id ? { skillId: parsed.options.id } : {}),
|
|
87
|
+
}));
|
|
88
|
+
if (parsed.options.json) {
|
|
89
|
+
emitJson(data);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
emit([
|
|
93
|
+
`${data.created ? "Created" : "Updated"} ${bold(data.slug)} v${data.version} (${data.id})`,
|
|
94
|
+
"Private to you. Open TalentOS to share it.",
|
|
95
|
+
].join("\n"));
|
|
96
|
+
emitLink(data.url);
|
|
97
|
+
}
|
|
98
|
+
async function pullSkills(parsed) {
|
|
99
|
+
const dirs = pullDirs({
|
|
100
|
+
home: homeDir(),
|
|
101
|
+
target: parsed.options.for,
|
|
102
|
+
global: parsed.options.global,
|
|
103
|
+
to: parsed.options.to,
|
|
104
|
+
});
|
|
105
|
+
const results = await withClient(parsed.options["api-url"], (client) => pullInto(client, dirs, scopeFrom(parsed.options)));
|
|
106
|
+
if (parsed.options.json) {
|
|
107
|
+
emitJson(results);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (results.length === 0) {
|
|
111
|
+
emit("Nothing to pull.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const created = results.filter((result) => result.created).length;
|
|
115
|
+
emit([
|
|
116
|
+
renderTable(["SKILL", "STATUS"], results.map((result) => [
|
|
117
|
+
result.slug,
|
|
118
|
+
result.created ? "written" : "updated",
|
|
119
|
+
])),
|
|
120
|
+
`${created} written, ${results.length - created} updated in ${dirs.map((dir) => resolve(dir)).join(", ")}`,
|
|
121
|
+
].join("\n\n"));
|
|
122
|
+
}
|
|
123
|
+
function shareSkill() {
|
|
124
|
+
throw new Error("Share this Skill from TalentOS.");
|
|
125
|
+
}
|
|
126
|
+
/** Shared with `init`: every visible skill, written into each target folder. */
|
|
127
|
+
export async function pullInto(client, dirs, scope) {
|
|
128
|
+
const { data } = await client.call("list_skills", { scope, limit: PULL_LIMIT });
|
|
129
|
+
if (data.skills.length === PULL_LIMIT) {
|
|
130
|
+
warn(`Pulled the first ${PULL_LIMIT} skills; there may be more. Narrow with --mine, --company or -q <query>.`);
|
|
131
|
+
}
|
|
132
|
+
const packages = await mapConcurrent(data.skills, PULL_CONCURRENCY, async (skill) => (await client.call("get_skill", { skillId: skill.id })).data);
|
|
133
|
+
const results = [];
|
|
134
|
+
const taken = new Set();
|
|
135
|
+
// Fetched in parallel, written in slug order, so a run is reproducible.
|
|
136
|
+
for (const pkg of [...packages].sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
137
|
+
const slug = folderFor(pkg, taken);
|
|
138
|
+
let created = false;
|
|
139
|
+
for (const dir of dirs) {
|
|
140
|
+
created = created || !(await exists(join(dir, slug, SKILL_ENTRY)));
|
|
141
|
+
await writeSkillPackage(dir, slug, pkg.files);
|
|
142
|
+
}
|
|
143
|
+
results.push({ slug, created });
|
|
144
|
+
}
|
|
145
|
+
return results;
|
|
146
|
+
}
|
|
147
|
+
/** Two skills may share a slug; the second keeps its own folder, not the first's. */
|
|
148
|
+
function folderFor(pkg, taken) {
|
|
149
|
+
if (!taken.has(pkg.slug)) {
|
|
150
|
+
taken.add(pkg.slug);
|
|
151
|
+
return pkg.slug;
|
|
152
|
+
}
|
|
153
|
+
const unique = `${pkg.slug}-${pkg.id.slice(-ID_SUFFIX_LENGTH)}`;
|
|
154
|
+
warn(`Two skills share the slug ${pkg.slug}; wrote this one to ${unique}.`);
|
|
155
|
+
taken.add(unique);
|
|
156
|
+
return unique;
|
|
157
|
+
}
|
|
158
|
+
/** Runs `work` over `items` with at most `limit` in flight, keeping order. */
|
|
159
|
+
async function mapConcurrent(items, limit, work) {
|
|
160
|
+
const results = [];
|
|
161
|
+
let next = 0;
|
|
162
|
+
const worker = async () => {
|
|
163
|
+
while (next < items.length) {
|
|
164
|
+
const index = next;
|
|
165
|
+
next += 1;
|
|
166
|
+
const item = items[index];
|
|
167
|
+
if (item !== undefined) {
|
|
168
|
+
results[index] = await work(item);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
async function exists(path) {
|
|
176
|
+
try {
|
|
177
|
+
await access(path);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { withClient } from "../client.js";
|
|
2
|
+
import { bold, dim, emit, emitJson } from "../format.js";
|
|
3
|
+
export async function fetchWhoami(client) {
|
|
4
|
+
const { data } = await client.call("whoami", {});
|
|
5
|
+
return data;
|
|
6
|
+
}
|
|
7
|
+
/** A member can exist before they have a name; the email always identifies them. */
|
|
8
|
+
export function memberLabel(member) {
|
|
9
|
+
return member.name ?? member.email;
|
|
10
|
+
}
|
|
11
|
+
export function renderWhoami(who) {
|
|
12
|
+
const talent = who.talent
|
|
13
|
+
? `${who.talent.name} (${who.talent.status})`
|
|
14
|
+
: "none yet";
|
|
15
|
+
return [
|
|
16
|
+
`${bold(memberLabel(who.member))} ${dim(who.member.email)}`,
|
|
17
|
+
`${who.organization.name} · ${who.member.role}`,
|
|
18
|
+
`Talent: ${talent}`,
|
|
19
|
+
`Skills: ${who.counts.mySkills} mine, ${who.counts.companySkills} company · Routines: ${who.counts.routines}`,
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
export async function runWhoami(parsed) {
|
|
23
|
+
const who = await withClient(parsed.options["api-url"], fetchWhoami);
|
|
24
|
+
if (parsed.options.json) {
|
|
25
|
+
emitJson(who);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
emit(renderWhoami(who));
|
|
29
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
5
|
+
export const DEFAULT_API_URL = "https://woozy-oyster-769.convex.site";
|
|
6
|
+
export function configDir(env = process.env) {
|
|
7
|
+
// An empty XDG_CONFIG_HOME is unset per the XDG spec; taking it literally
|
|
8
|
+
// would write the key into whatever directory the CLI was started from.
|
|
9
|
+
const base = env.XDG_CONFIG_HOME;
|
|
10
|
+
const root = base === undefined || base === "" ? join(homedir(), ".config") : base;
|
|
11
|
+
return join(root, "talentos");
|
|
12
|
+
}
|
|
13
|
+
export function configPath(env = process.env) {
|
|
14
|
+
return join(configDir(env), "config.json");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The API url from any source, normalised the same way: a pasted `--api-url`
|
|
18
|
+
* with a trailing slash must not produce `//mcp`.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveApiUrl(override, fallback) {
|
|
21
|
+
const chosen = override === undefined || override.trim() === "" ? fallback : override;
|
|
22
|
+
return chosen.trim().replace(TRAILING_SLASHES, "");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* File values first, then env overrides. A machine with TALENTOS_API_KEY set
|
|
26
|
+
* (CI, a container, `codex` launching the stdio proxy) must never fall back to
|
|
27
|
+
* whatever key happens to be on disk.
|
|
28
|
+
*/
|
|
29
|
+
export async function readConfig(env = process.env) {
|
|
30
|
+
const stored = await readStoredConfig(env);
|
|
31
|
+
const apiUrl = resolveApiUrl(env.TALENTOS_API_URL, stored.apiUrl ?? DEFAULT_API_URL);
|
|
32
|
+
const apiKey = env.TALENTOS_API_KEY ?? stored.apiKey;
|
|
33
|
+
return apiKey ? { apiUrl, apiKey } : { apiUrl };
|
|
34
|
+
}
|
|
35
|
+
export async function writeConfig(config, env = process.env) {
|
|
36
|
+
const dir = configDir(env);
|
|
37
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
38
|
+
const path = configPath(env);
|
|
39
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, {
|
|
40
|
+
mode: 0o600,
|
|
41
|
+
});
|
|
42
|
+
return path;
|
|
43
|
+
}
|
|
44
|
+
export async function clearConfig(env = process.env) {
|
|
45
|
+
await rm(configPath(env), { force: true });
|
|
46
|
+
}
|
|
47
|
+
export async function requireCredentials(env = process.env) {
|
|
48
|
+
const config = await readConfig(env);
|
|
49
|
+
if (!config.apiKey) {
|
|
50
|
+
throw new Error("Not logged in. Run `talentos login`.");
|
|
51
|
+
}
|
|
52
|
+
return { apiUrl: config.apiUrl, apiKey: config.apiKey };
|
|
53
|
+
}
|
|
54
|
+
async function readStoredConfig(env) {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await readFile(configPath(env), "utf8");
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
if (!parsed || typeof parsed !== "object") {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
const record = parsed;
|
|
62
|
+
return {
|
|
63
|
+
apiUrl: typeof record.apiUrl === "string" ? record.apiUrl : undefined,
|
|
64
|
+
apiKey: typeof record.apiKey === "string" ? record.apiKey : undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const WHITESPACE = /\s+/g;
|
|
2
|
+
const ANSI = {
|
|
3
|
+
bold: "[1m",
|
|
4
|
+
dim: "[2m",
|
|
5
|
+
reset: "[0m",
|
|
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
|
+
}
|