totemlayer 0.1.2 → 0.1.4
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 -2
- package/bin/collaboration-companion.mjs +309 -0
- package/bin/collaboration-listener.mjs +411 -0
- package/bin/device-approval.mjs +93 -0
- package/bin/local-mcp.mjs +97 -0
- package/bin/managed-collaboration.mjs +94 -0
- package/bin/mcp-install.mjs +159 -0
- package/bin/mcp.mjs +777 -0
- package/bin/network-runtime.mjs +685 -0
- package/bin/network.mjs +323 -0
- package/bin/platform-client.mjs +83 -0
- package/bin/presenter-mcp.mjs +238 -0
- package/bin/repository.d.mts +27 -0
- package/bin/repository.mjs +135 -0
- package/bin/sdk/errors.js +54 -0
- package/bin/sdk/index.js +307 -0
- package/bin/sdk/transport.js +197 -0
- package/bin/sdk/types.js +1 -0
- package/bin/totem.mjs +37 -1
- package/package.json +10 -2
- package/scripts/prepare.mjs +15 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { runCollaborationListener } from "./collaboration-listener.mjs";
|
|
5
|
+
import { loadTotemMcpConfig } from "./mcp.mjs";
|
|
6
|
+
import { Totem } from "./sdk/index.js";
|
|
7
|
+
|
|
8
|
+
const configPath = process.argv[2];
|
|
9
|
+
if (!configPath) throw new Error("The managed listener needs its private configuration file.");
|
|
10
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
11
|
+
const statusPath = join(config.stateDirectory, "managed-status.json");
|
|
12
|
+
const stopPath = join(config.stateDirectory, "managed-stop.json");
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
let current = { state: "starting", event: null, message: null };
|
|
15
|
+
let writes = Promise.resolve();
|
|
16
|
+
|
|
17
|
+
function saveStatus(next = {}) {
|
|
18
|
+
current = { ...current, ...next };
|
|
19
|
+
const body = JSON.stringify({
|
|
20
|
+
runId: config.runId,
|
|
21
|
+
pid: process.pid,
|
|
22
|
+
...current,
|
|
23
|
+
updatedAt: new Date().toISOString(),
|
|
24
|
+
});
|
|
25
|
+
writes = writes.then(async () => {
|
|
26
|
+
const temporary = `${statusPath}.${process.pid}.tmp`;
|
|
27
|
+
await writeFile(temporary, body, { mode: 0o600 });
|
|
28
|
+
await rename(temporary, statusPath);
|
|
29
|
+
});
|
|
30
|
+
return writes;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function checkStop() {
|
|
34
|
+
try {
|
|
35
|
+
const request = JSON.parse(await readFile(stopPath, "utf8"));
|
|
36
|
+
if (request.runId === config.runId) controller.abort();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error?.code !== "ENOENT")
|
|
39
|
+
await saveStatus({ event: "stop_check_failed", message: "Could not read stop request." });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stop = () => controller.abort();
|
|
44
|
+
process.once("SIGTERM", stop);
|
|
45
|
+
process.once("SIGINT", stop);
|
|
46
|
+
const stopTimer = setInterval(() => void checkStop(), 1000);
|
|
47
|
+
const heartbeat = setInterval(() => void saveStatus(), 5000);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await saveStatus();
|
|
51
|
+
const credentials = await loadTotemMcpConfig({
|
|
52
|
+
TOTEM_BASE_URL: config.baseUrl,
|
|
53
|
+
TOTEM_NETWORK_API_KEY_FILE: config.keyFile,
|
|
54
|
+
});
|
|
55
|
+
const client = new Totem(credentials);
|
|
56
|
+
const identity = await client.agent({ signal: controller.signal });
|
|
57
|
+
if (identity.id !== config.agentId)
|
|
58
|
+
throw new Error("This key no longer belongs to the agent that enabled the listener.");
|
|
59
|
+
await runCollaborationListener({
|
|
60
|
+
client,
|
|
61
|
+
peerAddress: config.peerAddress,
|
|
62
|
+
provider: config.provider,
|
|
63
|
+
repositoryRoot: config.repositoryRoot,
|
|
64
|
+
stateDirectory: config.stateDirectory,
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
emit: (event) => {
|
|
67
|
+
const attention = [
|
|
68
|
+
"poll_failed",
|
|
69
|
+
"run_failed",
|
|
70
|
+
"checkout_mismatch",
|
|
71
|
+
"needs_attention",
|
|
72
|
+
"provider_mismatch",
|
|
73
|
+
"run_limit",
|
|
74
|
+
].includes(event.type);
|
|
75
|
+
void saveStatus({
|
|
76
|
+
state: attention ? "needs_attention" : event.type === "ready" ? "ready" : current.state,
|
|
77
|
+
event: event.type,
|
|
78
|
+
message: event.type === "poll_failed" || event.type === "run_failed" ? event.message : null,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
await saveStatus({ state: "stopped", event: "stopped", message: null });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
await saveStatus({
|
|
85
|
+
state: controller.signal.aborted ? "stopped" : "failed",
|
|
86
|
+
event: controller.signal.aborted ? "stopped" : "failed",
|
|
87
|
+
message: error instanceof Error ? error.message : "The local listener failed.",
|
|
88
|
+
});
|
|
89
|
+
if (!controller.signal.aborted) process.exitCode = 1;
|
|
90
|
+
} finally {
|
|
91
|
+
clearInterval(stopTimer);
|
|
92
|
+
clearInterval(heartbeat);
|
|
93
|
+
await writes;
|
|
94
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { installedClientInvocation } from "./platform-client.mjs";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_ORIGIN = "https://totemlayer.com";
|
|
9
|
+
const DEFAULT_NAME = "totem-local";
|
|
10
|
+
const SERVER_ENTRY = fileURLToPath(new URL("./totem.mjs", import.meta.url));
|
|
11
|
+
|
|
12
|
+
export const MCP_INSTALL_HELP = `Connect Totem to an AI app
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
totem mcp install codex --key-file /absolute/path/to/agent.key
|
|
16
|
+
totem mcp install claude --key-file /absolute/path/to/agent.key
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--base-url <origin> Totem origin (default: https://totemlayer.com)
|
|
20
|
+
--name <name> MCP server name (default: totem-local)
|
|
21
|
+
--key-file <path> Existing private agent key file (required)
|
|
22
|
+
--help Show this guide
|
|
23
|
+
|
|
24
|
+
This installs a user-level local stdio MCP connection. It never puts the key
|
|
25
|
+
value in a command, client config, or repository. It will not overwrite an
|
|
26
|
+
existing MCP server. Restart the AI app afterward and ask for totem_get_identity.
|
|
27
|
+
On macOS/Linux the key file must be mode 0600. On Windows, protect it with
|
|
28
|
+
your user's filesystem ACL. The package must remain installed.
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
function fail(message) {
|
|
32
|
+
throw new Error(message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseInstallArgs(args, environment) {
|
|
36
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) return { help: true };
|
|
37
|
+
const [client, ...rest] = args;
|
|
38
|
+
if (client !== "codex" && client !== "claude")
|
|
39
|
+
fail("Choose codex or claude. Run `totem mcp install --help` for examples.");
|
|
40
|
+
const values = {};
|
|
41
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
42
|
+
const option = rest[index];
|
|
43
|
+
const value = rest[index + 1];
|
|
44
|
+
if (
|
|
45
|
+
!["--key-file", "--base-url", "--name"].includes(option) ||
|
|
46
|
+
!value ||
|
|
47
|
+
value.startsWith("--")
|
|
48
|
+
)
|
|
49
|
+
fail(`Invalid option ${option ?? ""}. Run \`totem mcp install --help\`.`);
|
|
50
|
+
if (values[option] !== undefined) fail(`${option} was provided more than once.`);
|
|
51
|
+
values[option] = value;
|
|
52
|
+
}
|
|
53
|
+
const keyFile = values["--key-file"] ?? environment.TOTEM_NETWORK_API_KEY_FILE;
|
|
54
|
+
if (!keyFile) fail("Pass --key-file with the path to your own Totem agent key file.");
|
|
55
|
+
const name = values["--name"] ?? DEFAULT_NAME;
|
|
56
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name))
|
|
57
|
+
fail(
|
|
58
|
+
"The MCP name must be 1-64 letters, numbers, hyphens or underscores and start with a letter.",
|
|
59
|
+
);
|
|
60
|
+
const rawOrigin = values["--base-url"] ?? environment.TOTEM_BASE_URL ?? DEFAULT_ORIGIN;
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(rawOrigin);
|
|
64
|
+
} catch {
|
|
65
|
+
fail("--base-url must be a valid HTTPS origin (or HTTP loopback for local development).");
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
url.username ||
|
|
69
|
+
url.password ||
|
|
70
|
+
url.search ||
|
|
71
|
+
url.hash ||
|
|
72
|
+
url.pathname !== "/" ||
|
|
73
|
+
(url.protocol !== "https:" &&
|
|
74
|
+
!(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
75
|
+
)
|
|
76
|
+
fail(
|
|
77
|
+
"--base-url must be a credential-free HTTPS origin (or HTTP loopback for local development).",
|
|
78
|
+
);
|
|
79
|
+
return { client, keyFile: resolve(keyFile), name, baseUrl: url.origin };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function validateKeyFile(path, platform) {
|
|
83
|
+
const info = await stat(path);
|
|
84
|
+
if (!info.isFile() || info.size === 0 || info.size > 8 * 1024)
|
|
85
|
+
fail("The Totem agent key must be a nonempty regular file no larger than 8 KiB.");
|
|
86
|
+
if (platform !== "win32" && (info.mode & 0o077) !== 0)
|
|
87
|
+
fail("The Totem agent key file must be private (mode 0600). Fix its permissions and retry.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function runClient(command, args, environment, runtime) {
|
|
91
|
+
const invocation =
|
|
92
|
+
runtime.platform === "win32"
|
|
93
|
+
? installedClientInvocation(command, args, environment)
|
|
94
|
+
: { command, args };
|
|
95
|
+
const result = runtime.spawnProcess(invocation.command, invocation.args, {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
env: environment,
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
99
|
+
timeout: 20_000,
|
|
100
|
+
});
|
|
101
|
+
if (result.error?.code === "ENOENT")
|
|
102
|
+
fail(
|
|
103
|
+
`${command} is not installed or is not on PATH. Install and sign in to that AI client first.`,
|
|
104
|
+
);
|
|
105
|
+
if (result.error) fail(`${command} could not complete MCP setup: ${result.error.message}`);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function installTotemMcp(args, environment = process.env, runtime = {}) {
|
|
110
|
+
const options = parseInstallArgs(args, environment);
|
|
111
|
+
if (options.help) return MCP_INSTALL_HELP;
|
|
112
|
+
runtime = { platform: process.platform, spawnProcess: spawnSync, ...runtime };
|
|
113
|
+
await validateKeyFile(options.keyFile, runtime.platform);
|
|
114
|
+
|
|
115
|
+
const command = options.client;
|
|
116
|
+
const existing = runClient(command, ["mcp", "get", options.name], environment, runtime);
|
|
117
|
+
if (existing.status === 0)
|
|
118
|
+
fail(
|
|
119
|
+
`${options.name} already exists in ${command}. No configuration was changed. Choose another --name or inspect the existing connection first.`,
|
|
120
|
+
);
|
|
121
|
+
if (!/no mcp server|not found|does not exist/i.test(`${existing.stderr}\n${existing.stdout}`))
|
|
122
|
+
fail(
|
|
123
|
+
`${command} could not check whether ${options.name} already exists. No configuration was changed.`,
|
|
124
|
+
);
|
|
125
|
+
const envArgs = [
|
|
126
|
+
"--env",
|
|
127
|
+
`TOTEM_BASE_URL=${options.baseUrl}`,
|
|
128
|
+
"--env",
|
|
129
|
+
`TOTEM_NETWORK_API_KEY_FILE=${options.keyFile}`,
|
|
130
|
+
];
|
|
131
|
+
const addArgs =
|
|
132
|
+
options.client === "codex"
|
|
133
|
+
? ["mcp", "add", options.name, ...envArgs, "--", process.execPath, SERVER_ENTRY, "mcp"]
|
|
134
|
+
: [
|
|
135
|
+
"mcp",
|
|
136
|
+
"add",
|
|
137
|
+
...envArgs,
|
|
138
|
+
"--scope",
|
|
139
|
+
"user",
|
|
140
|
+
"--transport",
|
|
141
|
+
"stdio",
|
|
142
|
+
options.name,
|
|
143
|
+
"--",
|
|
144
|
+
process.execPath,
|
|
145
|
+
SERVER_ENTRY,
|
|
146
|
+
"mcp",
|
|
147
|
+
];
|
|
148
|
+
const added = runClient(command, addArgs, environment, runtime);
|
|
149
|
+
if (added.status !== 0)
|
|
150
|
+
fail(
|
|
151
|
+
`${command} could not add ${options.name}. No Totem key value was sent. Check the client's MCP setup and retry.`,
|
|
152
|
+
);
|
|
153
|
+
const verified = runClient(command, ["mcp", "get", options.name], environment, runtime);
|
|
154
|
+
if (verified.status !== 0)
|
|
155
|
+
fail(
|
|
156
|
+
`${command} reported an install, but ${options.name} could not be verified. Inspect its MCP settings before using it.`,
|
|
157
|
+
);
|
|
158
|
+
return `Connected ${options.name} to ${command} at ${options.baseUrl}. Restart ${command}, then ask it to call totem_get_identity. The Totem CLI package must remain installed.\n`;
|
|
159
|
+
}
|