@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 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 `gitops write`, `gitops commit`, `gitops push`, then a sync or `projects up`. Never try to edit a running project through the CLI; the tool refuses it.
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
- const result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
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: `[${parsed.agent}] ${isFailed(result) ? "failed" : "done"}\n\n${resultOutput(result)}`, display: true },
253
- { deliverAs: "nextTurn" },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -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 `gitops` subcommands for the repository.
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": ["gitops", "status"] }
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**: Compose files, env layout, new or removed projects. Only through the repository:
25
- `gitops pull` → `gitops write <path> <content>` → `gitops diff` → `gitops commit <message>` → `gitops push` → then `["projects", "up", "<name>"]` or a GitOps sync through the CLI, then verify with `["projects", "list"]` and container logs.
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 `projects create|edit|update|import`, `config`, `auth`, `self-update`, `completion`.
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
 
@@ -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
- - Arcane definitions change only through `gitops`; `REFUSED_PREFIXES` keeps the CLI from bypassing it and from touching its own config or auth.
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
@@ -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; Compose changes through `gitops write|commit|push`, then sync |
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
- /** Definitions change through GitOps; these CLI paths would bypass it or alter the wrapper's own setup. */
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...> run arcane-cli with a fresh federated token; JSON output, sensitive fields removed
76
- gitops status branch, ahead/behind, changed files of the GitOps checkout
77
- gitops pull fast-forward the checkout from origin
78
- gitops diff unstaged and staged changes
79
- gitops log [n] last n commits (default 10)
80
- gitops write <path> <content> write a file inside the checkout (creates folders)
81
- gitops commit <message> stage everything and commit
82
- gitops push push the branch to origin
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(", ")}. Project definitions change through gitops, then sync with the CLI.`;
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 gitops(config: ArcaneConfig, args: string[]): Promise<number> {
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("gitops log [n]: n must be 1..200");
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("gitops write <path> <content>");
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("gitops commit <message>");
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 gitops subcommand: ${sub ?? "(none)"}\n${HELP}`);
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; project definitions change through gitops`);
236
+ if (refused) throw new UsageError(`"${refused}" is refused by the wrapper`);
215
237
  const token = await federatedToken(config);
216
- const wantsJson = args.includes("--json") || args.includes("--output");
217
- const cliArgs = ["--config", config.arcaneCliConfig, ...(wantsJson ? [] : ["--output", "json"]), "--no-color", ...args];
218
- const result = await run(config.arcaneCli, cliArgs, { env: { ARCANE_TOKEN: token }, timeoutMs: TIMEOUT_MS });
219
- if (result.stdout.trim().length > 0) {
220
- printRaw(filterJsonText(result.stdout, (v) => stripKeys(v, ARCANE_DENIED_KEYS)));
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] === "gitops") return gitops(config, args.slice(1));
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 CLI paths that bypass GitOps or touch the wrapper setup", () => {
12
- expect(refusedPrefix(["projects", "create", "x"])).toBe("projects create");
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("gitops write");
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
  });