auggy 0.3.0 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-05-12
11
+
12
+ The deployable-runtime release. First npm-installable Auggy CLI with shipped feature set (0.3.0 was a name-claim publish with no functional changes). End-to-end Railway deployment support and a structural eval suite for the layered-memory autoSave path.
13
+
14
+ ### Added
15
+
16
+ #### Deployment
17
+
18
+ - **`auggy deploy <name> --to railway` command.** Ships an agent to Railway end-to-end: presence + auth checks, bundle staging with secure exclusions (`.env`, `*.db*`, `workspace/`, `node_modules/`, `.git/`, `.worktrees/`, `.claude/`, `.DS_Store`, `*.tmp`), Dockerfile + entrypoint generation, `railway link`, `railway volume add` (one-time, mounted at `/app/data`), `railway domain --generate`, secrets push including `AUGGY_PUBLIC_URL`, then `railway up`. Redeploys reuse the existing `CloudRecord` from `~/.auggy/agents.json` for idempotency. (See `docs/18-deploy.md`, ADR-021.)
19
+ - **`auggy remove <name> --cloud` flag.** Destroys the Railway service alongside the local index entry. Tolerates Railway destruction failures with a warning — local cleanup proceeds regardless.
20
+ - **`agent-index` cloud mutators.** `setCloud(name, record)` + `clearCloud(name)` with the same atomic-write + advisory-lock discipline as `addAgent` / `removeAgent`. Cloud state persists in `~/.auggy/agents.json` per the existing `CloudRecord` type.
21
+ - **Operator deployment guide** (`docs/18-deploy.md`) — prerequisites, first-deploy + redeploy flows, autoSave cost surface guidance (citing the new eval suite), persistent state contract, visitorAuth's `${AUGGY_PUBLIC_URL}` interpolation, tear-down, troubleshooting.
22
+ - **npm publish workflow** (`.github/workflows/publish.yml`) — publishes `auggy` to npm on `v*.*.*` tag push. Runs tests + typecheck + version-matches-tag check before `npm publish --provenance --access public`. Uses `NPM_TOKEN` secret.
23
+
24
+ #### Quality
25
+
26
+ - **`evals/layered-memory/` integration eval suite.** Seven fixtures × seven structural graders measure end-to-end autoSave behavior under real `agent.inject()` machinery: `factual-recall`, `peer-isolation`, `prompt-rendering`, `cost-overhead`, `false-extract`, `cross-session-recall` (multi-session persistence headliner), and `cross-identity-promotion` (anon → recognized flush). Mock-mode runner is deterministic, no API key required, <100ms. Live Haiku smoke (`evals/layered-memory/smoke.ts`) validates end-to-end against a real model with seven pass criteria at ~$0.005 spend. (See `evals/layered-memory/README.md`.)
27
+ - **`extractJsonArray` JSON extractor.** Replaces the strict `JSON.parse` in `src/augments/layered-memory/extractor/parse.ts` with balanced-bracket extraction — structurally robust to any model wrapper style (markdown fences, leading/trailing prose, language tags, single-line layout, CRLF, escaped quotes, nested objects). Closed the 100% extraction-failure rate on Haiku 4.5 caught by the smoke test.
28
+
29
+ ### Changed
30
+
31
+ - **`auggy --version`** now reads from `package.json` instead of a hardcoded string. Eliminates the drift class that surfaced after the first npm publish.
32
+
33
+ ### Process
34
+
35
+ - **First npm publish.** `auggy` is now available via `npm i -g auggy`. Distribution pattern matches Wrangler / Vercel: install → create → dev/start/deploy.
36
+
37
+ ## [0.3.0] - 2026-05-12
38
+
39
+ Name-claim release. Same code as `c15d3cb` + `@auggy/link@0.1.2` bump. Published manually to claim the unscoped `auggy` package name on npm; no functional changes vs. the prior `0.2.0` release.
40
+
41
+ ## [0.2.0-pre] (pre-OSS items, now folded into 0.4.0)
42
+
43
+ Items below shipped during the pre-OSS phase and are functionally part of 0.4.0 in the OSS distribution. Kept here for historical reference:
44
+
10
45
  ### Architecture
11
46
 
12
47
  - **ADR-030 — model-facing skill surface separation.** The three Auggy primitives now surface to the engine on three orthogonal channels: **tools** (eager full schema in `tools[]`), **skills** (new built-in `skills` augment emits one system-placement context block sourced from each SKILL.md's YAML frontmatter, body on-demand via `fs_read`), and **augments** (invisible to the model). `{SKILL_MANIFEST}` is gone from `src/scaffold-templates/identity.md`; `scaffold-skills.ts` shed `buildSkillManifest` + `TOOL_INVENTORY`; `src/cli/skill-manifest.ts` is deleted; the kernel context allocator no longer wraps blocks with `[AUGMENT CONTEXT: <source>]` (the augment-name attribution is suppressed pre-send, preserved only in operator-facing trace data). The 8 bundled SKILL.md files already shipped agentskills.io-compatible frontmatter. `auggy create` default-mounts the new `skills` augment.
package/README.md CHANGED
@@ -20,8 +20,8 @@ Auggy (augment-1) is a modular agent runtime in TypeScript/Bun, purpose-built fo
20
20
  ## Quick start
21
21
 
22
22
  ```bash
23
- # Install dependencies
24
- bun install
23
+ # Install the CLI globally (requires Bun: https://bun.sh/install)
24
+ npm i -g auggy
25
25
 
26
26
  # Create an agent (interactive augment selection)
27
27
  auggy create zip
@@ -30,10 +30,23 @@ auggy create zip
30
30
  cp zip/.env.example zip/.env
31
31
  # Add your API key to zip/.env
32
32
 
33
- # Run it
33
+ # Run locally (foreground)
34
34
  auggy dev zip
35
+
36
+ # Or install as a launchd service (macOS, always-on)
37
+ auggy start zip
38
+
39
+ # Or deploy to the cloud (requires Railway CLI + `railway login`)
40
+ auggy deploy zip --to railway
35
41
  ```
36
42
 
43
+ The `auggy` binary requires [Bun](https://bun.sh) at runtime. The package
44
+ ships TypeScript sources; Bun executes them directly without a build step.
45
+
46
+ > Until the package is on npm, use the development install:
47
+ > `git clone …augment-1 && cd augment-1 && bun install && bun link`.
48
+ > The `auggy` binary lands on PATH the same way.
49
+
37
50
  ## How it works
38
51
 
39
52
  **Engines** drive the model call (one per agent). **Augments** plug in around it — context, tools, transport, memory (many per agent). Both are swappable via YAML.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auggy",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Modular agent runtime — composable augments, multi-engine, SQLite-first memory.",
5
5
  "keywords": [
6
6
  "agent",
@@ -17,25 +17,83 @@ export type ParseResult =
17
17
  | { success: false; error: string };
18
18
 
19
19
  /**
20
- * Defensive JSON parser for the extraction LLM's response. The model
21
- * should emit a top-level JSON array of fact objects per `prompt.md`,
22
- * but real models occasionally drift (extra prose, missing fields,
23
- * type mismatches). This parser:
20
+ * Defensive JSON parser for the extraction LLM's response. The model should
21
+ * emit a top-level JSON array of fact objects per `prompt.md`, but real
22
+ * models drift (markdown code fences, leading/trailing prose, language tags,
23
+ * extra fields, type mismatches). This parser:
24
24
  *
25
- * - Returns `{ success: false, error }` on any failure mode rather
26
- * than throwing, so the auto-save handler can log and skip without
27
- * killing the injected turn's tool-call execution.
28
- * - Validates each entry's shape strictly: all five required fields
29
- * must be present and the right primitive type. One bad entry
30
- * fails the whole batch partial writes would leave inconsistent
31
- * storage, and the cost of a re-extraction is bounded.
32
- * - Strips unknown keys to keep the storage schema clean across
33
- * prompt-template revisions.
25
+ * - Locates the JSON array via balanced-bracket extraction so wrappers
26
+ * (Haiku's ```json fences, Sonnet's leading prose, etc) are tolerated
27
+ * structurally see `extractJsonArray` for the why.
28
+ * - Returns `{ success: false, error }` on any failure mode rather than
29
+ * throwing, so the auto-save handler can log and skip without killing
30
+ * the injected turn's tool-call execution.
31
+ * - Validates each entry's shape strictly: all five required fields must
32
+ * be present and the right primitive type. One bad entry fails the
33
+ * whole batch — partial writes would leave inconsistent storage and the
34
+ * cost of a re-extraction is bounded.
35
+ * - Strips unknown keys to keep the storage schema clean across prompt-
36
+ * template revisions.
34
37
  */
38
+
39
+ /**
40
+ * Locate and return the first top-level JSON array in the response, ignoring
41
+ * any wrapping (markdown code fences, leading prose, trailing prose, language
42
+ * tags, single-line vs multi-line layout, etc).
43
+ *
44
+ * Walks the raw string looking for the first `[`, then advances depth-tracking
45
+ * through nested arrays and objects, respecting JSON string boundaries and
46
+ * backslash escapes. Returns the substring `[..matching..]` or null when no
47
+ * balanced array is found.
48
+ *
49
+ * Why this approach: models wrap JSON output in different ways depending on
50
+ * the model, the prompt phrasing, and the moon's phase. Haiku 4.5 emits
51
+ * ```json\n[...]\n```; Sonnet sometimes leads with prose ("Here's the JSON:");
52
+ * Gemini occasionally appends "Hope this helps!". A regex tuned to one style
53
+ * breaks on the next variant. Balanced-bracket extraction is structurally
54
+ * robust — any wrapping the model adds is just text outside the array.
55
+ */
56
+ function extractJsonArray(raw: string): string | null {
57
+ const start = raw.indexOf("[");
58
+ if (start === -1) return null;
59
+
60
+ let depth = 0;
61
+ let inString = false;
62
+ let escaped = false;
63
+
64
+ for (let i = start; i < raw.length; i++) {
65
+ const c = raw[i];
66
+ if (escaped) {
67
+ escaped = false;
68
+ continue;
69
+ }
70
+ if (inString) {
71
+ if (c === "\\") escaped = true;
72
+ else if (c === '"') inString = false;
73
+ continue;
74
+ }
75
+ if (c === '"') {
76
+ inString = true;
77
+ continue;
78
+ }
79
+ if (c === "[") depth++;
80
+ else if (c === "]") {
81
+ depth--;
82
+ if (depth === 0) return raw.slice(start, i + 1);
83
+ }
84
+ }
85
+
86
+ return null;
87
+ }
88
+
35
89
  export function parseExtractionResponse(raw: string): ParseResult {
90
+ const jsonText = extractJsonArray(raw);
91
+ if (jsonText === null) {
92
+ return { success: false, error: "no balanced JSON array found in response" };
93
+ }
36
94
  let parsed: unknown;
37
95
  try {
38
- parsed = JSON.parse(raw);
96
+ parsed = JSON.parse(jsonText);
39
97
  } catch (err) {
40
98
  return { success: false, error: `failed to parse JSON: ${(err as Error).message}` };
41
99
  }
@@ -287,3 +287,47 @@ export function listAgents(opts: IndexOptions = {}): Array<IndexEntry & { name:
287
287
  ...entry,
288
288
  }));
289
289
  }
290
+
291
+ /**
292
+ * Attach a cloud deployment record to a registered agent. Throws when the
293
+ * agent isn't registered — first call `addAgent`, then `setCloud`. Overwrites
294
+ * any prior cloud record (redeploy case).
295
+ *
296
+ * Holds the advisory lock for the read-modify-write window.
297
+ */
298
+ export function setCloud(
299
+ name: string,
300
+ record: NonNullable<import("./types").CloudRecord>,
301
+ opts: IndexOptions = {},
302
+ ): void {
303
+ const lock = acquireLock(opts);
304
+ try {
305
+ const idx = readIndex(opts);
306
+ const entry = idx.agents[name];
307
+ if (!entry) {
308
+ throw new Error(`Agent "${name}" not registered — run \`auggy create ${name}\` first.`);
309
+ }
310
+ entry.cloud = record;
311
+ writeIndex(idx, opts);
312
+ } finally {
313
+ lock.release();
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Clear an agent's cloud deployment record. Idempotent — no-op when already
319
+ * null. Does not throw when the agent isn't registered (matches removeAgent
320
+ * idempotency).
321
+ */
322
+ export function clearCloud(name: string, opts: IndexOptions = {}): void {
323
+ const lock = acquireLock(opts);
324
+ try {
325
+ const idx = readIndex(opts);
326
+ const entry = idx.agents[name];
327
+ if (!entry) return;
328
+ entry.cloud = null;
329
+ writeIndex(idx, opts);
330
+ } finally {
331
+ lock.release();
332
+ }
333
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `auggy deploy <name> --to railway` command.
3
+ *
4
+ * Orchestrates the first-deploy + redeploy flows:
5
+ *
6
+ * first-deploy: presence + auth checks → operator prompt for projectId →
7
+ * stage bundle → write Dockerfile + entrypoint → link → addVolume →
8
+ * generateDomain → push secrets (.env + AUGGY_PUBLIC_URL) → up →
9
+ * capture status → write CloudRecord to index.
10
+ *
11
+ * redeploy: presence + auth checks → stage bundle → write Dockerfile +
12
+ * entrypoint → link (idempotent) → re-push secrets → up → status →
13
+ * update CloudRecord.deployedAt.
14
+ *
15
+ * Cleanly testable via a dependency-injected `RailwayCli` (real or mocked)
16
+ * plus pluggable prompt + logger helpers — no I/O hardcoded into the orchestrator.
17
+ *
18
+ * Per D7 (architecture deltas in the v1.0 plan): the URL is captured BEFORE
19
+ * `up` runs so AUGGY_PUBLIC_URL is set as a Railway env var, ensuring
20
+ * visitorAuth sees the publicUrl on first boot. This avoids a first-boot
21
+ * crash when agent.yaml interpolates `${AUGGY_PUBLIC_URL}` or similar.
22
+ */
23
+
24
+ import { writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { getAgent, setCloud } from "../agent-index";
27
+ import { stageBundle } from "../deploy/bundle";
28
+ import { generateDockerfile, generateEntrypoint } from "../deploy/dockerfile";
29
+ import type { RailwayCli } from "../deploy/railway-cli";
30
+ import { loadSecretsPlan } from "../deploy/secrets";
31
+
32
+ export interface DeployLogger {
33
+ info(msg: string): void;
34
+ warn(msg: string): void;
35
+ error(msg: string): void;
36
+ }
37
+
38
+ export interface DeployOptions {
39
+ to: "railway";
40
+ yes: boolean;
41
+ auggyDir?: string;
42
+ cli: RailwayCli;
43
+ /** Prompt the operator for a Railway project ID. */
44
+ promptProjectId: () => Promise<string>;
45
+ /** Prompt the operator for yes/no confirmation. Receives a human-readable message. */
46
+ promptConfirm: (message: string) => Promise<boolean>;
47
+ logger: DeployLogger;
48
+ }
49
+
50
+ export interface DeployResult {
51
+ url: string;
52
+ projectId: string;
53
+ serviceId: string;
54
+ volumeId: string;
55
+ }
56
+
57
+ const VOLUME_MOUNT_PATH = "/app/data";
58
+
59
+ export async function runDeploy(name: string, opts: DeployOptions): Promise<DeployResult> {
60
+ if (opts.to !== "railway") {
61
+ throw new Error(
62
+ `Only "railway" is supported in v1.0 (got "${opts.to}"). Other targets: deferred.`,
63
+ );
64
+ }
65
+
66
+ const entry = getAgent(name, { auggyDir: opts.auggyDir });
67
+ if (!entry) {
68
+ throw new Error(
69
+ `Agent "${name}" not registered. Run \`auggy create ${name}\` first, then \`auggy deploy ${name}\`.`,
70
+ );
71
+ }
72
+ const agentDir = entry.localDir;
73
+
74
+ // 1) Presence + auth checks (fail fast before any subprocess work).
75
+ await opts.cli.checkPresence();
76
+ await opts.cli.checkAuth();
77
+ opts.logger.info(`Railway CLI ready.`);
78
+
79
+ // 2) Determine first-deploy vs redeploy from existing CloudRecord.
80
+ const existingCloud = entry.cloud;
81
+ const isRedeploy = existingCloud !== null;
82
+
83
+ let projectId: string;
84
+ if (isRedeploy && existingCloud) {
85
+ projectId = existingCloud.projectId;
86
+ opts.logger.info(`Redeploying ${name} to Railway project ${projectId}.`);
87
+ } else {
88
+ projectId = await opts.promptProjectId();
89
+ opts.logger.info(`First deploy of ${name} to Railway project ${projectId}.`);
90
+ }
91
+
92
+ // 3) Stage the bundle (excludes secrets + volume-bound state).
93
+ const stagingDir = stageBundle({ agentDir, agentName: name });
94
+ opts.logger.info(`Bundle staged at ${stagingDir}.`);
95
+
96
+ // 4) Write Dockerfile + entrypoint into the staging dir.
97
+ writeFileSync(join(stagingDir, "Dockerfile"), generateDockerfile({ agentName: name }));
98
+ writeFileSync(join(stagingDir, "auggy-entrypoint.sh"), generateEntrypoint());
99
+
100
+ // 5) Load secrets plan and confirm with operator unless --yes.
101
+ const envPath = join(agentDir, ".env");
102
+ const plan = loadSecretsPlan(envPath);
103
+ if (plan.warnings.length > 0) {
104
+ for (const w of plan.warnings) opts.logger.warn(w);
105
+ }
106
+ if (!opts.yes) {
107
+ const summary =
108
+ plan.variables.length === 0
109
+ ? `No secrets to push (no .env file or all entries malformed).`
110
+ : `Push ${plan.variables.length} secret(s) to Railway:\n` +
111
+ plan.variables.map((v) => ` ${v.key} = ${v.redactedValue}`).join("\n");
112
+ const confirmed = await opts.promptConfirm(`${summary}\n\nProceed?`);
113
+ if (!confirmed) {
114
+ throw new Error("Deploy aborted by operator (declined secrets push).");
115
+ }
116
+ }
117
+
118
+ // 6) Link the staging dir to the Railway service. First deploy: Railway
119
+ // auto-creates the service if --service name doesn't exist in the
120
+ // project. Redeploy: idempotent — re-links the same service.
121
+ await opts.cli.link({ projectId, serviceName: name, cwd: stagingDir });
122
+ opts.logger.info(`Linked staging dir to project ${projectId}, service ${name}.`);
123
+
124
+ // 7) Volume: only add on first deploy. Redeploys keep the existing volume
125
+ // (Railway preserves it via the volumeId in the existing CloudRecord).
126
+ if (!isRedeploy) {
127
+ await opts.cli.addVolume({
128
+ name: `${name}-data`,
129
+ mountPath: VOLUME_MOUNT_PATH,
130
+ cwd: stagingDir,
131
+ });
132
+ opts.logger.info(`Volume "${name}-data" mounted at ${VOLUME_MOUNT_PATH}.`);
133
+ }
134
+
135
+ // 8) Generate (or recover) the public domain. Idempotent: second call
136
+ // returns the existing URL.
137
+ const url = await opts.cli.generateDomain({ cwd: stagingDir });
138
+ opts.logger.info(`Public URL: ${url}`);
139
+
140
+ // 9) Push secrets (.env keys + AUGGY_PUBLIC_URL). D7: AUGGY_PUBLIC_URL must
141
+ // be set BEFORE `up` so visitorAuth sees the publicUrl on first boot.
142
+ for (const v of plan.variables) {
143
+ await opts.cli.setVariable({ key: v.key, value: v.value, cwd: stagingDir });
144
+ }
145
+ await opts.cli.setVariable({ key: "AUGGY_PUBLIC_URL", value: url, cwd: stagingDir });
146
+ opts.logger.info(`Pushed ${plan.variables.length + 1} env var(s) to Railway.`);
147
+
148
+ // 10) Trigger the build + deploy. --detach so we return without tailing
149
+ // build logs; operator follows progress via Railway UI / `railway logs`.
150
+ await opts.cli.up({ cwd: stagingDir });
151
+ opts.logger.info(`Build queued.`);
152
+
153
+ // 11) Capture service metadata for the CloudRecord.
154
+ const status = await opts.cli.status({ cwd: stagingDir });
155
+ opts.logger.info(`Service status: ${status.deployment.status}.`);
156
+
157
+ // 12) Write CloudRecord to the agent index.
158
+ const result: DeployResult = {
159
+ url,
160
+ projectId,
161
+ serviceId: status.service.id,
162
+ volumeId: `${name}-data`,
163
+ };
164
+ setCloud(
165
+ name,
166
+ {
167
+ provider: "railway",
168
+ projectId: result.projectId,
169
+ serviceId: result.serviceId,
170
+ url: result.url,
171
+ volumeId: result.volumeId,
172
+ deployedAt: new Date().toISOString(),
173
+ },
174
+ { auggyDir: opts.auggyDir },
175
+ );
176
+
177
+ return result;
178
+ }
@@ -5,10 +5,12 @@
5
5
  * --yes). Tolerates missing localDir (still cleans the index entry).
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, rmSync } from "node:fs";
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
9
10
  import { join } from "node:path";
10
11
  import { confirm } from "@inquirer/prompts";
11
- import { getAgent, removeAgent } from "../agent-index";
12
+ import { clearCloud, getAgent, removeAgent } from "../agent-index";
13
+ import { createRailwayCli, type RailwayCli } from "../deploy/railway-cli";
12
14
  import { readPidManifest, isProcessAlive, removePidManifest } from "../pid-registry";
13
15
 
14
16
  function readConfigName(localDir: string): string | null {
@@ -26,8 +28,12 @@ function readConfigName(localDir: string): string | null {
26
28
  interface RemoveOptions {
27
29
  /** Skip the y/N prompt. */
28
30
  yes?: boolean;
31
+ /** When set with a cloud-deployed agent, also destroy the Railway service. */
32
+ cloud?: boolean;
29
33
  /** Override `~/.auggy/` for tests. */
30
34
  auggyDir?: string;
35
+ /** Inject a RailwayCli for tests (defaults to the real one). */
36
+ railwayCli?: RailwayCli;
31
37
  }
32
38
 
33
39
  export async function runRemove(name: string, opts: RemoveOptions = {}): Promise<void> {
@@ -88,6 +94,35 @@ export async function runRemove(name: string, opts: RemoveOptions = {}): Promise
88
94
  if (pidByCli) removePidManifest(name);
89
95
  if (pidByConfig && configName) removePidManifest(configName);
90
96
 
97
+ // --cloud: also destroy the Railway service before clearing the index.
98
+ // We do this AFTER local cleanup so a failed Railway call doesn't leave
99
+ // local state in an inconsistent half-deleted shape.
100
+ if (opts.cloud && entry.cloud) {
101
+ const cli = opts.railwayCli ?? createRailwayCli();
102
+ // `railway service delete` needs to be run from a dir linked to the
103
+ // service. Create a temp dir, link it, then delete.
104
+ const tmp = mkdtempSync(join(tmpdir(), `auggy-remove-${name}-`));
105
+ try {
106
+ await cli.link({
107
+ projectId: entry.cloud.projectId,
108
+ serviceName: name,
109
+ cwd: tmp,
110
+ });
111
+ await cli.destroyService({ cwd: tmp });
112
+ console.log(`Destroyed Railway service "${name}" (project ${entry.cloud.projectId}).`);
113
+ } catch (err) {
114
+ console.warn(
115
+ `warn: Railway service destruction failed: ${(err as Error).message}\n` +
116
+ ` Local cleanup proceeding; remove the Railway service manually if needed.`,
117
+ );
118
+ } finally {
119
+ try {
120
+ rmSync(tmp, { recursive: true, force: true });
121
+ } catch {}
122
+ }
123
+ clearCloud(name, { auggyDir: opts.auggyDir });
124
+ }
125
+
91
126
  // Clear index entry.
92
127
  removeAgent(name, { auggyDir: opts.auggyDir });
93
128
 
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Bundle staging — copies an agent dir into a fresh temp directory minus the
3
+ * exclusions defined by ADR-021 (`.env`, `workspace/`, `*.db*`, `node_modules/`,
4
+ * `.git/`, `.DS_Store`, `*.tmp`) plus this PR's additions (`.worktrees/`,
5
+ * `.claude/`).
6
+ *
7
+ * The deploy command runs `railway up` from the staging dir so volume-bound
8
+ * state (SQLite files) and secrets (`.env`) never enter the Railway image.
9
+ */
10
+
11
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ interface StageBundleOptions {
16
+ agentDir: string;
17
+ agentName: string;
18
+ }
19
+
20
+ const EXCLUDED_NAMES = new Set([
21
+ ".env",
22
+ ".git",
23
+ ".DS_Store",
24
+ "node_modules",
25
+ "workspace",
26
+ ".worktrees",
27
+ ".claude",
28
+ ]);
29
+
30
+ function isExcluded(name: string): boolean {
31
+ if (EXCLUDED_NAMES.has(name)) return true;
32
+ if (/\.db(-(?:wal|shm))?$/.test(name)) return true;
33
+ if (name.endsWith(".tmp")) return true;
34
+ return false;
35
+ }
36
+
37
+ function copyTree(src: string, dst: string): void {
38
+ mkdirSync(dst, { recursive: true });
39
+ for (const entry of readdirSync(src)) {
40
+ if (isExcluded(entry)) continue;
41
+ const srcPath = join(src, entry);
42
+ const dstPath = join(dst, entry);
43
+ const stats = statSync(srcPath);
44
+ if (stats.isDirectory()) {
45
+ copyTree(srcPath, dstPath);
46
+ } else if (stats.isFile()) {
47
+ copyFileSync(srcPath, dstPath);
48
+ }
49
+ // Skip symlinks/sockets/etc — agent dirs shouldn't have them.
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Copy the agent dir into a fresh temp staging dir, minus exclusions.
55
+ * Returns the absolute path to the staging dir.
56
+ */
57
+ export function stageBundle(opts: StageBundleOptions): string {
58
+ if (!existsSync(opts.agentDir)) {
59
+ throw new Error(`Agent directory not found: ${opts.agentDir}`);
60
+ }
61
+ const staging = mkdtempSync(join(tmpdir(), `auggy-deploy-${opts.agentName}-`));
62
+ copyTree(opts.agentDir, staging);
63
+ return staging;
64
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Dockerfile + entrypoint generator for `auggy deploy`.
3
+ *
4
+ * The deploy command writes these strings into the staging dir before
5
+ * `railway up`. ADR-021 cloud design: bake-in the volume symlink dance +
6
+ * `auggy dev --internal-mode railway` invocation so `agent.yaml`'s
7
+ * `dbPath: ./<name>.db` paths work unchanged in cloud.
8
+ */
9
+
10
+ const BUN_VERSION = "1.1-alpine";
11
+
12
+ /**
13
+ * Known SQLite paths in the agent dir that need to live on the Railway
14
+ * volume across redeploys. Each is symlinked in the entrypoint script:
15
+ *
16
+ * /app/<name>.db → /app/data/<name>.db (volume target)
17
+ *
18
+ * The .db files do NOT exist at boot — they're created by SQLite on first
19
+ * attach, following the symlink. WAL/SHM sibling files are created
20
+ * alongside the resolved symlink target (i.e. on the volume).
21
+ *
22
+ * Drift risk (per plan §D2): if a new SQLite-backed augment ships with a
23
+ * non-listed path, its DB falls outside the volume and gets lost on
24
+ * redeploy. Update this list when new augments land. Today's eval suite
25
+ * catches data loss empirically via the cross-session-recall grader.
26
+ */
27
+ const SQLITE_DB_NAMES = ["memory.db", "budgets.db", "visitor-auth.db", "link.db"];
28
+
29
+ interface DockerfileOptions {
30
+ agentName: string;
31
+ }
32
+
33
+ export function generateDockerfile(opts: DockerfileOptions): string {
34
+ return `FROM oven/bun:${BUN_VERSION}
35
+
36
+ WORKDIR /app
37
+
38
+ # Install auggy globally so the entrypoint can call \`auggy dev\`.
39
+ # (Phase 3 of the deploy plan: package will be published as @auggy/cli;
40
+ # update this to "@auggy/cli" once published. Until then, build images
41
+ # from a workspace clone.)
42
+ RUN bun install -g auggy
43
+
44
+ COPY . /app
45
+
46
+ # Make the entrypoint executable.
47
+ RUN chmod +x /app/auggy-entrypoint.sh
48
+
49
+ # Railway mounts the persistent volume here.
50
+ VOLUME ["/app/data"]
51
+
52
+ # Railway routes traffic to the port declared by the app via PORT env.
53
+ EXPOSE 8080
54
+
55
+ ENTRYPOINT ["/app/auggy-entrypoint.sh", "${opts.agentName}"]
56
+ `;
57
+ }
58
+
59
+ export function generateEntrypoint(): string {
60
+ const symlinks = SQLITE_DB_NAMES.map((name) => `ln -sf /app/data/${name} /app/${name}`).join(
61
+ "\n",
62
+ );
63
+
64
+ return `#!/bin/sh
65
+ # Auggy Railway entrypoint.
66
+ #
67
+ # - The Railway volume is mounted at /app/data (persists across redeploys).
68
+ # - SQLite-backed augments (layeredMemory, budgets, visitorAuth, link) write
69
+ # to ./<name>.db; we symlink each name into the volume so paths in
70
+ # agent.yaml stay unchanged between local and cloud.
71
+ # - WAL/SHM siblings are created alongside the resolved symlink target by
72
+ # SQLite, i.e. they land on the volume.
73
+ # - -f overwrites any prior symlink so redeploys don't fail on the second run.
74
+ # - The .db files don't need to exist at boot — SQLite creates them on first
75
+ # attach, following the symlink to the volume.
76
+ #
77
+ # Drift risk: when a new SQLite-backed augment ships, add its .db name to the
78
+ # SQLITE_DB_NAMES list in src/cli/deploy/dockerfile.ts.
79
+
80
+ set -e
81
+
82
+ mkdir -p /app/data
83
+
84
+ ${symlinks}
85
+
86
+ # $1 is the agent name passed by ENTRYPOINT.
87
+ exec auggy dev "$1" --internal-mode railway
88
+ `;
89
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Railway CLI subprocess wrapper.
3
+ *
4
+ * Sole owner of `railway` binary knowledge in the codebase. Every Railway
5
+ * operation calls `runRailway(args, opts)` which uses the injected spawn
6
+ * factory (defaults to `Bun.spawn`). Tests override the factory to mock
7
+ * subprocess behavior without spawning anything.
8
+ *
9
+ * Operator pre-requisites: `railway` CLI installed (https://docs.railway.com/develop/cli)
10
+ * and `railway login` completed. We trust the operator's auth context — same
11
+ * pattern as `git push` trusts `git`. No token storage in this codebase.
12
+ */
13
+
14
+ export class RailwayCliMissingError extends Error {
15
+ constructor() {
16
+ super(
17
+ "Railway CLI not found. Install it: https://docs.railway.com/develop/cli, then `railway login`.",
18
+ );
19
+ this.name = "RailwayCliMissingError";
20
+ }
21
+ }
22
+
23
+ export class RailwayNotLoggedInError extends Error {
24
+ constructor(detail: string) {
25
+ super(`Railway CLI not authenticated: ${detail}\nRun \`railway login\` and try again.`);
26
+ this.name = "RailwayNotLoggedInError";
27
+ }
28
+ }
29
+
30
+ interface SpawnHandle {
31
+ exited: Promise<number>;
32
+ stdout: ReadableStream<Uint8Array>;
33
+ stderr: ReadableStream<Uint8Array>;
34
+ }
35
+
36
+ export type RailwaySpawnFactory = (
37
+ cmd: string[],
38
+ opts?: { cwd?: string; env?: Record<string, string> },
39
+ ) => SpawnHandle;
40
+
41
+ interface CreateRailwayCliOptions {
42
+ spawn?: RailwaySpawnFactory;
43
+ }
44
+
45
+ interface RunOptions {
46
+ cwd?: string;
47
+ env?: Record<string, string>;
48
+ }
49
+
50
+ export interface RailwayStatus {
51
+ project: { id: string; name: string };
52
+ service: { id: string; name: string };
53
+ deployment: { status: string };
54
+ }
55
+
56
+ async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
57
+ const reader = stream.getReader();
58
+ const chunks: Uint8Array[] = [];
59
+ while (true) {
60
+ const { done, value } = await reader.read();
61
+ if (done) break;
62
+ if (value) chunks.push(value);
63
+ }
64
+ const total = chunks.reduce((n, c) => n + c.byteLength, 0);
65
+ const merged = new Uint8Array(total);
66
+ let offset = 0;
67
+ for (const c of chunks) {
68
+ merged.set(c, offset);
69
+ offset += c.byteLength;
70
+ }
71
+ return new TextDecoder().decode(merged);
72
+ }
73
+
74
+ const defaultSpawn: RailwaySpawnFactory = (cmd, opts = {}) => {
75
+ const proc = Bun.spawn(cmd, {
76
+ cwd: opts.cwd,
77
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+ return {
82
+ exited: proc.exited,
83
+ stdout: proc.stdout,
84
+ stderr: proc.stderr,
85
+ };
86
+ };
87
+
88
+ export interface RailwayCli {
89
+ checkPresence(): Promise<true>;
90
+ checkAuth(): Promise<string>;
91
+ link(args: { projectId: string; serviceName: string; cwd: string }): Promise<void>;
92
+ setVariable(args: { key: string; value: string; cwd: string }): Promise<void>;
93
+ up(args: { cwd: string }): Promise<void>;
94
+ generateDomain(args: { cwd: string }): Promise<string>;
95
+ addVolume(args: { name: string; mountPath: string; cwd: string }): Promise<void>;
96
+ status(args: { cwd: string }): Promise<RailwayStatus>;
97
+ destroyService(args: { cwd: string }): Promise<void>;
98
+ }
99
+
100
+ export function createRailwayCli(opts: CreateRailwayCliOptions = {}): RailwayCli {
101
+ const spawn = opts.spawn ?? defaultSpawn;
102
+
103
+ async function runRailway(
104
+ args: string[],
105
+ runOpts: RunOptions = {},
106
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
107
+ let handle: SpawnHandle;
108
+ try {
109
+ handle = spawn(["railway", ...args], runOpts);
110
+ } catch (err) {
111
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
112
+ throw new RailwayCliMissingError();
113
+ }
114
+ throw err;
115
+ }
116
+ const [stdout, stderr, exitCode] = await Promise.all([
117
+ readAll(handle.stdout),
118
+ readAll(handle.stderr),
119
+ handle.exited,
120
+ ]);
121
+ return { stdout, stderr, exitCode };
122
+ }
123
+
124
+ async function runOrThrow(
125
+ args: string[],
126
+ runOpts: RunOptions = {},
127
+ ): Promise<{ stdout: string; stderr: string }> {
128
+ const { stdout, stderr, exitCode } = await runRailway(args, runOpts);
129
+ if (exitCode !== 0) {
130
+ throw new Error(
131
+ `railway ${args.join(" ")} exited ${exitCode}${stderr ? `: ${stderr.trim()}` : ""}`,
132
+ );
133
+ }
134
+ return { stdout, stderr };
135
+ }
136
+
137
+ return {
138
+ async checkPresence() {
139
+ const { exitCode } = await runRailway(["--version"]);
140
+ if (exitCode !== 0) throw new RailwayCliMissingError();
141
+ return true;
142
+ },
143
+
144
+ async checkAuth() {
145
+ const { stdout, stderr, exitCode } = await runRailway(["whoami"]);
146
+ if (exitCode !== 0) {
147
+ throw new RailwayNotLoggedInError(stderr.trim() || "non-zero exit");
148
+ }
149
+ // Output forms observed: "Logged in as foo@example.com" or just "foo@example.com".
150
+ const match = stdout.match(/(?:Logged in as\s+)?([^\s]+@[^\s]+|\S+)$/m);
151
+ return match ? match[1]!.trim() : stdout.trim();
152
+ },
153
+
154
+ async link({ projectId, serviceName, cwd }) {
155
+ await runOrThrow(["link", "--project", projectId, "--service", serviceName], { cwd });
156
+ },
157
+
158
+ async setVariable({ key, value, cwd }) {
159
+ await runOrThrow(["variables", "--set", `${key}=${value}`], { cwd });
160
+ },
161
+
162
+ async up({ cwd }) {
163
+ await runOrThrow(["up", "--detach"], { cwd });
164
+ },
165
+
166
+ async generateDomain({ cwd }) {
167
+ // Idempotent: first call generates, second returns the existing URL.
168
+ const { stdout } = await runOrThrow(["domain", "--generate"], { cwd });
169
+ const match = stdout.match(/https:\/\/[a-z0-9.-]+/i);
170
+ if (!match) {
171
+ throw new Error(`railway domain --generate produced no URL: ${stdout.trim()}`);
172
+ }
173
+ return match[0];
174
+ },
175
+
176
+ async addVolume({ name, mountPath, cwd }) {
177
+ await runOrThrow(["volume", "add", name, "--mount-path", mountPath], { cwd });
178
+ },
179
+
180
+ async status({ cwd }) {
181
+ const { stdout } = await runOrThrow(["status", "--json"], { cwd });
182
+ const parsed = JSON.parse(stdout) as RailwayStatus;
183
+ return parsed;
184
+ },
185
+
186
+ async destroyService({ cwd }) {
187
+ await runOrThrow(["service", "delete", "--yes"], { cwd });
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * .env parser + Railway env-var push plan.
3
+ *
4
+ * Parses the agent dir's `.env` file (excluded from the bundle by design) and
5
+ * produces a structured plan the deploy command shows to the operator for
6
+ * confirmation BEFORE pushing to Railway. Plan structure:
7
+ *
8
+ * { variables: [{ key, value, redactedValue }], warnings: [...] }
9
+ *
10
+ * `redactedValue` masks the middle of each value so the operator can confirm
11
+ * the right secret without the full key being printed to the terminal.
12
+ *
13
+ * This module does NOT call Railway. The deploy command calls `railway-cli`'s
14
+ * `setVariable` for each plan entry after operator confirmation.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+
19
+ export interface EnvVariable {
20
+ key: string;
21
+ value: string;
22
+ redactedValue: string;
23
+ }
24
+
25
+ export interface SecretsPlan {
26
+ variables: EnvVariable[];
27
+ warnings: string[];
28
+ }
29
+
30
+ const KEY_RE = /^[A-Z_][A-Z0-9_]*$/i;
31
+
32
+ function unquote(raw: string): string {
33
+ if (raw.length >= 2) {
34
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
35
+ return raw.slice(1, -1);
36
+ }
37
+ }
38
+ return raw;
39
+ }
40
+
41
+ /**
42
+ * Redact a secret for terminal display. Reveals the first 4 and last 4 chars
43
+ * for values >= 12 chars; shorter values are fully masked. Empty strings stay
44
+ * empty so the operator notices.
45
+ */
46
+ export function redactValue(value: string): string {
47
+ if (value.length === 0) return "";
48
+ if (value.length < 12) return "*".repeat(value.length);
49
+ return `${value.slice(0, 4)}${"*".repeat(value.length - 8)}${value.slice(-4)}`;
50
+ }
51
+
52
+ /**
53
+ * Parse a `.env` file string into a SecretsPlan. Skips blank lines and
54
+ * comments. Tolerates quoted values + `export KEY=value` shorthand. Records
55
+ * a warning for any malformed line rather than throwing — operator gets the
56
+ * full picture before deciding to push.
57
+ */
58
+ export function parseEnvText(text: string): SecretsPlan {
59
+ const variables: EnvVariable[] = [];
60
+ const warnings: string[] = [];
61
+ const seen = new Set<string>();
62
+
63
+ const lines = text.split(/\r?\n/);
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const raw = lines[i]!;
66
+ const line = raw.trim();
67
+ if (line === "" || line.startsWith("#")) continue;
68
+
69
+ const rest = line.startsWith("export ") ? line.slice("export ".length).trimStart() : line;
70
+ const eqIdx = rest.indexOf("=");
71
+ if (eqIdx < 0) {
72
+ warnings.push(`line ${i + 1}: no '=' found, skipped: "${line}"`);
73
+ continue;
74
+ }
75
+
76
+ const key = rest.slice(0, eqIdx).trim();
77
+ const value = unquote(rest.slice(eqIdx + 1).trim());
78
+
79
+ if (!KEY_RE.test(key)) {
80
+ warnings.push(`line ${i + 1}: invalid key "${key}", skipped`);
81
+ continue;
82
+ }
83
+
84
+ if (seen.has(key)) {
85
+ warnings.push(`line ${i + 1}: duplicate key "${key}" — later value overrides earlier`);
86
+ }
87
+ seen.add(key);
88
+
89
+ // Replace any prior entry for the same key.
90
+ const existing = variables.findIndex((v) => v.key === key);
91
+ const entry: EnvVariable = { key, value, redactedValue: redactValue(value) };
92
+ if (existing >= 0) {
93
+ variables[existing] = entry;
94
+ } else {
95
+ variables.push(entry);
96
+ }
97
+ }
98
+
99
+ return { variables, warnings };
100
+ }
101
+
102
+ /**
103
+ * Read the agent dir's `.env` file and return a SecretsPlan. Returns an empty
104
+ * plan + warning when `.env` doesn't exist (the agent may have no secrets,
105
+ * which is rare but allowed).
106
+ */
107
+ export function loadSecretsPlan(envPath: string): SecretsPlan {
108
+ if (!existsSync(envPath)) {
109
+ return {
110
+ variables: [],
111
+ warnings: [`no .env file at ${envPath} — nothing to push to Railway`],
112
+ };
113
+ }
114
+ const text = readFileSync(envPath, "utf-8");
115
+ return parseEnvText(text);
116
+ }
package/src/cli/index.ts CHANGED
@@ -12,12 +12,14 @@
12
12
  * auggy restart <name> Stop + start
13
13
  * auggy status [name] Show running agents
14
14
  * auggy ls List registered agents
15
- * auggy remove <name> [--yes] Delete an agent (dir + index entry)
15
+ * auggy remove <name> [--yes] [--cloud] Delete an agent (dir + index, optionally Railway service)
16
+ * auggy deploy <name> --to railway Deploy an agent to Railway
16
17
  * auggy chat Launch local GUI
17
18
  * auggy eval [name] Run portable security eval suite
18
19
  */
19
20
 
20
21
  import { Command } from "commander";
22
+ import pkg from "../../package.json" with { type: "json" };
21
23
  import { runCreate } from "./commands/create";
22
24
  import { runAdd } from "./commands/add";
23
25
  import { addSkillCommand } from "./commands/add-skill";
@@ -33,7 +35,7 @@ import { runLs } from "./commands/ls";
33
35
 
34
36
  const program = new Command();
35
37
 
36
- program.name("auggy").description("Auggy agent runtime CLI").version("0.1.0");
38
+ program.name("auggy").description("Auggy agent runtime CLI").version(pkg.version);
37
39
 
38
40
  program
39
41
  .command("create <name>")
@@ -129,11 +131,14 @@ program
129
131
 
130
132
  program
131
133
  .command("remove <name>")
132
- .description("Remove an agent (delete dir + clear index entry)")
134
+ .description(
135
+ "Remove an agent (delete dir + clear index entry; --cloud also destroys Railway service)",
136
+ )
133
137
  .option("--yes", "skip the confirmation prompt")
134
- .action(async (name: string, opts: { yes?: boolean }) => {
138
+ .option("--cloud", "also destroy the agent's Railway service (when cloud-deployed)")
139
+ .action(async (name: string, opts: { yes?: boolean; cloud?: boolean }) => {
135
140
  try {
136
- await runRemove(name, { yes: opts.yes });
141
+ await runRemove(name, { yes: opts.yes, cloud: opts.cloud });
137
142
  } catch (err) {
138
143
  console.error(`Error: ${(err as Error).message}`);
139
144
  process.exit(1);
@@ -172,6 +177,47 @@ program
172
177
  }
173
178
  });
174
179
 
180
+ program
181
+ .command("deploy <name>")
182
+ .description("Deploy an agent to the cloud (--to railway)")
183
+ .option("--to <provider>", "deploy target (only `railway` supported in v1.0)", "railway")
184
+ .option("--yes", "skip the secrets-push confirmation prompt")
185
+ .action(async (name: string, opts: { to: string; yes?: boolean }) => {
186
+ try {
187
+ const { runDeploy } = await import("./commands/deploy");
188
+ const { createRailwayCli } = await import("./deploy/railway-cli");
189
+ const { input, confirm } = await import("@inquirer/prompts");
190
+
191
+ const cli = createRailwayCli();
192
+ const result = await runDeploy(name, {
193
+ to: opts.to as "railway",
194
+ yes: opts.yes ?? false,
195
+ cli,
196
+ promptProjectId: () =>
197
+ input({
198
+ message:
199
+ "Railway project ID (find it in the Railway dashboard URL or via `railway list`):",
200
+ validate: (v) => v.trim().length > 0 || "project ID required",
201
+ }),
202
+ promptConfirm: (message) => confirm({ message, default: false }),
203
+ logger: {
204
+ info: (msg) => console.log(msg),
205
+ warn: (msg) => console.warn(`warn: ${msg}`),
206
+ error: (msg) => console.error(`error: ${msg}`),
207
+ },
208
+ });
209
+ console.log(`\nDeployed ${name} to Railway.`);
210
+ console.log(` URL: ${result.url}`);
211
+ console.log(` Project: ${result.projectId}`);
212
+ console.log(` Service: ${result.serviceId}`);
213
+ console.log(` Volume: ${result.volumeId} (mounted at /app/data)`);
214
+ console.log(`\nFollow the build in the Railway dashboard or with \`railway logs\`.`);
215
+ } catch (err) {
216
+ console.error(`Error: ${(err as Error).message}`);
217
+ process.exit(1);
218
+ }
219
+ });
220
+
175
221
  program.addCommand(chatCommand());
176
222
  program.addCommand(evalCommand());
177
223