@astrofoundry/pi-astro 0.18.1 → 0.18.3
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/agents/arcane.md +2 -1
- package/extensions/astro-subagents/index.ts +11 -3
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +16 -6
- package/specialists/AGENTS.md +3 -1
- package/specialists/README.md +1 -1
- package/specialists/arcane/run.ts +62 -35
- package/specialists/wrappers.test.ts +7 -3
package/agents/arcane.md
CHANGED
|
@@ -13,8 +13,9 @@ You are the Arcane specialist. The `arcane` tool is your only way to reach Arcan
|
|
|
13
13
|
Rules:
|
|
14
14
|
|
|
15
15
|
- Read first. Run `arcane` with `["projects", "list"]` or `["containers", "list"]` before proposing a change.
|
|
16
|
-
- Project definitions (Compose files, `.env` layout, project folders) change only through `
|
|
16
|
+
- Project definitions of GitOps-managed projects (Compose files, `.env` layout, project folders) change only through `git show`, `git write`, `git commit`, `git push`, then the project's server-side sync (`arcane-cli gitops`). Never edit a managed project through `projects update` or `projects workspace`, even though the CLI allows it.
|
|
17
17
|
- Operational actions (restart, logs, stats, redeploy, image pull, prune) go straight through the CLI.
|
|
18
18
|
- Confirm destructive operations (`down`, `delete`, `prune`) are explicitly requested in the task before running them.
|
|
19
|
+
- Updating Arcane itself ends with a hand-over: after the repository change and the file sync, stop and give the caller the host commands from the skill. Never run `system upgrade` or `projects upgrade arcane`.
|
|
19
20
|
- Report facts from the JSON output. Sensitive fields are already removed; never guess at what was removed.
|
|
20
21
|
- Delegate through `subagent` only to another specialist (`astro.identity`, `astro.network`) and only for that specialist's own area.
|
|
@@ -247,10 +247,18 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
247
247
|
}
|
|
248
248
|
const agents = listAgents(ctx.cwd, "user").agents;
|
|
249
249
|
ctx.ui.notify(`running ${parsed.agent}...`, "info");
|
|
250
|
-
|
|
250
|
+
ctx.ui.setStatus("subagent", `${parsed.agent} running`);
|
|
251
|
+
let result: RunResult;
|
|
252
|
+
try {
|
|
253
|
+
result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
|
|
254
|
+
} finally {
|
|
255
|
+
ctx.ui.setStatus("subagent", undefined);
|
|
256
|
+
}
|
|
257
|
+
const status = isFailed(result) ? "failed" : "done";
|
|
258
|
+
ctx.ui.notify(`${parsed.agent} ${status}`, isFailed(result) ? "warning" : "info");
|
|
251
259
|
pi.sendMessage(
|
|
252
|
-
{ customType: "astro-subagents", content: `
|
|
253
|
-
{ deliverAs: "
|
|
260
|
+
{ customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true },
|
|
261
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
254
262
|
);
|
|
255
263
|
},
|
|
256
264
|
});
|
package/package.json
CHANGED
package/skills/arcane/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: How the Arcane specialist operates the Arcane container platform an
|
|
|
5
5
|
|
|
6
6
|
# Arcane specialist
|
|
7
7
|
|
|
8
|
-
Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose project synced from the GitOps repository `astronaute77/arcane`, branch `main`, into `/opt/docker`. The `arcane` tool wraps `arcane-cli` with a fresh short-lived token on every call and adds `
|
|
8
|
+
Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose project synced from the GitOps repository `astronaute77/arcane`, branch `main`, into `/opt/docker`. The `arcane` tool wraps `arcane-cli` with a fresh short-lived token on every call and adds `git` subcommands for the repository checkout. `arcane-cli gitops ...` (server-side syncs) passes through untouched.
|
|
9
9
|
|
|
10
10
|
## Calling the tool
|
|
11
11
|
|
|
@@ -14,18 +14,28 @@ Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose
|
|
|
14
14
|
```json
|
|
15
15
|
{ "args": ["projects", "list"] }
|
|
16
16
|
{ "args": ["containers", "logs", "mealie-mealie-1", "--tail", "200"] }
|
|
17
|
-
{ "args": ["
|
|
17
|
+
{ "args": ["git", "status"] }
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
`["--help"]` prints the wrapper's help. `arcane-cli` subcommands accept `--help` too, for example `["projects", "--help"]`.
|
|
21
21
|
|
|
22
22
|
## Two kinds of operations
|
|
23
23
|
|
|
24
|
-
1. **Definitions
|
|
25
|
-
`
|
|
26
|
-
2. **Operations**: restart, logs, stats, redeploy, pull images, prune. Straight through the CLI.
|
|
24
|
+
1. **Definitions** of GitOps-managed projects (every project whose `gitOpsManagedBy` field is set, including `arcane` itself): Compose files, env layout, new or removed projects. Only through the repository:
|
|
25
|
+
`git pull` → `git ls` / `git show <path>` to read the current files → `git write <path> <content>` → `git diff` → `git commit <message>` → `git push` → then run the project's server-side sync with `["gitops", ...]` (`["gitops", "--help"]` lists the sync commands; find the sync by project, then trigger it) → verify with `["projects", "list"]` and container logs. Editing a managed project directly (`projects update`, `projects workspace`) makes the repository lie; do not do it.
|
|
26
|
+
2. **Operations**: restart, logs, stats, redeploy, pull images, upgrade, prune. Straight through the CLI.
|
|
27
27
|
|
|
28
|
-
The tool refuses
|
|
28
|
+
The full `arcane-cli` is available. The tool refuses only `config`, `auth`, `self-update`, `completion`, which would change its own setup.
|
|
29
|
+
|
|
30
|
+
## Updating Arcane itself
|
|
31
|
+
|
|
32
|
+
The `arcane` project is GitOps-managed and pinned by digest (`ghcr.io/getarcaneapp/manager@sha256:...`). The documented path (Arcane installation guide, homelab self-management exception) has three steps; you do the first two and hand over the third.
|
|
33
|
+
|
|
34
|
+
1. Repository: `git pull`, `git show arcane/compose.yaml`, find the new digest with `["projects", "updates"]`, `git write` the file with the new digest, `git diff`, `git commit "Update Arcane manager image"`, `git push`.
|
|
35
|
+
2. Sync files: trigger the `arcane` project's GitOps sync through `["gitops", ...]` so `/opt/docker/arcane/compose.yaml` matches the repository. The manager cannot recreate itself from its own sync.
|
|
36
|
+
3. Stop and report: the caller runs on VM 100, in `/opt/docker/arcane`, `docker compose pull arcane && docker compose up -d arcane`, then asks you to verify with `["version"]` and `["projects", "list"]`.
|
|
37
|
+
|
|
38
|
+
Never use `["system", "upgrade"]` or `["projects", "upgrade", "arcane"]` for the manager: both bypass the pinned digest in the repository.
|
|
29
39
|
|
|
30
40
|
## Output
|
|
31
41
|
|
package/specialists/AGENTS.md
CHANGED
|
@@ -38,7 +38,9 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
|
|
|
38
38
|
- Exit codes: `UsageError` 2, `ServiceError` 1, success 0. Diagnostics on stderr, results on stdout, JSON where possible.
|
|
39
39
|
- Output filters: Arcane responses pass `stripKeys(ARCANE_DENIED_KEYS)`; Zitadel responses pass `redactSecrets`. Add keys there rather than in a wrapper.
|
|
40
40
|
- Fixed remote commands: `DMZ_COMMANDS` and `PULSAR_COMMANDS` must match the entry scripts in `entry/remote/` and their tracked copies in the homelab repository (`02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`). Change both sides in the same commit and redeploy the remote script.
|
|
41
|
-
-
|
|
41
|
+
- `REFUSED_PREFIXES` protects only the wrapper itself (`config`, `auth`, `self-update`, `completion`). The GitOps-first rule for managed projects lives in the skill and the agent prompt, not in code, because unmanaged projects and emergencies need the direct CLI.
|
|
42
|
+
- The wrapper's repository commands are the `git` group; `gitops` must stay free because it is an `arcane-cli` command group (server-side syncs).
|
|
43
|
+
- Every passthrough call writes a private per-call copy of `arcanecli.yml` with `jwt_token` set, because some `arcane-cli` commands validate the config file before reading `ARCANE_TOKEN`. The copy is removed after the call.
|
|
42
44
|
- Every wrapper answers `--help` without reading config, so agents can discover subcommands before anything else is set up.
|
|
43
45
|
|
|
44
46
|
## Gate
|
package/specialists/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
|
|
|
4
4
|
|
|
5
5
|
| Agent | Area | What it can do |
|
|
6
6
|
|---|---|---|
|
|
7
|
-
| `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation
|
|
7
|
+
| `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
|
|
8
8
|
| `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104 | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew` |
|
|
9
9
|
| `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, firewall policies, WANs; RADIUS and Tailscale checks on Pulsar |
|
|
10
10
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
2
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
4
|
import { readConfig } from "../lib/config.ts";
|
|
4
5
|
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
@@ -32,17 +33,8 @@ const SHAPE = {
|
|
|
32
33
|
gitopsBranch: "string",
|
|
33
34
|
} as const;
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
-
export const REFUSED_PREFIXES: readonly string[] = [
|
|
37
|
-
"projects create",
|
|
38
|
-
"projects edit",
|
|
39
|
-
"projects update",
|
|
40
|
-
"projects import",
|
|
41
|
-
"config",
|
|
42
|
-
"auth",
|
|
43
|
-
"self-update",
|
|
44
|
-
"completion",
|
|
45
|
-
];
|
|
36
|
+
/** CLI paths that would alter the wrapper's own setup or the binary; everything else passes through. */
|
|
37
|
+
export const REFUSED_PREFIXES: readonly string[] = ["config", "auth", "self-update", "completion"];
|
|
46
38
|
|
|
47
39
|
/** Global arcane-cli flags that take a separate value. */
|
|
48
40
|
const VALUE_FLAGS = ["--env", "--output", "--config", "-c", "--request-timeout", "--log-level", "--limit"];
|
|
@@ -72,16 +64,19 @@ export function refusedPrefix(args: string[]): string | null {
|
|
|
72
64
|
|
|
73
65
|
const HELP = `arcane specialist
|
|
74
66
|
|
|
75
|
-
<arcane-cli args...>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
67
|
+
<arcane-cli args...> run arcane-cli with a fresh federated token; JSON output, sensitive fields removed
|
|
68
|
+
(arcane-cli gitops ... manages the server-side syncs)
|
|
69
|
+
git status branch, ahead/behind, changed files of the GitOps repository checkout
|
|
70
|
+
git pull fast-forward the checkout from origin
|
|
71
|
+
git ls [dir] list files in the checkout
|
|
72
|
+
git show <path> print a file from the checkout
|
|
73
|
+
git diff unstaged and staged changes
|
|
74
|
+
git log [n] last n commits (default 10)
|
|
75
|
+
git write <path> <content> write a file inside the checkout (creates folders)
|
|
76
|
+
git commit <message> stage everything and commit
|
|
77
|
+
git push push the branch to origin
|
|
83
78
|
|
|
84
|
-
Refused: ${REFUSED_PREFIXES.join(", ")}.
|
|
79
|
+
Refused: ${REFUSED_PREFIXES.join(", ")}. GitOps-managed projects (gitOpsManagedBy set) change in the repository, then sync.`;
|
|
85
80
|
|
|
86
81
|
const TIMEOUT_MS = 240_000;
|
|
87
82
|
|
|
@@ -128,7 +123,7 @@ export function safeRepoPath(dir: string, rel: string): string {
|
|
|
128
123
|
return target;
|
|
129
124
|
}
|
|
130
125
|
|
|
131
|
-
async function
|
|
126
|
+
async function repo(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
132
127
|
const [sub, ...rest] = args;
|
|
133
128
|
const dir = await ensureCheckout(config);
|
|
134
129
|
switch (sub) {
|
|
@@ -147,14 +142,28 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
|
147
142
|
case "diff":
|
|
148
143
|
printRaw((await git(dir, ["diff", "HEAD"])) || "(no changes)");
|
|
149
144
|
return 0;
|
|
145
|
+
case "ls": {
|
|
146
|
+
if (rest.length > 1) throw new UsageError("git ls [dir]");
|
|
147
|
+
const target = rest[0] === undefined ? dir : safeRepoPath(dir, rest[0]);
|
|
148
|
+
if (!existsSync(target) || !statSync(target).isDirectory()) throw new UsageError(`not a directory in the checkout: ${rest[0] ?? "."}`);
|
|
149
|
+
printJson(readdirSync(target, { withFileTypes: true }).filter((e) => e.name !== ".git").map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort());
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
case "show": {
|
|
153
|
+
if (rest.length !== 1) throw new UsageError("git show <path>");
|
|
154
|
+
const target = safeRepoPath(dir, rest[0]);
|
|
155
|
+
if (!existsSync(target) || !statSync(target).isFile()) throw new UsageError(`not a file in the checkout: ${rest[0]}`);
|
|
156
|
+
printRaw(readFileSync(target, "utf-8"));
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
150
159
|
case "log": {
|
|
151
160
|
const n = rest[0] === undefined ? 10 : Number(rest[0]);
|
|
152
|
-
if (!Number.isInteger(n) || n <= 0 || n > 200) throw new UsageError("
|
|
161
|
+
if (!Number.isInteger(n) || n <= 0 || n > 200) throw new UsageError("git log [n]: n must be 1..200");
|
|
153
162
|
printRaw(await git(dir, ["log", `-n${n}`, "--format=%h %ad %s", "--date=short"]));
|
|
154
163
|
return 0;
|
|
155
164
|
}
|
|
156
165
|
case "write": {
|
|
157
|
-
if (rest.length !== 2) throw new UsageError("
|
|
166
|
+
if (rest.length !== 2) throw new UsageError("git write <path> <content>");
|
|
158
167
|
const target = safeRepoPath(dir, rest[0]);
|
|
159
168
|
mkdirSync(dirname(target), { recursive: true });
|
|
160
169
|
writeFileSync(target, rest[1].endsWith("\n") ? rest[1] : `${rest[1]}\n`, { mode: 0o600 });
|
|
@@ -163,7 +172,7 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
|
163
172
|
}
|
|
164
173
|
case "commit": {
|
|
165
174
|
const message = rest.join(" ").trim();
|
|
166
|
-
if (message.length === 0) throw new UsageError("
|
|
175
|
+
if (message.length === 0) throw new UsageError("git commit <message>");
|
|
167
176
|
await git(dir, ["add", "--all"]);
|
|
168
177
|
const staged = (await git(dir, ["diff", "--cached", "--name-only"])).split("\n").filter(Boolean);
|
|
169
178
|
if (staged.length === 0) throw new UsageError("nothing to commit");
|
|
@@ -178,7 +187,7 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
|
178
187
|
return 0;
|
|
179
188
|
}
|
|
180
189
|
default:
|
|
181
|
-
throw new UsageError(`unknown
|
|
190
|
+
throw new UsageError(`unknown git subcommand: ${sub ?? "(none)"}\n${HELP}`);
|
|
182
191
|
}
|
|
183
192
|
}
|
|
184
193
|
|
|
@@ -209,18 +218,36 @@ async function federatedToken(config: ArcaneConfig): Promise<string> {
|
|
|
209
218
|
return match[1];
|
|
210
219
|
}
|
|
211
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Some arcane-cli commands validate credentials in the config file before they
|
|
223
|
+
* look at ARCANE_TOKEN, so the token goes into a private per-call copy of the
|
|
224
|
+
* config as well. The copy lives for one command and is removed afterwards.
|
|
225
|
+
*/
|
|
226
|
+
function writeCallConfig(config: ArcaneConfig, token: string): string {
|
|
227
|
+
const base = readFileSync(config.arcaneCliConfig, "utf-8").replace(/^(jwt_token|api_key|refresh_token):.*$/gm, "");
|
|
228
|
+
const dir = mkdtempSync(join(tmpdir(), "arcane-run-"));
|
|
229
|
+
const file = join(dir, "arcanecli.yml");
|
|
230
|
+
writeFileSync(file, `${base.trimEnd()}\njwt_token: ${JSON.stringify(token)}\n`, { mode: 0o600 });
|
|
231
|
+
return file;
|
|
232
|
+
}
|
|
233
|
+
|
|
212
234
|
async function passthrough(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
213
235
|
const refused = refusedPrefix(args);
|
|
214
|
-
if (refused) throw new UsageError(`"${refused}" is refused
|
|
236
|
+
if (refused) throw new UsageError(`"${refused}" is refused by the wrapper`);
|
|
215
237
|
const token = await federatedToken(config);
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
238
|
+
const callConfig = writeCallConfig(config, token);
|
|
239
|
+
try {
|
|
240
|
+
const wantsJson = args.includes("--json") || args.includes("--output");
|
|
241
|
+
const cliArgs = ["--config", callConfig, ...(wantsJson ? [] : ["--output", "json"]), "--no-color", ...args];
|
|
242
|
+
const result = await run(config.arcaneCli, cliArgs, { env: { ARCANE_TOKEN: token }, timeoutMs: TIMEOUT_MS });
|
|
243
|
+
if (result.stdout.trim().length > 0) {
|
|
244
|
+
printRaw(filterJsonText(result.stdout, (v) => stripKeys(v, ARCANE_DENIED_KEYS)));
|
|
245
|
+
}
|
|
246
|
+
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `arcane-cli exited ${result.code}`);
|
|
247
|
+
return 0;
|
|
248
|
+
} finally {
|
|
249
|
+
rmSync(dirname(callConfig), { recursive: true, force: true });
|
|
221
250
|
}
|
|
222
|
-
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `arcane-cli exited ${result.code}`);
|
|
223
|
-
return 0;
|
|
224
251
|
}
|
|
225
252
|
|
|
226
253
|
export async function command(args: string[]): Promise<number> {
|
|
@@ -229,7 +256,7 @@ export async function command(args: string[]): Promise<number> {
|
|
|
229
256
|
return 0;
|
|
230
257
|
}
|
|
231
258
|
const config = readConfig<ArcaneConfig>(SERVICE, SHAPE);
|
|
232
|
-
if (args[0] === "
|
|
259
|
+
if (args[0] === "git") return repo(config, args.slice(1));
|
|
233
260
|
return passthrough(config, args);
|
|
234
261
|
}
|
|
235
262
|
|
|
@@ -8,9 +8,11 @@ import { UsageError } from "./lib/errors.ts";
|
|
|
8
8
|
import { buildPulsarRemote, parseQuery, unifiPath, command as networkCommand } from "./network/run.ts";
|
|
9
9
|
|
|
10
10
|
describe("arcane wrapper", () => {
|
|
11
|
-
it("refuses
|
|
12
|
-
expect(refusedPrefix(["projects", "create", "x"])).
|
|
11
|
+
it("refuses only the wrapper's own setup paths", () => {
|
|
12
|
+
expect(refusedPrefix(["projects", "create", "x"])).toBeNull();
|
|
13
|
+
expect(refusedPrefix(["projects", "update", "arcane", "--flag"])).toBeNull();
|
|
13
14
|
expect(refusedPrefix(["--env", "0", "auth", "login"])).toBe("auth");
|
|
15
|
+
expect(refusedPrefix(["self-update"])).toBe("self-update");
|
|
14
16
|
expect(refusedPrefix(["config", "set", "api-key", "x"])).toBe("config");
|
|
15
17
|
expect(refusedPrefix(["projects", "list"])).toBeNull();
|
|
16
18
|
expect(refusedPrefix(["projects", "up", "my-app"])).toBeNull();
|
|
@@ -28,7 +30,9 @@ describe("arcane wrapper", () => {
|
|
|
28
30
|
it("prints help without touching config", async () => {
|
|
29
31
|
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
30
32
|
expect(await arcaneCommand(["--help"])).toBe(0);
|
|
31
|
-
expect(String(write.mock.calls[0][0])).toContain("
|
|
33
|
+
expect(String(write.mock.calls[0][0])).toContain("git write");
|
|
34
|
+
expect(String(write.mock.calls[0][0])).toContain("git show");
|
|
35
|
+
expect(String(write.mock.calls[0][0])).not.toMatch(/^\s+gitops /m);
|
|
32
36
|
write.mockRestore();
|
|
33
37
|
});
|
|
34
38
|
});
|