agent-factorio 0.2.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 ADDED
@@ -0,0 +1,72 @@
1
+ # agent-factorio
2
+
3
+ CLI for [AgentFactorio](https://github.com/your-org/agent-factorio) — AI Agent Fleet Management hub.
4
+
5
+ Register and manage your AI agents from any project.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g agent-factorio
11
+ ```
12
+
13
+ Or use directly with `npx`:
14
+
15
+ ```bash
16
+ npx agent-factorio <command>
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ ### `agent-factorio login`
22
+
23
+ Connect to an AgentFactorio hub and join an organization.
24
+
25
+ ```bash
26
+ npx agent-factorio login
27
+ ```
28
+
29
+ Prompts for hub URL and email, then lets you create or join an organization via invite code.
30
+
31
+ ### `agent-factorio push`
32
+
33
+ Detect and push agent configuration to the hub.
34
+
35
+ ```bash
36
+ npx agent-factorio push
37
+ ```
38
+
39
+ Auto-detects git repo, CLAUDE.md, MCP servers, skills, and plugins from the current project directory.
40
+
41
+ ### `agent-factorio status`
42
+
43
+ Show registration status for the current project.
44
+
45
+ ```bash
46
+ npx agent-factorio status
47
+ ```
48
+
49
+ ### `agent-factorio whoami`
50
+
51
+ Show login info (hub URL, organizations).
52
+
53
+ ```bash
54
+ npx agent-factorio whoami
55
+ ```
56
+
57
+ ### `agent-factorio logout`
58
+
59
+ Remove global config and log out.
60
+
61
+ ```bash
62
+ npx agent-factorio logout
63
+ ```
64
+
65
+ ## Configuration
66
+
67
+ - Global config: `~/.agent-factorio/config.json` (hub URL, member ID, organizations)
68
+ - Project config: `.agent-factorio/config.json` (agent ID, hub URL — gitignored)
69
+
70
+ ## Requirements
71
+
72
+ - Node.js >= 18
package/bin.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * AgentFactorio CLI — register and manage agents from any project
5
+ *
6
+ * Usage:
7
+ * npx agent-factorio login # Connect to hub + join organization
8
+ * npx agent-factorio push # Push agent config to hub
9
+ * npx agent-factorio status # Show registration status
10
+ * npx agent-factorio whoami # Show login info
11
+ * npx agent-factorio logout # Remove global config
12
+ */
13
+
14
+ import { readFileSync } from "fs";
15
+ import { fileURLToPath } from "url";
16
+ import { dirname, join } from "path";
17
+ import { Command } from "commander";
18
+ import { loginCommand } from "./commands/login.mjs";
19
+ import { pushCommand } from "./commands/push.mjs";
20
+ import { statusCommand } from "./commands/status.mjs";
21
+ import { whoamiCommand } from "./commands/whoami.mjs";
22
+ import { logoutCommand } from "./commands/logout.mjs";
23
+ import { connectCommand } from "./commands/connect.mjs";
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
27
+
28
+ const program = new Command();
29
+
30
+ program
31
+ .name("agent-factorio")
32
+ .description("AgentFactorio CLI — AI Agent Fleet Management")
33
+ .version(pkg.version);
34
+
35
+ program
36
+ .command("login")
37
+ .description("Connect to an AgentFactorio hub and join an organization")
38
+ .action(loginCommand);
39
+
40
+ program
41
+ .command("push")
42
+ .description("Detect and push agent configuration to the hub")
43
+ .action(pushCommand);
44
+
45
+ program
46
+ .command("status")
47
+ .description("Show registration status for the current project")
48
+ .action(statusCommand);
49
+
50
+ program
51
+ .command("whoami")
52
+ .description("Show login info (hub URL, organizations)")
53
+ .action(whoamiCommand);
54
+
55
+ program
56
+ .command("logout")
57
+ .description("Remove global config and log out")
58
+ .action(logoutCommand);
59
+
60
+ program
61
+ .command("connect")
62
+ .description("Poll hub and relay messages to local OpenClaw Gateway")
63
+ .action(connectCommand);
64
+
65
+ program.parse();
@@ -0,0 +1,163 @@
1
+ /**
2
+ * agent-factorio connect — Poll hub for messages, relay to local OpenClaw Gateway
3
+ */
4
+ import { readLocalConfig, findProjectRoot } from "../lib/config.mjs";
5
+ import { success, error, info, label, heading } from "../lib/log.mjs";
6
+
7
+ const POLL_INTERVAL_MS = 2000;
8
+ const LOCAL_GATEWAY_URL = "http://localhost:18789";
9
+
10
+ export async function connectCommand() {
11
+ const projectRoot = findProjectRoot();
12
+ const localConfig = readLocalConfig(projectRoot);
13
+
14
+ if (!localConfig?.agentId || !localConfig?.hubUrl || !localConfig?.pollToken) {
15
+ error(
16
+ "Missing agentId, hubUrl, or pollToken in .agent-factorio/config.json.\n" +
17
+ "Run `agent-factorio push` with runtimeType=openclaw first."
18
+ );
19
+ process.exit(1);
20
+ }
21
+
22
+ const { agentId, hubUrl, pollToken, agentName } = localConfig;
23
+ const pollUrl = `${hubUrl}/api/agents/${agentId}/poll`;
24
+ const respondUrl = `${hubUrl}/api/agents/${agentId}/respond`;
25
+ const headers = {
26
+ Authorization: `Bearer ${pollToken}`,
27
+ "Content-Type": "application/json",
28
+ };
29
+
30
+ heading("AgentFactorio Connector");
31
+ label("Agent", agentName || agentId);
32
+ label("Hub", hubUrl);
33
+ label("Gateway", LOCAL_GATEWAY_URL);
34
+ info(`Polling every ${POLL_INTERVAL_MS / 1000}s... (Ctrl+C to stop)\n`);
35
+
36
+ // Graceful shutdown
37
+ let running = true;
38
+ process.on("SIGINT", () => {
39
+ running = false;
40
+ info("\nShutting down connector...");
41
+ process.exit(0);
42
+ });
43
+
44
+ while (running) {
45
+ try {
46
+ // 1. Poll for pending messages
47
+ const pollRes = await fetch(pollUrl, { headers });
48
+ if (!pollRes.ok) {
49
+ const errText = await pollRes.text().catch(() => "");
50
+ error(`Poll failed (${pollRes.status}): ${errText}`);
51
+ await sleep(POLL_INTERVAL_MS);
52
+ continue;
53
+ }
54
+
55
+ const { items } = await pollRes.json();
56
+
57
+ if (!items || items.length === 0) {
58
+ await sleep(POLL_INTERVAL_MS);
59
+ continue;
60
+ }
61
+
62
+ // 2. Process each item
63
+ for (const item of items) {
64
+ info(`Received message: "${item.message.slice(0, 60)}${item.message.length > 60 ? "..." : ""}"`);
65
+
66
+ try {
67
+ // 3. Forward to local OpenClaw Gateway
68
+ const gwRes = await fetch(`${LOCAL_GATEWAY_URL}/api/message`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({
72
+ message: item.message,
73
+ history: item.history,
74
+ }),
75
+ });
76
+
77
+ if (!gwRes.ok) {
78
+ const errText = await gwRes.text().catch(() => "Gateway error");
79
+ error(`Gateway returned ${gwRes.status}: ${errText}`);
80
+
81
+ // Report failure back to hub
82
+ await fetch(respondUrl, {
83
+ method: "POST",
84
+ headers,
85
+ body: JSON.stringify({
86
+ queueItemId: item.queueItemId,
87
+ content: `[Error] Gateway returned ${gwRes.status}: ${errText}`,
88
+ }),
89
+ });
90
+ continue;
91
+ }
92
+
93
+ // Collect response — handle both SSE and plain JSON
94
+ let responseText = "";
95
+ const contentType = gwRes.headers.get("content-type") || "";
96
+
97
+ if (contentType.includes("text/event-stream") && gwRes.body) {
98
+ // Parse SSE stream
99
+ const reader = gwRes.body.getReader();
100
+ const decoder = new TextDecoder();
101
+ while (true) {
102
+ const { done, value } = await reader.read();
103
+ if (done) break;
104
+ const chunk = decoder.decode(value, { stream: true });
105
+ const lines = chunk.split("\n");
106
+ for (const line of lines) {
107
+ if (line.startsWith("data: ")) {
108
+ try {
109
+ const parsed = JSON.parse(line.slice(6));
110
+ const text = parsed.text ?? parsed.content ?? "";
111
+ if (text) responseText += text;
112
+ } catch {
113
+ responseText += line.slice(6);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ } else {
119
+ // Plain JSON response
120
+ const data = await gwRes.json().catch(() => null);
121
+ responseText = data?.content ?? data?.text ?? data?.message ?? JSON.stringify(data);
122
+ }
123
+
124
+ // 4. Send response back to hub
125
+ const respondRes = await fetch(respondUrl, {
126
+ method: "POST",
127
+ headers,
128
+ body: JSON.stringify({
129
+ queueItemId: item.queueItemId,
130
+ content: responseText,
131
+ }),
132
+ });
133
+
134
+ if (respondRes.ok) {
135
+ success(`Response sent (${responseText.length} chars)`);
136
+ } else {
137
+ const errText = await respondRes.text().catch(() => "");
138
+ error(`Failed to send response: ${errText}`);
139
+ }
140
+ } catch (gwErr) {
141
+ error(`Gateway error: ${gwErr.message}`);
142
+ // Report failure
143
+ await fetch(respondUrl, {
144
+ method: "POST",
145
+ headers,
146
+ body: JSON.stringify({
147
+ queueItemId: item.queueItemId,
148
+ content: `[Error] ${gwErr.message}`,
149
+ }),
150
+ }).catch(() => {});
151
+ }
152
+ }
153
+ } catch (pollErr) {
154
+ error(`Poll error: ${pollErr.message}`);
155
+ }
156
+
157
+ await sleep(POLL_INTERVAL_MS);
158
+ }
159
+ }
160
+
161
+ function sleep(ms) {
162
+ return new Promise((resolve) => setTimeout(resolve, ms));
163
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * agent-factorio login — Connect to hub + join organization (with email verification)
3
+ */
4
+ import { ask, choose } from "../lib/prompt.mjs";
5
+ import { readGlobalConfig, upsertOrg } from "../lib/config.mjs";
6
+ import { apiCall, checkHub } from "../lib/api.mjs";
7
+ import { success, error, info, dim } from "../lib/log.mjs";
8
+
9
+ /**
10
+ * Poll verification status until verified or expired
11
+ * @param {string} hubUrl
12
+ * @param {string} loginToken
13
+ * @returns {Promise<{ userId: string, email: string }>}
14
+ */
15
+ async function waitForVerification(hubUrl, loginToken) {
16
+ const POLL_INTERVAL = 2000; // 2 seconds
17
+ const MAX_WAIT = 10 * 60 * 1000; // 10 minutes
18
+ const start = Date.now();
19
+
20
+ while (Date.now() - start < MAX_WAIT) {
21
+ const res = await apiCall(hubUrl, "/api/cli/login", {
22
+ body: { action: "check-verification", loginToken },
23
+ });
24
+
25
+ if (res.status === 410) {
26
+ throw new Error("Verification link expired. Please try again.");
27
+ }
28
+
29
+ if (!res.ok) {
30
+ throw new Error(res.data?.error || "Verification check failed.");
31
+ }
32
+
33
+ if (res.data.verified) {
34
+ return { userId: res.data.userId, email: res.data.email };
35
+ }
36
+
37
+ // Wait before next poll
38
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
39
+ }
40
+
41
+ throw new Error("Verification timed out. Please try again.");
42
+ }
43
+
44
+ export async function loginCommand() {
45
+ const existing = readGlobalConfig();
46
+ const defaultUrl = existing?.organizations?.[0]?.hubUrl || "";
47
+
48
+ // 1. Hub URL
49
+ const hubUrl = await ask("AgentFactorio Hub URL", defaultUrl || "http://localhost:3000");
50
+ if (!hubUrl) {
51
+ error("Hub URL is required.");
52
+ process.exit(1);
53
+ }
54
+
55
+ // Check connectivity
56
+ const reachable = await checkHub(hubUrl);
57
+ if (!reachable) {
58
+ error(`Cannot connect to ${hubUrl}. Is the hub running?`);
59
+ process.exit(1);
60
+ }
61
+ success("Hub connected.");
62
+
63
+ // 2. Email input
64
+ const email = await ask("Your email (used as your identifier)");
65
+ if (!email) {
66
+ error("Email is required.");
67
+ process.exit(1);
68
+ }
69
+
70
+ // 3. Send verification email
71
+ info("Sending verification email...");
72
+ const sendRes = await apiCall(hubUrl, "/api/cli/login", {
73
+ body: { action: "send-verification", email },
74
+ });
75
+
76
+ if (!sendRes.ok) {
77
+ error(`Failed to send verification email: ${sendRes.data?.error || "Unknown error"}`);
78
+ process.exit(1);
79
+ }
80
+
81
+ const { loginToken } = sendRes.data;
82
+ success("Verification email sent!");
83
+ info("Check your inbox and click the verification link.");
84
+ dim("Waiting for verification...");
85
+
86
+ // 4. Poll for verification
87
+ let userId;
88
+ try {
89
+ const result = await waitForVerification(hubUrl, loginToken);
90
+ userId = result.userId;
91
+ } catch (err) {
92
+ error(err.message);
93
+ process.exit(1);
94
+ }
95
+
96
+ success("Email verified!");
97
+
98
+ // 5. Name input
99
+ const memberName = await ask("Your name (displayed in the org)", "CLI User");
100
+
101
+ // 6. Create or Join
102
+ const { index: actionIdx } = await choose("Create or join an organization?", [
103
+ "Join existing (invite code)",
104
+ "Create new",
105
+ ]);
106
+
107
+ if (actionIdx === 1) {
108
+ // Create new org
109
+ const orgName = await ask("Organization name");
110
+ if (!orgName) {
111
+ error("Organization name is required.");
112
+ process.exit(1);
113
+ }
114
+
115
+ const res = await apiCall(hubUrl, "/api/cli/login", {
116
+ body: { action: "create", orgName, memberName, email, userId },
117
+ });
118
+
119
+ if (!res.ok) {
120
+ error(`Failed to create organization: ${res.data?.error || "Unknown error"}`);
121
+ process.exit(1);
122
+ }
123
+
124
+ const { orgId, orgName: name, inviteCode, memberId } = res.data;
125
+ upsertOrg({ hubUrl, orgId, orgName: name, inviteCode, memberName, email, memberId, userId });
126
+
127
+ success(`Created "${name}" (${orgId})`);
128
+ info(`Invite code: ${inviteCode} — share with your team!`);
129
+ } else {
130
+ // Join existing
131
+ const inviteCode = await ask("Invite code");
132
+ if (!inviteCode) {
133
+ error("Invite code is required.");
134
+ process.exit(1);
135
+ }
136
+
137
+ const res = await apiCall(hubUrl, "/api/cli/login", {
138
+ body: { action: "join", inviteCode, memberName, email, userId },
139
+ });
140
+
141
+ if (!res.ok) {
142
+ error(`Failed to join: ${res.data?.error || "Invalid invite code"}`);
143
+ process.exit(1);
144
+ }
145
+
146
+ const { orgId, orgName, memberId } = res.data;
147
+ upsertOrg({ hubUrl, orgId, orgName, inviteCode: inviteCode.toUpperCase(), memberName, email, memberId, userId });
148
+
149
+ success(`Joined "${orgName}" (${orgId})`);
150
+ }
151
+
152
+ console.log("\nLogged in! Run `agent-factorio push` in any project to register an agent.");
153
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * agent-factorio logout — Delete global config
3
+ */
4
+ import { deleteGlobalConfig, GLOBAL_CONFIG } from "../lib/config.mjs";
5
+ import { success, warn } from "../lib/log.mjs";
6
+ import * as fs from "fs";
7
+
8
+ export async function logoutCommand() {
9
+ try {
10
+ fs.accessSync(GLOBAL_CONFIG);
11
+ } catch {
12
+ warn("Not logged in.");
13
+ return;
14
+ }
15
+
16
+ deleteGlobalConfig();
17
+ success("Logged out. Global config removed.");
18
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * agent-factorio push — Detect and push agent config to hub
3
+ */
4
+ import * as path from "path";
5
+ import { ask, choose } from "../lib/prompt.mjs";
6
+ import { getDefaultOrg, readLocalConfig, writeLocalConfig, findProjectRoot } from "../lib/config.mjs";
7
+ import { apiCall } from "../lib/api.mjs";
8
+ import { detectAll } from "../lib/detect.mjs";
9
+ import { success, error, info, label, heading } from "../lib/log.mjs";
10
+
11
+ export async function pushCommand() {
12
+ // 1. Check login
13
+ const org = getDefaultOrg();
14
+ if (!org) {
15
+ error("Not logged in. Run `agent-factorio login` first.");
16
+ process.exit(1);
17
+ }
18
+
19
+ const projectRoot = findProjectRoot();
20
+ const localConfig = readLocalConfig(projectRoot);
21
+
22
+ // 2. Auto-detect
23
+ heading("Detecting agent configuration...");
24
+ const detected = detectAll(projectRoot);
25
+
26
+ label("Git repo", detected.git.repoUrl || "(none)");
27
+ label("Skills", detected.skills.length > 0
28
+ ? `${detected.skills.join(", ")} (${detected.skills.length})`
29
+ : "(none)");
30
+ label("MCP servers", detected.mcpServers.length > 0
31
+ ? `${detected.mcpServers.join(", ")} (${detected.mcpServers.length})`
32
+ : "(none)");
33
+ label("CLAUDE.md", detected.claudeMd.found
34
+ ? `found (${detected.claudeMd.path})`
35
+ : "(not found)");
36
+ label("Subscriptions", detected.subscriptions.length > 0
37
+ ? `${detected.subscriptions.map((s) => s.name).join(", ")} (auto-detected)`
38
+ : "(none)");
39
+ console.log();
40
+
41
+ // 3. Agent name, vendor, model
42
+ const defaultName = localConfig?.agentName || path.basename(projectRoot);
43
+ const agentName = await ask("Agent name", defaultName);
44
+
45
+ const vendorOptions = ["anthropic", "openai", "google"];
46
+ const { value: vendor } = await choose("Vendor", vendorOptions);
47
+
48
+ const modelOptions = getModelOptions(vendor);
49
+ const defaultModel = localConfig?.model;
50
+ const { value: model } = defaultModel && modelOptions.includes(defaultModel)
51
+ ? { value: defaultModel }
52
+ : await choose("Model", modelOptions);
53
+
54
+ // 4. Build request body
55
+ const body = {
56
+ agentId: localConfig?.agentId || undefined,
57
+ agentName,
58
+ vendor,
59
+ model,
60
+ orgId: org.orgId,
61
+ memberId: org.memberId || undefined,
62
+ description: `Pushed via CLI at ${new Date().toISOString()}`,
63
+ };
64
+
65
+ // Attach MCP tools
66
+ if (detected.mcpServers.length > 0) {
67
+ body.mcpTools = detected.mcpServers.map((name) => ({ name, server: name }));
68
+ }
69
+
70
+ // Attach skills
71
+ if (detected.skills.length > 0) {
72
+ body.skills = detected.skills;
73
+ }
74
+
75
+ // Attach git repo URL
76
+ if (detected.git.repoUrl) {
77
+ body.repoUrl = detected.git.repoUrl;
78
+ }
79
+
80
+ // Attach detected subscriptions
81
+ if (detected.subscriptions.length > 0) {
82
+ body.detectedSubscriptions = detected.subscriptions;
83
+ }
84
+
85
+ // Attach CLAUDE.md as context
86
+ if (detected.claudeMd.found) {
87
+ body.context = [{
88
+ type: "claude-md",
89
+ content: detected.claudeMd.content,
90
+ sourceFile: detected.claudeMd.path,
91
+ }];
92
+ }
93
+
94
+ // 5. Push to hub
95
+ console.log();
96
+ info(`Pushing to "${org.orgName}" at ${org.hubUrl}...`);
97
+
98
+ const res = await apiCall(org.hubUrl, "/api/cli/push", { body });
99
+
100
+ if (!res.ok) {
101
+ error(`Failed to push: ${res.data?.error || "Unknown error"}`);
102
+ process.exit(1);
103
+ }
104
+
105
+ const { id: agentId, updated, pollToken } = res.data;
106
+
107
+ const configData = {
108
+ hubUrl: org.hubUrl,
109
+ orgId: org.orgId,
110
+ agentId,
111
+ agentName,
112
+ vendor,
113
+ model,
114
+ pushedAt: new Date().toISOString(),
115
+ };
116
+
117
+ // Save pollToken if returned (openclaw agents)
118
+ if (pollToken) {
119
+ configData.pollToken = pollToken;
120
+ }
121
+
122
+ writeLocalConfig(configData, projectRoot);
123
+
124
+ if (updated) {
125
+ success(`Agent updated! (${agentId})`);
126
+ } else {
127
+ success(`Agent registered! (${agentId})`);
128
+ }
129
+
130
+ console.log(`\nDashboard: ${org.hubUrl}`);
131
+ }
132
+
133
+ function getModelOptions(vendor) {
134
+ switch (vendor) {
135
+ case "anthropic":
136
+ return ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"];
137
+ case "openai":
138
+ return ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini"];
139
+ case "google":
140
+ return ["gemini-2.0-flash", "gemini-2.0-pro", "gemini-1.5-pro"];
141
+ default:
142
+ return ["default"];
143
+ }
144
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * agent-factorio status — Show registration status for current project
3
+ */
4
+ import { readLocalConfig, getDefaultOrg, findProjectRoot } from "../lib/config.mjs";
5
+ import { label, heading, warn, success } from "../lib/log.mjs";
6
+
7
+ export async function statusCommand() {
8
+ const org = getDefaultOrg();
9
+
10
+ // Account info
11
+ heading("Account");
12
+ if (org) {
13
+ label("Name", org.memberName || "(not set)");
14
+ label("Email", org.email || "(not set)");
15
+ label("Member ID", org.memberId || "(not set)");
16
+ label("Organization", org.orgName);
17
+ label("Org ID", org.orgId);
18
+ label("Invite code", org.inviteCode);
19
+ label("Hub URL", org.hubUrl);
20
+ } else {
21
+ warn("Not logged in. Run `agent-factorio login` first.");
22
+ }
23
+
24
+ console.log();
25
+
26
+ // Agent info
27
+ const projectRoot = findProjectRoot();
28
+ const localConfig = readLocalConfig(projectRoot);
29
+
30
+ heading("Agent");
31
+ if (!localConfig) {
32
+ warn("No agent registered in this project.");
33
+ console.log('Run `agent-factorio push` to register.');
34
+ return;
35
+ }
36
+
37
+ label("Agent ID", localConfig.agentId);
38
+ label("Agent name", localConfig.agentName);
39
+ label("Vendor", localConfig.vendor);
40
+ label("Model", localConfig.model);
41
+ label("Last pushed", localConfig.pushedAt || "unknown");
42
+ console.log();
43
+ success("Agent is registered.");
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * agent-factorio whoami — Show login info (hub URL, organizations)
3
+ */
4
+ import { readGlobalConfig } from "../lib/config.mjs";
5
+ import { label, heading, warn } from "../lib/log.mjs";
6
+
7
+ export async function whoamiCommand() {
8
+ const config = readGlobalConfig();
9
+
10
+ if (!config || !config.organizations?.length) {
11
+ warn("Not logged in.");
12
+ console.log('Run `agent-factorio login` to connect to a hub.');
13
+ return;
14
+ }
15
+
16
+ heading("Login Info");
17
+
18
+ for (const org of config.organizations) {
19
+ const isDefault = org.orgId === config.defaultOrg ? " (default)" : "";
20
+ console.log();
21
+ label("Organization", `${org.orgName}${isDefault}`);
22
+ label("Org ID", org.orgId);
23
+ label("Hub URL", org.hubUrl);
24
+ label("Invite code", org.inviteCode);
25
+ if (org.memberName) {
26
+ label("Member name", org.memberName);
27
+ }
28
+ }
29
+ console.log();
30
+ }
package/lib/api.mjs ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Hub API call helper
3
+ */
4
+
5
+ /**
6
+ * Make an API request to the AgentFactorio hub
7
+ * @param {string} hubUrl - Base URL of the hub
8
+ * @param {string} path - API path (e.g. "/api/cli/login")
9
+ * @param {{ method?: string, body?: unknown }} [options]
10
+ * @returns {Promise<{ ok: boolean, status: number, data: unknown }>}
11
+ */
12
+ export async function apiCall(hubUrl, path, options = {}) {
13
+ const url = `${hubUrl.replace(/\/$/, "")}${path}`;
14
+ const method = options.method || (options.body ? "POST" : "GET");
15
+
16
+ const fetchOptions = {
17
+ method,
18
+ headers: { "Content-Type": "application/json" },
19
+ };
20
+
21
+ if (options.body) {
22
+ fetchOptions.body = JSON.stringify(options.body);
23
+ }
24
+
25
+ const res = await fetch(url, fetchOptions);
26
+ let data;
27
+ try {
28
+ data = await res.json();
29
+ } catch {
30
+ data = null;
31
+ }
32
+
33
+ return { ok: res.ok, status: res.status, data };
34
+ }
35
+
36
+ /**
37
+ * Check if hub is reachable
38
+ * @param {string} hubUrl
39
+ * @returns {Promise<boolean>}
40
+ */
41
+ export async function checkHub(hubUrl) {
42
+ try {
43
+ const res = await fetch(`${hubUrl.replace(/\/$/, "")}/api/cli/login`, {
44
+ method: "OPTIONS",
45
+ });
46
+ // Any response (even 405) means the server is reachable
47
+ return res.status < 500;
48
+ } catch {
49
+ // Try a simple GET to the root
50
+ try {
51
+ const res = await fetch(hubUrl);
52
+ return res.ok;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Global (~/.agent-factorio/config.json) and local (.agent-factorio/config.json) config management
3
+ */
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+
8
+ const GLOBAL_DIR = path.join(os.homedir(), ".agent-factorio");
9
+ const GLOBAL_CONFIG = path.join(GLOBAL_DIR, "config.json");
10
+ const LOCAL_DIR_NAME = ".agent-factorio";
11
+ const LOCAL_CONFIG_NAME = "config.json";
12
+
13
+ // --- Global config ---
14
+
15
+ /**
16
+ * @typedef {{ hubUrl: string, orgId: string, orgName: string, inviteCode: string, memberName?: string, email?: string, memberId?: string, userId?: string }} OrgEntry
17
+ * @typedef {{ organizations: OrgEntry[], defaultOrg?: string }} GlobalConfig
18
+ */
19
+
20
+ /** @returns {GlobalConfig | null} */
21
+ export function readGlobalConfig() {
22
+ try {
23
+ const raw = fs.readFileSync(GLOBAL_CONFIG, "utf-8");
24
+ return JSON.parse(raw);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** @param {GlobalConfig} config */
31
+ export function writeGlobalConfig(config) {
32
+ fs.mkdirSync(GLOBAL_DIR, { recursive: true });
33
+ fs.writeFileSync(GLOBAL_CONFIG, JSON.stringify(config, null, 2) + "\n");
34
+ }
35
+
36
+ export function deleteGlobalConfig() {
37
+ try {
38
+ fs.unlinkSync(GLOBAL_CONFIG);
39
+ } catch {
40
+ // ignore if not exists
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Get the default (or only) organization from global config
46
+ * @returns {OrgEntry | null}
47
+ */
48
+ export function getDefaultOrg() {
49
+ const config = readGlobalConfig();
50
+ if (!config || !config.organizations?.length) return null;
51
+
52
+ if (config.defaultOrg) {
53
+ const found = config.organizations.find((o) => o.orgId === config.defaultOrg);
54
+ if (found) return found;
55
+ }
56
+ return config.organizations[0];
57
+ }
58
+
59
+ /**
60
+ * Add or update an organization in global config
61
+ * @param {OrgEntry} org
62
+ */
63
+ export function upsertOrg(org) {
64
+ const config = readGlobalConfig() || { organizations: [] };
65
+ const idx = config.organizations.findIndex(
66
+ (o) => o.orgId === org.orgId && o.hubUrl === org.hubUrl
67
+ );
68
+ if (idx >= 0) {
69
+ config.organizations[idx] = org;
70
+ } else {
71
+ config.organizations.push(org);
72
+ }
73
+ if (!config.defaultOrg) {
74
+ config.defaultOrg = org.orgId;
75
+ }
76
+ writeGlobalConfig(config);
77
+ }
78
+
79
+ // --- Local (project) config ---
80
+
81
+ /**
82
+ * @typedef {{ hubUrl: string, orgId: string, agentId: string, agentName: string, vendor: string, model: string, pushedAt: string }} LocalConfig
83
+ */
84
+
85
+ /**
86
+ * Find project root by looking for .git directory
87
+ * @param {string} [startDir]
88
+ * @returns {string}
89
+ */
90
+ export function findProjectRoot(startDir) {
91
+ let dir = startDir || process.cwd();
92
+ while (dir !== path.dirname(dir)) {
93
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
94
+ dir = path.dirname(dir);
95
+ }
96
+ return process.cwd();
97
+ }
98
+
99
+ /** @param {string} [projectRoot] @returns {LocalConfig | null} */
100
+ export function readLocalConfig(projectRoot) {
101
+ const root = projectRoot || findProjectRoot();
102
+ const configPath = path.join(root, LOCAL_DIR_NAME, LOCAL_CONFIG_NAME);
103
+ try {
104
+ const raw = fs.readFileSync(configPath, "utf-8");
105
+ return JSON.parse(raw);
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /** @param {LocalConfig} config @param {string} [projectRoot] */
112
+ export function writeLocalConfig(config, projectRoot) {
113
+ const root = projectRoot || findProjectRoot();
114
+ const dir = path.join(root, LOCAL_DIR_NAME);
115
+ fs.mkdirSync(dir, { recursive: true });
116
+ fs.writeFileSync(
117
+ path.join(dir, LOCAL_CONFIG_NAME),
118
+ JSON.stringify(config, null, 2) + "\n"
119
+ );
120
+ }
121
+
122
+ export { GLOBAL_CONFIG };
package/lib/detect.mjs ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Auto-detect project configuration: git repo, skills, MCP servers, CLAUDE.md
3
+ */
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { execSync } from "child_process";
7
+ import { findProjectRoot } from "./config.mjs";
8
+
9
+ /**
10
+ * Detect git remote URL and repo root
11
+ * @param {string} [projectRoot]
12
+ * @returns {{ repoUrl: string | null, repoRoot: string }}
13
+ */
14
+ export function detectGitRepo(projectRoot) {
15
+ const root = projectRoot || findProjectRoot();
16
+ let repoUrl = null;
17
+ try {
18
+ repoUrl = execSync("git remote get-url origin", { cwd: root, encoding: "utf-8" }).trim();
19
+ } catch {
20
+ // no git remote
21
+ }
22
+ return { repoUrl, repoRoot: root };
23
+ }
24
+
25
+ /**
26
+ * Detect skills from .claude/commands/*.md and .claude/skills/ ** /*.md
27
+ * @param {string} [projectRoot]
28
+ * @returns {string[]}
29
+ */
30
+ export function detectSkills(projectRoot) {
31
+ const root = projectRoot || findProjectRoot();
32
+ const skills = [];
33
+
34
+ // .claude/commands/*.md
35
+ const commandsDir = path.join(root, ".claude", "commands");
36
+ if (fs.existsSync(commandsDir)) {
37
+ for (const file of readdirRecursive(commandsDir, ".md")) {
38
+ const name = extractSkillName(file);
39
+ if (name) skills.push(name);
40
+ }
41
+ }
42
+
43
+ // .claude/skills/**/SKILL.md (only the entry-point file per skill)
44
+ const skillsDir = path.join(root, ".claude", "skills");
45
+ if (fs.existsSync(skillsDir)) {
46
+ for (const file of readdirRecursive(skillsDir, ".md")) {
47
+ if (path.basename(file) !== "SKILL.md") continue;
48
+ const name = extractSkillName(file);
49
+ if (name) skills.push(name);
50
+ }
51
+ }
52
+
53
+ // Also check top-level skills/ directory (same: only SKILL.md)
54
+ const topSkillsDir = path.join(root, "skills");
55
+ if (fs.existsSync(topSkillsDir)) {
56
+ for (const file of readdirRecursive(topSkillsDir, ".md")) {
57
+ if (path.basename(file) !== "SKILL.md") continue;
58
+ const name = extractSkillName(file);
59
+ if (name) skills.push(name);
60
+ }
61
+ }
62
+
63
+ return [...new Set(skills)];
64
+ }
65
+
66
+ /**
67
+ * Detect MCP servers from multiple config locations:
68
+ * 1. .claude/settings.local.json (project-local)
69
+ * 2. .claude/settings.json (project-local)
70
+ * 3. ~/.claude.json (global, project-scoped settings)
71
+ * @param {string} [projectRoot]
72
+ * @returns {string[]}
73
+ */
74
+ export function detectMcpServers(projectRoot) {
75
+ const root = projectRoot || findProjectRoot();
76
+ const servers = new Set();
77
+
78
+ // 1. Project-local settings
79
+ for (const filename of ["settings.local.json", "settings.json"]) {
80
+ const settingsPath = path.join(root, ".claude", filename);
81
+ try {
82
+ const raw = fs.readFileSync(settingsPath, "utf-8");
83
+ const settings = JSON.parse(raw);
84
+ if (settings.mcpServers && typeof settings.mcpServers === "object") {
85
+ for (const name of Object.keys(settings.mcpServers)) {
86
+ servers.add(name);
87
+ }
88
+ }
89
+ } catch {
90
+ // file not found or invalid JSON
91
+ }
92
+ }
93
+
94
+ // 2. Global ~/.claude.json — project-scoped MCP settings
95
+ const resolvedRoot = fs.realpathSync(root);
96
+ const globalConfigPath = path.join(process.env.HOME || "", ".claude.json");
97
+ try {
98
+ const raw = fs.readFileSync(globalConfigPath, "utf-8");
99
+ const config = JSON.parse(raw);
100
+ if (config.projects && typeof config.projects === "object") {
101
+ const projectSettings = config.projects[resolvedRoot];
102
+ if (projectSettings?.mcpServers && typeof projectSettings.mcpServers === "object") {
103
+ for (const name of Object.keys(projectSettings.mcpServers)) {
104
+ if (Object.keys(projectSettings.mcpServers[name]).length > 0) {
105
+ servers.add(name);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ } catch {
111
+ // file not found or invalid JSON
112
+ }
113
+
114
+ return [...servers];
115
+ }
116
+
117
+ /**
118
+ * Detect CLAUDE.md content
119
+ * @param {string} [projectRoot]
120
+ * @returns {{ found: boolean, path: string | null, content: string | null }}
121
+ */
122
+ export function detectClaudeMd(projectRoot) {
123
+ const root = projectRoot || findProjectRoot();
124
+
125
+ // Check .claude/CLAUDE.md first, then root CLAUDE.md
126
+ for (const relPath of [".claude/CLAUDE.md", "CLAUDE.md"]) {
127
+ const fullPath = path.join(root, relPath);
128
+ try {
129
+ const content = fs.readFileSync(fullPath, "utf-8");
130
+ return { found: true, path: relPath, content };
131
+ } catch {
132
+ // not found
133
+ }
134
+ }
135
+
136
+ return { found: false, path: null, content: null };
137
+ }
138
+
139
+ /**
140
+ * Detect AI service subscriptions from environment and local tooling
141
+ * @returns {{ name: string, detectionSource: string }[]}
142
+ */
143
+ export function detectSubscriptions() {
144
+ const subs = [];
145
+
146
+ // Claude Code — always true when running inside Claude Code
147
+ if (process.env.CLAUDE_CODE_VERSION) {
148
+ subs.push({ name: "Claude Code", detectionSource: "env_var" });
149
+ }
150
+
151
+ // API keys
152
+ if (process.env.ANTHROPIC_API_KEY) {
153
+ subs.push({ name: "Anthropic API", detectionSource: "env_var" });
154
+ }
155
+ if (process.env.OPENAI_API_KEY) {
156
+ subs.push({ name: "OpenAI API", detectionSource: "env_var" });
157
+ }
158
+
159
+ // Cursor
160
+ const cursorDir = path.join(process.env.HOME || "", ".cursor");
161
+ if (fs.existsSync(cursorDir)) {
162
+ subs.push({ name: "Cursor", detectionSource: "cli_push" });
163
+ }
164
+
165
+ // GitHub Copilot
166
+ const vscodeExtDir = path.join(process.env.HOME || "", ".vscode", "extensions");
167
+ try {
168
+ if (fs.existsSync(vscodeExtDir)) {
169
+ const entries = fs.readdirSync(vscodeExtDir);
170
+ if (entries.some((e) => e.startsWith("github.copilot"))) {
171
+ subs.push({ name: "GitHub Copilot", detectionSource: "cli_push" });
172
+ }
173
+ }
174
+ } catch {
175
+ // ignore
176
+ }
177
+
178
+ // Windsurf
179
+ const windsurfDir = path.join(process.env.HOME || "", ".windsurf");
180
+ if (fs.existsSync(windsurfDir)) {
181
+ subs.push({ name: "Windsurf", detectionSource: "cli_push" });
182
+ }
183
+
184
+ return subs;
185
+ }
186
+
187
+ /**
188
+ * Run all detections and return a summary
189
+ * @param {string} [projectRoot]
190
+ */
191
+ export function detectAll(projectRoot) {
192
+ const root = projectRoot || findProjectRoot();
193
+ return {
194
+ git: detectGitRepo(root),
195
+ skills: detectSkills(root),
196
+ mcpServers: detectMcpServers(root),
197
+ claudeMd: detectClaudeMd(root),
198
+ subscriptions: detectSubscriptions(),
199
+ };
200
+ }
201
+
202
+ // --- Helpers ---
203
+
204
+ /**
205
+ * Recursively find files with a given extension
206
+ * @param {string} dir
207
+ * @param {string} ext
208
+ * @returns {string[]}
209
+ */
210
+ function readdirRecursive(dir, ext) {
211
+ const results = [];
212
+ try {
213
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
214
+ for (const entry of entries) {
215
+ const fullPath = path.join(dir, entry.name);
216
+ if (entry.isDirectory()) {
217
+ results.push(...readdirRecursive(fullPath, ext));
218
+ } else if (entry.name.endsWith(ext)) {
219
+ results.push(fullPath);
220
+ }
221
+ }
222
+ } catch {
223
+ // permission error or not found
224
+ }
225
+ return results;
226
+ }
227
+
228
+ /**
229
+ * Extract skill name from a markdown file (first heading or filename)
230
+ * @param {string} filePath
231
+ * @returns {string | null}
232
+ */
233
+ function extractSkillName(filePath) {
234
+ try {
235
+ const content = fs.readFileSync(filePath, "utf-8");
236
+ // Try to find first heading
237
+ const match = content.match(/^#\s+(.+)/m);
238
+ if (match) return match[1].trim();
239
+ } catch {
240
+ // fall through
241
+ }
242
+ // Use filename without extension
243
+ const base = path.basename(filePath, ".md");
244
+ if (base === "SKILL" || base === "README") {
245
+ // Use parent directory name
246
+ return path.basename(path.dirname(filePath));
247
+ }
248
+ return base;
249
+ }
package/lib/log.mjs ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Colored output utilities for CLI
3
+ */
4
+
5
+ const RESET = "\x1b[0m";
6
+ const GREEN = "\x1b[32m";
7
+ const RED = "\x1b[31m";
8
+ const YELLOW = "\x1b[33m";
9
+ const CYAN = "\x1b[36m";
10
+ const DIM = "\x1b[2m";
11
+ const BOLD = "\x1b[1m";
12
+
13
+ export function success(msg) {
14
+ console.log(`${GREEN}\u2713${RESET} ${msg}`);
15
+ }
16
+
17
+ export function error(msg) {
18
+ console.error(`${RED}\u2717${RESET} ${msg}`);
19
+ }
20
+
21
+ export function warn(msg) {
22
+ console.log(`${YELLOW}!${RESET} ${msg}`);
23
+ }
24
+
25
+ export function info(msg) {
26
+ console.log(`${CYAN}i${RESET} ${msg}`);
27
+ }
28
+
29
+ export function dim(msg) {
30
+ console.log(`${DIM}${msg}${RESET}`);
31
+ }
32
+
33
+ export function bold(msg) {
34
+ return `${BOLD}${msg}${RESET}`;
35
+ }
36
+
37
+ export function label(key, value) {
38
+ console.log(` ${DIM}${key}:${RESET} ${value}`);
39
+ }
40
+
41
+ export function heading(msg) {
42
+ console.log(`\n${BOLD}${msg}${RESET}`);
43
+ }
package/lib/prompt.mjs ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Interactive CLI prompts (reuses patterns from scripts/lib/stdin.mjs)
3
+ */
4
+ import * as readline from "readline";
5
+
6
+ /**
7
+ * Ask a question and return the user's answer
8
+ * @param {string} question - The prompt to display
9
+ * @param {string} [defaultValue] - Default value if user presses Enter
10
+ * @returns {Promise<string>}
11
+ */
12
+ export function ask(question, defaultValue) {
13
+ const rl = readline.createInterface({
14
+ input: process.stdin,
15
+ output: process.stderr,
16
+ });
17
+
18
+ const hint = defaultValue ? ` [${defaultValue}]` : "";
19
+ const prompt = `? ${question}${hint}: `;
20
+
21
+ return new Promise((resolve) => {
22
+ rl.question(prompt, (answer) => {
23
+ rl.close();
24
+ resolve(answer.trim() || defaultValue || "");
25
+ });
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Ask user to choose from a list of options
31
+ * @param {string} question - The prompt to display
32
+ * @param {string[]} options - List of options
33
+ * @returns {Promise<{index: number, value: string}>}
34
+ */
35
+ export function choose(question, options) {
36
+ const rl = readline.createInterface({
37
+ input: process.stdin,
38
+ output: process.stderr,
39
+ });
40
+
41
+ return new Promise((resolve) => {
42
+ process.stderr.write(`? ${question}\n`);
43
+ options.forEach((opt, i) => {
44
+ process.stderr.write(` ${i + 1}. ${opt}\n`);
45
+ });
46
+ rl.question("Choice: ", (answer) => {
47
+ rl.close();
48
+ const idx = parseInt(answer, 10) - 1;
49
+ if (idx >= 0 && idx < options.length) {
50
+ resolve({ index: idx, value: options[idx] });
51
+ } else {
52
+ resolve({ index: 0, value: options[0] });
53
+ }
54
+ });
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Ask a yes/no question
60
+ * @param {string} question - The prompt to display
61
+ * @param {boolean} [defaultYes=true] - Default answer
62
+ * @returns {Promise<boolean>}
63
+ */
64
+ export function confirm(question, defaultYes = true) {
65
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
66
+ const rl = readline.createInterface({
67
+ input: process.stdin,
68
+ output: process.stderr,
69
+ });
70
+
71
+ return new Promise((resolve) => {
72
+ rl.question(`? ${question} ${suffix}: `, (answer) => {
73
+ rl.close();
74
+ const a = answer.trim().toLowerCase();
75
+ if (a === "") resolve(defaultYes);
76
+ else resolve(a === "y" || a === "yes");
77
+ });
78
+ });
79
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "agent-factorio",
3
+ "version": "0.2.0",
4
+ "description": "CLI for AgentFactorio — AI Agent Fleet Management hub",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-factorio": "bin.js"
8
+ },
9
+ "files": [
10
+ "bin.js",
11
+ "commands/",
12
+ "lib/"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "ai",
19
+ "agent",
20
+ "claude",
21
+ "fleet",
22
+ "management",
23
+ "cli"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/gmuffiness/agent-factorio.git",
28
+ "directory": "cli"
29
+ },
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "commander": "^14.0.3"
33
+ }
34
+ }