@astrofoundry/pi-astro 0.18.2 → 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 of GitOps-managed projects (Compose files, `.env` layout, project folders) change only through `gitops show`, `gitops write`, `gitops commit`, `gitops push`, then a sync or `projects up`. Never edit a managed project through `projects update` or `projects workspace`, even though the CLI allows 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.18.2",
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,7 +14,7 @@ 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"]`.
@@ -22,12 +22,20 @@ Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose
22
22
  ## Two kinds of operations
23
23
 
24
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
- `gitops pull` → `gitops ls` / `gitops show <path>` to read the current files → `gitops write <path> <content>` → `gitops diff` → `gitops commit <message>` → `gitops push` → then the project's GitOps sync or `["projects", "up", "<name>"]`, then verify with `["projects", "list"]` and container logs. Editing a managed project directly (`projects update`, `projects workspace`) makes the repository lie; do not do it.
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
26
  2. **Operations**: restart, logs, stats, redeploy, pull images, upgrade, prune. Straight through the CLI.
27
27
 
28
28
  The full `arcane-cli` is available. The tool refuses only `config`, `auth`, `self-update`, `completion`, which would change its own setup.
29
29
 
30
- Updating Arcane itself: the `arcane` project is GitOps-managed. Bump the image tag in its Compose file through gitops, push, sync. The sync redeploys the manager that runs the sync; if the manager does not come back, tell the caller to finish with `docker compose pull && docker compose up -d` in `/opt/docker/arcane` on VM 100.
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.
31
39
 
32
40
  ## Output
33
41
 
@@ -39,6 +39,8 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
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
@@ -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 except its own `config`, `auth`, `self-update`; Compose changes of GitOps-managed projects through `gitops show|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, readdirSync, readFileSync, statSync, 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";
@@ -63,18 +64,19 @@ export function refusedPrefix(args: string[]): string | null {
63
64
 
64
65
  const HELP = `arcane specialist
65
66
 
66
- <arcane-cli args...> run arcane-cli with a fresh federated token; JSON output, sensitive fields removed
67
- gitops status branch, ahead/behind, changed files of the GitOps checkout
68
- gitops pull fast-forward the checkout from origin
69
- gitops ls [dir] list files in the checkout
70
- gitops show <path> print a file from the checkout
71
- gitops diff unstaged and staged changes
72
- gitops log [n] last n commits (default 10)
73
- gitops write <path> <content> write a file inside the checkout (creates folders)
74
- gitops commit <message> stage everything and commit
75
- 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
76
78
 
77
- Refused: ${REFUSED_PREFIXES.join(", ")}. GitOps-managed projects (gitOpsManagedBy set) change through gitops, then sync.`;
79
+ Refused: ${REFUSED_PREFIXES.join(", ")}. GitOps-managed projects (gitOpsManagedBy set) change in the repository, then sync.`;
78
80
 
79
81
  const TIMEOUT_MS = 240_000;
80
82
 
@@ -121,7 +123,7 @@ export function safeRepoPath(dir: string, rel: string): string {
121
123
  return target;
122
124
  }
123
125
 
124
- async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
126
+ async function repo(config: ArcaneConfig, args: string[]): Promise<number> {
125
127
  const [sub, ...rest] = args;
126
128
  const dir = await ensureCheckout(config);
127
129
  switch (sub) {
@@ -141,14 +143,14 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
141
143
  printRaw((await git(dir, ["diff", "HEAD"])) || "(no changes)");
142
144
  return 0;
143
145
  case "ls": {
144
- if (rest.length > 1) throw new UsageError("gitops ls [dir]");
146
+ if (rest.length > 1) throw new UsageError("git ls [dir]");
145
147
  const target = rest[0] === undefined ? dir : safeRepoPath(dir, rest[0]);
146
148
  if (!existsSync(target) || !statSync(target).isDirectory()) throw new UsageError(`not a directory in the checkout: ${rest[0] ?? "."}`);
147
149
  printJson(readdirSync(target, { withFileTypes: true }).filter((e) => e.name !== ".git").map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort());
148
150
  return 0;
149
151
  }
150
152
  case "show": {
151
- if (rest.length !== 1) throw new UsageError("gitops show <path>");
153
+ if (rest.length !== 1) throw new UsageError("git show <path>");
152
154
  const target = safeRepoPath(dir, rest[0]);
153
155
  if (!existsSync(target) || !statSync(target).isFile()) throw new UsageError(`not a file in the checkout: ${rest[0]}`);
154
156
  printRaw(readFileSync(target, "utf-8"));
@@ -156,12 +158,12 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
156
158
  }
157
159
  case "log": {
158
160
  const n = rest[0] === undefined ? 10 : Number(rest[0]);
159
- 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");
160
162
  printRaw(await git(dir, ["log", `-n${n}`, "--format=%h %ad %s", "--date=short"]));
161
163
  return 0;
162
164
  }
163
165
  case "write": {
164
- if (rest.length !== 2) throw new UsageError("gitops write <path> <content>");
166
+ if (rest.length !== 2) throw new UsageError("git write <path> <content>");
165
167
  const target = safeRepoPath(dir, rest[0]);
166
168
  mkdirSync(dirname(target), { recursive: true });
167
169
  writeFileSync(target, rest[1].endsWith("\n") ? rest[1] : `${rest[1]}\n`, { mode: 0o600 });
@@ -170,7 +172,7 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
170
172
  }
171
173
  case "commit": {
172
174
  const message = rest.join(" ").trim();
173
- if (message.length === 0) throw new UsageError("gitops commit <message>");
175
+ if (message.length === 0) throw new UsageError("git commit <message>");
174
176
  await git(dir, ["add", "--all"]);
175
177
  const staged = (await git(dir, ["diff", "--cached", "--name-only"])).split("\n").filter(Boolean);
176
178
  if (staged.length === 0) throw new UsageError("nothing to commit");
@@ -185,7 +187,7 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
185
187
  return 0;
186
188
  }
187
189
  default:
188
- throw new UsageError(`unknown gitops subcommand: ${sub ?? "(none)"}\n${HELP}`);
190
+ throw new UsageError(`unknown git subcommand: ${sub ?? "(none)"}\n${HELP}`);
189
191
  }
190
192
  }
191
193
 
@@ -216,18 +218,36 @@ async function federatedToken(config: ArcaneConfig): Promise<string> {
216
218
  return match[1];
217
219
  }
218
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
+
219
234
  async function passthrough(config: ArcaneConfig, args: string[]): Promise<number> {
220
235
  const refused = refusedPrefix(args);
221
236
  if (refused) throw new UsageError(`"${refused}" is refused by the wrapper`);
222
237
  const token = await federatedToken(config);
223
- const wantsJson = args.includes("--json") || args.includes("--output");
224
- const cliArgs = ["--config", config.arcaneCliConfig, ...(wantsJson ? [] : ["--output", "json"]), "--no-color", ...args];
225
- const result = await run(config.arcaneCli, cliArgs, { env: { ARCANE_TOKEN: token }, timeoutMs: TIMEOUT_MS });
226
- if (result.stdout.trim().length > 0) {
227
- 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 });
228
250
  }
229
- if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `arcane-cli exited ${result.code}`);
230
- return 0;
231
251
  }
232
252
 
233
253
  export async function command(args: string[]): Promise<number> {
@@ -236,7 +256,7 @@ export async function command(args: string[]): Promise<number> {
236
256
  return 0;
237
257
  }
238
258
  const config = readConfig<ArcaneConfig>(SERVICE, SHAPE);
239
- if (args[0] === "gitops") return gitops(config, args.slice(1));
259
+ if (args[0] === "git") return repo(config, args.slice(1));
240
260
  return passthrough(config, args);
241
261
  }
242
262
 
@@ -30,8 +30,9 @@ describe("arcane wrapper", () => {
30
30
  it("prints help without touching config", async () => {
31
31
  const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
32
32
  expect(await arcaneCommand(["--help"])).toBe(0);
33
- expect(String(write.mock.calls[0][0])).toContain("gitops write");
34
- expect(String(write.mock.calls[0][0])).toContain("gitops show");
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);
35
36
  write.mockRestore();
36
37
  });
37
38
  });