@platless/axom-cli 0.1.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 +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { AuthError, loadDeviceId } from './config.js';
|
|
3
|
+
import { requestCredentialBroker, runCredentialBroker } from './credentialBroker.js';
|
|
4
|
+
const service = 'com.platless.axom.cli';
|
|
5
|
+
const account = (server) => createHash('sha256').update(server).digest('hex');
|
|
6
|
+
const accessCache = new Map();
|
|
7
|
+
const memoryStore = new Map();
|
|
8
|
+
const usesTestMemoryStore = () => process.env.AXOM_CREDENTIAL_STORE === 'memory';
|
|
9
|
+
export async function saveTokens(server, tokens) {
|
|
10
|
+
if (usesTestMemoryStore()) {
|
|
11
|
+
memoryStore.set(server, tokens);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (process.platform === 'darwin') {
|
|
15
|
+
await requestCredentialBroker({ action: 'save', server, tokens });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await saveNativeTokens(server, tokens);
|
|
19
|
+
}
|
|
20
|
+
/** Returns only an access token. A macOS CLI process never receives the persisted refresh token. */
|
|
21
|
+
export async function accessTokenFor(server, forceRefresh = false) {
|
|
22
|
+
// AXOM_ACCESS_TOKEN wins over every stored credential (gh / Claude Code convention) and returns
|
|
23
|
+
// before anything below touches the credential store. That's what makes a container work: on
|
|
24
|
+
// Linux the store goes through @napi-rs/keyring's Secret Service over DBus, which a container
|
|
25
|
+
// without DBus cannot reach — so this branch must stay first, not be added after a store lookup.
|
|
26
|
+
const fromEnv = process.env.AXOM_ACCESS_TOKEN?.trim();
|
|
27
|
+
if (fromEnv)
|
|
28
|
+
return fromEnv;
|
|
29
|
+
if (usesTestMemoryStore())
|
|
30
|
+
return accessTokenFromMemoryStore(server, forceRefresh);
|
|
31
|
+
if (process.platform === 'darwin') {
|
|
32
|
+
const response = await requestCredentialBroker({ action: 'access', server, forceRefresh });
|
|
33
|
+
return response.accessToken;
|
|
34
|
+
}
|
|
35
|
+
return accessTokenFromNativeStore(server, forceRefresh);
|
|
36
|
+
}
|
|
37
|
+
export async function clearTokens(server) {
|
|
38
|
+
if (usesTestMemoryStore()) {
|
|
39
|
+
memoryStore.delete(server);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (process.platform === 'darwin') {
|
|
43
|
+
await requestCredentialBroker({ action: 'clear', server });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
await clearNativeTokens(server);
|
|
47
|
+
}
|
|
48
|
+
export async function revokeAndClearTokens(server) {
|
|
49
|
+
if (usesTestMemoryStore()) {
|
|
50
|
+
const tokens = memoryStore.get(server);
|
|
51
|
+
if (tokens?.refreshToken)
|
|
52
|
+
await revokeRefreshToken(server, tokens.refreshToken);
|
|
53
|
+
memoryStore.delete(server);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (process.platform === 'darwin') {
|
|
57
|
+
await requestCredentialBroker({ action: 'revoke', server });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const persisted = await loadNativeTokens(server);
|
|
61
|
+
if (persisted?.refreshToken)
|
|
62
|
+
await revokeRefreshToken(server, persisted.refreshToken);
|
|
63
|
+
await clearNativeTokens(server);
|
|
64
|
+
}
|
|
65
|
+
export { runCredentialBroker };
|
|
66
|
+
/** Test-only inspection. Production callers must use accessTokenFor so refresh tokens stay private. */
|
|
67
|
+
export async function loadTokens(server) {
|
|
68
|
+
if (!usesTestMemoryStore())
|
|
69
|
+
throw new Error('loadTokens is available only with the in-memory test credential store.');
|
|
70
|
+
return memoryStore.get(server);
|
|
71
|
+
}
|
|
72
|
+
export async function loadNativeTokens(server) {
|
|
73
|
+
const keyring = await import('@napi-rs/keyring');
|
|
74
|
+
// getPassword() returns null for "no entry" but the read can *throw* instead when the store
|
|
75
|
+
// itself is unreachable — on Linux that's the Secret Service over DBus, which a container
|
|
76
|
+
// without DBus cannot reach. In practice @napi-rs/keyring surfaces that failure as early as
|
|
77
|
+
// `new Entry(...)` (opening the store), not only from getPassword() itself, so both go inside
|
|
78
|
+
// the same try. For a read, an unreachable store and an empty one are the same answer: there is
|
|
79
|
+
// no credential to return. Reporting that (rather than propagating the native error) is what
|
|
80
|
+
// lets `accessTokenFor` fall through to its own refusal, which names AXOM_ACCESS_TOKEN and
|
|
81
|
+
// points at Settings → Security → Access Tokens, instead of the caller seeing a raw
|
|
82
|
+
// "PermissionDenied" from @napi-rs/keyring.
|
|
83
|
+
// Scoped to just the read attempt: the "credential stored in the system keyring is invalid"
|
|
84
|
+
// throw below is ours and must keep propagating, not get swallowed by a broader try/catch.
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = new keyring.Entry(service, account(server)).getPassword();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (!raw)
|
|
93
|
+
return undefined;
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (typeof parsed.refreshToken !== 'string' || !parsed.refreshToken) {
|
|
96
|
+
throw new Error('The Axomwork credential stored in the system keyring is invalid. Run axom auth again.');
|
|
97
|
+
}
|
|
98
|
+
return { refreshToken: parsed.refreshToken };
|
|
99
|
+
}
|
|
100
|
+
export async function saveNativeTokens(server, tokens) {
|
|
101
|
+
const keyring = await import('@napi-rs/keyring');
|
|
102
|
+
new keyring.Entry(service, account(server)).setPassword(JSON.stringify({ refreshToken: tokens.refreshToken }));
|
|
103
|
+
accessCache.set(server, { accessToken: tokens.accessToken, expiresAt: tokens.expiresAt });
|
|
104
|
+
}
|
|
105
|
+
export async function clearNativeTokens(server) {
|
|
106
|
+
accessCache.delete(server);
|
|
107
|
+
const keyring = await import('@napi-rs/keyring');
|
|
108
|
+
new keyring.Entry(service, account(server)).deletePassword();
|
|
109
|
+
}
|
|
110
|
+
export async function accessTokenFromNativeStore(server, forceRefresh) {
|
|
111
|
+
const persisted = await loadNativeTokens(server);
|
|
112
|
+
if (!persisted)
|
|
113
|
+
return undefined;
|
|
114
|
+
const cached = accessCache.get(server);
|
|
115
|
+
if (!forceRefresh && cached?.accessToken && Date.parse(cached.expiresAt) - Date.now() >= 30_000) {
|
|
116
|
+
return cached.accessToken;
|
|
117
|
+
}
|
|
118
|
+
const response = await fetch(`${normalizedServer(server)}/oauth/token`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
// `device_id` keeps this machine on the one session row it already has, and re-asserts it if
|
|
121
|
+
// the row was migrated from a pre-session token family.
|
|
122
|
+
body: new URLSearchParams({
|
|
123
|
+
grant_type: 'refresh_token',
|
|
124
|
+
client_id: 'axom-cli',
|
|
125
|
+
refresh_token: persisted.refreshToken,
|
|
126
|
+
device_id: await loadDeviceId(),
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
if (response.status !== 200) {
|
|
130
|
+
if (response.status >= 400 && response.status < 500)
|
|
131
|
+
await clearNativeTokens(server);
|
|
132
|
+
// `AuthError`, not `Error`: `runCommand` turns this type into exit code 3 ("re-authenticate")
|
|
133
|
+
// and everything else into 1 ("it broke"). A dead refresh token is the former — the message
|
|
134
|
+
// already tells the reader to run `axom auth` — and it used to be typed that way, in `mcp.ts`,
|
|
135
|
+
// until the credential-broker refactor moved the throw here and dropped the type with it.
|
|
136
|
+
throw new AuthError(`Token refresh failed: ${await oauthError(response)}. Run: axom auth --server ${server}`);
|
|
137
|
+
}
|
|
138
|
+
const token = await parseTokenResponse(response);
|
|
139
|
+
await saveNativeTokens(server, token);
|
|
140
|
+
return token.accessToken;
|
|
141
|
+
}
|
|
142
|
+
async function accessTokenFromMemoryStore(server, forceRefresh) {
|
|
143
|
+
const tokens = memoryStore.get(server);
|
|
144
|
+
if (!tokens)
|
|
145
|
+
return undefined;
|
|
146
|
+
if (!forceRefresh && tokens.accessToken && Date.parse(tokens.expiresAt) - Date.now() >= 30_000)
|
|
147
|
+
return tokens.accessToken;
|
|
148
|
+
const response = await refreshRequest(server, tokens.refreshToken);
|
|
149
|
+
if (response.status !== 200) {
|
|
150
|
+
if (response.status >= 400 && response.status < 500)
|
|
151
|
+
memoryStore.delete(server);
|
|
152
|
+
// Same reasoning as the native-store path above: the store this token lived in does not change
|
|
153
|
+
// what a rejected refresh means to the caller.
|
|
154
|
+
throw new AuthError(`Token refresh failed: ${await oauthError(response)}. Run: axom auth --server ${server}`);
|
|
155
|
+
}
|
|
156
|
+
const refreshed = await parseTokenResponse(response);
|
|
157
|
+
memoryStore.set(server, refreshed);
|
|
158
|
+
return refreshed.accessToken;
|
|
159
|
+
}
|
|
160
|
+
async function refreshRequest(server, refreshToken) {
|
|
161
|
+
return fetch(`${normalizedServer(server)}/oauth/token`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
body: new URLSearchParams({
|
|
164
|
+
grant_type: 'refresh_token',
|
|
165
|
+
client_id: 'axom-cli',
|
|
166
|
+
refresh_token: refreshToken,
|
|
167
|
+
device_id: await loadDeviceId(),
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export async function revokeRefreshToken(server, refreshToken) {
|
|
172
|
+
await fetch(`${normalizedServer(server)}/oauth/revoke`, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
175
|
+
body: new URLSearchParams({ token: refreshToken, token_type_hint: 'refresh_token', client_id: 'axom-cli' }),
|
|
176
|
+
}).catch(() => undefined);
|
|
177
|
+
}
|
|
178
|
+
async function parseTokenResponse(response) {
|
|
179
|
+
const value = await response.json();
|
|
180
|
+
if (typeof value.access_token !== 'string' || typeof value.refresh_token !== 'string' ||
|
|
181
|
+
typeof value.expires_in !== 'number' || !Number.isFinite(value.expires_in) || value.expires_in <= 0) {
|
|
182
|
+
throw new Error('Axomwork OAuth server returned an invalid token response.');
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
accessToken: value.access_token,
|
|
186
|
+
refreshToken: value.refresh_token,
|
|
187
|
+
expiresAt: new Date(Date.now() + value.expires_in * 1000).toISOString(),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async function oauthError(response) {
|
|
191
|
+
try {
|
|
192
|
+
const value = await response.json();
|
|
193
|
+
const error = typeof value.error === 'string' ? value.error : undefined;
|
|
194
|
+
const description = typeof value.error_description === 'string' ? value.error_description : undefined;
|
|
195
|
+
if (error && description)
|
|
196
|
+
return `${error}: ${description}`;
|
|
197
|
+
if (error)
|
|
198
|
+
return error;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// The status is sufficient when the server did not return an OAuth JSON error.
|
|
202
|
+
}
|
|
203
|
+
return `HTTP ${response.status}`;
|
|
204
|
+
}
|
|
205
|
+
function normalizedServer(server) {
|
|
206
|
+
const normalized = server.trim().replace(/\/+$/, '');
|
|
207
|
+
const url = new URL(normalized);
|
|
208
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
209
|
+
throw new Error('Axomwork server must use HTTP or HTTPS.');
|
|
210
|
+
return normalized;
|
|
211
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SKILL_NAMES } from './installer.js';
|
|
4
|
+
/**
|
|
5
|
+
* Materializes the bundled Axom skills into `<projectDir>/.agents/skills/<skill>/SKILL.md` — the
|
|
6
|
+
* convention Codex, Cursor, OpenCode, Pi and others already read on their own, needing no
|
|
7
|
+
* per-agent adapter code. This is the default `axom skills add` path; `--claude` is the one
|
|
8
|
+
* agent that still needs bespoke materialization (see install/claude/materialize.ts) because
|
|
9
|
+
* Claude Code reads skills through its own plugin/marketplace mechanism instead.
|
|
10
|
+
*/
|
|
11
|
+
export async function materializeAgentsSkills(sourceDir, projectDir) {
|
|
12
|
+
const files = [];
|
|
13
|
+
for (const name of SKILL_NAMES) {
|
|
14
|
+
const destination = path.join(projectDir, '.agents/skills', name, 'SKILL.md');
|
|
15
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
16
|
+
await fs.copyFile(path.join(sourceDir, name, 'SKILL.md'), destination);
|
|
17
|
+
files.push(destination);
|
|
18
|
+
}
|
|
19
|
+
return { files };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Removes only the skill directories `materializeAgentsSkills` creates. A user's own directory
|
|
23
|
+
* under `.agents/skills/` (anything not in SKILL_NAMES) is left untouched.
|
|
24
|
+
*/
|
|
25
|
+
export async function removeAgentsSkills(projectDir) {
|
|
26
|
+
const removed = [];
|
|
27
|
+
for (const name of SKILL_NAMES) {
|
|
28
|
+
const dir = path.join(projectDir, '.agents/skills', name);
|
|
29
|
+
if (existsSync(dir)) {
|
|
30
|
+
await fs.rm(dir, { recursive: true });
|
|
31
|
+
removed.push(dir);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { removed };
|
|
35
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const COMMANDS = {
|
|
2
|
+
struct: `---
|
|
3
|
+
description: Inspect Axomwork applications and models (structure, attributes, operations)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Use the Axomwork MCP tools to answer this structural question about the platform:
|
|
7
|
+
|
|
8
|
+
$ARGUMENTS
|
|
9
|
+
|
|
10
|
+
Workflow:
|
|
11
|
+
1. \`axom__struct__list_applications\` to see applications; \`axom__struct__get_application\` for one app's models and menus.
|
|
12
|
+
2. \`axom__struct__list_models\` / \`axom__struct__get_model\` for a model's attributes, operations, and display metadata.
|
|
13
|
+
3. Prefer read-only struct tools here; do not mutate anything from this command.
|
|
14
|
+
|
|
15
|
+
For the underlying concepts (applications, models, attributes, references), load guide \`concepts/data-model/index.md\` with \`axom__guide__load\`.
|
|
16
|
+
`,
|
|
17
|
+
record: `---
|
|
18
|
+
description: Query, create, update, upsert, or delete Axomwork records
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
Use the Axomwork MCP record tools to fulfil this request:
|
|
22
|
+
|
|
23
|
+
$ARGUMENTS
|
|
24
|
+
|
|
25
|
+
Workflow:
|
|
26
|
+
1. Discover structure first when unsure: \`axom__struct__get_model\` for attribute names and types.
|
|
27
|
+
2. Query with \`axom__record__query\` (filters/sorts/page criteria — for criteria syntax, load guide \`agent-integration/mcp/record-query-criteria.md\`).
|
|
28
|
+
3. Mutate with \`axom__record__create\` / \`axom__record__update\` / \`axom__record__upsert\` / \`axom__record__delete\`. Read the record first before updating; confirm with the user before deleting.
|
|
29
|
+
4. \`axom__record__upsert\` needs \`identityField\` and the identity value inside \`record\`.
|
|
30
|
+
|
|
31
|
+
For safe mutation patterns, load guide \`agent-integration/operating-patterns/safe-record-mutations.md\`.
|
|
32
|
+
`,
|
|
33
|
+
execute: `---
|
|
34
|
+
description: Execute Axomwork operations and services, following async runs to completion
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
Execute the requested Axomwork operation or service:
|
|
38
|
+
|
|
39
|
+
$ARGUMENTS
|
|
40
|
+
|
|
41
|
+
Workflow:
|
|
42
|
+
1. Find the operation on its model (\`axom__struct__get_model\`) or the service by name.
|
|
43
|
+
2. Call \`axom__operation__execute\` (applicationName/modelName/operationName/input) or \`axom__service__execute\` (serviceName/input).
|
|
44
|
+
3. Interpret the ExecuteResult payload: \`status\` (success/failure/running), \`message\`, \`text\`, \`returnValue\`, \`runId\`/\`runType\`, \`errors[]\` ({target,code,message}).
|
|
45
|
+
4. When \`status\` is \`running\`, follow \`runId\` (see \`runType\`) until a terminal state before reporting the outcome.
|
|
46
|
+
|
|
47
|
+
For sync-vs-job semantics load guide \`concepts/execution/synchronous-and-task-execution.md\`; for run following load guide \`agent-integration/operating-patterns/following-runs.md\`.
|
|
48
|
+
`,
|
|
49
|
+
definition: `---
|
|
50
|
+
description: Develop Axomwork definitions — apps, models, attributes, operations, workflows (requires ADMIN)
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
You are editing Axomwork application definitions with the canonical \`axom__definition__*\` MCP tools (ADMIN token required).
|
|
54
|
+
|
|
55
|
+
$ARGUMENTS
|
|
56
|
+
|
|
57
|
+
Rules:
|
|
58
|
+
1. ETag workflow: read the resource first, send its \`etag\` back as \`expectedEtag\` (bodies use \`expectedBodyEtag\`). On conflict, re-read and merge intentionally.
|
|
59
|
+
2. Before authoring \`.axom.kts\` script bodies, load guide \`script-language/sdk/index.md\`; workflow DSL: \`script-language/workflows/index.md\`; sync/job/streaming patterns: \`script-language/patterns/index.md\`.
|
|
60
|
+
3. For definition semantics (apps, models, attributes, operations, workflows), load guide \`application-development/index.md\`.
|
|
61
|
+
4. Confirm destructive definition deletes with the user first.
|
|
62
|
+
`,
|
|
63
|
+
platform: `---
|
|
64
|
+
description: Operate Axomwork platform configuration — AI vendors/profiles, cowork agents, datasources, platform skills and settings (requires ADMIN)
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
You are operating Axomwork platform configuration with the canonical \`axom__platform__*\` MCP tools (skills, settings, AI vendors/profiles/memory, cowork agents, data sources; ADMIN token required).
|
|
68
|
+
|
|
69
|
+
$ARGUMENTS
|
|
70
|
+
|
|
71
|
+
Rules:
|
|
72
|
+
1. Read current configuration before editing; respect ETag guards where tools expose them.
|
|
73
|
+
2. For configuration semantics (AI vendors and profiles, cowork agents, datasources, platform skills and settings), load guide \`platform-operations/index.md\` first.
|
|
74
|
+
3. Confirm with the user before changes that can interrupt running workflows or agent sessions.
|
|
75
|
+
`,
|
|
76
|
+
guide: `---
|
|
77
|
+
description: Look something up in the Axomwork Guide (platform manual)
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
Answer using the Axomwork Guide:
|
|
81
|
+
|
|
82
|
+
$ARGUMENTS
|
|
83
|
+
|
|
84
|
+
Workflow:
|
|
85
|
+
1. \`axom__guide__search\` with keywords or the question itself; read the ranked snippets.
|
|
86
|
+
2. \`axom__guide__load\` a hit's \`path\` for the full page (omit \`path\` for the table of contents).
|
|
87
|
+
3. Answer from guide content and cite the guide paths you used. If the guide has no answer, say so and fall back to tool discovery (\`tools/list\`).
|
|
88
|
+
`,
|
|
89
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { packageVersion, SKILL_NAMES } from '../installer.js';
|
|
4
|
+
import { COMMANDS } from './commands.js';
|
|
5
|
+
export function claudeAgentDir(home) {
|
|
6
|
+
return path.join(home, '.axom/agents/claude');
|
|
7
|
+
}
|
|
8
|
+
export async function materializePlugin(ctx) {
|
|
9
|
+
const marketplaceDir = path.join(claudeAgentDir(ctx.home), 'marketplace');
|
|
10
|
+
const pluginDir = path.join(marketplaceDir, 'axom');
|
|
11
|
+
await fs.rm(marketplaceDir, { recursive: true, force: true });
|
|
12
|
+
const files = [];
|
|
13
|
+
const write = async (relative, content) => {
|
|
14
|
+
const file = path.join(marketplaceDir, relative);
|
|
15
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
16
|
+
await fs.writeFile(file, content);
|
|
17
|
+
files.push(file);
|
|
18
|
+
};
|
|
19
|
+
await write('.claude-plugin/marketplace.json', `${JSON.stringify({
|
|
20
|
+
name: 'axom',
|
|
21
|
+
owner: { name: 'axom-cli' },
|
|
22
|
+
plugins: [{ name: 'axom', source: './axom', description: 'Operate the Axomwork platform' }],
|
|
23
|
+
}, null, 2)}\n`);
|
|
24
|
+
await write('axom/.claude-plugin/plugin.json', `${JSON.stringify({
|
|
25
|
+
name: 'axom',
|
|
26
|
+
version: await packageVersion(),
|
|
27
|
+
description: 'Query, mutate, execute, develop, and operate the Axomwork platform via MCP tools.',
|
|
28
|
+
}, null, 2)}\n`);
|
|
29
|
+
await write('axom/.mcp.json', `${JSON.stringify({
|
|
30
|
+
mcpServers: { axom: { type: 'http', url: `${ctx.serverUrl.replace(/\/+$/, '')}/mcp` } },
|
|
31
|
+
}, null, 2)}\n`);
|
|
32
|
+
for (const [name, content] of Object.entries(COMMANDS)) {
|
|
33
|
+
await write(`axom/commands/${name}.md`, content);
|
|
34
|
+
}
|
|
35
|
+
for (const skill of SKILL_NAMES) {
|
|
36
|
+
const destination = path.join(pluginDir, 'skills', skill, 'SKILL.md');
|
|
37
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
38
|
+
await fs.copyFile(path.join(ctx.skillsSourceDir, skill, 'SKILL.md'), destination);
|
|
39
|
+
files.push(destination);
|
|
40
|
+
}
|
|
41
|
+
return { marketplaceDir, files };
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { UsageError } from '../config.js';
|
|
6
|
+
import { claudeTarget } from './targets/claude.js';
|
|
7
|
+
/**
|
|
8
|
+
* The bundled skills every install path materializes. Single source of truth shared by both
|
|
9
|
+
* the `.agents/skills` convention path (agentsDir.ts) and the Claude plugin path
|
|
10
|
+
* (claude/materialize.ts) — two copies of this list would let the two paths drift apart.
|
|
11
|
+
*/
|
|
12
|
+
export const SKILL_NAMES = ['axom', 'axom-definition', 'axom-platform'];
|
|
13
|
+
export const TARGETS = {
|
|
14
|
+
claude: claudeTarget,
|
|
15
|
+
};
|
|
16
|
+
export async function installAgent(targetName, ctx) {
|
|
17
|
+
return requireTarget(targetName).install(fullContext(ctx));
|
|
18
|
+
}
|
|
19
|
+
export async function uninstallAgent(targetName, ctx) {
|
|
20
|
+
return requireTarget(targetName).uninstall(fullContext(ctx));
|
|
21
|
+
}
|
|
22
|
+
function requireTarget(targetName) {
|
|
23
|
+
const target = TARGETS[targetName];
|
|
24
|
+
if (!target) {
|
|
25
|
+
throw new UsageError(`Unknown agent target "${targetName}". Valid targets: ${Object.keys(TARGETS).join(', ')}`);
|
|
26
|
+
}
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
function fullContext(ctx) {
|
|
30
|
+
return { ...ctx, home: ctx.home ?? targetHome(), cwd: ctx.cwd ?? process.cwd(), skillsSourceDir: skillsSourceDir() };
|
|
31
|
+
}
|
|
32
|
+
export function skillsSourceDir() {
|
|
33
|
+
const sourceDir = fileURLToPath(new URL('../../skills', import.meta.url));
|
|
34
|
+
if (!existsSync(sourceDir)) {
|
|
35
|
+
throw new Error(`Bundled Axomwork skills directory not found: ${sourceDir}`);
|
|
36
|
+
}
|
|
37
|
+
return sourceDir;
|
|
38
|
+
}
|
|
39
|
+
export function targetHome() {
|
|
40
|
+
return process.env.AXOM_AGENT_HOME ?? homedir();
|
|
41
|
+
}
|
|
42
|
+
export async function packageVersion() {
|
|
43
|
+
const raw = await readFile(new URL('../../package.json', import.meta.url), 'utf8');
|
|
44
|
+
return JSON.parse(raw).version;
|
|
45
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
2
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { UsageError } from '../../config.js';
|
|
6
|
+
import { materializePlugin, claudeAgentDir } from '../claude/materialize.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
// NOTE: the exact column format of `claude plugin marketplace list` / `claude plugin list`
|
|
9
|
+
// output has NOT been verified against a live `claude` install (deferred site-adaptation).
|
|
10
|
+
// This assumes the entry name is the first whitespace-delimited token on each line. If the
|
|
11
|
+
// real output puts the name elsewhere (e.g. a leading marker/icon column), this parse must
|
|
12
|
+
// be revisited.
|
|
13
|
+
function listContainsName(stdout, name) {
|
|
14
|
+
return stdout.split('\n').some((line) => {
|
|
15
|
+
const first = line.trim().split(/\s+/)[0];
|
|
16
|
+
return first === name || first === `${name}@${name}`;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function systemClaudeCli() {
|
|
20
|
+
// Synchronous probe at construction time: PATH 上无 claude 时返回 null,
|
|
21
|
+
// 让 install 走 manual 分支而不是等到 execFileAsync 抛异常。
|
|
22
|
+
try {
|
|
23
|
+
execFileSync('claude', ['--version'], { stdio: 'ignore' });
|
|
24
|
+
return {
|
|
25
|
+
async run(args) {
|
|
26
|
+
const { stdout } = await execFileAsync('claude', args);
|
|
27
|
+
return { stdout };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function createClaudeTarget(cli) {
|
|
36
|
+
return {
|
|
37
|
+
name: 'claude',
|
|
38
|
+
async install(ctx) {
|
|
39
|
+
const { marketplaceDir, files } = await materializePlugin(ctx);
|
|
40
|
+
if (cli === null) {
|
|
41
|
+
return {
|
|
42
|
+
target: this.name,
|
|
43
|
+
installedFiles: files,
|
|
44
|
+
mcpRegistration: [
|
|
45
|
+
'claude executable not found; plugin materialized. Manual steps:',
|
|
46
|
+
` claude plugin marketplace add ${marketplaceDir}`,
|
|
47
|
+
' claude plugin install axom@axom',
|
|
48
|
+
].join('\n'),
|
|
49
|
+
manual: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const marketplaces = (await cli.run(['plugin', 'marketplace', 'list'])).stdout;
|
|
53
|
+
if (listContainsName(marketplaces, 'axom')) {
|
|
54
|
+
await cli.run(['plugin', 'marketplace', 'update', 'axom']);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
await cli.run(['plugin', 'marketplace', 'add', marketplaceDir]);
|
|
58
|
+
}
|
|
59
|
+
const plugins = (await cli.run(['plugin', 'list'])).stdout;
|
|
60
|
+
if (listContainsName(plugins, 'axom')) {
|
|
61
|
+
await cli.run(['plugin', 'update', 'axom']);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
await cli.run(['plugin', 'install', 'axom@axom']);
|
|
65
|
+
}
|
|
66
|
+
if (ctx.scope === 'project') {
|
|
67
|
+
await enableInProject(ctx.cwd);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
target: this.name,
|
|
71
|
+
installedFiles: files,
|
|
72
|
+
mcpRegistration: 'axom plugin installed into Claude Code; MCP OAuth happens in-app on first connection',
|
|
73
|
+
manual: false,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
async uninstall(ctx) {
|
|
77
|
+
const removed = [];
|
|
78
|
+
const notes = [];
|
|
79
|
+
if (cli !== null) {
|
|
80
|
+
for (const args of [['plugin', 'uninstall', 'axom'], ['plugin', 'marketplace', 'remove', 'axom']]) {
|
|
81
|
+
try {
|
|
82
|
+
await cli.run(args);
|
|
83
|
+
removed.push(`claude ${args.join(' ')}`);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
notes.push(`claude ${args.join(' ')} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
notes.push('claude executable not found; remove the plugin from Claude Code manually if installed');
|
|
92
|
+
}
|
|
93
|
+
const agentDir = claudeAgentDir(ctx.home);
|
|
94
|
+
if (existsSync(agentDir)) {
|
|
95
|
+
await fs.rm(agentDir, { recursive: true });
|
|
96
|
+
removed.push(agentDir);
|
|
97
|
+
}
|
|
98
|
+
if (ctx.scope === 'project') {
|
|
99
|
+
await disableInProject(ctx.cwd, removed);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
target: this.name,
|
|
103
|
+
removed,
|
|
104
|
+
notes: notes.length > 0 ? notes.join('\n') : (removed.length === 0 ? 'nothing to remove (not installed)' : 'axom removed from Claude Code'),
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async function enableInProject(cwd) {
|
|
110
|
+
const file = path.join(cwd, '.claude/settings.json');
|
|
111
|
+
const settings = await readJson(file);
|
|
112
|
+
const enabled = isRecord(settings.enabledPlugins) ? settings.enabledPlugins : {};
|
|
113
|
+
settings.enabledPlugins = { ...enabled, 'axom@axom': true };
|
|
114
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
115
|
+
await fs.writeFile(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
116
|
+
}
|
|
117
|
+
async function disableInProject(cwd, removed) {
|
|
118
|
+
const file = path.join(cwd, '.claude/settings.json');
|
|
119
|
+
if (!existsSync(file))
|
|
120
|
+
return;
|
|
121
|
+
const settings = await readJson(file);
|
|
122
|
+
if (isRecord(settings.enabledPlugins) && 'axom@axom' in settings.enabledPlugins) {
|
|
123
|
+
delete settings.enabledPlugins['axom@axom'];
|
|
124
|
+
await fs.writeFile(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
125
|
+
removed.push(`${file} (enabledPlugins["axom@axom"])`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function readJson(file) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
131
|
+
if (!isRecord(parsed))
|
|
132
|
+
throw new UsageError(`Refusing to overwrite ${file}: not a JSON object.`);
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
137
|
+
return {};
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function isRecord(value) {
|
|
142
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
143
|
+
}
|
|
144
|
+
let resolvedClaudeTarget = null;
|
|
145
|
+
function resolveClaudeTarget() {
|
|
146
|
+
if (resolvedClaudeTarget === null)
|
|
147
|
+
resolvedClaudeTarget = createClaudeTarget(systemClaudeCli());
|
|
148
|
+
return resolvedClaudeTarget;
|
|
149
|
+
}
|
|
150
|
+
export const claudeTarget = {
|
|
151
|
+
name: 'claude',
|
|
152
|
+
install: (ctx) => resolveClaudeTarget().install(ctx),
|
|
153
|
+
uninstall: (ctx) => resolveClaudeTarget().uninstall(ctx),
|
|
154
|
+
};
|