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 +72 -0
- package/bin.js +65 -0
- package/commands/connect.mjs +163 -0
- package/commands/login.mjs +153 -0
- package/commands/logout.mjs +18 -0
- package/commands/push.mjs +144 -0
- package/commands/status.mjs +44 -0
- package/commands/whoami.mjs +30 -0
- package/lib/api.mjs +57 -0
- package/lib/config.mjs +122 -0
- package/lib/detect.mjs +249 -0
- package/lib/log.mjs +43 -0
- package/lib/prompt.mjs +79 -0
- package/package.json +34 -0
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
|
+
}
|